mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-10 14:18:42 +02:00
Add OIDC provider support.
Add exclusive SSO login support. Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -124,6 +124,8 @@ func NewControllers(
|
||||
user := &controller.User{
|
||||
Common: common,
|
||||
UserService: services.User,
|
||||
SSOService: services.SSO,
|
||||
Config: conf,
|
||||
}
|
||||
domain := &controller.Domain{
|
||||
Common: common,
|
||||
|
||||
+24
-11
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+127
-5
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+16
-5
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+180
-12
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+13
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<ApiResponse>}
|
||||
*/
|
||||
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`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -129,6 +129,13 @@
|
||||
category: 'Development',
|
||||
external: true
|
||||
});
|
||||
|
||||
items.push({
|
||||
label: 'Keycloak',
|
||||
url: 'http://keycloak.test:8108',
|
||||
category: 'Development',
|
||||
external: true
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
|
||||
@@ -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}
|
||||
<div class="flex flex-col w-full p-4 h-24">
|
||||
<label
|
||||
for="Username"
|
||||
@@ -326,16 +347,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CTAbutton disabled={isSubmitting} />
|
||||
{/if}
|
||||
{#if !exclusiveLogin}
|
||||
<CTAbutton disabled={isSubmitting} />
|
||||
{/if}
|
||||
{#if isSSOEnabled}
|
||||
<div class="absolute bottom-12">
|
||||
<div
|
||||
class="text-center font-bold text-gray-900 dark:text-white transition-colors duration-200"
|
||||
<div class="flex justify-center w-full p-4 {exclusiveLogin ? 'mt-8' : 'mt-2'}">
|
||||
<a
|
||||
href={ssoProviderType === 'oidc'
|
||||
? '/api/v1/sso/oidc/login'
|
||||
: '/api/v1/sso/entra-id/login'}
|
||||
class="font-titilium font-semibold text-cta-blue dark:text-highlight-blue hover:underline transition-colors duration-200"
|
||||
>
|
||||
SSO
|
||||
</div>
|
||||
<a href="/api/v1/sso/entra-id/login">
|
||||
<img src="/ms-login-light.svg" alt="Login with Microsoft" />
|
||||
Sign in with SSO
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -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
|
||||
</h2>
|
||||
{#if !isSSOUser}
|
||||
{#if hasPassword}
|
||||
<Form on:submit={onClickChangePassword}>
|
||||
<div class="flex flex-col h-full">
|
||||
<div>
|
||||
@@ -448,7 +446,7 @@
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-800 p-4 rounded-md text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Password changes are disabled for SSO users.
|
||||
This account has no password set and signs in via SSO.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -461,7 +459,7 @@
|
||||
>
|
||||
Multi-Factor Authentication
|
||||
</h2>
|
||||
{#if !isSSOUser}
|
||||
{#if hasPassword}
|
||||
{#if isMFAEnabled}
|
||||
<div class="flex flex-col h-full pt-5 w-60">
|
||||
<div class="flex items-center justify-between bg-green-50 p-4 rounded-md">
|
||||
|
||||
@@ -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}
|
||||
<SettingsLoading />
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Single Sign-On">
|
||||
<div class="bg-gray-50 rounded-md p-3">
|
||||
{#if isSSOEnabled}
|
||||
<p class="text-sm font-medium text-green-600">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
|
||||
Enabled
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-600">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-gray-400 mr-2"></span>
|
||||
Disabled
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
{#if isSSOEnabled}
|
||||
<Button
|
||||
size={'large'}
|
||||
on:click={() => {
|
||||
isSSODeleteAlertVisible = true;
|
||||
}}>Disable SSO</Button
|
||||
>
|
||||
{:else}
|
||||
<Button size={'large'} on:click={openSSOModal}>Configure SSO</Button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Single Sign-On">
|
||||
<div class="bg-gray-50 rounded-md p-3">
|
||||
{#if isSSOEnabled}
|
||||
<p class="text-sm font-medium text-green-600">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-2"></span>
|
||||
Enabled
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-600">
|
||||
<span class="inline-block w-2 h-2 rounded-full bg-gray-400 mr-2"></span>
|
||||
Disabled
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:fragment slot="footer">
|
||||
{#if isSSOEnabled}
|
||||
<div class="flex gap-2">
|
||||
<Button size={'medium'} on:click={openEditSSO}>Edit SSO</Button>
|
||||
<Button
|
||||
size={'medium'}
|
||||
backgroundColor="bg-red-600"
|
||||
on:click={() => {
|
||||
isSSODeleteAlertVisible = true;
|
||||
}}>Disable SSO</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<Button size={'large'} on:click={openConfigureSSO}>Configure SSO</Button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isSSOModalVisible}
|
||||
<Modal bind:visible={isSSOModalVisible} headerText="SSO configuration" onClose={closeSSOModal}>
|
||||
<div class="mt-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-white">Microsoft SSO Setup</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Configure Single Sign-On with Microsoft Azure AD.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
|
||||
<p class="font-semibold text-gray-900 dark:text-white mb-2">Important:</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Accounts that login with SSO will no longer be able to use password login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
bind:visible={isSSOModalVisible}
|
||||
headerText={isEditingSSO ? 'Edit SSO' : 'SSO configuration'}
|
||||
onClose={closeSSOModal}
|
||||
>
|
||||
<FormGrid on:submit={onSubmitSSO} bind:bindTo={ssoForm} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
<TextFieldSelect
|
||||
id="ssoProvider"
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.clientID}
|
||||
placeholder="e.g., 8adf8e7c-d3ef-4a1b-b6c5-12345678abcd">Client ID</TextField
|
||||
>
|
||||
<TextField
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.tenantID}
|
||||
placeholder="e.g., contoso.onmicrosoft.com">Tenant ID</TextField
|
||||
options={providerOptions}
|
||||
bind:value={ssoSettingsFormValues.providerType}
|
||||
onSelect={onProviderChange}>Provider</TextFieldSelect
|
||||
>
|
||||
{#if ssoSettingsFormValues.providerType === 'oidc'}
|
||||
<TextField
|
||||
required
|
||||
type="url"
|
||||
bind:value={ssoSettingsFormValues.issuerURL}
|
||||
placeholder="https://oidc.example.com/realms/myrealm">Issuer URL</TextField
|
||||
>
|
||||
<TextField
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.clientID}
|
||||
placeholder="e.g., phishingclub">Client ID</TextField
|
||||
>
|
||||
<TextField
|
||||
optional
|
||||
bind:value={ssoSettingsFormValues.scopes}
|
||||
placeholder="openid profile email">Scopes</TextField
|
||||
>
|
||||
{:else}
|
||||
<TextField
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.clientID}
|
||||
placeholder="e.g., 8adf8e7c-d3ef-4a1b-b6c5-12345678abcd">Client ID</TextField
|
||||
>
|
||||
<TextField
|
||||
required
|
||||
bind:value={ssoSettingsFormValues.tenantID}
|
||||
placeholder="e.g., contoso.onmicrosoft.com">Tenant ID</TextField
|
||||
>
|
||||
{/if}
|
||||
</FormColumn>
|
||||
<FormColumn>
|
||||
<TextField
|
||||
@@ -185,14 +248,32 @@
|
||||
>
|
||||
|
||||
<PasswordField
|
||||
required
|
||||
required={!isEditingSSO && ssoSettingsFormValues.providerType !== 'oidc'}
|
||||
optional={isEditingSSO || ssoSettingsFormValues.providerType === 'oidc'}
|
||||
bind:value={ssoSettingsFormValues.clientSecret}
|
||||
placeholder="Enter your client secret">Client Secret</PasswordField
|
||||
placeholder={isEditingSSO
|
||||
? 'Leave blank to keep current secret'
|
||||
: 'Enter your client secret'}>Client Secret</PasswordField
|
||||
>
|
||||
{#if ssoSettingsFormValues.providerType === 'oidc'}
|
||||
<TextField optional bind:value={ssoSettingsFormValues.acrValues} placeholder="e.g., mfa"
|
||||
>ACR Values</TextField
|
||||
>
|
||||
{/if}
|
||||
<CheckboxField
|
||||
inline
|
||||
bind:value={ssoSettingsFormValues.exclusiveLogin}
|
||||
toolTipText="Disable password login while SSO is enabled.">Exclusive SSO</CheckboxField
|
||||
>
|
||||
</FormColumn>
|
||||
<FormError message={updateSSOError} />
|
||||
</FormColumns>
|
||||
<FormFooter closeModal={closeSSOModal} okText="Enable SSO" closeText="Cancel" {isSubmitting} />
|
||||
<FormFooter
|
||||
closeModal={closeSSOModal}
|
||||
okText={isEditingSSO ? 'Save' : 'Enable SSO'}
|
||||
closeText="Cancel"
|
||||
{isSubmitting}
|
||||
/>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
{/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'}
|
||||
|
||||
Reference in New Issue
Block a user