509 lines
15 KiB
Go
509 lines
15 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"github.com/example/sessionguard/internal/model"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
const backchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
|
|
|
|
type AccessSessionStore interface {
|
|
PutAuthSession(model.AuthSession) error
|
|
GetAuthSession(hash string) (model.AuthSession, bool)
|
|
DeleteAuthSession(hash string) error
|
|
RevokeAuthSessions(sid, sub string) (int, error)
|
|
CleanupAuthSessions(time.Time) error
|
|
}
|
|
|
|
type accessPending struct {
|
|
Nonce string
|
|
CodeVerifier string
|
|
ReturnURL string
|
|
Exp time.Time
|
|
}
|
|
|
|
type AccessManager struct {
|
|
cfg model.AccessAuthConfig
|
|
provider *oidc.Provider
|
|
verifier *oidc.IDTokenVerifier
|
|
logoutVer *oidc.IDTokenVerifier
|
|
oauth oauth2.Config
|
|
sessions AccessSessionStore
|
|
endSession string
|
|
mu sync.Mutex
|
|
pending map[string]accessPending
|
|
logoutSeen map[string]time.Time
|
|
}
|
|
|
|
func NewAccess(ctx context.Context, cfg model.AccessAuthConfig, sessions AccessSessionStore) (*AccessManager, error) {
|
|
if !cfg.Enabled {
|
|
return nil, nil
|
|
}
|
|
if sessions == nil {
|
|
return nil, errors.New("access auth session store is required")
|
|
}
|
|
p, err := oidc.NewProvider(ctx, strings.TrimRight(cfg.Issuer, "/"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var discovery struct {
|
|
EndSessionEndpoint string `json:"end_session_endpoint"`
|
|
}
|
|
_ = p.Claims(&discovery)
|
|
return &AccessManager{
|
|
cfg: cfg,
|
|
provider: p,
|
|
verifier: p.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
|
// Back-channel logout tokens are not ID tokens and may omit exp. We
|
|
// still verify issuer, audience and signature, then validate the
|
|
// logout-specific claims below.
|
|
logoutVer: p.Verifier(&oidc.Config{ClientID: cfg.ClientID, SkipExpiryCheck: true}),
|
|
oauth: oauth2.Config{
|
|
ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret,
|
|
Endpoint: p.Endpoint(), RedirectURL: cfg.RedirectURL,
|
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"},
|
|
},
|
|
sessions: sessions,
|
|
endSession: discovery.EndSessionEndpoint,
|
|
pending: map[string]accessPending{},
|
|
logoutSeen: map[string]time.Time{},
|
|
}, nil
|
|
}
|
|
|
|
func (m *AccessManager) Register(mux *http.ServeMux) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
mux.HandleFunc("/auth/verify", m.Verify)
|
|
mux.HandleFunc("GET /auth/login", m.Login)
|
|
mux.HandleFunc("GET /auth/oidc/callback", m.Callback)
|
|
mux.HandleFunc("GET /auth/logout", m.Logout)
|
|
mux.HandleFunc("POST /auth/logout", m.Logout)
|
|
mux.HandleFunc("POST /auth/backchannel-logout", m.BackchannelLogout)
|
|
mux.HandleFunc("GET /auth/status", m.Status)
|
|
}
|
|
|
|
func (m *AccessManager) Login(w http.ResponseWriter, r *http.Request) {
|
|
_ = m.sessions.CleanupAuthSessions(time.Now().UTC())
|
|
target := m.validReturnURL(r.URL.Query().Get("return"))
|
|
state, nonce := randomAccessToken(24), randomAccessToken(24)
|
|
verifier := oauth2.GenerateVerifier()
|
|
m.mu.Lock()
|
|
m.prunePendingLocked(time.Now())
|
|
m.pending[state] = accessPending{Nonce: nonce, CodeVerifier: verifier, ReturnURL: target, Exp: time.Now().Add(5 * time.Minute)}
|
|
m.mu.Unlock()
|
|
|
|
// One state cookie per login flow avoids the common multi-tab race where a
|
|
// second login overwrites the first flow's single state cookie.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: stateCookieName(state), Value: state, Path: m.externalCallbackPath(),
|
|
HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
|
|
MaxAge: 300,
|
|
})
|
|
http.Redirect(w, r, m.oauth.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier)), http.StatusFound)
|
|
}
|
|
|
|
func (m *AccessManager) Callback(w http.ResponseWriter, r *http.Request) {
|
|
if e := r.URL.Query().Get("error"); e != "" {
|
|
http.Error(w, "OIDC: "+e, http.StatusUnauthorized)
|
|
return
|
|
}
|
|
state := strings.TrimSpace(r.URL.Query().Get("state"))
|
|
if state == "" {
|
|
http.Error(w, "missing OIDC state", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
cookieName := stateCookieName(state)
|
|
c, err := r.Cookie(cookieName)
|
|
if err != nil || c.Value != state {
|
|
http.Error(w, "OIDC state is not bound to this browser", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
clearCookie(w, cookieName, "", m.externalCallbackPath(), m.cfg.SecureCookie)
|
|
|
|
m.mu.Lock()
|
|
p, ok := m.pending[state]
|
|
delete(m.pending, state)
|
|
m.mu.Unlock()
|
|
if !ok || time.Now().After(p.Exp) {
|
|
http.Error(w, "invalid or expired OIDC state", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tok, err := m.oauth.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(p.CodeVerifier))
|
|
if err != nil {
|
|
http.Error(w, "OIDC token exchange failed", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
rawIDToken, ok := tok.Extra("id_token").(string)
|
|
if !ok || strings.TrimSpace(rawIDToken) == "" {
|
|
http.Error(w, "missing id_token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
idToken, err := m.verifier.Verify(r.Context(), rawIDToken)
|
|
if err != nil {
|
|
http.Error(w, "invalid id_token", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if idToken.Nonce != p.Nonce {
|
|
http.Error(w, "invalid OIDC nonce", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var claims map[string]any
|
|
if err := idToken.Claims(&claims); err != nil {
|
|
http.Error(w, "invalid OIDC claims", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
username := claimString(claims, m.cfg.UsernameClaim)
|
|
if username == "" {
|
|
// Pocket ID documents preferred_username and it is a safer Guacamole
|
|
// identity than display-name. Never invent a fallback identity.
|
|
username = claimString(claims, "preferred_username")
|
|
}
|
|
if username == "" {
|
|
http.Error(w, "OIDC token has no usable username claim", http.StatusForbidden)
|
|
return
|
|
}
|
|
groups := claimStrings(claims, "groups")
|
|
if !allowedGroups(groups, m.cfg.AllowedGroups) {
|
|
http.Error(w, "user is not in an allowed access group", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
browserToken := randomAccessToken(32)
|
|
now := time.Now().UTC()
|
|
sess := model.AuthSession{
|
|
ID: randomAccessToken(12), TokenHash: hashAccessToken(browserToken), Subject: idToken.Subject,
|
|
SID: claimString(claims, "sid"), Username: username,
|
|
Email: claimString(claims, "email"), Name: claimString(claims, "name"),
|
|
Groups: groups, IDToken: rawIDToken, CreatedAt: now,
|
|
ExpiresAt: now.Add(time.Duration(m.cfg.SessionHours) * time.Hour),
|
|
}
|
|
if err := m.sessions.PutAuthSession(sess); err != nil {
|
|
http.Error(w, "could not create access session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
m.setSessionCookie(w, browserToken, int(time.Until(sess.ExpiresAt).Seconds()))
|
|
http.Redirect(w, r, p.ReturnURL, http.StatusFound)
|
|
}
|
|
|
|
func (m *AccessManager) Verify(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := m.sessionFromRequest(r)
|
|
if !ok {
|
|
target := m.forwardedTarget(r)
|
|
http.Redirect(w, r, m.loginURL(target), http.StatusFound)
|
|
return
|
|
}
|
|
// These are the only identity headers Traefik should copy to Guacamole.
|
|
// authResponseHeaders replaces conflicting client-provided values.
|
|
w.Header().Set("X-Guacamole-User", sess.Username)
|
|
w.Header().Set("X-SessionGuard-User", sess.Username)
|
|
if sess.Email != "" {
|
|
w.Header().Set("X-SessionGuard-Email", sess.Email)
|
|
}
|
|
if len(sess.Groups) > 0 {
|
|
w.Header().Set("X-SessionGuard-Groups", strings.Join(sess.Groups, ","))
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (m *AccessManager) Status(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := m.sessionFromRequest(r)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
if !ok {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"authenticated":false}`))
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"authenticated": true, "username": sess.Username, "email": sess.Email,
|
|
"groups": sess.Groups, "expires_at": sess.ExpiresAt,
|
|
})
|
|
}
|
|
|
|
func (m *AccessManager) Logout(w http.ResponseWriter, r *http.Request) {
|
|
sess, _ := m.sessionFromRequest(r)
|
|
if c, err := r.Cookie(m.cfg.CookieName); err == nil {
|
|
_ = m.sessions.DeleteAuthSession(hashAccessToken(c.Value))
|
|
}
|
|
clearCookie(w, m.cfg.CookieName, m.cfg.CookieDomain, "/", m.cfg.SecureCookie)
|
|
|
|
target := strings.TrimSpace(m.cfg.LogoutRedirectURL)
|
|
if target == "" {
|
|
target = m.validReturnURL(r.URL.Query().Get("return"))
|
|
}
|
|
if m.endSession == "" {
|
|
http.Redirect(w, r, target, http.StatusFound)
|
|
return
|
|
}
|
|
u, err := url.Parse(m.endSession)
|
|
if err != nil {
|
|
http.Redirect(w, r, target, http.StatusFound)
|
|
return
|
|
}
|
|
q := u.Query()
|
|
q.Set("client_id", m.cfg.ClientID)
|
|
if sess.IDToken != "" {
|
|
q.Set("id_token_hint", sess.IDToken)
|
|
}
|
|
if target != "" {
|
|
q.Set("post_logout_redirect_uri", target)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
http.Redirect(w, r, u.String(), http.StatusFound)
|
|
}
|
|
|
|
func (m *AccessManager) BackchannelLogout(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
raw := strings.TrimSpace(r.Form.Get("logout_token"))
|
|
if raw == "" {
|
|
http.Error(w, "missing logout_token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
tok, err := m.logoutVer.Verify(r.Context(), raw)
|
|
if err != nil {
|
|
http.Error(w, "invalid logout_token", http.StatusBadRequest)
|
|
return
|
|
}
|
|
var claims struct {
|
|
SID string `json:"sid"`
|
|
Sub string `json:"sub"`
|
|
Nonce string `json:"nonce"`
|
|
JTI string `json:"jti"`
|
|
IAT int64 `json:"iat"`
|
|
Events map[string]json.RawMessage `json:"events"`
|
|
}
|
|
if err := tok.Claims(&claims); err != nil {
|
|
http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if claims.Nonce != "" || claims.Events == nil {
|
|
http.Error(w, "invalid logout_token claims", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, ok := claims.Events[backchannelLogoutEvent]; !ok {
|
|
http.Error(w, "missing backchannel logout event", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if claims.SID == "" && claims.Sub == "" {
|
|
http.Error(w, "logout_token has neither sid nor sub", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if strings.TrimSpace(claims.JTI) == "" {
|
|
http.Error(w, "logout_token has no jti", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if claims.IAT == 0 || time.Since(time.Unix(claims.IAT, 0)) > 10*time.Minute || time.Until(time.Unix(claims.IAT, 0)) > 5*time.Minute {
|
|
http.Error(w, "logout_token iat outside allowed window", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !m.acceptLogoutJTI(claims.JTI, time.Now()) {
|
|
http.Error(w, "logout_token replayed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if _, err := m.sessions.RevokeAuthSessions(claims.SID, claims.Sub); err != nil {
|
|
http.Error(w, "could not revoke access session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (m *AccessManager) sessionFromRequest(r *http.Request) (model.AuthSession, bool) {
|
|
c, err := r.Cookie(m.cfg.CookieName)
|
|
if err != nil || strings.TrimSpace(c.Value) == "" {
|
|
return model.AuthSession{}, false
|
|
}
|
|
return m.sessions.GetAuthSession(hashAccessToken(c.Value))
|
|
}
|
|
|
|
func (m *AccessManager) setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: m.cfg.CookieName, Value: token, Path: "/", Domain: m.cfg.CookieDomain,
|
|
HttpOnly: true, Secure: m.cfg.SecureCookie, SameSite: http.SameSiteLaxMode,
|
|
MaxAge: maxAge,
|
|
})
|
|
}
|
|
|
|
func (m *AccessManager) forwardedTarget(r *http.Request) string {
|
|
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
|
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
|
uri := strings.TrimSpace(r.Header.Get("X-Forwarded-Uri"))
|
|
if proto == "" {
|
|
proto = "https"
|
|
}
|
|
if uri == "" {
|
|
uri = "/"
|
|
}
|
|
if host == "" {
|
|
return m.cfg.LogoutRedirectURL
|
|
}
|
|
return m.validReturnURL(proto + "://" + host + uri)
|
|
}
|
|
|
|
func (m *AccessManager) externalCallbackPath() string {
|
|
u, err := url.Parse(m.cfg.RedirectURL)
|
|
if err != nil || strings.TrimSpace(u.Path) == "" {
|
|
return "/auth/oidc/callback"
|
|
}
|
|
return u.Path
|
|
}
|
|
|
|
func (m *AccessManager) loginURL(target string) string {
|
|
u, _ := url.Parse(m.cfg.RedirectURL)
|
|
// Preserve an external Traefik prefix such as /_sessionguard. The Master
|
|
// itself sees /auth/* after StripPrefix, while the browser must be sent to
|
|
// the externally routable prefixed URL.
|
|
base := strings.TrimSuffix(u.Path, "/oidc/callback")
|
|
if base == u.Path {
|
|
base = strings.TrimSuffix(u.Path, "/")
|
|
}
|
|
u.Path = base + "/login"
|
|
u.RawQuery = url.Values{"return": []string{target}}.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
func (m *AccessManager) validReturnURL(raw string) string {
|
|
fallback := strings.TrimSpace(m.cfg.LogoutRedirectURL)
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || u.Scheme != "https" || u.Hostname() == "" {
|
|
return fallback
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
for _, allowed := range m.cfg.AllowedHosts {
|
|
allowed = strings.ToLower(strings.TrimSpace(allowed))
|
|
if allowed == host {
|
|
return u.String()
|
|
}
|
|
if strings.HasPrefix(allowed, "*.") && strings.HasSuffix(host, allowed[1:]) {
|
|
return u.String()
|
|
}
|
|
}
|
|
// If no allow-list was provided, constrain redirects to the configured
|
|
// cookie domain or, for host-only cookies, to logout_redirect_url. This is
|
|
// still closed against arbitrary open redirects.
|
|
if len(m.cfg.AllowedHosts) == 0 {
|
|
if m.cfg.CookieDomain != "" {
|
|
d := strings.TrimPrefix(strings.ToLower(m.cfg.CookieDomain), ".")
|
|
if host == d || strings.HasSuffix(host, "."+d) {
|
|
return u.String()
|
|
}
|
|
}
|
|
if f, err := url.Parse(fallback); err == nil && strings.EqualFold(f.Hostname(), host) {
|
|
return u.String()
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func (m *AccessManager) acceptLogoutJTI(jti string, now time.Time) bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for k, exp := range m.logoutSeen {
|
|
if !now.Before(exp) {
|
|
delete(m.logoutSeen, k)
|
|
}
|
|
}
|
|
if _, exists := m.logoutSeen[jti]; exists {
|
|
return false
|
|
}
|
|
m.logoutSeen[jti] = now.Add(15 * time.Minute)
|
|
return true
|
|
}
|
|
|
|
func (m *AccessManager) prunePendingLocked(now time.Time) {
|
|
for k, p := range m.pending {
|
|
if now.After(p.Exp) {
|
|
delete(m.pending, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func allowedGroups(got, allowed []string) bool {
|
|
if len(allowed) == 0 {
|
|
return true
|
|
}
|
|
set := map[string]struct{}{}
|
|
for _, g := range got {
|
|
set[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
|
|
}
|
|
for _, g := range allowed {
|
|
if _, ok := set[strings.ToLower(strings.TrimSpace(g))]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func claimString(claims map[string]any, key string) string {
|
|
v, ok := claims[key]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func claimStrings(claims map[string]any, key string) []string {
|
|
v, ok := claims[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch x := v.(type) {
|
|
case []any:
|
|
out := make([]string, 0, len(x))
|
|
for _, e := range x {
|
|
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
|
|
out = append(out, strings.TrimSpace(s))
|
|
}
|
|
}
|
|
return out
|
|
case []string:
|
|
return append([]string(nil), x...)
|
|
case string:
|
|
if strings.TrimSpace(x) != "" {
|
|
return []string{strings.TrimSpace(x)}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func randomAccessToken(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
func hashAccessToken(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
|
|
func stateCookieName(state string) string {
|
|
if len(state) > 16 {
|
|
state = state[:16]
|
|
}
|
|
return "sg_access_state_" + state
|
|
}
|
|
func clearCookie(w http.ResponseWriter, name, domain, path string, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Domain: domain, Path: path, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
|
|
}
|