mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-03 19:07:58 +02:00
df555820f9
Add exclusive SSO login support. Signed-off-by: Ronni Skansing <rskansing@gmail.com>
182 lines
5.5 KiB
Go
182 lines
5.5 KiB
Go
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
|
|
}
|