Compare commits

...

5 Commits

Author SHA1 Message Date
hyung-hwan a28b6cfa80 enhanced the logout process 2026-06-18 21:27:38 +09:00
hyung-hwan 37cc8052a9 fixed the sso signout issue 2026-06-18 13:07:26 +09:00
hyung-hwan ef6b857ac6 added more charts 2026-06-18 12:49:50 +09:00
hyung-hwan 36b09b9853 more charts 2026-06-18 11:56:57 +09:00
hyung-hwan 7021c55e1b added more charts for a board 2026-06-18 11:47:01 +09:00
16 changed files with 1016 additions and 115 deletions
+7 -6
View File
@@ -46,6 +46,7 @@ func scanAuthProvider(row interface {
&item.OIDCGroupsClaim,
&item.OIDCAdmissionExpr,
&item.OIDCEndSessionURL,
&item.OIDCPostLogoutRedirect,
&item.GroupSyncMode,
&defaultUserGroupID,
&item.UserCount,
@@ -65,7 +66,7 @@ const authProviderSelectCols = `
p.ldap_tls_insecure_skip_verify,
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
p.oidc_userinfo_url, p.oidc_redirect_url, p.oidc_scopes, p.oidc_tls_insecure_skip_verify,
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.group_sync_mode,
p.oidc_groups_claim, p.oidc_admission_expr, p.oidc_end_session_url, p.oidc_post_logout_redirect, p.group_sync_mode,
COALESCE(ug.public_id, ''),
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
p.created_at, p.updated_at`
@@ -307,16 +308,16 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify,
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
oidc_userinfo_url, oidc_redirect_url, oidc_scopes, oidc_tls_insecure_skip_verify,
oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, group_sync_mode,
oidc_groups_claim, oidc_admission_expr, oidc_end_session_url, oidc_post_logout_redirect, group_sync_mode,
default_user_group_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END, ?, ?)`,
item.ID, item.Name, item.Type, item.Enabled,
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.CreatedAt, item.UpdatedAt,
)
@@ -367,7 +368,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
ldap_tls_insecure_skip_verify = ?,
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
oidc_userinfo_url = ?, oidc_redirect_url = ?, oidc_scopes = ?, oidc_tls_insecure_skip_verify = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, group_sync_mode = ?,
oidc_groups_claim = ?, oidc_admission_expr = ?, oidc_end_session_url = ?, oidc_post_logout_redirect = ?, group_sync_mode = ?,
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
updated_at = ?
WHERE public_id = ?`,
@@ -376,7 +377,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
item.LDAPTLSInsecureSkipVerify,
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.GroupSyncMode,
item.OIDCGroupsClaim, item.OIDCAdmissionExpr, item.OIDCEndSessionURL, item.OIDCPostLogoutRedirect, item.GroupSyncMode,
item.DefaultUserGroupID, item.DefaultUserGroupID,
item.UpdatedAt,
strings.TrimSpace(item.ID),
+11 -4
View File
@@ -647,14 +647,21 @@ func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
return user, nil
}
func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool) error {
func (s *Store) CreateSession(userID string, token string, expiresAt time.Time, totpVerified bool, oidcIDToken string) error {
var err error
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?)`,
mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified)
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified, oidc_id_token)
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?)`,
mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified, oidcIDToken)
return err
}
func (s *Store) GetSessionOIDCIDToken(token string) (string, error) {
var idToken string
var err error
err = s.QueryRow(`SELECT oidc_id_token FROM sessions WHERE token = ?`, token).Scan(&idToken)
return idToken, err
}
func (s *Store) SetSessionTOTPVerified(token string) error {
var err error
_, err = s.Exec(`UPDATE sessions SET totp_verified = 1 WHERE token = ?`, token)
+87 -3
View File
@@ -11,6 +11,7 @@ import "fmt"
import "io"
import "mime/multipart"
import "net/http"
import "net/url"
import "os"
import "path/filepath"
import "sort"
@@ -537,7 +538,7 @@ func (api *API) Login(w http.ResponseWriter, r *http.Request, _ map[string]strin
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "invalid credentials")
}
func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User, totpVerified bool) {
func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models.User, totpVerified bool, oidcIDToken string) {
var token string
var err error
var expires time.Time
@@ -548,7 +549,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
expires = auth.SessionExpiry(api.Cfg)
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
_ = api.store(r).CreateSession(user.ID, token, expires, totpVerified)
_ = api.store(r).CreateSession(user.ID, token, expires, totpVerified, oidcIDToken)
cookie = &http.Cookie{
Name: api.sessionCookieName(),
Value: token,
@@ -562,6 +563,7 @@ func (api *API) issueSession(w http.ResponseWriter, r *http.Request, user models
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var cookie *http.Cookie
var endSessionURL string
var oidcIDToken string
var err error
cookie, err = r.Cookie(api.sessionCookieName())
@@ -572,9 +574,14 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
if userErr == nil && strings.TrimSpace(user.AuthProviderID) != "" {
var provider models.AuthProvider
var provErr error
var tokenErr error
provider, provErr = api.store(r).GetAuthProvider(user.AuthProviderID)
if provErr == nil {
endSessionURL = strings.TrimSpace(provider.OIDCEndSessionURL)
oidcIDToken, tokenErr = api.store(r).GetSessionOIDCIDToken(cookie.Value)
if tokenErr != nil {
oidcIDToken = ""
}
endSessionURL = api.buildOIDCEndSessionURL(r, provider, oidcIDToken)
}
}
_ = api.store(r).DeleteSession(cookie.Value)
@@ -594,6 +601,68 @@ func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]stri
w.WriteHeader(http.StatusNoContent)
}
func (api *API) buildOIDCEndSessionURL(r *http.Request, provider models.AuthProvider, idToken string) string {
var raw string
var parsed *url.URL
var values url.Values
var redirectURL string
var err error
raw = strings.TrimSpace(provider.OIDCEndSessionURL)
if raw == "" {
return ""
}
parsed, err = url.Parse(raw)
if err != nil {
return raw
}
values = parsed.Query()
if strings.TrimSpace(idToken) != "" && strings.TrimSpace(values.Get("id_token_hint")) == "" {
values.Set("id_token_hint", strings.TrimSpace(idToken))
}
if provider.OIDCPostLogoutRedirect {
redirectURL = api.postLogoutRedirectURL(r)
if redirectURL != "" && strings.TrimSpace(values.Get("post_logout_redirect_uri")) == "" {
values.Set("post_logout_redirect_uri", redirectURL)
}
}
parsed.RawQuery = values.Encode()
return parsed.String()
}
func (api *API) postLogoutRedirectURL(r *http.Request) string {
var base string
var host string
var scheme string
base = strings.TrimRight(strings.TrimSpace(api.Cfg.PublicBaseURL), "/")
if base != "" {
return base + "/login"
}
host = strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
if host != "" {
host = strings.TrimSpace(strings.Split(host, ",")[0])
}
if host == "" {
host = strings.TrimSpace(r.Host)
}
if host == "" {
return ""
}
scheme = strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
if scheme != "" {
scheme = strings.TrimSpace(strings.Split(scheme, ",")[0])
}
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
return scheme + "://" + host + "/login"
}
func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var user models.User
var permissions []string
@@ -616,9 +685,24 @@ func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string)
if ok {
response.TOTPVerifyRequired = user.TOTPEffectiveRequired && user.TOTPEnabled && !session.TOTPVerified
}
response.LogoutRequiresConfirmation = api.logoutRequiresConfirmation(r, user)
WriteJSON(w, http.StatusOK, response)
}
func (api *API) logoutRequiresConfirmation(r *http.Request, user models.User) bool {
var provider models.AuthProvider
var err error
if strings.TrimSpace(user.AuthProviderID) == "" {
return false
}
provider, err = api.store(r).GetAuthProvider(user.AuthProviderID)
if err != nil {
return false
}
return provider.Type == db.AuthProviderTypeOIDC && strings.TrimSpace(provider.OIDCEndSessionURL) != ""
}
func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var ctxUser models.User
var ok bool
+2 -2
View File
@@ -157,7 +157,7 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
user, err = api.oidcGetOrCreateUser(r.Context(), claims, provider)
if err != nil {
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc user mapping failed provider=%s err=%v", provider.Name, err)
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, "oidc user mapping failed")
WriteJSONWithErrorReason(w, r, http.StatusUnauthorized, fmt.Sprintf("oidc user mapping failed - %s", err.Error()))
return
}
required, err = api.store(r).UserRequiresTOTP(user.ID)
@@ -173,7 +173,7 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
user.TOTPEffectiveRequired = required
user.TOTPSetupRequired = required && !user.TOTPEnabled
sessionVerified = !required
api.issueSession(w, r, user, sessionVerified)
api.issueSession(w, r, user, sessionVerified, token.IDToken)
api.Logger.Write(logIDAuth, codit_logger.LOG_INFO, "oidc login success username=%s provider=%s", user.Username, provider.Name)
http.Redirect(w, r, "/", http.StatusFound)
}
@@ -25,6 +25,7 @@ type meResponse struct {
TOTPEffectiveRequired bool `json:"totp_effective_required"`
TOTPSetupRequired bool `json:"totp_setup_required"`
TOTPVerifyRequired bool `json:"totp_verify_required"`
LogoutRequiresConfirmation bool `json:"logout_requires_confirmation"`
AvatarURL string `json:"avatar_url"`
AvatarUpdatedAt int64 `json:"avatar_updated_at"`
CreatedAt int64 `json:"created_at"`
+1 -1
View File
@@ -61,7 +61,7 @@ func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.
if required && !user.TOTPEnabled {
sessionVerified = false
}
api.issueSession(w, r, user, sessionVerified)
api.issueSession(w, r, user, sessionVerified, "")
WriteJSON(w, http.StatusOK, user)
}
+1
View File
@@ -50,6 +50,7 @@ type AuthProvider struct {
OIDCGroupsClaim string `json:"oidc_groups_claim"`
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
OIDCEndSessionURL string `json:"oidc_end_session_url"`
OIDCPostLogoutRedirect bool `json:"oidc_post_logout_redirect"`
GroupSyncMode string `json:"group_sync_mode"`
GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"`
DefaultUserGroupID string `json:"default_user_group_id"`
@@ -0,0 +1 @@
ALTER TABLE auth_providers ADD COLUMN oidc_post_logout_redirect INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1 @@
ALTER TABLE sessions ADD COLUMN oidc_id_token TEXT NOT NULL DEFAULT '';
+2
View File
@@ -10,6 +10,7 @@ export interface User {
totp_effective_required?: boolean
totp_setup_required?: boolean
totp_verify_required?: boolean
logout_requires_confirmation?: boolean
avatar_url?: string
avatar_updated_at?: number
disabled?: boolean
@@ -467,6 +468,7 @@ export interface AuthProvider {
oidc_groups_claim: string
oidc_admission_expr: string
oidc_end_session_url: string
oidc_post_logout_redirect: boolean
group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings?: AuthProviderGroupMapping[]
default_user_group_id: string
+49 -3
View File
@@ -4,6 +4,9 @@ import {
Box,
Button,
Container,
DialogActions,
DialogContent,
DialogTitle,
Divider,
Drawer,
IconButton,
@@ -18,6 +21,7 @@ import {
} from '@mui/material'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { api, User } from '../api'
import ModalDialog from '../components/ModalDialog'
import { useBrand } from '../branding'
import MenuIcon from '@mui/icons-material/Menu'
import DashboardIcon from '@mui/icons-material/Dashboard'
@@ -70,6 +74,8 @@ export default function Layout() {
const [drawerOpen, setDrawerOpen] = useState(true)
const [appearanceMenuAnchor, setAppearanceMenuAnchor] = useState<HTMLElement | null>(null)
const [accountMenuAnchor, setAccountMenuAnchor] = useState<HTMLElement | null>(null)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const [logoutBusy, setLogoutBusy] = useState(false)
const themeMode = useContext(ThemeModeContext)
const navigate = useNavigate()
const location = useLocation()
@@ -128,10 +134,28 @@ export default function Layout() {
]
}, [user])
const handleLogout = async () => {
const openLogoutDialog = () => {
closeAccountMenu()
const result = await api.logout()
if (!user?.logout_requires_confirmation) {
setLogoutBusy(true)
handleLogout().catch(() => {
setLogoutBusy(false)
})
return
}
setLogoutDialogOpen(true)
}
const closeLogoutDialog = () => {
if (logoutBusy) return
setLogoutDialogOpen(false)
}
const handleLogout = async () => {
const result: { oidc_end_session_url?: string } | null = await api.logout()
setUser(null)
setLogoutDialogOpen(false)
setLogoutBusy(false)
if (result?.oidc_end_session_url) {
window.location.href = result.oidc_end_session_url
} else {
@@ -139,6 +163,13 @@ export default function Layout() {
}
}
const confirmLogout = () => {
setLogoutBusy(true)
handleLogout().catch(() => {
setLogoutBusy(false)
})
}
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
setAccountMenuAnchor(event.currentTarget)
}
@@ -247,7 +278,7 @@ export default function Layout() {
<MenuItem disabled>{user.username}</MenuItem>
<Divider />
<MenuItem onClick={openAccountPage}>Account</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
<MenuItem onClick={openLogoutDialog}>Sign out</MenuItem>
</Menu>
</>
) : null}
@@ -330,6 +361,21 @@ export default function Layout() {
<Outlet context={{ user, setUser }} />
</Container>
</Box>
<ModalDialog open={logoutDialogOpen} onClose={closeLogoutDialog} maxWidth="xs" fullWidth>
<DialogTitle>Sign out?</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
This will sign you out of {brand.site_name}. If your account uses OIDC, it may also sign you out of your identity provider session.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={confirmLogout} disabled={logoutBusy} variant="contained" color="primary">
{logoutBusy ? 'Signing out...' : 'Sign out'}
</Button>
<Button onClick={closeLogoutDialog} disabled={logoutBusy}>Cancel</Button>
</DialogActions>
</ModalDialog>
</Box>
)
}
@@ -94,6 +94,7 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
</Box>
<TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} />
<TextField label="End Session URL" value={item.oidc_end_session_url || '-'} InputProps={{ readOnly: true }} />
<TextField label="Post Logout Redirect" value={yesNo(item.oidc_post_logout_redirect)} InputProps={{ readOnly: true }} />
</>
) : null}
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
@@ -40,6 +40,7 @@ export type AuthProviderFormState = {
oidc_groups_claim: string
oidc_admission_expr: string
oidc_end_session_url: string
oidc_post_logout_redirect: boolean
group_sync_mode: 'off' | 'first_login' | 'sync'
group_mappings: AuthProviderGroupMapping[]
default_user_group_id: string
@@ -80,6 +81,7 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
oidc_groups_claim: 'groups',
oidc_admission_expr: '',
oidc_end_session_url: '',
oidc_post_logout_redirect: false,
group_sync_mode: 'off',
group_mappings: [],
default_user_group_id: ''
@@ -108,6 +110,7 @@ export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
oidc_groups_claim: item.oidc_groups_claim || 'groups',
oidc_admission_expr: item.oidc_admission_expr || '',
oidc_end_session_url: item.oidc_end_session_url || '',
oidc_post_logout_redirect: item.oidc_post_logout_redirect,
group_sync_mode: (item.group_sync_mode as 'off' | 'first_login' | 'sync') || 'off',
group_mappings: Array.isArray(item.group_mappings) ? item.group_mappings.map((mapping: AuthProviderGroupMapping) => ({
claim_value: mapping.claim_value || '',
@@ -369,6 +372,18 @@ export default function AuthProviderFormDialog(props: AuthProviderFormDialogProp
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_end_session_url: event.target.value }))}
helperText="RP-initiated logout endpoint (end_session_endpoint). When set, users are redirected here on logout to also clear their SSO session."
/>
<FormControlLabel
control={
<Checkbox
checked={props.form.oidc_post_logout_redirect}
onChange={(event) => props.setForm((prev) => ({ ...prev, oidc_post_logout_redirect: event.target.checked }))}
/>
}
label="Append post-logout redirect URI"
/>
<Typography variant="caption" color="text.secondary">
When enabled, logout appends post_logout_redirect_uri back to the login page. The ID token is also sent as id_token_hint when available.
</Typography>
</Box>
</>
) : null}
+3 -1
View File
@@ -88,7 +88,9 @@ export default function BoardCardContent(props: BoardCardContentProps) {
))}
{descriptionText ? (
<Tooltip title={descriptionText} arrow>
<BadgeBox><DescriptionIcon sx={{ fontSize: 13 }} /></BadgeBox>
<span>
<BadgeBox><DescriptionIcon sx={{ fontSize: 13 }} /></BadgeBox>
</span>
</Tooltip>
) : null}
{checklistTotal > 0 ? (
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -33,7 +33,7 @@ export default function LoginPage() {
await api.login(username, password, otpRequired ? otpCode : '')
navigate('/')
} catch (err) {
message = err instanceof Error ? err.message : 'Login failed'
message = err instanceof Error ? err.message : 'Sign in failed'
if (message === 'totp_required') {
setOTPRequired(true)
setOTPCode('')
@@ -69,7 +69,7 @@ export default function LoginPage() {
</Typography>
)}
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
{otpRequired ? 'Verify' : 'Login'}
{otpRequired ? 'Verify' : 'Sign in'}
</Button>
{oidcProviders.map((p) => (
<Button
@@ -82,7 +82,7 @@ export default function LoginPage() {
window.location.assign(`/api/auth/oidc/${p.id}/login`)
}}
>
Login with {p.name}
Sign in with {p.name}
</Button>
))}
</Box>