Compare commits
5 Commits
6c193ee9c9
...
a28b6cfa80
| Author | SHA1 | Date | |
|---|---|---|---|
| a28b6cfa80 | |||
| 37cc8052a9 | |||
| ef6b857ac6 | |||
| 36b09b9853 | |||
| 7021c55e1b |
@@ -46,6 +46,7 @@ func scanAuthProvider(row interface {
|
|||||||
&item.OIDCGroupsClaim,
|
&item.OIDCGroupsClaim,
|
||||||
&item.OIDCAdmissionExpr,
|
&item.OIDCAdmissionExpr,
|
||||||
&item.OIDCEndSessionURL,
|
&item.OIDCEndSessionURL,
|
||||||
|
&item.OIDCPostLogoutRedirect,
|
||||||
&item.GroupSyncMode,
|
&item.GroupSyncMode,
|
||||||
&defaultUserGroupID,
|
&defaultUserGroupID,
|
||||||
&item.UserCount,
|
&item.UserCount,
|
||||||
@@ -65,7 +66,7 @@ const authProviderSelectCols = `
|
|||||||
p.ldap_tls_insecure_skip_verify,
|
p.ldap_tls_insecure_skip_verify,
|
||||||
p.oidc_client_id, p.oidc_client_secret, p.oidc_authorize_url, p.oidc_token_url,
|
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_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, ''),
|
COALESCE(ug.public_id, ''),
|
||||||
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
|
(SELECT COUNT(*) FROM users WHERE auth_provider_id = p.id),
|
||||||
p.created_at, p.updated_at`
|
p.created_at, p.updated_at`
|
||||||
@@ -307,16 +308,16 @@ func (s *Store) CreateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
|||||||
ldap_tls_insecure_skip_verify,
|
ldap_tls_insecure_skip_verify,
|
||||||
oidc_client_id, oidc_client_secret, oidc_authorize_url, oidc_token_url,
|
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_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,
|
default_user_group_id,
|
||||||
created_at, updated_at
|
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.ID, item.Name, item.Type, item.Enabled,
|
||||||
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
|
item.LDAPUrl, item.LDAPBindDN, item.LDAPBindPassword, item.LDAPUserBaseDN, item.LDAPUserFilter,
|
||||||
item.LDAPTLSInsecureSkipVerify,
|
item.LDAPTLSInsecureSkipVerify,
|
||||||
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
||||||
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
|
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.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||||
item.CreatedAt, item.UpdatedAt,
|
item.CreatedAt, item.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -367,7 +368,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
|||||||
ldap_tls_insecure_skip_verify = ?,
|
ldap_tls_insecure_skip_verify = ?,
|
||||||
oidc_client_id = ?, oidc_client_secret = ?, oidc_authorize_url = ?, oidc_token_url = ?,
|
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_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,
|
default_user_group_id = CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM user_groups WHERE public_id = ?) END,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE public_id = ?`,
|
WHERE public_id = ?`,
|
||||||
@@ -376,7 +377,7 @@ func (s *Store) UpdateAuthProvider(item models.AuthProvider) (models.AuthProvide
|
|||||||
item.LDAPTLSInsecureSkipVerify,
|
item.LDAPTLSInsecureSkipVerify,
|
||||||
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
item.OIDCClientID, item.OIDCClientSecret, item.OIDCAuthorizeURL, item.OIDCTokenURL,
|
||||||
item.OIDCUserInfoURL, item.OIDCRedirectURL, item.OIDCScopes, item.OIDCTLSInsecureSkipVerify,
|
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.DefaultUserGroupID, item.DefaultUserGroupID,
|
||||||
item.UpdatedAt,
|
item.UpdatedAt,
|
||||||
strings.TrimSpace(item.ID),
|
strings.TrimSpace(item.ID),
|
||||||
|
|||||||
@@ -647,14 +647,21 @@ func (s *Store) GetUserByAPIKeyHash(tokenHash string) (models.User, error) {
|
|||||||
return user, nil
|
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
|
var err error
|
||||||
_, err = s.Exec(`INSERT INTO sessions (id, user_id, token, expires_at, created_at, totp_verified)
|
_, 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 = ?), ?, ?, ?, ?)`,
|
VALUES (?, (SELECT id FROM users WHERE public_id = ?), ?, ?, ?, ?, ?)`,
|
||||||
mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified)
|
mustID(), userID, token, expiresAt.Unix(), time.Now().UTC().Unix(), totpVerified, oidcIDToken)
|
||||||
return err
|
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 {
|
func (s *Store) SetSessionTOTPVerified(token string) error {
|
||||||
var err error
|
var err error
|
||||||
_, err = s.Exec(`UPDATE sessions SET totp_verified = 1 WHERE token = ?`, token)
|
_, err = s.Exec(`UPDATE sessions SET totp_verified = 1 WHERE token = ?`, token)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import "fmt"
|
|||||||
import "io"
|
import "io"
|
||||||
import "mime/multipart"
|
import "mime/multipart"
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
import "net/url"
|
||||||
import "os"
|
import "os"
|
||||||
import "path/filepath"
|
import "path/filepath"
|
||||||
import "sort"
|
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")
|
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 token string
|
||||||
var err error
|
var err error
|
||||||
var expires time.Time
|
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)
|
expires = auth.SessionExpiry(api.Cfg)
|
||||||
_ = api.store(r).DeleteExpiredSessions(time.Now().UTC())
|
_ = 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{
|
cookie = &http.Cookie{
|
||||||
Name: api.sessionCookieName(),
|
Name: api.sessionCookieName(),
|
||||||
Value: token,
|
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) {
|
func (api *API) Logout(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
var cookie *http.Cookie
|
var cookie *http.Cookie
|
||||||
var endSessionURL string
|
var endSessionURL string
|
||||||
|
var oidcIDToken string
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
cookie, err = r.Cookie(api.sessionCookieName())
|
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) != "" {
|
if userErr == nil && strings.TrimSpace(user.AuthProviderID) != "" {
|
||||||
var provider models.AuthProvider
|
var provider models.AuthProvider
|
||||||
var provErr error
|
var provErr error
|
||||||
|
var tokenErr error
|
||||||
provider, provErr = api.store(r).GetAuthProvider(user.AuthProviderID)
|
provider, provErr = api.store(r).GetAuthProvider(user.AuthProviderID)
|
||||||
if provErr == nil {
|
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)
|
_ = 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)
|
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) {
|
func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
var user models.User
|
var user models.User
|
||||||
var permissions []string
|
var permissions []string
|
||||||
@@ -616,9 +685,24 @@ func (api *API) Me(w http.ResponseWriter, r *http.Request, _ map[string]string)
|
|||||||
if ok {
|
if ok {
|
||||||
response.TOTPVerifyRequired = user.TOTPEffectiveRequired && user.TOTPEnabled && !session.TOTPVerified
|
response.TOTPVerifyRequired = user.TOTPEffectiveRequired && user.TOTPEnabled && !session.TOTPVerified
|
||||||
}
|
}
|
||||||
|
response.LogoutRequiresConfirmation = api.logoutRequiresConfirmation(r, user)
|
||||||
WriteJSON(w, http.StatusOK, response)
|
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) {
|
func (api *API) UpdateMe(w http.ResponseWriter, r *http.Request, _ map[string]string) {
|
||||||
var ctxUser models.User
|
var ctxUser models.User
|
||||||
var ok bool
|
var ok bool
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func (api *API) OIDCCallbackWithProvider(w http.ResponseWriter, r *http.Request,
|
|||||||
user, err = api.oidcGetOrCreateUser(r.Context(), claims, provider)
|
user, err = api.oidcGetOrCreateUser(r.Context(), claims, provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.Logger.Write(logIDAuth, codit_logger.LOG_WARN, "oidc user mapping failed provider=%s err=%v", provider.Name, err)
|
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
|
return
|
||||||
}
|
}
|
||||||
required, err = api.store(r).UserRequiresTOTP(user.ID)
|
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.TOTPEffectiveRequired = required
|
||||||
user.TOTPSetupRequired = required && !user.TOTPEnabled
|
user.TOTPSetupRequired = required && !user.TOTPEnabled
|
||||||
sessionVerified = !required
|
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)
|
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)
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type meResponse struct {
|
|||||||
TOTPEffectiveRequired bool `json:"totp_effective_required"`
|
TOTPEffectiveRequired bool `json:"totp_effective_required"`
|
||||||
TOTPSetupRequired bool `json:"totp_setup_required"`
|
TOTPSetupRequired bool `json:"totp_setup_required"`
|
||||||
TOTPVerifyRequired bool `json:"totp_verify_required"`
|
TOTPVerifyRequired bool `json:"totp_verify_required"`
|
||||||
|
LogoutRequiresConfirmation bool `json:"logout_requires_confirmation"`
|
||||||
AvatarURL string `json:"avatar_url"`
|
AvatarURL string `json:"avatar_url"`
|
||||||
AvatarUpdatedAt int64 `json:"avatar_updated_at"`
|
AvatarUpdatedAt int64 `json:"avatar_updated_at"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (api *API) finishLogin(w http.ResponseWriter, r *http.Request, user models.
|
|||||||
if required && !user.TOTPEnabled {
|
if required && !user.TOTPEnabled {
|
||||||
sessionVerified = false
|
sessionVerified = false
|
||||||
}
|
}
|
||||||
api.issueSession(w, r, user, sessionVerified)
|
api.issueSession(w, r, user, sessionVerified, "")
|
||||||
WriteJSON(w, http.StatusOK, user)
|
WriteJSON(w, http.StatusOK, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type AuthProvider struct {
|
|||||||
OIDCGroupsClaim string `json:"oidc_groups_claim"`
|
OIDCGroupsClaim string `json:"oidc_groups_claim"`
|
||||||
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
|
OIDCAdmissionExpr string `json:"oidc_admission_expr"`
|
||||||
OIDCEndSessionURL string `json:"oidc_end_session_url"`
|
OIDCEndSessionURL string `json:"oidc_end_session_url"`
|
||||||
|
OIDCPostLogoutRedirect bool `json:"oidc_post_logout_redirect"`
|
||||||
GroupSyncMode string `json:"group_sync_mode"`
|
GroupSyncMode string `json:"group_sync_mode"`
|
||||||
GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"`
|
GroupMappings []AuthProviderGroupMapping `json:"group_mappings,omitempty"`
|
||||||
DefaultUserGroupID string `json:"default_user_group_id"`
|
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 '';
|
||||||
@@ -10,6 +10,7 @@ export interface User {
|
|||||||
totp_effective_required?: boolean
|
totp_effective_required?: boolean
|
||||||
totp_setup_required?: boolean
|
totp_setup_required?: boolean
|
||||||
totp_verify_required?: boolean
|
totp_verify_required?: boolean
|
||||||
|
logout_requires_confirmation?: boolean
|
||||||
avatar_url?: string
|
avatar_url?: string
|
||||||
avatar_updated_at?: number
|
avatar_updated_at?: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -467,6 +468,7 @@ export interface AuthProvider {
|
|||||||
oidc_groups_claim: string
|
oidc_groups_claim: string
|
||||||
oidc_admission_expr: string
|
oidc_admission_expr: string
|
||||||
oidc_end_session_url: string
|
oidc_end_session_url: string
|
||||||
|
oidc_post_logout_redirect: boolean
|
||||||
group_sync_mode: 'off' | 'first_login' | 'sync'
|
group_sync_mode: 'off' | 'first_login' | 'sync'
|
||||||
group_mappings?: AuthProviderGroupMapping[]
|
group_mappings?: AuthProviderGroupMapping[]
|
||||||
default_user_group_id: string
|
default_user_group_id: string
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
Divider,
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
IconButton,
|
IconButton,
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { api, User } from '../api'
|
import { api, User } from '../api'
|
||||||
|
import ModalDialog from '../components/ModalDialog'
|
||||||
import { useBrand } from '../branding'
|
import { useBrand } from '../branding'
|
||||||
import MenuIcon from '@mui/icons-material/Menu'
|
import MenuIcon from '@mui/icons-material/Menu'
|
||||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||||
@@ -70,6 +74,8 @@ export default function Layout() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(true)
|
const [drawerOpen, setDrawerOpen] = useState(true)
|
||||||
const [appearanceMenuAnchor, setAppearanceMenuAnchor] = useState<HTMLElement | null>(null)
|
const [appearanceMenuAnchor, setAppearanceMenuAnchor] = useState<HTMLElement | null>(null)
|
||||||
const [accountMenuAnchor, setAccountMenuAnchor] = 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 themeMode = useContext(ThemeModeContext)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -128,10 +134,28 @@ export default function Layout() {
|
|||||||
]
|
]
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const openLogoutDialog = () => {
|
||||||
closeAccountMenu()
|
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)
|
setUser(null)
|
||||||
|
setLogoutDialogOpen(false)
|
||||||
|
setLogoutBusy(false)
|
||||||
if (result?.oidc_end_session_url) {
|
if (result?.oidc_end_session_url) {
|
||||||
window.location.href = result.oidc_end_session_url
|
window.location.href = result.oidc_end_session_url
|
||||||
} else {
|
} else {
|
||||||
@@ -139,6 +163,13 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmLogout = () => {
|
||||||
|
setLogoutBusy(true)
|
||||||
|
handleLogout().catch(() => {
|
||||||
|
setLogoutBusy(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
|
const openAccountMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAccountMenuAnchor(event.currentTarget)
|
setAccountMenuAnchor(event.currentTarget)
|
||||||
}
|
}
|
||||||
@@ -247,7 +278,7 @@ export default function Layout() {
|
|||||||
<MenuItem disabled>{user.username}</MenuItem>
|
<MenuItem disabled>{user.username}</MenuItem>
|
||||||
<Divider />
|
<Divider />
|
||||||
<MenuItem onClick={openAccountPage}>Account</MenuItem>
|
<MenuItem onClick={openAccountPage}>Account</MenuItem>
|
||||||
<MenuItem onClick={handleLogout}>Logout</MenuItem>
|
<MenuItem onClick={openLogoutDialog}>Sign out</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -330,6 +361,21 @@ export default function Layout() {
|
|||||||
<Outlet context={{ user, setUser }} />
|
<Outlet context={{ user, setUser }} />
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export default function AuthProviderDetailsDialog(props: AuthProviderDetailsDial
|
|||||||
</Box>
|
</Box>
|
||||||
<TextField label="Admission Expression" value={item.oidc_admission_expr || '-'} InputProps={{ readOnly: true }} multiline minRows={2} />
|
<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="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}
|
) : null}
|
||||||
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
|
<TextField label="Created At" value={fmt(item?.created_at)} InputProps={{ readOnly: true }} />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type AuthProviderFormState = {
|
|||||||
oidc_groups_claim: string
|
oidc_groups_claim: string
|
||||||
oidc_admission_expr: string
|
oidc_admission_expr: string
|
||||||
oidc_end_session_url: string
|
oidc_end_session_url: string
|
||||||
|
oidc_post_logout_redirect: boolean
|
||||||
group_sync_mode: 'off' | 'first_login' | 'sync'
|
group_sync_mode: 'off' | 'first_login' | 'sync'
|
||||||
group_mappings: AuthProviderGroupMapping[]
|
group_mappings: AuthProviderGroupMapping[]
|
||||||
default_user_group_id: string
|
default_user_group_id: string
|
||||||
@@ -80,6 +81,7 @@ export function emptyAuthProviderForm(): AuthProviderFormState {
|
|||||||
oidc_groups_claim: 'groups',
|
oidc_groups_claim: 'groups',
|
||||||
oidc_admission_expr: '',
|
oidc_admission_expr: '',
|
||||||
oidc_end_session_url: '',
|
oidc_end_session_url: '',
|
||||||
|
oidc_post_logout_redirect: false,
|
||||||
group_sync_mode: 'off',
|
group_sync_mode: 'off',
|
||||||
group_mappings: [],
|
group_mappings: [],
|
||||||
default_user_group_id: ''
|
default_user_group_id: ''
|
||||||
@@ -108,6 +110,7 @@ export function authProviderToForm(item: AuthProvider): AuthProviderFormState {
|
|||||||
oidc_groups_claim: item.oidc_groups_claim || 'groups',
|
oidc_groups_claim: item.oidc_groups_claim || 'groups',
|
||||||
oidc_admission_expr: item.oidc_admission_expr || '',
|
oidc_admission_expr: item.oidc_admission_expr || '',
|
||||||
oidc_end_session_url: item.oidc_end_session_url || '',
|
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_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) => ({
|
group_mappings: Array.isArray(item.group_mappings) ? item.group_mappings.map((mapping: AuthProviderGroupMapping) => ({
|
||||||
claim_value: mapping.claim_value || '',
|
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 }))}
|
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."
|
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>
|
</Box>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ export default function BoardCardContent(props: BoardCardContentProps) {
|
|||||||
))}
|
))}
|
||||||
{descriptionText ? (
|
{descriptionText ? (
|
||||||
<Tooltip title={descriptionText} arrow>
|
<Tooltip title={descriptionText} arrow>
|
||||||
<BadgeBox><DescriptionIcon sx={{ fontSize: 13 }} /></BadgeBox>
|
<span>
|
||||||
|
<BadgeBox><DescriptionIcon sx={{ fontSize: 13 }} /></BadgeBox>
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{checklistTotal > 0 ? (
|
{checklistTotal > 0 ? (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
|||||||
await api.login(username, password, otpRequired ? otpCode : '')
|
await api.login(username, password, otpRequired ? otpCode : '')
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message = err instanceof Error ? err.message : 'Login failed'
|
message = err instanceof Error ? err.message : 'Sign in failed'
|
||||||
if (message === 'totp_required') {
|
if (message === 'totp_required') {
|
||||||
setOTPRequired(true)
|
setOTPRequired(true)
|
||||||
setOTPCode('')
|
setOTPCode('')
|
||||||
@@ -69,7 +69,7 @@ export default function LoginPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
|
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
|
||||||
{otpRequired ? 'Verify' : 'Login'}
|
{otpRequired ? 'Verify' : 'Sign in'}
|
||||||
</Button>
|
</Button>
|
||||||
{oidcProviders.map((p) => (
|
{oidcProviders.map((p) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -82,7 +82,7 @@ export default function LoginPage() {
|
|||||||
window.location.assign(`/api/auth/oidc/${p.id}/login`)
|
window.location.assign(`/api/auth/oidc/${p.id}/login`)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Login with {p.name}
|
Sign in with {p.name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user