feat: authentication with local accounts, sessions, CSRF, RBAC, and OIDC PKCE (phase 3)
- 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>
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user