mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-25 21:20:50 +02:00
df555820f9
Add exclusive SSO login support. Signed-off-by: Ronni Skansing <rskansing@gmail.com>
433 lines
13 KiB
Go
433 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/confidential"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-errors/errors"
|
|
|
|
"github.com/phishingclub/phishingclub/data"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/model"
|
|
"github.com/phishingclub/phishingclub/sso"
|
|
"github.com/phishingclub/phishingclub/vo"
|
|
)
|
|
|
|
type SSO struct {
|
|
Common
|
|
OptionsService *Option
|
|
UserService *User
|
|
SessionService *Session
|
|
MSALClient *confidential.Client
|
|
OIDCClient *sso.OIDCClient
|
|
}
|
|
|
|
type MsGraphUserInfo struct {
|
|
DisplayName string `json:"displayName"` // Full name
|
|
Email string `json:"mail"` // Primary email
|
|
UserPrincipalName string `json:"userPrincipalName"` // Often email or login
|
|
GivenName string `json:"givenName"` // First name
|
|
Surname string `json:"surname"` // Last name
|
|
ID string `json:"id"` // Unique Azure AD ID
|
|
}
|
|
|
|
// Get is the auth protected method for getting SSO details
|
|
func (s *SSO) Get(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
) (*model.SSOOption, error) {
|
|
ae := NewAuditEvent("SSO.Get", session)
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
|
s.LogAuthError(err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
if !isAuthorized {
|
|
s.AuditLogNotAuthorized(ae)
|
|
return nil, errs.ErrAuthorizationFailed
|
|
}
|
|
return s.GetSSOOptionWithoutAuth(ctx)
|
|
}
|
|
|
|
// Upsert upserts SSO config it also replaces the in memory SSO configuration
|
|
func (s *SSO) Upsert(
|
|
ctx context.Context,
|
|
session *model.Session,
|
|
ssoOpt *model.SSOOption,
|
|
) error {
|
|
ae := NewAuditEvent("SSO.Upsert", session)
|
|
// check permissions
|
|
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
|
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
|
s.LogAuthError(err)
|
|
return errs.Wrap(err)
|
|
}
|
|
if !isAuthorized {
|
|
s.AuditLogNotAuthorized(ae)
|
|
return errs.ErrAuthorizationFailed
|
|
}
|
|
// 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 {
|
|
ssoOpt.ClientID = *vo.NewEmptyOptionalString64()
|
|
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 {
|
|
return errs.Wrap(err)
|
|
}
|
|
err = s.OptionsService.SetOptionByKey(ctx, session, opt)
|
|
if err != nil {
|
|
s.Logger.Errorw("failed to upsert sso option", "error", err)
|
|
return errs.Wrap(err)
|
|
}
|
|
s.AuditLogAuthorized(ae)
|
|
// swap the in memory clients only after the new config is persisted
|
|
s.MSALClient = msalClient
|
|
s.OIDCClient = oidcClient
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *SSO) GetSSOOptionWithoutAuth(ctx context.Context) (*model.SSOOption, error) {
|
|
opt, err := s.OptionsService.GetOptionWithoutAuth(ctx, data.OptionKeyAdminSSOLogin)
|
|
if err != nil {
|
|
s.Logger.Errorw("failed to get sso option",
|
|
"key", data.OptionKeyAdminSSOLogin,
|
|
"error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
ssoOpt, err := model.NewSSOOptionFromJSON([]byte(opt.Value.String()))
|
|
if err != nil {
|
|
s.Logger.Errorw("failed to unmarshall sso option", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
return ssoOpt, nil
|
|
}
|
|
|
|
func (s *SSO) EntreIDLogin(ctx context.Context) (string, error) {
|
|
// check if sso is enabled
|
|
ssoOpt, err := s.GetSSOOptionWithoutAuth(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !ssoOpt.Enabled {
|
|
s.Logger.Debugf("SSO login URL visited but it is disabed")
|
|
return "", errs.Wrap(errs.ErrSSODisabled)
|
|
}
|
|
// the MSALCLient is set on application start up
|
|
// and when a upsert is done, replacing the old client with new details
|
|
if s.MSALClient == nil {
|
|
return "", errs.Wrap(errors.New("no MSAL client"))
|
|
}
|
|
authURL, err := s.MSALClient.AuthCodeURL(
|
|
ctx,
|
|
ssoOpt.ClientID.String(),
|
|
ssoOpt.RedirectURL.String(),
|
|
[]string{"https://graph.microsoft.com/User.Read"},
|
|
)
|
|
if err != nil {
|
|
return "", errs.Wrap(err)
|
|
}
|
|
return authURL, nil
|
|
}
|
|
|
|
// EntreIDCallBack checks if the callback is OK then requests user details from the graph API
|
|
func (s *SSO) HandlEntraIDCallback(
|
|
g *gin.Context,
|
|
code string,
|
|
) (*model.Session, error) {
|
|
ssoOpt, err := s.GetSSOOptionWithoutAuth(g)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ssoOpt.Enabled {
|
|
return nil, errs.Wrap(errs.ErrSSODisabled)
|
|
}
|
|
if s.MSALClient == nil {
|
|
return nil, errors.New("no msal client in memory")
|
|
}
|
|
result, err := s.MSALClient.AcquireTokenByAuthCode(
|
|
context.Background(),
|
|
code,
|
|
ssoOpt.RedirectURL.String(),
|
|
[]string{"User.Read"},
|
|
)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
userInfo, err := s.getMsGraphMe(g, result.AccessToken)
|
|
if err != nil {
|
|
s.Logger.Debugw("failed to get /me graph info", "error", err)
|
|
return nil, err
|
|
}
|
|
// validate required fields
|
|
if userInfo.Email == "" && userInfo.UserPrincipalName == "" {
|
|
err := errors.New("no email provided from SSO")
|
|
s.Logger.Debugw("no email or userPrincipalName from SSO", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
// determine email (prefer mail over UPN)
|
|
email := userInfo.Email
|
|
if email == "" {
|
|
email = userInfo.UserPrincipalName
|
|
}
|
|
// determine name
|
|
name := userInfo.DisplayName
|
|
if name == "" {
|
|
name = strings.TrimSpace(fmt.Sprintf("%s %s", userInfo.GivenName, userInfo.Surname))
|
|
}
|
|
if name == "" {
|
|
// Fallback to email prefix if no name available
|
|
name = strings.Split(email, "@")[0]
|
|
}
|
|
userID, err := s.UserService.CreateFromSSO(g, name, email, userInfo.ID)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
if userID == nil {
|
|
return nil, errs.Wrap(errors.New("user ID is unexpectedly nil"))
|
|
}
|
|
// get the user and create a session
|
|
user, err := s.UserService.GetByIDWithoutAuth(g, userID)
|
|
if err != nil {
|
|
s.Logger.Debugf("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.Debugf("failed to create session from SSO", "error", err)
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
func (s *SSO) getMsGraphMe(ctx context.Context, accessToken string) (*MsGraphUserInfo, error) {
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://graph.microsoft.com/v1.0/me", nil)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
|
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, errs.Wrap(fmt.Errorf("graph API returned status %d", resp.StatusCode))
|
|
}
|
|
|
|
// Read and log raw response
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
s.Logger.Debugw("Raw Microsoft Graph response", "body", string(body))
|
|
|
|
var userInfo MsGraphUserInfo
|
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
|
return nil, errs.Wrap(err)
|
|
}
|
|
|
|
s.Logger.Debugw("Parsed user info",
|
|
"id", userInfo.ID,
|
|
"email", userInfo.Email,
|
|
"displayName", userInfo.DisplayName,
|
|
"userPrincipalName", userInfo.UserPrincipalName,
|
|
)
|
|
|
|
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
|
|
}
|