c1cc781279
- argon2id (t=3, m=64MiB, p=2) PHC hashing honoring embedded params - token-bucket rate limiting (10/15min per IP+email) on login/register - opaque 32B session tokens in Mongo, 30-day sliding expiry, logout-all - CSRF double-submit cookie/header on authenticated mutations - bootstrap admin from env with forced first-login password change - requireAuth/requireRole middleware with disabled-account enforcement - OIDC code flow with PKCE: lazy discovery, account linking only on verified email, auto-created developer accounts - unit tests (RBAC matrix, CSRF, password, rate limiter) + integration suite covering the full auth matrix incl. an in-test fake IdP Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
174 lines
4.9 KiB
Go
174 lines
4.9 KiB
Go
package httpx
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"bountyboard/internal/domain"
|
|
"bountyboard/internal/store"
|
|
"bountyboard/internal/ulid"
|
|
)
|
|
|
|
const (
|
|
oidcStateCookie = "bb_oidc_state"
|
|
oidcVerifierCookie = "bb_oidc_verifier"
|
|
oidcCookieTTL = 300 // seconds; the hop to the IdP and back
|
|
)
|
|
|
|
func (s *Server) oidcFlowCookie(name, value string) *http.Cookie {
|
|
maxAge := oidcCookieTTL
|
|
if value == "" {
|
|
maxAge = -1
|
|
}
|
|
return &http.Cookie{
|
|
Name: name, Value: value, Path: "/api/v1/auth/oidc",
|
|
MaxAge: maxAge, HttpOnly: true, Secure: s.cfg.CookieSecure(),
|
|
SameSite: http.SameSiteLaxMode,
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
|
if !s.oidc.Enabled() {
|
|
writeError(w, http.StatusNotFound, "oidc_disabled", "SSO is not configured")
|
|
return
|
|
}
|
|
authURL, state, verifier, err := s.oidc.AuthURL(r.Context())
|
|
if err != nil {
|
|
s.internalError(w, r, "oidc auth url", err)
|
|
return
|
|
}
|
|
http.SetCookie(w, s.oidcFlowCookie(oidcStateCookie, state))
|
|
http.SetCookie(w, s.oidcFlowCookie(oidcVerifierCookie, verifier))
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
// loginRedirect sends browser-flow errors back to the login page (the API
|
|
// has no JSON consumer mid-redirect).
|
|
func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request, errCode string) {
|
|
target := "/login"
|
|
if errCode != "" {
|
|
target += "?error=" + url.QueryEscape(errCode)
|
|
}
|
|
http.Redirect(w, r, target, http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
|
if !s.oidc.Enabled() {
|
|
writeError(w, http.StatusNotFound, "oidc_disabled", "SSO is not configured")
|
|
return
|
|
}
|
|
// Always clear the one-shot flow cookies.
|
|
defer func() {
|
|
http.SetCookie(w, s.oidcFlowCookie(oidcStateCookie, ""))
|
|
http.SetCookie(w, s.oidcFlowCookie(oidcVerifierCookie, ""))
|
|
}()
|
|
|
|
stateCookie, err1 := r.Cookie(oidcStateCookie)
|
|
verifierCookie, err2 := r.Cookie(oidcVerifierCookie)
|
|
if err1 != nil || err2 != nil || stateCookie.Value == "" ||
|
|
r.URL.Query().Get("state") != stateCookie.Value {
|
|
s.loginRedirect(w, r, "oidc_state_mismatch")
|
|
return
|
|
}
|
|
code := r.URL.Query().Get("code")
|
|
if code == "" {
|
|
s.loginRedirect(w, r, "oidc_denied")
|
|
return
|
|
}
|
|
|
|
claims, err := s.oidc.Exchange(r.Context(), code, verifierCookie.Value)
|
|
if err != nil {
|
|
s.log.Error("oidc exchange", "err", err, "requestId", RequestID(r.Context()))
|
|
s.loginRedirect(w, r, "oidc_failed")
|
|
return
|
|
}
|
|
|
|
u, err := s.resolveOIDCUser(r, claims.Issuer, claims.Subject, claims.Email, claims.EmailVerified, claims.Name)
|
|
if err != nil {
|
|
var code *oidcLoginError
|
|
if errors.As(err, &code) {
|
|
s.loginRedirect(w, r, code.code)
|
|
return
|
|
}
|
|
s.internalError(w, r, "oidc user resolution", err)
|
|
return
|
|
}
|
|
|
|
sess, csrf, err := s.newSessionFor(r, u)
|
|
if err != nil {
|
|
s.internalError(w, r, "create session", err)
|
|
return
|
|
}
|
|
s.setAuthCookies(w, sess.Token, csrf)
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
}
|
|
|
|
type oidcLoginError struct{ code string }
|
|
|
|
func (e *oidcLoginError) Error() string { return "oidc login rejected: " + e.code }
|
|
|
|
// resolveOIDCUser implements §7 linking: by (issuer,subject) first, then by
|
|
// verified email, otherwise a fresh developer account.
|
|
func (s *Server) resolveOIDCUser(r *http.Request, issuer, subject, email string, emailVerified bool, name string) (*domain.User, error) {
|
|
ctx := r.Context()
|
|
|
|
u, err := s.store.UserByOIDC(ctx, issuer, subject)
|
|
switch {
|
|
case err == nil:
|
|
if u.Disabled {
|
|
return nil, &oidcLoginError{code: "account_disabled"}
|
|
}
|
|
return u, nil
|
|
case !errors.Is(err, store.ErrNotFound):
|
|
return nil, err
|
|
}
|
|
|
|
email = store.NormalizeEmail(email)
|
|
if email == "" {
|
|
return nil, &oidcLoginError{code: "oidc_no_email"}
|
|
}
|
|
|
|
existing, err := s.store.UserByEmail(ctx, email)
|
|
switch {
|
|
case err == nil:
|
|
// Linking an SSO identity to a pre-existing local account requires
|
|
// the IdP to vouch for the address, otherwise anyone controlling the
|
|
// IdP account could take over the local one.
|
|
if !emailVerified {
|
|
return nil, &oidcLoginError{code: "oidc_email_unverified"}
|
|
}
|
|
if existing.Disabled {
|
|
return nil, &oidcLoginError{code: "account_disabled"}
|
|
}
|
|
if err := s.store.LinkOIDC(ctx, existing.ID, issuer, subject); err != nil {
|
|
return nil, err
|
|
}
|
|
existing.Auth.OIDC = &domain.OIDCAuth{Issuer: issuer, Subject: subject}
|
|
return existing, nil
|
|
case !errors.Is(err, store.ErrNotFound):
|
|
return nil, err
|
|
}
|
|
|
|
if strings.TrimSpace(name) == "" {
|
|
name = email
|
|
}
|
|
u = &domain.User{
|
|
ID: ulid.New(),
|
|
Email: email,
|
|
Name: name,
|
|
Roles: domain.Roles{Developer: true}, // first OIDC login = developer (§7)
|
|
Auth: domain.Auth{OIDC: &domain.OIDCAuth{Issuer: issuer, Subject: subject}},
|
|
Settings: domain.UserSettings{
|
|
Theme: "light",
|
|
Notifications: domain.NotificationPrefs{Email: true, InApp: true},
|
|
},
|
|
}
|
|
if err := s.store.CreateUser(ctx, u); err != nil {
|
|
return nil, err
|
|
}
|
|
s.metrics.Inc("auth_oidc_signups_total", 1)
|
|
return u, nil
|
|
}
|