diff --git a/backend/app/administration.go b/backend/app/administration.go index 7f09676..c76d6ba 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -54,10 +54,14 @@ const ( ROUTE_V1_USER_SESSIONS_INVALIDATE = "/api/v1/user/sessions/invalidate" ROUTE_V1_USER_API = "/api/v1/user/api" // sso - ROUTE_V1_SSO_ENTRA_ID = "/api/v1/sso/entra-id" - ROUTE_V1_SSO_ENTRA_ID_ENABLED = "/api/v1/sso/entra-id/enabled" + // the config upsert and the enabled status are provider neutral, they serve + // both Entra ID and generic OIDC + ROUTE_V1_SSO = "/api/v1/sso" + ROUTE_V1_SSO_ENABLED = "/api/v1/sso/enabled" ROUTE_V1_SSO_ENTRA_ID_LOGIN = "/api/v1/sso/entra-id/login" ROUTE_V1_SSO_ENTRA_ID_CALLBACK = "/api/v1/sso/entra-id/auth" + ROUTE_V1_SSO_OIDC_LOGIN = "/api/v1/sso/oidc/login" + ROUTE_V1_SSO_OIDC_CALLBACK = "/api/v1/sso/oidc/auth" // mfa ROUTE_V1_USER_MFA_TOTP_SETUP = "/api/v1/user/mfa/totp/setup" ROUTE_V1_USER_MFA_TOTP_SETUP_VERIFY = "/api/v1/user/mfa/totp/setup/verify" @@ -338,10 +342,12 @@ func setupRoutes( POST(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.UpsertAPIKey). DELETE(ROUTE_V1_USER_API, middleware.SessionHandler, controllers.User.RemoveAPIKey). // sso - GET(ROUTE_V1_SSO_ENTRA_ID_ENABLED, controllers.SSO.IsEnabled). - POST(ROUTE_V1_SSO_ENTRA_ID, middleware.SessionHandler, controllers.SSO.Upsert). + GET(ROUTE_V1_SSO_ENABLED, controllers.SSO.IsEnabled). + POST(ROUTE_V1_SSO, middleware.SessionHandler, controllers.SSO.Upsert). GET(ROUTE_V1_SSO_ENTRA_ID_LOGIN, controllers.SSO.EntreIDLogin). GET(ROUTE_V1_SSO_ENTRA_ID_CALLBACK, controllers.SSO.EntreIDCallBack). + GET(ROUTE_V1_SSO_OIDC_LOGIN, controllers.SSO.OIDCLogin). + GET(ROUTE_V1_SSO_OIDC_CALLBACK, controllers.SSO.OIDCCallback). // user mfa GET(ROUTE_V1_USER_MFA_TOTP, middleware.SessionHandler, controllers.User.IsTOTPEnabled). POST(ROUTE_V1_USER_MFA_TOTP_SETUP, middleware.LoginRateLimiter, middleware.SessionHandler, controllers.User.SetupTOTP). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 69aaec2..37879c8 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -124,6 +124,8 @@ func NewControllers( user := &controller.User{ Common: common, UserService: services.User, + SSOService: services.SSO, + Config: conf, } domain := &controller.Domain{ Common: common, diff --git a/backend/config/config.go b/backend/config/config.go index 07cba53..91bed65 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -78,19 +78,30 @@ type ( LogPath string ErrLogPath string - IPSecurity IPSecurityConfig - RemoteBrowser RemoteBrowserServerConfig + IPSecurity IPSecurityConfig + RemoteBrowser RemoteBrowserServerConfig + Authentication AuthenticationConfig } // ConfigDTO config DTO ConfigDTO struct { - ACME ACME `json:"acme"` - AdministrationServer AdministrationServer `json:"administration"` - PhishingServer PhishingServer `json:"phishing"` - Database Database `json:"database"` - Log Log `json:"log"` - IPSecurity IPSecurityConfig `json:"ip_security"` - RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"` + ACME ACME `json:"acme"` + AdministrationServer AdministrationServer `json:"administration"` + PhishingServer PhishingServer `json:"phishing"` + Database Database `json:"database"` + Log Log `json:"log"` + IPSecurity IPSecurityConfig `json:"ip_security"` + RemoteBrowser RemoteBrowserServerConfig `json:"remote_browser"` + Authentication AuthenticationConfig `json:"authentication"` + } + + // AuthenticationConfig holds server level authentication settings. + AuthenticationConfig struct { + // LocalLoginBreakglass keeps username and password login available even + // when exclusive SSO is enabled. Set at the server level only as a + // recovery path if the identity provider is misconfigured and locks + // everyone out. Defaults to false. + LocalLoginBreakglass bool `json:"local_login_breakglass"` } Log struct { @@ -495,6 +506,7 @@ func FromDTO(dto *ConfigDTO) (*Config, error) { return nil, err } cfg.RemoteBrowser = dto.RemoteBrowser + cfg.Authentication = dto.Authentication return cfg, nil } @@ -523,8 +535,9 @@ func (c *Config) ToDTO() *ConfigDTO { Path: c.LogPath, ErrorPath: c.ErrLogPath, }, - IPSecurity: c.IPSecurity, - RemoteBrowser: c.RemoteBrowser, + IPSecurity: c.IPSecurity, + RemoteBrowser: c.RemoteBrowser, + Authentication: c.Authentication, } } diff --git a/backend/controller/sso.go b/backend/controller/sso.go index ba34db9..23677f1 100644 --- a/backend/controller/sso.go +++ b/backend/controller/sso.go @@ -13,9 +13,14 @@ import ( "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/service" + "github.com/phishingclub/phishingclub/sso" ) -const ssoStateCookieKey = "sso_state" +const ( + ssoStateCookieKey = "sso_state" + ssoNonceCookieKey = "sso_nonce" + ssoVerifierCookieKey = "sso_pkce_verifier" +) // SSO the single sign on controller type SSO struct { @@ -48,12 +53,129 @@ func (s *SSO) Upsert(g *gin.Context) { } func (s *SSO) IsEnabled(g *gin.Context) { - // if no sso client is setup, then it is not enabled - if s.SSO.MSALClient == nil { - s.Response.OK(g, false) + status, err := s.SSO.LoginStatus(g.Request.Context()) + if err != nil { + // fail closed for the login page, report SSO as unavailable + s.Response.OK(g, &service.SSOLoginStatus{}) return } - s.Response.OK(g, true) + s.Response.OK(g, status) +} + +// setSSOCookie sets a short lived, http only, secure cookie used to carry the +// SSO state, nonce and PKCE verifier across the redirect to the provider. +func (s *SSO) setSSOCookie(g *gin.Context, name string, value string) { + http.SetCookie(g.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: "/", + MaxAge: int(5 * time.Minute / time.Second), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// clearSSOCookie removes a cookie previously set by setSSOCookie. +func (s *SSO) clearSSOCookie(g *gin.Context, name string) { + http.SetCookie(g.Writer, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// ssoErrorReason maps a callback error to a stable reason code shown on the +// login page. Unknown errors map to a generic code so internal details are not +// leaked to the unauthenticated login page. +func ssoErrorReason(err error) string { + switch { + case errors.Is(err, errs.ErrSSOUserNotProvisioned): + return "not_provisioned" + case errors.Is(err, errs.ErrSSOEmailNotVerified): + return "email_not_verified" + case errors.Is(err, errs.ErrSSONoEmail): + return "no_email" + case errors.Is(err, errs.ErrSSOMFARequired): + return "mfa_required" + default: + return "generic" + } +} + +// randomHex returns n random bytes hex encoded. +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// OIDCLogin starts the generic OIDC authorization code flow. +func (s *SSO) OIDCLogin(g *gin.Context) { + state, err := randomHex(32) + if err != nil { + s.Response.ServerError(g) + return + } + nonce, err := randomHex(32) + if err != nil { + s.Response.ServerError(g) + return + } + verifier := sso.NewPKCEVerifier() + + authURL, err := s.SSO.OIDCAuthCodeURL(g.Request.Context(), state, nonce, verifier) + if err != nil { + s.Response.BadRequest(g) + return + } + s.setSSOCookie(g, ssoStateCookieKey, state) + s.setSSOCookie(g, ssoNonceCookieKey, nonce) + s.setSSOCookie(g, ssoVerifierCookieKey, verifier) + g.Redirect(http.StatusTemporaryRedirect, authURL) +} + +// OIDCCallback completes the generic OIDC authorization code flow. +func (s *SSO) OIDCCallback(g *gin.Context) { + stateCookie, errState := g.Request.Cookie(ssoStateCookieKey) + nonceCookie, errNonce := g.Request.Cookie(ssoNonceCookieKey) + verifierCookie, errVerifier := g.Request.Cookie(ssoVerifierCookieKey) + // always clear the temporary cookies + s.clearSSOCookie(g, ssoStateCookieKey) + s.clearSSOCookie(g, ssoNonceCookieKey) + s.clearSSOCookie(g, ssoVerifierCookieKey) + + stateParam := g.Query("state") + if errState != nil || errNonce != nil || errVerifier != nil || + stateCookie.Value == "" || stateParam == "" || + subtle.ConstantTimeCompare([]byte(stateCookie.Value), []byte(stateParam)) != 1 { + g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1") + return + } + + code := g.Query("code") + session, err := s.SSO.HandleOIDCCallback(g, code, nonceCookie.Value, verifierCookie.Value) + if err != nil { + g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1&reason="+ssoErrorReason(err)) + return + } + cookie := &http.Cookie{ + Name: data.SessionCookieKey, + Value: session.ID.String(), + Path: "/", + SameSite: http.SameSiteStrictMode, + HttpOnly: true, + Secure: true, + Expires: *session.MaxAgeAt, + } + http.SetCookie(g.Writer, cookie) + g.Redirect(http.StatusTemporaryRedirect, "/dashboard") } func (s *SSO) EntreIDLogin(g *gin.Context) { diff --git a/backend/controller/user.go b/backend/controller/user.go index d224c18..bb4df13 100644 --- a/backend/controller/user.go +++ b/backend/controller/user.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/phishingclub/phishingclub/config" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/database" "github.com/phishingclub/phishingclub/errs" @@ -65,6 +66,8 @@ type UserLoginWithMFARecoveryCodeRequest struct { type User struct { Common UserService *service.User + SSOService *service.SSO + Config *config.Config } // Create creates a new user @@ -469,6 +472,19 @@ func (c *User) GetSessionsOnLoggedInUser(g *gin.Context) { // Login logs in a user func (c *User) Login(g *gin.Context) { + // when exclusive SSO is enabled local login is disabled, unless the server + // level break glass in config.json keeps it available for recovery + breakglass := c.Config.Authentication.LocalLoginBreakglass + blocked, err := c.SSOService.IsLocalLoginBlocked(g.Request.Context(), breakglass, g.ClientIP()) + if err != nil { + c.Logger.Errorw("failed to check exclusive SSO login", "error", err) + c.Response.ServerError(g) + return + } + if blocked { + c.Response.Forbidden(g) + return + } // parse req var req UserLoginRequest if ok := c.handleParseRequest(g, &req); !ok { diff --git a/backend/data/option.go b/backend/data/option.go index b99c907..50210af 100644 --- a/backend/data/option.go +++ b/backend/data/option.go @@ -24,6 +24,15 @@ const ( OptionKeyAdminSSOLogin = "sso_login" + // SSOProviderEntra is the Microsoft Entra ID provider, also the default when + // the stored provider type is empty so existing configurations keep working + SSOProviderEntra = "entra" + // SSOProviderOIDC is a generic OpenID Connect provider such as Keycloak + SSOProviderOIDC = "oidc" + + // SSODefaultScopes are the OIDC scopes requested when none are configured + SSODefaultScopes = "openid profile email" + OptionKeyProxyCookieName = "proxy_cookie_name" // OptionKeyRemoteBrowserWSPath is the seeded random path segment used for the diff --git a/backend/errs/all.go b/backend/errs/all.go index e62a0ab..7fd1efa 100644 --- a/backend/errs/all.go +++ b/backend/errs/all.go @@ -56,6 +56,14 @@ var ( // ErrSSOUserNotProvisioned is returned when an SSO login matches no existing // user, users are never auto provisioned and must be created by an admin first ErrSSOUserNotProvisioned = goerrors.New("SSO user not provisioned") + // ErrSSOEmailNotVerified is returned when the provider has not verified the + // email claim, an unverified email is not trusted for account matching + ErrSSOEmailNotVerified = goerrors.New("SSO email not verified by provider") + // ErrSSONoEmail is returned when the provider returns no email claim + ErrSSONoEmail = goerrors.New("SSO provider returned no email") + // ErrSSOMFARequired is returned when the authentication context returned by + // the provider does not satisfy the required acr values + ErrSSOMFARequired = goerrors.New("SSO authentication context requirement not met") ) // format messages diff --git a/backend/go.mod b/backend/go.mod index fbd5e86..952a3b2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/charmbracelet/bubbles v0.20.0 github.com/charmbracelet/bubbletea v1.3.4 github.com/charmbracelet/lipgloss v1.1.0 + github.com/coreos/go-oidc/v3 v3.11.0 github.com/dop251/goja v0.0.0-20260226184354-913bd86fb70c github.com/enetx/surf v1.0.141 github.com/exaring/ja4plus v0.0.2 @@ -32,6 +33,7 @@ require ( golang.org/x/crypto v0.51.0 golang.org/x/mod v0.35.0 golang.org/x/net v0.55.0 + golang.org/x/oauth2 v0.24.0 golang.org/x/sync v0.20.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 @@ -65,6 +67,7 @@ require ( github.com/gaukas/clienthellod v0.4.2 // indirect github.com/gaukas/godicttls v0.0.4 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.22.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 2aaf18a..e9680a8 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -42,6 +42,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= +github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -89,6 +91,8 @@ github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01 github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= +github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -290,6 +294,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= diff --git a/backend/main.go b/backend/main.go index 080c104..c9b818e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -242,12 +242,23 @@ func main() { return } if ssoOpt.Enabled { - services.SSO.MSALClient, err = sso.NewEntreIDClient(ssoOpt) - if err != nil && !errors.Is(err, errs.ErrSSODisabled) { - logger.Errorw("failed to setup msal client", "error", err) - return + switch ssoOpt.Provider() { + case data.SSOProviderOIDC: + services.SSO.OIDCClient, err = sso.NewOIDCClient(context.Background(), ssoOpt) + if err != nil && !errors.Is(err, errs.ErrSSODisabled) { + // discovery can fail if the provider is unreachable at startup, + // log and continue so the rest of the server still comes up + logger.Errorw("failed to setup OIDC client", "error", err) + } + // the failure is recoverable, do not let it leak into later checks + err = nil + default: + services.SSO.MSALClient, err = sso.NewEntreIDClient(ssoOpt) + if err != nil && !errors.Is(err, errs.ErrSSODisabled) { + logger.Errorw("failed to setup msal client", "error", err) + return + } } - } middlewares := app.NewMiddlewares( 1, diff --git a/backend/model/ssoOption.go b/backend/model/ssoOption.go index 09a57c1..1ee9b4d 100644 --- a/backend/model/ssoOption.go +++ b/backend/model/ssoOption.go @@ -3,6 +3,7 @@ package model import ( "encoding/json" "fmt" + "strings" "github.com/go-errors/errors" @@ -13,23 +14,55 @@ import ( ) type SSOOption struct { - Enabled bool `json:"enabled"` + Enabled bool `json:"enabled"` + // ProviderType is "entra" or "oidc". An empty value is treated as "entra" + // so configurations stored before generic OIDC support keep working. + ProviderType string `json:"providerType"` ClientID vo.OptionalString64 `json:"clientID"` TenantID vo.OptionalString64 `json:"tenantID"` ClientSecret vo.OptionalString1024 `json:"clientSecret"` RedirectURL vo.OptionalString1024 `json:"redirectURL"` + // OIDC only fields + IssuerURL vo.OptionalString1024 `json:"issuerURL"` + Scopes vo.OptionalString1024 `json:"scopes"` + ACRValues vo.OptionalString1024 `json:"acrValues"` + // ExclusiveLogin disables username and password login when SSO is enabled. + // A server level break glass in config.json can still allow local login. + ExclusiveLogin bool `json:"exclusiveLogin"` } func NewSSOOptionDefault() *SSOOption { return &SSOOption{ Enabled: false, + ProviderType: data.SSOProviderEntra, ClientID: *vo.NewEmptyOptionalString64(), TenantID: *vo.NewEmptyOptionalString64(), ClientSecret: *vo.NewEmptyOptionalString1024(), RedirectURL: *vo.NewEmptyOptionalString1024(), + IssuerURL: *vo.NewEmptyOptionalString1024(), + Scopes: *vo.NewEmptyOptionalString1024(), + ACRValues: *vo.NewEmptyOptionalString1024(), } } +// Provider returns the configured provider type, defaulting to Entra ID when +// the stored value is empty so older configurations keep working. +func (l *SSOOption) Provider() string { + if l.ProviderType == data.SSOProviderOIDC { + return data.SSOProviderOIDC + } + return data.SSOProviderEntra +} + +// ScopesOrDefault returns the configured OIDC scopes or the default set. +func (l *SSOOption) ScopesOrDefault() string { + s := strings.TrimSpace(l.Scopes.String()) + if s == "" { + return data.SSODefaultScopes + } + return s +} + func NewSSOOptionFromJSON(jsonData []byte) (*SSOOption, error) { option := &SSOOption{} err := json.Unmarshal(jsonData, option) diff --git a/backend/model/user.go b/backend/model/user.go index 3925ffd..36748c3 100644 --- a/backend/model/user.go +++ b/backend/model/user.go @@ -26,6 +26,9 @@ type User struct { RoleID nullable.Nullable[uuid.UUID] `json:"roleID"` Role *Role `json:"role"` SSOID nullable.Nullable[string] `json:"ssoID"` + // HasPassword reports whether a password is set on the account, so the + // profile can offer a change or a set password flow. The hash is never output + HasPassword bool `json:"hasPassword"` // apiKey is only get/set externally from this and never output except when created } diff --git a/backend/repository/user.go b/backend/repository/user.go index cb27914..f2ab164 100644 --- a/backend/repository/user.go +++ b/backend/repository/user.go @@ -507,8 +507,10 @@ func (r *User) updateUsernameByID( return nil } -// UpdateUserToSSO removes the password hash and sets a sso id -func (r *User) UpdateUserToSSO( +// SetSSOID stores the SSO subject id on the user. The password hash is left +// intact so whether password login is allowed is governed by the SSO mode and +// not destroyed as a side effect of an SSO login. +func (r *User) SetSSOID( ctx context.Context, id *uuid.UUID, ssoID string, @@ -517,8 +519,7 @@ func (r *User) UpdateUserToSSO( Table(database.USER_TABLE). Where("id = ?", id.String()). Updates(map[string]interface{}{ - "password_hash": "", - "sso_id": ssoID, + "sso_id": ssoID, }) if result.Error != nil { @@ -758,5 +759,6 @@ func ToUser(row *database.User) (*model.User, error) { CompanyID: companyID, Company: company, SSOID: ssoID, + HasPassword: len(row.PasswordHash) > 0, }, nil } diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 672be82..15f7144 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -5565,6 +5565,9 @@ func (c *Campaign) SendCampaignReport( if err != nil { c.Logger.Errorw("failed to render report PDF", "error", err) writeReportSendLog("failed", nil, fmt.Errorf("failed to render report PDF: %w", err)) + if onDemand { + return errs.NewCustomError(fmt.Errorf("could not generate the report PDF: %w", err)) + } return errs.Wrap(err) } // load the smtp configuration to send through @@ -5611,12 +5614,21 @@ func (c *Campaign) SendCampaignReport( filename := fmt.Sprintf("report-%s.pdf", sanitizeReportFilename(campaignName)) m := mail.NewMsg(mail.WithNoDefaultUserAgent()) if err := m.EnvelopeFrom(senderEmail.String()); err != nil { + if onDemand { + return errs.NewCustomError(fmt.Errorf("the report sender email address is invalid: %w", err)) + } return errs.Wrap(err) } if err := m.From(senderEmail.String()); err != nil { + if onDemand { + return errs.NewCustomError(fmt.Errorf("the report sender email address is invalid: %w", err)) + } return errs.Wrap(err) } if err := m.To(toEmails...); err != nil { + if onDemand { + return errs.NewCustomError(fmt.Errorf("a report recipient email address is invalid: %w", err)) + } return errs.Wrap(err) } if headers := smtpConfig.Headers; headers != nil { @@ -5649,6 +5661,9 @@ func (c *Campaign) SendCampaignReport( if err := c.SMTPConfigService.SendMessages(ctx, smtpConfig, m); err != nil { c.Logger.Errorw("failed to send report email", "error", err) writeReportSendLog("failed", toEmails, err) + if onDemand { + return errs.NewCustomError(fmt.Errorf("could not send the report email, check the SMTP configuration: %w", err)) + } return errs.Wrap(err) } writeReportSendLog("sent", toEmails, nil) diff --git a/backend/service/sso.go b/backend/service/sso.go index 2a759f1..425ae01 100644 --- a/backend/service/sso.go +++ b/backend/service/sso.go @@ -26,6 +26,7 @@ type SSO struct { UserService *User SessionService *Session MSALClient *confidential.Client + OIDCClient *sso.OIDCClient } type MsGraphUserInfo struct { @@ -73,9 +74,26 @@ func (s *SSO) Upsert( s.AuditLogNotAuthorized(ae) return errs.ErrAuthorizationFailed } - ssoOpt.Enabled = len(ssoOpt.ClientID.String()) > 0 && - len(ssoOpt.TenantID.String()) > 0 && - len(ssoOpt.ClientSecret.String()) > 0 + // the client secret is masked to blank when the config is read, so editing an + // enabled config submits a blank secret. Keep the stored secret when it is + // left blank and the provider is unchanged, so editing other fields does not + // wipe the secret + if ssoOpt.ClientSecret.String() == "" { + existing, err := s.GetSSOOptionWithoutAuth(ctx) + if err == nil && existing != nil && existing.Provider() == ssoOpt.Provider() { + ssoOpt.ClientSecret = existing.ClientSecret + } + } + switch ssoOpt.Provider() { + case data.SSOProviderOIDC: + // PKCE is always used, so a public client without a secret is valid + ssoOpt.Enabled = len(ssoOpt.IssuerURL.String()) > 0 && + len(ssoOpt.ClientID.String()) > 0 + default: + ssoOpt.Enabled = len(ssoOpt.ClientID.String()) > 0 && + len(ssoOpt.TenantID.String()) > 0 && + len(ssoOpt.ClientSecret.String()) > 0 + } // if the config is incomplete, we clear it if !ssoOpt.Enabled { @@ -83,6 +101,41 @@ func (s *SSO) Upsert( ssoOpt.TenantID = *vo.NewEmptyOptionalString64() ssoOpt.ClientSecret = *vo.NewEmptyOptionalString1024() ssoOpt.RedirectURL = *vo.NewEmptyOptionalString1024() + ssoOpt.IssuerURL = *vo.NewEmptyOptionalString1024() + ssoOpt.Scopes = *vo.NewEmptyOptionalString1024() + ssoOpt.ACRValues = *vo.NewEmptyOptionalString1024() + // never leave exclusive login on without a working SSO, it would lock + // everyone out of the local login as well + ssoOpt.ExclusiveLogin = false + } + // build the provider client before persisting so a misconfigured or + // unreachable provider is reported back to the admin as a readable error + // and a broken configuration is never saved as enabled + var ( + oidcClient *sso.OIDCClient + msalClient *confidential.Client + ) + if ssoOpt.Enabled { + switch ssoOpt.Provider() { + case data.SSOProviderOIDC: + oidcClient, err = sso.NewOIDCClient(ctx, ssoOpt) + if err != nil { + s.Logger.Debugw("failed to configure OIDC provider", "error", err) + return errs.NewCustomError(fmt.Errorf( + "could not reach the OpenID Connect provider, check the issuer URL is correct and reachable: %w", + err, + )) + } + default: + msalClient, err = sso.NewEntreIDClient(ssoOpt) + if err != nil { + s.Logger.Debugw("failed to configure Entra ID provider", "error", err) + return errs.NewCustomError(fmt.Errorf( + "could not configure the Entra ID provider, check the tenant, client ID and secret: %w", + err, + )) + } + } } opt, err := ssoOpt.ToOption() if err != nil { @@ -94,15 +147,9 @@ func (s *SSO) Upsert( return errs.Wrap(err) } s.AuditLogAuthorized(ae) - // replace the in memory msal client - if ssoOpt.Enabled { - s.MSALClient, err = sso.NewEntreIDClient(ssoOpt) - if err != nil { - return errs.Wrap(err) - } - } else { - s.MSALClient = nil - } + // swap the in memory clients only after the new config is persisted + s.MSALClient = msalClient + s.OIDCClient = oidcClient return nil } @@ -262,3 +309,124 @@ func (s *SSO) getMsGraphMe(ctx context.Context, accessToken string) (*MsGraphUse return &userInfo, nil } + +// SSOLoginStatus is returned to the login page so it can render the correct +// provider button and hide local login when exclusive SSO is on. +type SSOLoginStatus struct { + Enabled bool `json:"enabled"` + ProviderType string `json:"providerType"` + ExclusiveLogin bool `json:"exclusiveLogin"` +} + +// LoginStatus reports whether SSO is active, which provider is configured and +// whether local login is exclusive. +func (s *SSO) LoginStatus(ctx context.Context) (*SSOLoginStatus, error) { + ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx) + if err != nil { + return nil, errs.Wrap(err) + } + enabled := s.MSALClient != nil || s.OIDCClient != nil + return &SSOLoginStatus{ + Enabled: enabled, + ProviderType: ssoOpt.Provider(), + ExclusiveLogin: enabled && ssoOpt.ExclusiveLogin, + }, nil +} + +// IsExclusiveLoginEnabled reports whether local username and password login is +// disabled. It is only true when SSO is actually active in memory, so a broken +// or unconfigured SSO never locks out local login. +func (s *SSO) IsExclusiveLoginEnabled(ctx context.Context) (bool, error) { + ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx) + if err != nil { + return false, errs.Wrap(err) + } + enabled := s.MSALClient != nil || s.OIDCClient != nil + return enabled && ssoOpt.ExclusiveLogin, nil +} + +// IsLocalLoginBlocked reports whether a username and password login must be +// refused because exclusive SSO is enabled. The breakglass argument comes from +// the server config and keeps local login available for recovery. A blocked +// attempt is audited. +func (s *SSO) IsLocalLoginBlocked(ctx context.Context, breakglass bool, ip string) (bool, error) { + exclusive, err := s.IsExclusiveLoginEnabled(ctx) + if err != nil { + return false, errs.Wrap(err) + } + if !exclusive || breakglass { + return false, nil + } + ae := NewAuditEvent("User.LocalLoginDenied", nil) + ae.Details["ip"] = ip + s.AuditLogNotAuthorized(ae) + return true, nil +} + +// OIDCAuthCodeURL builds the OIDC authorization request URL. The caller supplies +// the state, nonce and PKCE verifier so it can store them for the callback. +func (s *SSO) OIDCAuthCodeURL( + ctx context.Context, + state string, + nonce string, + verifier string, +) (string, error) { + ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx) + if err != nil { + return "", err + } + if !ssoOpt.Enabled || ssoOpt.Provider() != data.SSOProviderOIDC { + return "", errs.Wrap(errs.ErrSSODisabled) + } + if s.OIDCClient == nil { + return "", errs.Wrap(errors.New("no OIDC client")) + } + return s.OIDCClient.AuthCodeURL(state, nonce, verifier), nil +} + +// HandleOIDCCallback verifies the OIDC callback, resolves the pre provisioned +// user by their verified email and creates a session. +func (s *SSO) HandleOIDCCallback( + g *gin.Context, + code string, + nonce string, + verifier string, +) (*model.Session, error) { + ssoOpt, err := s.GetSSOOptionWithoutAuth(g) + if err != nil { + return nil, err + } + if !ssoOpt.Enabled || ssoOpt.Provider() != data.SSOProviderOIDC { + return nil, errs.Wrap(errs.ErrSSODisabled) + } + if s.OIDCClient == nil { + return nil, errors.New("no OIDC client in memory") + } + userInfo, err := s.OIDCClient.Exchange(g, code, nonce, verifier) + if err != nil { + s.Logger.Warnw("OIDC code exchange failed", "error", err) + return nil, err + } + name := userInfo.Name + if name == "" { + name = strings.Split(userInfo.Email, "@")[0] + } + userID, err := s.UserService.CreateFromSSO(g, name, userInfo.Email, userInfo.Subject) + if err != nil { + return nil, errs.Wrap(err) + } + if userID == nil { + return nil, errs.Wrap(errors.New("user ID is unexpectedly nil")) + } + user, err := s.UserService.GetByIDWithoutAuth(g, userID) + if err != nil { + s.Logger.Debugw("failed to get SSO user", "error", err) + return nil, errs.Wrap(err) + } + session, err := s.SessionService.Create(g, user, g.ClientIP()) + if err != nil { + s.Logger.Debugw("failed to create session from SSO", "error", err) + return nil, errs.Wrap(err) + } + return session, nil +} diff --git a/backend/service/user.go b/backend/service/user.go index 87e8979..9144f76 100644 --- a/backend/service/user.go +++ b/backend/service/user.go @@ -165,11 +165,12 @@ func (u *User) CreateFromSSO( return nil, errs.Wrap(errs.ErrSSOUserNotProvisioned) } uid := existingUser.ID.MustGet() - // make the account SSO only by removing the password hash and store the - // current provider subject id for reference, overwriting any previous value - // so a change of SSO method does not lock the account out - if err := u.UserRepository.UpdateUserToSSO(ctx, &uid, externalID); err != nil { - u.Logger.Errorw("failed to update user to SSO", "error", err) + // store the current provider subject id for reference, overwriting any + // previous value so a change of SSO method does not lock the account out. + // the password is left intact, whether password login is allowed is + // governed by the exclusive SSO mode, not by destroying the password here + if err := u.UserRepository.SetSSOID(ctx, &uid, externalID); err != nil { + u.Logger.Errorw("failed to set SSO id on user", "error", err) return nil, errs.Wrap(err) } u.AuditLogAuthorized(ae) diff --git a/backend/sso/oidc.go b/backend/sso/oidc.go new file mode 100644 index 0000000..374a3da --- /dev/null +++ b/backend/sso/oidc.go @@ -0,0 +1,181 @@ +package sso + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/go-errors/errors" + "golang.org/x/oauth2" + + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" +) + +// oidcNetworkTimeout bounds the outbound calls to the identity provider +// (discovery, token exchange and JWKS) so a slow or unreachable provider cannot +// hang a request goroutine. +const oidcNetworkTimeout = 15 * time.Second + +// OIDCClient is a configured generic OpenID Connect relying party. It holds the +// discovered provider, the oauth2 config and the ID token verifier so logins and +// callbacks can be served without re running discovery each request. +type OIDCClient struct { + Provider *oidc.Provider + OAuth2 *oauth2.Config + Verifier *oidc.IDTokenVerifier + ACRValues string +} + +// OIDCUserInfo holds the identity claims taken from a verified ID token. +type OIDCUserInfo struct { + Subject string + Email string + EmailVerified bool + Name string +} + +// NewOIDCClient discovers the provider and builds the relying party from the SSO +// configuration. Discovery performs a network request to the issuer. +func NewOIDCClient(ctx context.Context, sso *model.SSOOption) (*OIDCClient, error) { + if !sso.Enabled { + return nil, errs.Wrap(errs.ErrSSODisabled) + } + issuer := strings.TrimSpace(sso.IssuerURL.String()) + if issuer == "" { + return nil, errs.Wrap(errors.New("missing OIDC issuer URL")) + } + clientID := sso.ClientID.String() + ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout) + defer cancel() + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, errs.Wrap(errors.Errorf("failed to discover OIDC provider: %s", err)) + } + oauth2Config := &oauth2.Config{ + ClientID: clientID, + ClientSecret: sso.ClientSecret.String(), + Endpoint: provider.Endpoint(), + RedirectURL: sso.RedirectURL.String(), + Scopes: strings.Fields(sso.ScopesOrDefault()), + } + verifier := provider.Verifier(&oidc.Config{ + ClientID: clientID, + }) + return &OIDCClient{ + Provider: provider, + OAuth2: oauth2Config, + Verifier: verifier, + ACRValues: strings.TrimSpace(sso.ACRValues.String()), + }, nil +} + +// AuthCodeURL builds the authorization request URL. The nonce binds the ID token +// to this login, PKCE protects the code exchange and acr_values requests a given +// authentication context such as multi factor authentication. +func (c *OIDCClient) AuthCodeURL(state string, nonce string, verifier string) string { + opts := []oauth2.AuthCodeOption{ + oidc.Nonce(nonce), + oauth2.S256ChallengeOption(verifier), + } + if c.ACRValues != "" { + opts = append(opts, oauth2.SetAuthURLParam("acr_values", c.ACRValues)) + } + return c.OAuth2.AuthCodeURL(state, opts...) +} + +// Exchange swaps the authorization code for tokens, verifies the ID token and +// returns the identity claims. It enforces the nonce, that the email is verified +// and, when configured, that the returned authentication context matches. +func (c *OIDCClient) Exchange( + ctx context.Context, + code string, + nonce string, + verifier string, +) (*OIDCUserInfo, error) { + ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout) + defer cancel() + token, err := c.OAuth2.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + return nil, errs.Wrap(err) + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return nil, errs.Wrap(errors.New("no id_token in token response")) + } + idToken, err := c.Verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, errs.Wrap(err) + } + if idToken.Nonce != nonce { + return nil, errs.Wrap(errors.New("OIDC nonce mismatch")) + } + var claims struct { + Subject string `json:"sub"` + Email string `json:"email"` + EmailVerified flexBool `json:"email_verified"` + Name string `json:"name"` + ACR string `json:"acr"` + } + if err := idToken.Claims(&claims); err != nil { + return nil, errs.Wrap(err) + } + if claims.Email == "" { + return nil, errs.Wrap(errs.ErrSSONoEmail) + } + // a generic provider can hand out self set, unverified emails so the email + // is only trusted for account matching when the provider has verified it + if !bool(claims.EmailVerified) { + return nil, errs.Wrap(errs.ErrSSOEmailNotVerified) + } + if c.ACRValues != "" && !acrSatisfied(claims.ACR, c.ACRValues) { + return nil, errs.Wrap(fmt.Errorf( + "%w (provider returned acr %q, required one of %q)", + errs.ErrSSOMFARequired, claims.ACR, c.ACRValues, + )) + } + return &OIDCUserInfo{ + Subject: claims.Subject, + Email: claims.Email, + EmailVerified: bool(claims.EmailVerified), + Name: claims.Name, + }, nil +} + +// NewPKCEVerifier returns a fresh high entropy PKCE code verifier (RFC 7636). +func NewPKCEVerifier() string { + return oauth2.GenerateVerifier() +} + +// acrSatisfied reports whether the acr returned by the provider is one of the +// space separated values that were requested. +func acrSatisfied(returned string, requested string) bool { + returned = strings.TrimSpace(returned) + if returned == "" { + return false + } + for _, want := range strings.Fields(requested) { + if returned == want { + return true + } + } + return false +} + +// flexBool accepts the email_verified claim as either a JSON boolean or a +// quoted string, since providers differ, and fails closed on anything else. +type flexBool bool + +func (b *flexBool) UnmarshalJSON(data []byte) error { + switch strings.Trim(string(data), `"`) { + case "true": + *b = true + case "false", "", "null": + *b = false + default: + return fmt.Errorf("invalid boolean value for claim: %s", string(data)) + } + return nil +} diff --git a/backend/vendor/modules.txt b/backend/vendor/modules.txt index 5bc8fb6..a22e5c8 100644 --- a/backend/vendor/modules.txt +++ b/backend/vendor/modules.txt @@ -122,6 +122,9 @@ github.com/cloudwego/base64x ## explicit; go 1.16 github.com/cloudwego/iasm/expr github.com/cloudwego/iasm/x86_64 +# github.com/coreos/go-oidc/v3 v3.11.0 +## explicit; go 1.21 +github.com/coreos/go-oidc/v3/oidc # github.com/davecgh/go-spew v1.1.1 ## explicit github.com/davecgh/go-spew/spew @@ -249,6 +252,11 @@ github.com/gin-gonic/gin/render # github.com/go-errors/errors v1.5.1 ## explicit; go 1.14 github.com/go-errors/errors +# github.com/go-jose/go-jose/v4 v4.0.2 +## explicit; go 1.21 +github.com/go-jose/go-jose/v4 +github.com/go-jose/go-jose/v4/cipher +github.com/go-jose/go-jose/v4/json # github.com/go-playground/locales v0.14.1 ## explicit; go 1.17 github.com/go-playground/locales @@ -551,6 +559,7 @@ golang.org/x/crypto/hkdf golang.org/x/crypto/internal/alias golang.org/x/crypto/internal/poly1305 golang.org/x/crypto/ocsp +golang.org/x/crypto/pbkdf2 golang.org/x/crypto/sha3 # golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 ## explicit; go 1.24.0 @@ -583,6 +592,10 @@ golang.org/x/net/ipv4 golang.org/x/net/ipv6 golang.org/x/net/proxy golang.org/x/net/publicsuffix +# golang.org/x/oauth2 v0.24.0 +## explicit; go 1.18 +golang.org/x/oauth2 +golang.org/x/oauth2/internal # golang.org/x/sync v0.20.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup diff --git a/docker/keycloak/realm-export.json b/docker/keycloak/realm-export.json new file mode 100644 index 0000000..b9c5897 --- /dev/null +++ b/docker/keycloak/realm-export.json @@ -0,0 +1,22 @@ +{ + "realm": "phishingclub", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "clients": [ + { + "clientId": "phishingclub", + "name": "Phishing Club", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": ["*"], + "webOrigins": ["*"], + "attributes": { + "pkce.code.challenge.method": "S256" + } + } + ] +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 8709d29..70d79e1 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -3271,18 +3271,23 @@ export class API { sso = { /** * @param {object} sso + * @param {string} [sso.providerType] - "entra" or "oidc" * @param {string} sso.clientID - * @param {string} sso.tenantID + * @param {string} [sso.tenantID] * @param {string} sso.clientSecret * @param {string} sso.redirectURL + * @param {string} [sso.issuerURL] + * @param {string} [sso.scopes] + * @param {string} [sso.acrValues] + * @param {boolean} [sso.exclusiveLogin] * @returns {Promise} */ upsert: async (sso) => { - return await postJSON(this.getPath(`/sso/entra-id`), sso); + return await postJSON(this.getPath(`/sso`), sso); }, isEnabled: async () => { - return await getJSON(this.getPath(`/sso/entra-id/enabled`)); + return await getJSON(this.getPath(`/sso/enabled`)); } }; diff --git a/frontend/src/lib/components/modal/CommandPalette.svelte b/frontend/src/lib/components/modal/CommandPalette.svelte index 2f142b6..64d6a6c 100644 --- a/frontend/src/lib/components/modal/CommandPalette.svelte +++ b/frontend/src/lib/components/modal/CommandPalette.svelte @@ -129,6 +129,13 @@ category: 'Development', external: true }); + + items.push({ + label: 'Keycloak', + url: 'http://keycloak.test:8108', + category: 'Development', + external: true + }); } return items; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 61bfeed..ae04773 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -40,6 +40,8 @@ let inputType = 'password'; let isPasswordVisible = true; let isSSOEnabled = false; + let ssoProviderType = 'entra'; + let exclusiveLogin = false; let isSubmitting = false; @@ -57,16 +59,34 @@ } refreshIsSSOEnabled(); if ($page.url.searchParams.get('ssoAuthError')) { - addToast('SSO login failed', 'Error'); + addToast(ssoErrorMessage($page.url.searchParams.get('reason')), 'Error'); } }); + // maps the reason code from the SSO callback to a message shown to the user + const ssoErrorMessage = (reason) => { + switch (reason) { + case 'not_provisioned': + return 'SSO login failed: this account does not exist in Phishing Club. An administrator must create it first.'; + case 'email_not_verified': + return 'SSO login failed: your email is not verified by the identity provider.'; + case 'no_email': + return 'SSO login failed: the identity provider did not return an email address.'; + case 'mfa_required': + return 'SSO login failed: multi factor authentication is required by the configured authentication context.'; + default: + return 'SSO login failed'; + } + }; + const refreshIsSSOEnabled = async () => { showIsLoading(); try { const res = await api.sso.isEnabled(); - if (res.data) { + if (res.data && res.data.enabled) { isSSOEnabled = true; + ssoProviderType = res.data.providerType || 'entra'; + exclusiveLogin = !!res.data.exclusiveLogin; } } catch (e) { addToast('failed to check sso status', 'Error'); @@ -252,6 +272,7 @@ on:submit={(e) => onSubmitLogin('password', e)} class="flex flex-col items-center justify-center w-full md:p-px lg:p-4" > + {#if !exclusiveLogin}
- + {/if} + {#if !exclusiveLogin} + + {/if} {#if isSSOEnabled} - {/if} diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 9e761ca..1d02ea8 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -74,7 +74,7 @@ totpRecoveryCode: '' }; - let isSSOUser = false; + let hasPassword = false; // component logic const resetMFAValues = () => { @@ -110,9 +110,7 @@ } changeUsernameFormValues.username = res2.data.username; changeNameFormValues.fullname = res2.data.name; - if (res2.data.ssoID) { - isSSOUser = true; - } + hasPassword = !!res2.data.hasPassword; return; } catch (e) { addToast('Failed to load user', 'Error'); @@ -413,7 +411,7 @@ > Password Settings - {#if !isSSOUser} + {#if hasPassword}
@@ -448,7 +446,7 @@
- Password changes are disabled for SSO users. + This account has no password set and signs in via SSO.
{/if}
@@ -461,7 +459,7 @@ > Multi-Factor Authentication - {#if !isSSOUser} + {#if hasPassword} {#if isMFAEnabled}
diff --git a/frontend/src/routes/settings/panels/Access.svelte b/frontend/src/routes/settings/panels/Access.svelte index 455648a..57f8570 100644 --- a/frontend/src/routes/settings/panels/Access.svelte +++ b/frontend/src/routes/settings/panels/Access.svelte @@ -14,6 +14,8 @@ import FormFooter from '$lib/components/FormFooter.svelte'; import TextField from '$lib/components/TextField.svelte'; import PasswordField from '$lib/components/PasswordField.svelte'; + import TextFieldSelect from '$lib/components/TextFieldSelect.svelte'; + import CheckboxField from '$lib/components/CheckboxField.svelte'; let loaded = false; let ssoForm = null; @@ -22,19 +24,42 @@ let updateSSOError = ''; let isSubmitting = false; let isSSOEnabled = false; - let ssoSettingsFormValues = { + + const providerOptions = [ + { value: 'entra', label: 'Microsoft Entra ID' }, + { value: 'oidc', label: 'Generic OpenID Connect' } + ]; + + const defaultSSOFormValues = () => ({ + providerType: 'entra', clientID: null, tenantID: null, redirectURL: null, - clientSecret: null + clientSecret: null, + issuerURL: null, + scopes: null, + acrValues: null, + exclusiveLogin: false + }); + + let ssoSettingsFormValues = defaultSSOFormValues(); + let currentSSO = defaultSSOFormValues(); + let isEditingSSO = false; + + const ssoAuthPath = (providerType) => + providerType === 'oidc' ? '/api/v1/sso/oidc/auth' : '/api/v1/sso/entra-id/auth'; + + const defaultRedirectURL = (providerType) => `${location.origin}${ssoAuthPath(providerType)}`; + + const onProviderChange = (value) => { + const providerType = value || ssoSettingsFormValues.providerType; + ssoSettingsFormValues.providerType = providerType; + ssoSettingsFormValues.redirectURL = defaultRedirectURL(providerType); }; onMount(async () => { try { await refreshSSO(); - if (!ssoSettingsFormValues.redirectURL) { - ssoSettingsFormValues.redirectURL = `${location.origin}/api/v1/sso/entra-id/auth`; - } } finally { loaded = true; } @@ -48,7 +73,10 @@ } const sso = JSON.parse(res.data.value); sso.clientSecret = ''; - ssoSettingsFormValues = sso; + currentSSO = { ...defaultSSOFormValues(), ...sso }; + if (!currentSSO.providerType) { + currentSSO.providerType = 'entra'; + } isSSOEnabled = sso.enabled; } catch (e) { console.error('failed to get SSO configuration', e); @@ -64,8 +92,10 @@ updateSSOError = res.error; return; } + const wasEditing = isEditingSSO; closeSSOModal(); refreshSSO(); + addToast(wasEditing ? 'SSO updated' : 'SSO enabled', 'Success'); } catch (e) { addToast('Failed to update SSO configuration', 'Error'); console.error('failed to update SSO configuration', e); @@ -74,19 +104,31 @@ } }; - const openSSOModal = async (e) => { + // open the modal for a fresh configuration from the disabled state + const openConfigureSSO = (e) => { e.preventDefault(); + isEditingSSO = false; + ssoSettingsFormValues = defaultSSOFormValues(); + ssoSettingsFormValues.redirectURL = defaultRedirectURL(ssoSettingsFormValues.providerType); + isSSOModalVisible = true; + }; + + // open the modal prefilled to edit the current enabled configuration, the + // client secret stays blank and is only changed if a new one is entered + const openEditSSO = (e) => { + e.preventDefault(); + isEditingSSO = true; + ssoSettingsFormValues = { ...currentSSO, clientSecret: '' }; + if (!ssoSettingsFormValues.redirectURL) { + ssoSettingsFormValues.redirectURL = defaultRedirectURL(ssoSettingsFormValues.providerType); + } isSSOModalVisible = true; }; const closeSSOModal = () => { updateSSOError = ''; - ssoSettingsFormValues = { - clientID: null, - tenantID: null, - redirectURL: null, - clientSecret: null - }; + isEditingSSO = false; + ssoSettingsFormValues = defaultSSOFormValues(); isSSOModalVisible = false; }; @@ -103,8 +145,10 @@ throw res.error; } refreshSSO(); + addToast('SSO disabled', 'Success'); }) .catch((e) => { + addToast('Failed to disable SSO', 'Error'); console.error('failed to remove SSO configuration:', e); }); return action; @@ -114,67 +158,86 @@ {#if !loaded} {:else} -
- -
- {#if isSSOEnabled} -

- - Enabled -

- {:else} -

- - Disabled -

- {/if} -
- - {#if isSSOEnabled} - - {:else} - - {/if} - -
-
+
+ +
+ {#if isSSOEnabled} +

+ + Enabled +

+ {:else} +

+ + Disabled +

+ {/if} +
+ + {#if isSSOEnabled} +
+ + +
+ {:else} + + {/if} +
+
+
{/if} {#if isSSOModalVisible} - -
-
-

Microsoft SSO Setup

-

- Configure Single Sign-On with Microsoft Azure AD. -

-
- -
-

Important:

-

- Accounts that login with SSO will no longer be able to use password login. -

-
-
+ - Client ID - Tenant IDProvider + {#if ssoSettingsFormValues.providerType === 'oidc'} + Issuer URL + Client ID + Scopes + {:else} + Client ID + Tenant ID + {/if} Client SecretClient Secret + {#if ssoSettingsFormValues.providerType === 'oidc'} + ACR Values + {/if} + Exclusive SSO - + {/if} @@ -202,8 +283,8 @@ list={[ 'SSO will be disabled', 'Configuration will be deleted', - 'SSO users will no longer be able to log in', - 'Be sure there is a administrative user without SSO' + 'Accounts that sign in only via SSO (no password) will be locked out', + 'Be sure an administrator has a password set' ]} confirm name={'SSO configuration'}