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>
252 lines
6.5 KiB
Go
252 lines
6.5 KiB
Go
package controller
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/phishingclub/phishingclub/data"
|
|
"github.com/phishingclub/phishingclub/errs"
|
|
"github.com/phishingclub/phishingclub/model"
|
|
"github.com/phishingclub/phishingclub/service"
|
|
"github.com/phishingclub/phishingclub/sso"
|
|
)
|
|
|
|
const (
|
|
ssoStateCookieKey = "sso_state"
|
|
ssoNonceCookieKey = "sso_nonce"
|
|
ssoVerifierCookieKey = "sso_pkce_verifier"
|
|
)
|
|
|
|
// SSO the single sign on controller
|
|
type SSO struct {
|
|
Common
|
|
*service.SSO
|
|
}
|
|
|
|
// Upsert upserts a SSO configuration
|
|
func (s *SSO) Upsert(g *gin.Context) {
|
|
session, _, ok := s.handleSession(g)
|
|
if !ok {
|
|
return
|
|
}
|
|
// parse request
|
|
var request model.SSOOption
|
|
if ok := s.handleParseRequest(g, &request); !ok {
|
|
return
|
|
}
|
|
// handle upsert
|
|
err := s.SSO.Upsert(
|
|
g.Request.Context(),
|
|
session,
|
|
&request,
|
|
)
|
|
// handle responses
|
|
if ok := s.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
s.Response.OK(g, gin.H{})
|
|
}
|
|
|
|
func (s *SSO) IsEnabled(g *gin.Context) {
|
|
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, 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) {
|
|
authURL, err := s.SSO.EntreIDLogin(g)
|
|
if errors.Is(err, errs.ErrSSODisabled) {
|
|
s.Response.BadRequest(g)
|
|
return
|
|
}
|
|
if ok := s.handleErrors(g, err); !ok {
|
|
s.Response.BadRequest(g)
|
|
return
|
|
}
|
|
|
|
stateBytes := make([]byte, 32)
|
|
if _, err := rand.Read(stateBytes); err != nil {
|
|
s.Response.ServerError(g)
|
|
return
|
|
}
|
|
state := hex.EncodeToString(stateBytes)
|
|
|
|
http.SetCookie(g.Writer, &http.Cookie{
|
|
Name: ssoStateCookieKey,
|
|
Value: state,
|
|
Path: "/",
|
|
MaxAge: int(5 * time.Minute / time.Second),
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
|
|
g.Redirect(http.StatusTemporaryRedirect, authURL+"&state="+state)
|
|
}
|
|
|
|
func (s *SSO) EntreIDCallBack(g *gin.Context) {
|
|
stateCookie, err := g.Request.Cookie(ssoStateCookieKey)
|
|
http.SetCookie(g.Writer, &http.Cookie{
|
|
Name: ssoStateCookieKey,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
stateParam := g.Query("state")
|
|
if err != 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.HandlEntraIDCallback(g, code)
|
|
if err != nil {
|
|
g.Redirect(http.StatusTemporaryRedirect, "/login?ssoAuthError=1")
|
|
return
|
|
}
|
|
if ok := s.handleErrors(g, err); !ok {
|
|
return
|
|
}
|
|
// Set the session in the cookie
|
|
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")
|
|
}
|