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,51 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"bountyboard/internal/config"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
// EnsureBootstrapAdmin creates the initial admin from ADMIN_EMAIL /
|
||||
// ADMIN_INITIAL_PASSWORD when the users collection is empty (§3). The
|
||||
// password change is forced at first login.
|
||||
func EnsureBootstrapAdmin(ctx context.Context, st *store.Store, cfg *config.Config, log *slog.Logger) error {
|
||||
n, err := st.CountUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
if cfg.AdminEmail == "" || cfg.AdminInitialPassword == "" {
|
||||
log.Warn("users collection is empty but ADMIN_EMAIL/ADMIN_INITIAL_PASSWORD are not set; no admin bootstrapped")
|
||||
return nil
|
||||
}
|
||||
hash, err := HashPassword(cfg.AdminInitialPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash bootstrap password: %w", err)
|
||||
}
|
||||
u := &domain.User{
|
||||
ID: ulid.New(),
|
||||
Email: cfg.AdminEmail,
|
||||
Name: "Administrator",
|
||||
Roles: domain.Roles{Admin: true},
|
||||
Auth: domain.Auth{
|
||||
Local: &domain.LocalAuth{PasswordHash: hash, MustChange: true},
|
||||
},
|
||||
Settings: domain.UserSettings{
|
||||
Theme: "light",
|
||||
Notifications: domain.NotificationPrefs{Email: true, InApp: true},
|
||||
},
|
||||
}
|
||||
if err := st.CreateUser(ctx, u); err != nil {
|
||||
return fmt.Errorf("bootstrap admin: %w", err)
|
||||
}
|
||||
log.Info("bootstrap admin created; password change is forced at first login", "email", u.Email)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"bountyboard/internal/config"
|
||||
)
|
||||
|
||||
// OIDCClient implements the §7 SSO code flow with PKCE. The provider is
|
||||
// discovered lazily on first use (and retried on failure) so a temporarily
|
||||
// unreachable IdP cannot prevent the app from booting.
|
||||
type OIDCClient struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
provider *oidc.Provider
|
||||
}
|
||||
|
||||
// OIDCClaims is what the rest of the app needs from a verified ID token.
|
||||
type OIDCClaims struct {
|
||||
Issuer string
|
||||
Subject string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Name string
|
||||
}
|
||||
|
||||
func NewOIDCClient(cfg *config.Config, log *slog.Logger) *OIDCClient {
|
||||
return &OIDCClient{cfg: cfg, log: log}
|
||||
}
|
||||
|
||||
func (o *OIDCClient) Enabled() bool { return o.cfg.OIDC.Enabled() }
|
||||
|
||||
func (o *OIDCClient) getProvider(ctx context.Context) (*oidc.Provider, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
if o.provider != nil {
|
||||
return o.provider, nil
|
||||
}
|
||||
p, err := oidc.NewProvider(ctx, o.cfg.OIDC.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery for %s: %w", o.cfg.OIDC.IssuerURL, err)
|
||||
}
|
||||
o.provider = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (o *OIDCClient) oauthConfig(p *oidc.Provider) *oauth2.Config {
|
||||
return &oauth2.Config{
|
||||
ClientID: o.cfg.OIDC.ClientID,
|
||||
ClientSecret: o.cfg.OIDC.ClientSecret,
|
||||
Endpoint: p.Endpoint(),
|
||||
RedirectURL: o.cfg.AppBaseURL + "/api/v1/auth/oidc/callback",
|
||||
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
|
||||
}
|
||||
}
|
||||
|
||||
// AuthURL starts the flow: returns the IdP redirect plus the state and PKCE
|
||||
// verifier the caller must persist (short-lived cookies).
|
||||
func (o *OIDCClient) AuthURL(ctx context.Context) (url, state, verifier string, err error) {
|
||||
if !o.Enabled() {
|
||||
return "", "", "", errors.New("oidc is not configured")
|
||||
}
|
||||
p, err := o.getProvider(ctx)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
state = NewToken()
|
||||
verifier = oauth2.GenerateVerifier()
|
||||
url = o.oauthConfig(p).AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
return url, state, verifier, nil
|
||||
}
|
||||
|
||||
// Exchange swaps the code (with PKCE verifier) for tokens and returns the
|
||||
// verified ID-token claims.
|
||||
func (o *OIDCClient) Exchange(ctx context.Context, code, verifier string) (*OIDCClaims, error) {
|
||||
p, err := o.getProvider(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := o.oauthConfig(p).Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc code exchange: %w", err)
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return nil, errors.New("token response missing id_token")
|
||||
}
|
||||
idToken, err := p.Verifier(&oidc.Config{ClientID: o.cfg.OIDC.ClientID}).Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
var claims struct {
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
return &OIDCClaims{
|
||||
Issuer: idToken.Issuer,
|
||||
Subject: idToken.Subject,
|
||||
Email: claims.Email,
|
||||
EmailVerified: claims.EmailVerified,
|
||||
Name: claims.Name,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package auth implements authentication primitives: argon2id password
|
||||
// hashing, opaque token generation, login rate limiting, bootstrap admin
|
||||
// creation, and the OIDC login flow.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2id parameters fixed by spec §7: t=3, m=64MiB, p=2.
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // KiB
|
||||
argonThreads = 2
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
// MinPasswordLen is the minimum accepted password length.
|
||||
const MinPasswordLen = 8
|
||||
|
||||
// HashPassword returns a PHC-format argon2id hash.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("salt: %w", err)
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks password against a PHC argon2id hash in constant
|
||||
// time. The parameters embedded in the hash are honored so stored hashes
|
||||
// survive future parameter changes.
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("not an argon2id hash")
|
||||
}
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, fmt.Errorf("hash version: %w", err)
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return false, fmt.Errorf("unsupported argon2 version %d", version)
|
||||
}
|
||||
var m uint32
|
||||
var t uint32
|
||||
var p uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
||||
return false, fmt.Errorf("hash params: %w", err)
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("hash salt: %w", err)
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("hash key: %w", err)
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
func TestHashVerifyRoundTrip(t *testing.T) {
|
||||
hash, err := HashPassword("correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$") {
|
||||
t.Fatalf("hash %q does not embed the spec parameters", hash)
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword("correct horse battery staple", hash)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("correct password: ok=%v err=%v", ok, err)
|
||||
}
|
||||
ok, err = VerifyPassword("wrong password", hash)
|
||||
if err != nil || ok {
|
||||
t.Fatalf("wrong password: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashesAreSalted(t *testing.T) {
|
||||
a, _ := HashPassword("same")
|
||||
b, _ := HashPassword("same")
|
||||
if a == b {
|
||||
t.Fatal("two hashes of the same password must differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsGarbage(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"plaintext",
|
||||
"$bcrypt$whatever",
|
||||
"$argon2id$v=19$m=65536,t=3,p=2$!!!$AAA",
|
||||
"$argon2id$v=18$m=65536,t=3,p=2$c2FsdA$AAA",
|
||||
} {
|
||||
if ok, err := VerifyPassword("x", bad); ok || err == nil {
|
||||
t.Errorf("VerifyPassword(%q): ok=%v err=%v, want rejection", bad, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyHonorsEmbeddedParams(t *testing.T) {
|
||||
// A hash created with different (weaker) params must still verify:
|
||||
// params come from the hash string, not the current constants.
|
||||
salt := []byte("saltsaltsaltsalt")
|
||||
key := argon2.IDKey([]byte("pw"), salt, 1, 16*1024, 1, 32)
|
||||
legacy := fmt.Sprintf("$argon2id$v=19$m=16384,t=1,p=1$%s$%s",
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key))
|
||||
ok, err := VerifyPassword("pw", legacy)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("legacy-param hash: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTokenShape(t *testing.T) {
|
||||
a, b := NewToken(), NewToken()
|
||||
if a == b {
|
||||
t.Fatal("tokens must be unique")
|
||||
}
|
||||
if len(a) != 43 { // 32 bytes base64url, unpadded
|
||||
t.Fatalf("token length = %d, want 43", len(a))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RateLimiter is an in-memory token bucket per key (§7: 10 attempts per
|
||||
// 15 min per IP+email). Buckets refill continuously and idle entries are
|
||||
// swept opportunistically.
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
capacity float64
|
||||
window time.Duration
|
||||
now func() time.Time // injectable for tests
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func NewRateLimiter(attempts int, window time.Duration) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
buckets: make(map[string]*bucket),
|
||||
capacity: float64(attempts),
|
||||
window: window,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Allow consumes one attempt for key, reporting whether it was within the
|
||||
// limit.
|
||||
func (rl *RateLimiter) Allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := rl.now()
|
||||
rl.gcLocked(now)
|
||||
|
||||
b, ok := rl.buckets[key]
|
||||
if !ok {
|
||||
b = &bucket{tokens: rl.capacity, last: now}
|
||||
rl.buckets[key] = b
|
||||
}
|
||||
refill := now.Sub(b.last).Seconds() * (rl.capacity / rl.window.Seconds())
|
||||
b.tokens = min(rl.capacity, b.tokens+refill)
|
||||
b.last = now
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// gcLocked drops buckets that have fully refilled (idle for >= one window);
|
||||
// runs at most once per window to keep Allow O(1) amortized.
|
||||
func (rl *RateLimiter) gcLocked(now time.Time) {
|
||||
if now.Sub(rl.lastGC) < rl.window {
|
||||
return
|
||||
}
|
||||
rl.lastGC = now
|
||||
for k, b := range rl.buckets {
|
||||
if now.Sub(b.last) >= rl.window {
|
||||
delete(rl.buckets, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiterExhaustionAndRefill(t *testing.T) {
|
||||
rl := NewRateLimiter(10, 15*time.Minute)
|
||||
now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC)
|
||||
rl.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if !rl.Allow("ip|a@b.c") {
|
||||
t.Fatalf("attempt %d should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
if rl.Allow("ip|a@b.c") {
|
||||
t.Fatal("11th attempt must be blocked")
|
||||
}
|
||||
|
||||
// Other keys are unaffected.
|
||||
if !rl.Allow("ip|other@b.c") {
|
||||
t.Fatal("different key must have its own bucket")
|
||||
}
|
||||
|
||||
// After 1.5 minutes one token has refilled (10 per 15 min).
|
||||
now = now.Add(90 * time.Second)
|
||||
if !rl.Allow("ip|a@b.c") {
|
||||
t.Fatal("one attempt should have refilled")
|
||||
}
|
||||
if rl.Allow("ip|a@b.c") {
|
||||
t.Fatal("bucket must be empty again")
|
||||
}
|
||||
|
||||
// A full window refills completely, capped at capacity.
|
||||
now = now.Add(time.Hour)
|
||||
for i := 0; i < 10; i++ {
|
||||
if !rl.Allow("ip|a@b.c") {
|
||||
t.Fatalf("post-refill attempt %d should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
if rl.Allow("ip|a@b.c") {
|
||||
t.Fatal("refill must cap at capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterGC(t *testing.T) {
|
||||
rl := NewRateLimiter(10, 15*time.Minute)
|
||||
now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC)
|
||||
rl.now = func() time.Time { return now }
|
||||
|
||||
rl.Allow("stale")
|
||||
now = now.Add(16 * time.Minute)
|
||||
rl.Allow("fresh") // triggers gc
|
||||
rl.mu.Lock()
|
||||
_, staleExists := rl.buckets["stale"]
|
||||
rl.mu.Unlock()
|
||||
if staleExists {
|
||||
t.Fatal("idle bucket should have been swept")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// NewToken returns 32 bytes of cryptographic randomness, base64url-encoded —
|
||||
// used for session tokens, CSRF tokens, and OIDC state.
|
||||
func NewToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Same stance as ulid: a process that cannot read crypto/rand must
|
||||
// not mint security tokens.
|
||||
panic("auth: crypto/rand failed: " + err.Error())
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// Session is an opaque server-side session (§4.8). The random token is the
|
||||
// document id; the Mongo TTL index on expiresAt reaps stale rows.
|
||||
type Session struct {
|
||||
Token string `bson:"_id"`
|
||||
UserID string `bson:"userId"`
|
||||
CreatedAt time.Time `bson:"createdAt"`
|
||||
ExpiresAt time.Time `bson:"expiresAt"`
|
||||
RefreshedAt time.Time `bson:"refreshedAt"`
|
||||
IP string `bson:"ip"`
|
||||
UA string `bson:"ua"`
|
||||
}
|
||||
|
||||
// SessionTTL is the sliding session lifetime (§7).
|
||||
const SessionTTL = 30 * 24 * time.Hour
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package domain holds the core data model types and business rules shared
|
||||
// across handlers, stores, and workers. It has no Mongo or HTTP dependencies
|
||||
// beyond bson struct tags.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Roles struct {
|
||||
Admin bool `bson:"admin" json:"admin"`
|
||||
Consultant bool `bson:"consultant" json:"consultant"`
|
||||
Developer bool `bson:"developer" json:"developer"`
|
||||
}
|
||||
|
||||
// Has reports whether the role named r is set. Known names: admin,
|
||||
// consultant, developer.
|
||||
func (r Roles) Has(role string) bool {
|
||||
switch role {
|
||||
case "admin":
|
||||
return r.Admin
|
||||
case "consultant":
|
||||
return r.Consultant
|
||||
case "developer":
|
||||
return r.Developer
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type LocalAuth struct {
|
||||
PasswordHash string `bson:"passwordHash" json:"-"`
|
||||
MustChange bool `bson:"mustChange" json:"mustChange"`
|
||||
}
|
||||
|
||||
type OIDCAuth struct {
|
||||
Issuer string `bson:"issuer" json:"issuer"`
|
||||
Subject string `bson:"subject" json:"subject"`
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
Local *LocalAuth `bson:"local" json:"local,omitempty"`
|
||||
OIDC *OIDCAuth `bson:"oidc" json:"oidc,omitempty"`
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
Phone string `bson:"phone" json:"phone"`
|
||||
Location string `bson:"location" json:"location"`
|
||||
Links []string `bson:"links" json:"links"`
|
||||
}
|
||||
|
||||
type NotificationPrefs struct {
|
||||
Email bool `bson:"email" json:"email"`
|
||||
InApp bool `bson:"inApp" json:"inApp"`
|
||||
}
|
||||
|
||||
type UserSettings struct {
|
||||
Theme string `bson:"theme" json:"theme"`
|
||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
Email string `bson:"email" json:"email"`
|
||||
Name string `bson:"name" json:"name"`
|
||||
AvatarFileID string `bson:"avatarFileId,omitempty" json:"avatarFileId,omitempty"`
|
||||
Bio string `bson:"bio" json:"bio"`
|
||||
Contact Contact `bson:"contact" json:"contact"`
|
||||
Extra map[string]any `bson:"extra" json:"extra"`
|
||||
Roles Roles `bson:"roles" json:"roles"`
|
||||
Auth Auth `bson:"auth" json:"-"`
|
||||
Settings UserSettings `bson:"settings" json:"settings"`
|
||||
Disabled bool `bson:"disabled" json:"disabled"`
|
||||
LastSeenAt time.Time `bson:"lastSeenAt" json:"lastSeenAt"`
|
||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||
UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
|
||||
Version int64 `bson:"version" json:"version"`
|
||||
}
|
||||
|
||||
// MustChangePassword reports whether the user is locked to the
|
||||
// password-change flow.
|
||||
func (u *User) MustChangePassword() bool {
|
||||
return u.Auth.Local != nil && u.Auth.Local.MustChange
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/auth"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/ulid"
|
||||
)
|
||||
|
||||
func (s *Server) routesAuth(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister)
|
||||
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
mux.Handle("POST /api/v1/auth/logout", s.authed(s.handleLogout))
|
||||
mux.Handle("POST /api/v1/auth/logout-all", s.authed(s.handleLogoutAll))
|
||||
mux.Handle("POST /api/v1/auth/change-password", s.authed(s.handleChangePassword))
|
||||
mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe)))
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin)
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
|
||||
}
|
||||
|
||||
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validEmail(email string) bool {
|
||||
a, err := mail.ParseAddress(email)
|
||||
return err == nil && a.Address == email
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Email = store.NormalizeEmail(req.Email)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
switch {
|
||||
case !validEmail(req.Email):
|
||||
writeError(w, http.StatusBadRequest, "invalid_email", "a valid email address is required")
|
||||
return
|
||||
case req.Name == "":
|
||||
writeError(w, http.StatusBadRequest, "invalid_name", "name is required")
|
||||
return
|
||||
case len(req.Password) < auth.MinPasswordLen:
|
||||
writeError(w, http.StatusBadRequest, "weak_password",
|
||||
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
if !s.loginLimiter.Allow("register|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts, try again later")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
u := &domain.User{
|
||||
ID: ulid.New(),
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
// Open self-registration always creates a developer account (§3).
|
||||
Roles: domain.Roles{Developer: true},
|
||||
Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}},
|
||||
Settings: domain.UserSettings{
|
||||
Theme: "light",
|
||||
Notifications: domain.NotificationPrefs{Email: true, InApp: true},
|
||||
},
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicate) {
|
||||
writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "create user", err)
|
||||
return
|
||||
}
|
||||
s.metrics.Inc("auth_registrations_total", 1)
|
||||
s.startSession(w, r, u, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.Email = store.NormalizeEmail(req.Email)
|
||||
if !s.loginLimiter.Allow(ClientIP(r.Context()).String() + "|" + req.Email) {
|
||||
s.metrics.Inc("auth_rate_limited_total", 1)
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts, try again later")
|
||||
return
|
||||
}
|
||||
|
||||
denyInvalid := func() {
|
||||
s.metrics.Inc("auth_login_failures_total", 1)
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "invalid email or password")
|
||||
}
|
||||
u, err := s.store.UserByEmail(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
denyInvalid()
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "find user", err)
|
||||
return
|
||||
}
|
||||
if u.Auth.Local == nil {
|
||||
denyInvalid()
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(req.Password, u.Auth.Local.PasswordHash)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "verify password", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
denyInvalid()
|
||||
return
|
||||
}
|
||||
if u.Disabled {
|
||||
writeError(w, http.StatusForbidden, "account_disabled", "account is disabled")
|
||||
return
|
||||
}
|
||||
s.startSession(w, r, u, http.StatusOK)
|
||||
}
|
||||
|
||||
// newSessionFor persists a fresh session for u and returns it with a new
|
||||
// CSRF token.
|
||||
func (s *Server) newSessionFor(r *http.Request, u *domain.User) (*domain.Session, string, error) {
|
||||
now := time.Now().UTC()
|
||||
sess := &domain.Session{
|
||||
Token: auth.NewToken(),
|
||||
UserID: u.ID,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(domain.SessionTTL),
|
||||
RefreshedAt: now,
|
||||
IP: ClientIP(r.Context()).String(),
|
||||
UA: r.UserAgent(),
|
||||
}
|
||||
if err := s.store.CreateSession(r.Context(), sess); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := s.store.TouchLastSeen(r.Context(), u.ID); err != nil {
|
||||
s.log.Warn("touch lastSeenAt", "err", err)
|
||||
}
|
||||
s.metrics.Inc("auth_logins_total", 1)
|
||||
return sess, auth.NewToken(), nil
|
||||
}
|
||||
|
||||
// startSession mints session + CSRF cookies and writes the login response.
|
||||
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *domain.User, status int) {
|
||||
sess, csrf, err := s.newSessionFor(r, u)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setAuthCookies(w, sess.Token, csrf)
|
||||
writeJSON(w, status, map[string]any{
|
||||
"user": u,
|
||||
"mustChangePassword": u.MustChangePassword(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.DeleteSession(r.Context(), CurrentSession(r.Context()).Token); err != nil {
|
||||
s.internalError(w, r, "delete session", err)
|
||||
return
|
||||
}
|
||||
s.clearAuthCookies(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) {
|
||||
n, err := s.store.DeleteUserSessions(r.Context(), CurrentUser(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "delete sessions", err)
|
||||
return
|
||||
}
|
||||
s.clearAuthCookies(w)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"revoked": n})
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
u := CurrentUser(r.Context())
|
||||
if u.Auth.Local == nil {
|
||||
writeError(w, http.StatusBadRequest, "no_local_auth",
|
||||
"this account signs in via SSO and has no password")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < auth.MinPasswordLen {
|
||||
writeError(w, http.StatusBadRequest, "weak_password",
|
||||
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
ok, err := auth.VerifyPassword(req.CurrentPassword, u.Auth.Local.PasswordHash)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "verify password", err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetPassword(r.Context(), u.ID, hash, false); err != nil {
|
||||
s.internalError(w, r, "set password", err)
|
||||
return
|
||||
}
|
||||
// Revoke every other session: a password change is the recovery action
|
||||
// after credential leak.
|
||||
if _, err := s.store.DeleteUserSessionsExcept(r.Context(), u.ID, CurrentSession(r.Context()).Token); err != nil {
|
||||
s.log.Warn("revoke other sessions", "err", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
u := CurrentUser(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": u,
|
||||
"mustChangePassword": u.MustChangePassword(),
|
||||
"oidcEnabled": s.cfg.OIDC.Enabled(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//go:build integration
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/auth"
|
||||
"bountyboard/internal/config"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/metrics"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/testutil"
|
||||
)
|
||||
|
||||
// newAuthStack builds a full server backed by a per-run Mongo database.
|
||||
// extraEnv is applied before config.Load.
|
||||
func newAuthStack(t *testing.T, extraEnv map[string]string) (*httptest.Server, *Server, *store.Store, *config.Config) {
|
||||
t.Helper()
|
||||
db := testutil.MongoDB(t)
|
||||
if err := store.EnsureIndexes(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := &store.Store{Client: db.Client(), DB: db}
|
||||
|
||||
t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef")
|
||||
t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
for k, v := range extraEnv {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logOut := io.Writer(io.Discard)
|
||||
if os.Getenv("TEST_LOG") == "1" {
|
||||
logOut = os.Stderr
|
||||
}
|
||||
srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts, srv, st, cfg
|
||||
}
|
||||
|
||||
// client returns an http client with a cookie jar that does not follow
|
||||
// redirects (so OIDC hops can be inspected).
|
||||
func newClient(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Client{
|
||||
Jar: jar,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
}
|
||||
|
||||
func postJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if csrf != "" {
|
||||
req.Header.Set(csrfHeader, csrf)
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func bodyJSON(t *testing.T, resp *http.Response, dst any) {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func csrfFrom(t *testing.T, c *http.Client, baseURL string) string {
|
||||
t.Helper()
|
||||
u, _ := url.Parse(baseURL)
|
||||
for _, ck := range c.Jar.Cookies(u) {
|
||||
if ck.Name == csrfCookie {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func errCode(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
bodyJSON(t, resp, &env)
|
||||
return env.Error.Code
|
||||
}
|
||||
|
||||
func TestRegisterLoginLogoutFlow(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
c := newClient(t)
|
||||
|
||||
// invalid inputs
|
||||
for _, tc := range []struct {
|
||||
body map[string]string
|
||||
want string
|
||||
}{
|
||||
{map[string]string{"email": "not-an-email", "name": "X", "password": "longenough"}, "invalid_email"},
|
||||
{map[string]string{"email": "a@b.co", "name": "", "password": "longenough"}, "invalid_name"},
|
||||
{map[string]string{"email": "a@b.co", "name": "X", "password": "short"}, "weak_password"},
|
||||
} {
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", tc.body, "")
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("register %v: code %d", tc.body, resp.StatusCode)
|
||||
}
|
||||
if got := errCode(t, resp); got != tc.want {
|
||||
t.Errorf("error code %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// successful registration creates a developer and a session
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "Dev@Example.com", "name": "Dev One", "password": "password123"}, "")
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("register: %d", resp.StatusCode)
|
||||
}
|
||||
var reg struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
bodyJSON(t, resp, ®)
|
||||
if reg.User.Email != "dev@example.com" {
|
||||
t.Errorf("email not normalized: %q", reg.User.Email)
|
||||
}
|
||||
if !reg.User.Roles.Developer || reg.User.Roles.Admin || reg.User.Roles.Consultant {
|
||||
t.Errorf("self-registration roles wrong: %+v", reg.User.Roles)
|
||||
}
|
||||
|
||||
// session works
|
||||
meResp, err := c.Get(ts.URL + "/api/v1/auth/me")
|
||||
if err != nil || meResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("me after register: %v %d", err, meResp.StatusCode)
|
||||
}
|
||||
meResp.Body.Close()
|
||||
|
||||
// duplicate email
|
||||
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "dev@example.com", "name": "Imposter", "password": "password123"}, "")
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("duplicate register: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// logout requires CSRF
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, "")
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("logout without csrf: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
csrf := csrfFrom(t, c, ts.URL)
|
||||
if csrf == "" {
|
||||
t.Fatal("csrf cookie not set")
|
||||
}
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, csrf)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("logout: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
meResp, err = c.Get(ts.URL + "/api/v1/auth/me")
|
||||
if err != nil || meResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("me after logout: %v %d", err, meResp.StatusCode)
|
||||
}
|
||||
meResp.Body.Close()
|
||||
|
||||
// fresh login
|
||||
c2 := newClient(t)
|
||||
resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "dev@example.com", "password": "password123"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// wrong password and unknown email are indistinguishable 401s
|
||||
for _, body := range []map[string]string{
|
||||
{"email": "dev@example.com", "password": "wrong-password"},
|
||||
{"email": "ghost@example.com", "password": "password123"},
|
||||
} {
|
||||
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", body, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("login %v: %d", body, resp.StatusCode)
|
||||
}
|
||||
if got := errCode(t, resp); got != "invalid_credentials" {
|
||||
t.Errorf("error code %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
for i := 0; i < 10; i++ {
|
||||
resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "victim@example.com", "password": "guess"}, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: %d", i+1, resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "victim@example.com", "password": "guess"}, "")
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("11th attempt: %d, want 429", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// other accounts from the same IP are unaffected (key includes email)
|
||||
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "other@example.com", "password": "guess"}, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("different email after limit: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestDisabledAccount(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
c := newClient(t)
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "d@example.com", "name": "D", "password": "password123"}, "")
|
||||
var reg struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
bodyJSON(t, resp, ®)
|
||||
|
||||
// disable directly in the store
|
||||
if _, err := st.DB.Collection("users").UpdateOne(t.Context(),
|
||||
map[string]any{"_id": reg.User.ID}, map[string]any{"$set": map[string]any{"disabled": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// existing session is revoked on next use
|
||||
meResp, _ := c.Get(ts.URL + "/api/v1/auth/me")
|
||||
if meResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("me as disabled: %d", meResp.StatusCode)
|
||||
}
|
||||
meResp.Body.Close()
|
||||
|
||||
// fresh login rejected
|
||||
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "d@example.com", "password": "password123"}, "")
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("login as disabled: %d", resp.StatusCode)
|
||||
}
|
||||
if got := errCode(t, resp); got != "account_disabled" {
|
||||
t.Errorf("error code %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootstrapAdminAndForcedPasswordChange(t *testing.T) {
|
||||
ts, srv, st, cfg := newAuthStack(t, map[string]string{
|
||||
"ADMIN_EMAIL": "root@example.com",
|
||||
"ADMIN_INITIAL_PASSWORD": "initial-password",
|
||||
})
|
||||
if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// idempotent
|
||||
if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := st.CountUsers(t.Context()); n != 1 {
|
||||
t.Fatalf("user count = %d, want 1", n)
|
||||
}
|
||||
|
||||
c := newClient(t)
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "root@example.com", "password": "initial-password"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("admin login: %d", resp.StatusCode)
|
||||
}
|
||||
var login struct {
|
||||
User domain.User `json:"user"`
|
||||
MustChangePassword bool `json:"mustChangePassword"`
|
||||
}
|
||||
bodyJSON(t, resp, &login)
|
||||
if !login.User.Roles.Admin || !login.MustChangePassword {
|
||||
t.Fatalf("bootstrap admin login: %+v must=%v", login.User.Roles, login.MustChangePassword)
|
||||
}
|
||||
|
||||
// while mustChange is set, non-allowlisted endpoints are blocked
|
||||
csrf := csrfFrom(t, c, ts.URL)
|
||||
probe := httptest.NewServer(srv.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})))
|
||||
defer probe.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil)
|
||||
u, _ := url.Parse(ts.URL)
|
||||
for _, ck := range c.Jar.Cookies(u) {
|
||||
req.AddCookie(ck)
|
||||
}
|
||||
pResp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pResp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("protected endpoint under mustChange: %d, want 403", pResp.StatusCode)
|
||||
}
|
||||
pResp.Body.Close()
|
||||
|
||||
// wrong current password
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password",
|
||||
map[string]string{"currentPassword": "nope", "newPassword": "fresh-password-1"}, csrf)
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("change with wrong current: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// correct change clears the flag
|
||||
resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password",
|
||||
map[string]string{"currentPassword": "initial-password", "newPassword": "fresh-password-1"}, csrf)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("change password: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
req, _ = http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil)
|
||||
for _, ck := range c.Jar.Cookies(u) {
|
||||
req.AddCookie(ck)
|
||||
}
|
||||
pResp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pResp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("protected endpoint after change: %d", pResp.StatusCode)
|
||||
}
|
||||
pResp.Body.Close()
|
||||
|
||||
// old password dead, new one works
|
||||
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "root@example.com", "password": "initial-password"}, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("old password still works: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "root@example.com", "password": "fresh-password-1"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("new password rejected: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestPasswordChangeRevokesOtherSessions(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
|
||||
c1, c2 := newClient(t), newClient(t)
|
||||
resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "u@example.com", "name": "U", "password": "password123"}, "")
|
||||
resp.Body.Close()
|
||||
resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "u@example.com", "password": "password123"}, "")
|
||||
resp.Body.Close()
|
||||
|
||||
resp = postJSON(t, c1, ts.URL+"/api/v1/auth/change-password",
|
||||
map[string]string{"currentPassword": "password123", "newPassword": "password456"}, csrfFrom(t, c1, ts.URL))
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("change: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// c1 (the changer) still works, c2 is revoked
|
||||
r1, _ := c1.Get(ts.URL + "/api/v1/auth/me")
|
||||
if r1.StatusCode != http.StatusOK {
|
||||
t.Errorf("changer session: %d", r1.StatusCode)
|
||||
}
|
||||
r1.Body.Close()
|
||||
r2, _ := c2.Get(ts.URL + "/api/v1/auth/me")
|
||||
if r2.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("other session after password change: %d, want 401", r2.StatusCode)
|
||||
}
|
||||
r2.Body.Close()
|
||||
}
|
||||
|
||||
func TestExpiredSessionRejected(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
c := newClient(t)
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "e@example.com", "name": "E", "password": "password123"}, "")
|
||||
resp.Body.Close()
|
||||
|
||||
// force-expire every session for the user
|
||||
if _, err := st.DB.Collection("sessions").UpdateMany(t.Context(),
|
||||
map[string]any{}, map[string]any{"$set": map[string]any{"expiresAt": time.Now().Add(-time.Minute)}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meResp, _ := c.Get(ts.URL + "/api/v1/auth/me")
|
||||
if meResp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("expired session: %d", meResp.StatusCode)
|
||||
}
|
||||
meResp.Body.Close()
|
||||
}
|
||||
|
||||
func TestLogoutAll(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
c1, c2 := newClient(t), newClient(t)
|
||||
resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "la@example.com", "name": "LA", "password": "password123"}, "")
|
||||
resp.Body.Close()
|
||||
resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "la@example.com", "password": "password123"}, "")
|
||||
resp.Body.Close()
|
||||
|
||||
resp = postJSON(t, c1, ts.URL+"/api/v1/auth/logout-all", map[string]string{}, csrfFrom(t, c1, ts.URL))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("logout-all: %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Revoked int `json:"revoked"`
|
||||
}
|
||||
bodyJSON(t, resp, &out)
|
||||
if out.Revoked != 2 {
|
||||
t.Errorf("revoked = %d, want 2", out.Revoked)
|
||||
}
|
||||
for i, c := range []*http.Client{c1, c2} {
|
||||
r, _ := c.Get(ts.URL + "/api/v1/auth/me")
|
||||
if r.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("client %d after logout-all: %d", i+1, r.StatusCode)
|
||||
}
|
||||
r.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCOnlyAccountCannotPasswordLogin(t *testing.T) {
|
||||
ts, _, st, _ := newAuthStack(t, nil)
|
||||
if err := st.CreateUser(t.Context(), &domain.User{
|
||||
ID: "01JOIDCONLY0000000000000US", Email: "sso@example.com", Name: "SSO",
|
||||
Roles: domain.Roles{Developer: true},
|
||||
Auth: domain.Auth{OIDC: &domain.OIDCAuth{Issuer: "https://idp", Subject: "x"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "sso@example.com", "password": "whatever123"}, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("oidc-only password login: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
)
|
||||
|
||||
func userCtx(r *http.Request, u *domain.User) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), ctxKeyUser, u))
|
||||
}
|
||||
|
||||
// TestRBACMatrix exercises requireRole for every role/requirement combination.
|
||||
func TestRBACMatrix(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
roles := func(admin, consultant, developer bool) domain.Roles {
|
||||
return domain.Roles{Admin: admin, Consultant: consultant, Developer: developer}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
user *domain.User
|
||||
required string
|
||||
wantCode int
|
||||
}{
|
||||
{"admin passes admin gate", &domain.User{Roles: roles(true, false, false)}, "admin", http.StatusOK},
|
||||
{"developer blocked from admin gate", &domain.User{Roles: roles(false, false, true)}, "admin", http.StatusForbidden},
|
||||
{"consultant blocked from admin gate", &domain.User{Roles: roles(false, true, false)}, "admin", http.StatusForbidden},
|
||||
{"consultant passes consultant gate", &domain.User{Roles: roles(false, true, false)}, "consultant", http.StatusOK},
|
||||
{"developer blocked from consultant gate", &domain.User{Roles: roles(false, false, true)}, "consultant", http.StatusForbidden},
|
||||
{"admin blocked from consultant gate without flag", &domain.User{Roles: roles(true, false, false)}, "consultant", http.StatusForbidden},
|
||||
{"developer passes developer gate", &domain.User{Roles: roles(false, false, true)}, "developer", http.StatusOK},
|
||||
{"multi-role user passes both gates", &domain.User{Roles: roles(false, true, true)}, "developer", http.StatusOK},
|
||||
{"no user in context", nil, "developer", http.StatusForbidden},
|
||||
{"unknown role never passes", &domain.User{Roles: roles(true, true, true)}, "superuser", http.StatusForbidden},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := s.requireRole(tt.required, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodGet, "/probe", nil)
|
||||
if tt.user != nil {
|
||||
r = userCtx(r, tt.user)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, r)
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("code = %d, want %d", rec.Code, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFDoubleSubmit(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
cookie string
|
||||
header string
|
||||
wantCode int
|
||||
}{
|
||||
{"GET passes without token", http.MethodGet, "", "", http.StatusOK},
|
||||
{"HEAD passes without token", http.MethodHead, "", "", http.StatusOK},
|
||||
{"POST without anything blocked", http.MethodPost, "", "", http.StatusForbidden},
|
||||
{"POST with matching pair passes", http.MethodPost, "tok123", "tok123", http.StatusOK},
|
||||
{"POST with mismatched header blocked", http.MethodPost, "tok123", "other", http.StatusForbidden},
|
||||
{"POST with header but no cookie blocked", http.MethodPost, "", "tok123", http.StatusForbidden},
|
||||
{"POST with cookie but no header blocked", http.MethodPost, "tok123", "", http.StatusForbidden},
|
||||
{"DELETE requires token", http.MethodDelete, "tok123", "tok123", http.StatusOK},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(tt.method, "/probe", nil)
|
||||
if tt.cookie != "" {
|
||||
r.AddCookie(&http.Cookie{Name: csrfCookie, Value: tt.cookie})
|
||||
}
|
||||
if tt.header != "" {
|
||||
r.Header.Set(csrfHeader, tt.header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
s.requireCSRF(ok).ServeHTTP(rec, r)
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("code = %d, want %d", rec.Code, tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//go:build integration
|
||||
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
)
|
||||
|
||||
// fakeIdP is a minimal OIDC provider: discovery, JWKS, and a token endpoint
|
||||
// that mints RS256 id_tokens for whatever identity the test configures.
|
||||
type fakeIdP struct {
|
||||
srv *httptest.Server
|
||||
key *rsa.PrivateKey
|
||||
clientID string
|
||||
|
||||
// identity returned by the next token exchange
|
||||
sub string
|
||||
email string
|
||||
emailVerified bool
|
||||
name string
|
||||
}
|
||||
|
||||
func newFakeIdP(t *testing.T, clientID string) *fakeIdP {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idp := &fakeIdP{key: key, clientID: clientID}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"issuer": idp.srv.URL,
|
||||
"authorization_endpoint": idp.srv.URL + "/authorize",
|
||||
"token_endpoint": idp.srv.URL + "/token",
|
||||
"jwks_uri": idp.srv.URL + "/jwks",
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("GET /jwks", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
pub := key.Public().(*rsa.PublicKey)
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"keys": []map[string]any{{
|
||||
"kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test",
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
|
||||
}},
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// PKCE: a verifier must be present (the app always sends one).
|
||||
if r.PostForm.Get("code_verifier") == "" {
|
||||
http.Error(w, "missing code_verifier", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "fake-access-token",
|
||||
"token_type": "Bearer",
|
||||
"id_token": idp.mintIDToken(t),
|
||||
})
|
||||
})
|
||||
idp.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(idp.srv.Close)
|
||||
return idp
|
||||
}
|
||||
|
||||
func (idp *fakeIdP) mintIDToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
header, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"})
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"iss": idp.srv.URL,
|
||||
"sub": idp.sub,
|
||||
"aud": idp.clientID,
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"email": idp.email,
|
||||
"email_verified": idp.emailVerified,
|
||||
"name": idp.name,
|
||||
})
|
||||
signingInput := base64.RawURLEncoding.EncodeToString(header) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(payload)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, idp.key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
// oidcLogin walks the full browser flow and returns the final redirect
|
||||
// location after the callback.
|
||||
func oidcLogin(t *testing.T, c *http.Client, appURL string) string {
|
||||
t.Helper()
|
||||
resp, err := c.Get(appURL + "/api/v1/auth/oidc/login")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("oidc login: %d", resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := loc.Query()
|
||||
if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("authorize URL missing PKCE: %s", loc)
|
||||
}
|
||||
state := q.Get("state")
|
||||
if state == "" {
|
||||
t.Fatal("authorize URL missing state")
|
||||
}
|
||||
|
||||
cb := fmt.Sprintf("%s/api/v1/auth/oidc/callback?code=fake-code&state=%s",
|
||||
appURL, url.QueryEscape(state))
|
||||
resp, err = c.Get(cb)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("callback: %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header.Get("Location")
|
||||
}
|
||||
|
||||
func meUser(t *testing.T, c *http.Client, appURL string) (*domain.User, int) {
|
||||
t.Helper()
|
||||
resp, err := c.Get(appURL + "/api/v1/auth/me")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, resp.StatusCode
|
||||
}
|
||||
var out struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
bodyJSON(t, resp, &out)
|
||||
return &out.User, http.StatusOK
|
||||
}
|
||||
|
||||
func TestOIDCFullFlow(t *testing.T) {
|
||||
const clientID = "bountyboard-test"
|
||||
idp := newFakeIdP(t, clientID)
|
||||
ts, _, _, _ := newAuthStack(t, map[string]string{
|
||||
"OIDC_ISSUER_URL": idp.srv.URL,
|
||||
"OIDC_CLIENT_ID": clientID,
|
||||
"OIDC_CLIENT_SECRET": "shhh",
|
||||
"APP_BASE_URL": "http://localhost:8787", // redirect URI base; IdP doesn't validate it here
|
||||
})
|
||||
|
||||
idp.sub, idp.email, idp.emailVerified, idp.name = "subject-1", "sso-dev@example.com", true, "SSO Dev"
|
||||
|
||||
c := newClient(t)
|
||||
if loc := oidcLogin(t, c, ts.URL); loc != "/" {
|
||||
t.Fatalf("post-login redirect = %q, want /", loc)
|
||||
}
|
||||
u, code := meUser(t, c, ts.URL)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("me after oidc login: %d", code)
|
||||
}
|
||||
if u.Email != "sso-dev@example.com" || !u.Roles.Developer || u.Roles.Admin {
|
||||
t.Fatalf("oidc-created user wrong: %+v", u)
|
||||
}
|
||||
firstID := u.ID
|
||||
|
||||
// second login with the same subject reuses the account
|
||||
c2 := newClient(t)
|
||||
oidcLogin(t, c2, ts.URL)
|
||||
u2, _ := meUser(t, c2, ts.URL)
|
||||
if u2.ID != firstID {
|
||||
t.Fatalf("second oidc login created a new user: %s vs %s", u2.ID, firstID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCLinksVerifiedEmailToLocalAccount(t *testing.T) {
|
||||
const clientID = "bountyboard-test"
|
||||
idp := newFakeIdP(t, clientID)
|
||||
ts, _, _, _ := newAuthStack(t, map[string]string{
|
||||
"OIDC_ISSUER_URL": idp.srv.URL,
|
||||
"OIDC_CLIENT_ID": clientID,
|
||||
"OIDC_CLIENT_SECRET": "shhh",
|
||||
})
|
||||
|
||||
// existing local account
|
||||
c := newClient(t)
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "linked@example.com", "name": "Linked", "password": "password123"}, "")
|
||||
var reg struct {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
bodyJSON(t, resp, ®)
|
||||
|
||||
idp.sub, idp.email, idp.emailVerified, idp.name = "subject-link", "Linked@Example.com", true, "Linked"
|
||||
c2 := newClient(t)
|
||||
if loc := oidcLogin(t, c2, ts.URL); loc != "/" {
|
||||
t.Fatalf("link flow redirect = %q", loc)
|
||||
}
|
||||
u, _ := meUser(t, c2, ts.URL)
|
||||
if u.ID != reg.User.ID {
|
||||
t.Fatalf("oidc login did not link: got user %s, want %s", u.ID, reg.User.ID)
|
||||
}
|
||||
|
||||
// local password still works after linking
|
||||
resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login",
|
||||
map[string]string{"email": "linked@example.com", "password": "password123"}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("local login after link: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestOIDCUnverifiedEmailDoesNotLink(t *testing.T) {
|
||||
const clientID = "bountyboard-test"
|
||||
idp := newFakeIdP(t, clientID)
|
||||
ts, _, _, _ := newAuthStack(t, map[string]string{
|
||||
"OIDC_ISSUER_URL": idp.srv.URL,
|
||||
"OIDC_CLIENT_ID": clientID,
|
||||
"OIDC_CLIENT_SECRET": "shhh",
|
||||
})
|
||||
|
||||
c := newClient(t)
|
||||
resp := postJSON(t, c, ts.URL+"/api/v1/auth/register",
|
||||
map[string]string{"email": "victim@example.com", "name": "V", "password": "password123"}, "")
|
||||
resp.Body.Close()
|
||||
|
||||
idp.sub, idp.email, idp.emailVerified = "attacker", "victim@example.com", false
|
||||
c2 := newClient(t)
|
||||
loc := oidcLogin(t, c2, ts.URL)
|
||||
if !strings.Contains(loc, "error=oidc_email_unverified") {
|
||||
t.Fatalf("unverified email link must be refused, redirect = %q", loc)
|
||||
}
|
||||
if _, code := meUser(t, c2, ts.URL); code != http.StatusUnauthorized {
|
||||
t.Fatalf("attacker must not get a session, me = %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCStateMismatchRejected(t *testing.T) {
|
||||
const clientID = "bountyboard-test"
|
||||
idp := newFakeIdP(t, clientID)
|
||||
ts, _, _, _ := newAuthStack(t, map[string]string{
|
||||
"OIDC_ISSUER_URL": idp.srv.URL,
|
||||
"OIDC_CLIENT_ID": clientID,
|
||||
"OIDC_CLIENT_SECRET": "shhh",
|
||||
})
|
||||
idp.sub, idp.email, idp.emailVerified = "s", "s@example.com", true
|
||||
|
||||
c := newClient(t)
|
||||
resp, err := c.Get(ts.URL + "/api/v1/auth/oidc/login")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp, err = c.Get(ts.URL + "/api/v1/auth/oidc/callback?code=fake-code&state=forged-state")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if loc := resp.Header.Get("Location"); !strings.Contains(loc, "error=oidc_state_mismatch") {
|
||||
t.Fatalf("forged state redirect = %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCDisabledByDefault(t *testing.T) {
|
||||
ts, _, _, _ := newAuthStack(t, nil)
|
||||
resp, err := http.Get(ts.URL + "/api/v1/auth/oidc/login")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("oidc login without config: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -8,34 +8,43 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/auth"
|
||||
"bountyboard/internal/config"
|
||||
"bountyboard/internal/metrics"
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
// shutdownBudget is the §12 graceful-shutdown drain window.
|
||||
const shutdownBudget = 15 * time.Second
|
||||
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
metrics *metrics.Registry
|
||||
checks []ReadinessCheck
|
||||
startedAt time.Time
|
||||
httpSrv *http.Server
|
||||
cfg *config.Config
|
||||
log *slog.Logger
|
||||
metrics *metrics.Registry
|
||||
store *store.Store
|
||||
oidc *auth.OIDCClient
|
||||
loginLimiter *auth.RateLimiter
|
||||
checks []ReadinessCheck
|
||||
startedAt time.Time
|
||||
httpSrv *http.Server
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry) *Server {
|
||||
func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
metrics: reg,
|
||||
startedAt: time.Now(),
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
metrics: reg,
|
||||
store: st,
|
||||
oidc: auth.NewOIDCClient(cfg, log),
|
||||
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /readyz", s.handleReadyz)
|
||||
mux.HandleFunc("GET /metricsz", s.handleMetricsz)
|
||||
s.routesAuth(mux)
|
||||
|
||||
s.httpSrv = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.AppPort),
|
||||
|
||||
@@ -25,7 +25,7 @@ func newTestServer(t *testing.T) *Server {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return New(cfg, log, metrics.NewRegistry())
|
||||
return New(cfg, log, metrics.NewRegistry(), nil)
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "bb_session"
|
||||
csrfCookie = "bb_csrf"
|
||||
csrfHeader = "X-CSRF-Token"
|
||||
// sessions slide forward at most once per hour to avoid a write per
|
||||
// request
|
||||
refreshInterval = time.Hour
|
||||
)
|
||||
|
||||
const (
|
||||
ctxKeyUser ctxKey = 100 + iota
|
||||
ctxKeySession
|
||||
)
|
||||
|
||||
// CurrentUser returns the authenticated user, or nil outside requireAuth.
|
||||
func CurrentUser(ctx context.Context) *domain.User {
|
||||
u, _ := ctx.Value(ctxKeyUser).(*domain.User)
|
||||
return u
|
||||
}
|
||||
|
||||
// CurrentSession returns the active session, or nil outside requireAuth.
|
||||
func CurrentSession(ctx context.Context) *domain.Session {
|
||||
s, _ := ctx.Value(ctxKeySession).(*domain.Session)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken, csrfToken string) {
|
||||
secure := s.cfg.CookieSecure()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: sessionToken, Path: "/",
|
||||
MaxAge: int(domain.SessionTTL.Seconds()),
|
||||
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
// Readable by JS so the frontend can mirror it into X-CSRF-Token.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookie, Value: csrfToken, Path: "/",
|
||||
MaxAge: int(domain.SessionTTL.Seconds()),
|
||||
HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearAuthCookies(w http.ResponseWriter) {
|
||||
secure := s.cfg.CookieSecure()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookie, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// mustChangeAllowed are the only API paths reachable while a forced password
|
||||
// change is pending.
|
||||
var mustChangeAllowed = map[string]bool{
|
||||
"/api/v1/auth/me": true,
|
||||
"/api/v1/auth/logout": true,
|
||||
"/api/v1/auth/logout-all": true,
|
||||
"/api/v1/auth/change-password": true,
|
||||
}
|
||||
|
||||
// requireAuth resolves the session cookie into a user, enforces the disabled
|
||||
// flag and forced password changes, slides session expiry, and stores both
|
||||
// in the request context.
|
||||
func (s *Server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthenticated", "authentication required")
|
||||
return
|
||||
}
|
||||
sess, err := s.store.SessionByToken(r.Context(), c.Value)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
s.clearAuthCookies(w)
|
||||
writeError(w, http.StatusUnauthorized, "unauthenticated", "session expired")
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "load session", err)
|
||||
return
|
||||
}
|
||||
user, err := s.store.UserByID(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
s.clearAuthCookies(w)
|
||||
writeError(w, http.StatusUnauthorized, "unauthenticated", "account no longer exists")
|
||||
return
|
||||
}
|
||||
if user.Disabled {
|
||||
_ = s.store.DeleteSession(r.Context(), sess.Token)
|
||||
s.clearAuthCookies(w)
|
||||
writeError(w, http.StatusForbidden, "account_disabled", "account is disabled")
|
||||
return
|
||||
}
|
||||
if user.MustChangePassword() && !mustChangeAllowed[r.URL.Path] {
|
||||
writeError(w, http.StatusForbidden, "password_change_required",
|
||||
"password change required before continuing")
|
||||
return
|
||||
}
|
||||
if time.Since(sess.RefreshedAt) > refreshInterval {
|
||||
if err := s.store.RefreshSession(r.Context(), sess.Token); err != nil {
|
||||
s.log.Warn("refresh session", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxKeyUser, user)
|
||||
ctx = context.WithValue(ctx, ctxKeySession, sess)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// requireCSRF enforces the double-submit check on mutating methods: the
|
||||
// non-httpOnly cookie value must be echoed in the X-CSRF-Token header.
|
||||
func (s *Server) requireCSRF(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
c, err := r.Cookie(csrfCookie)
|
||||
if err != nil || c.Value == "" || r.Header.Get(csrfHeader) != c.Value {
|
||||
writeError(w, http.StatusForbidden, "csrf", "missing or mismatched CSRF token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// requireRole gates a handler on a role flag; must run inside requireAuth.
|
||||
func (s *Server) requireRole(role string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := CurrentUser(r.Context())
|
||||
if u == nil || !u.Roles.Has(role) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "requires "+role+" role")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// authed wires the standard chain for an authenticated JSON endpoint.
|
||||
func (s *Server) authed(h http.HandlerFunc) http.Handler {
|
||||
return s.requireAuth(s.requireCSRF(h))
|
||||
}
|
||||
|
||||
// authedRole wires authentication + CSRF + a role gate.
|
||||
func (s *Server) authedRole(role string, h http.HandlerFunc) http.Handler {
|
||||
return s.requireAuth(s.requireCSRF(s.requireRole(role, h)))
|
||||
}
|
||||
|
||||
func (s *Server) internalError(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
s.log.Error(what, "err", err, "requestId", RequestID(r.Context()), "path", r.URL.Path)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
)
|
||||
|
||||
func (s *Store) sessions() *mongo.Collection { return s.DB.Collection("sessions") }
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, sess *domain.Session) error {
|
||||
if _, err := s.sessions().InsertOne(ctx, sess); err != nil {
|
||||
return fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SessionByToken returns a live session. Expired-but-not-yet-reaped rows
|
||||
// (Mongo TTL sweeps roughly once a minute) are treated as absent.
|
||||
func (s *Store) SessionByToken(ctx context.Context, token string) (*domain.Session, error) {
|
||||
var sess domain.Session
|
||||
err := s.sessions().FindOne(ctx, bson.M{
|
||||
"_id": token,
|
||||
"expiresAt": bson.M{"$gt": time.Now().UTC()},
|
||||
}).Decode(&sess)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find session: %w", err)
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// RefreshSession slides the expiry window (§7: 30-day sliding expiry).
|
||||
func (s *Store) RefreshSession(ctx context.Context, token string) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := s.sessions().UpdateOne(ctx, bson.M{"_id": token}, bson.M{
|
||||
"$set": bson.M{"expiresAt": now.Add(domain.SessionTTL), "refreshedAt": now},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, token string) error {
|
||||
if _, err := s.sessions().DeleteOne(ctx, bson.M{"_id": token}); err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserSessions revokes every session of a user (logout-all, disable,
|
||||
// password change).
|
||||
func (s *Store) DeleteUserSessions(ctx context.Context, userID string) (int64, error) {
|
||||
res, err := s.sessions().DeleteMany(ctx, bson.M{"userId": userID})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete user sessions: %w", err)
|
||||
}
|
||||
return res.DeletedCount, nil
|
||||
}
|
||||
|
||||
// DeleteUserSessionsExcept revokes all sessions of a user except one (used
|
||||
// after a password change to keep the current session alive).
|
||||
func (s *Store) DeleteUserSessionsExcept(ctx context.Context, userID, keepToken string) (int64, error) {
|
||||
res, err := s.sessions().DeleteMany(ctx, bson.M{
|
||||
"userId": userID,
|
||||
"_id": bson.M{"$ne": keepToken},
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete user sessions: %w", err)
|
||||
}
|
||||
return res.DeletedCount, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
"bountyboard/internal/domain"
|
||||
)
|
||||
|
||||
// ErrDuplicate is returned when a unique index rejects an insert/update
|
||||
// (e.g. email already registered).
|
||||
var ErrDuplicate = errors.New("duplicate value")
|
||||
|
||||
func (s *Store) users() *mongo.Collection { return s.DB.Collection("users") }
|
||||
|
||||
// NormalizeEmail is the canonical form stored and queried everywhere.
|
||||
func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
func (s *Store) CreateUser(ctx context.Context, u *domain.User) error {
|
||||
now := time.Now().UTC()
|
||||
u.Email = NormalizeEmail(u.Email)
|
||||
u.CreatedAt, u.UpdatedAt, u.Version = now, now, 1
|
||||
if u.LastSeenAt.IsZero() {
|
||||
u.LastSeenAt = now
|
||||
}
|
||||
if _, err := s.users().InsertOne(ctx, u); err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return fmt.Errorf("user %s: %w", u.Email, ErrDuplicate)
|
||||
}
|
||||
return fmt.Errorf("insert user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) userBy(ctx context.Context, filter bson.M) (*domain.User, error) {
|
||||
var u domain.User
|
||||
err := s.users().FindOne(ctx, filter).Decode(&u)
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
return s.userBy(ctx, bson.M{"_id": id})
|
||||
}
|
||||
|
||||
func (s *Store) UserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
return s.userBy(ctx, bson.M{"email": NormalizeEmail(email)})
|
||||
}
|
||||
|
||||
func (s *Store) UserByOIDC(ctx context.Context, issuer, subject string) (*domain.User, error) {
|
||||
return s.userBy(ctx, bson.M{"auth.oidc.issuer": issuer, "auth.oidc.subject": subject})
|
||||
}
|
||||
|
||||
func (s *Store) CountUsers(ctx context.Context) (int64, error) {
|
||||
n, err := s.users().CountDocuments(ctx, bson.M{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetPassword replaces the local credential and clears/sets the forced
|
||||
// change flag. Not version-checked: password changes must never lose to a
|
||||
// concurrent profile edit.
|
||||
func (s *Store) SetPassword(ctx context.Context, userID, hash string, mustChange bool) error {
|
||||
res, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, bson.M{
|
||||
"$set": bson.M{
|
||||
"auth.local.passwordHash": hash,
|
||||
"auth.local.mustChange": mustChange,
|
||||
"updatedAt": time.Now().UTC(),
|
||||
},
|
||||
"$inc": bson.M{"version": 1},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("set password: %w", err)
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return fmt.Errorf("user %s: %w", userID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LinkOIDC attaches an OIDC identity to an existing account (login linking
|
||||
// by verified email, §7).
|
||||
func (s *Store) LinkOIDC(ctx context.Context, userID, issuer, subject string) error {
|
||||
res, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, bson.M{
|
||||
"$set": bson.M{
|
||||
"auth.oidc": domain.OIDCAuth{Issuer: issuer, Subject: subject},
|
||||
"updatedAt": time.Now().UTC(),
|
||||
},
|
||||
"$inc": bson.M{"version": 1},
|
||||
})
|
||||
if err != nil {
|
||||
if mongo.IsDuplicateKeyError(err) {
|
||||
return fmt.Errorf("oidc identity: %w", ErrDuplicate)
|
||||
}
|
||||
return fmt.Errorf("link oidc: %w", err)
|
||||
}
|
||||
if res.MatchedCount == 0 {
|
||||
return fmt.Errorf("user %s: %w", userID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchLastSeen updates lastSeenAt without bumping version (pure telemetry).
|
||||
func (s *Store) TouchLastSeen(ctx context.Context, userID string) error {
|
||||
_, err := s.users().UpdateOne(ctx, bson.M{"_id": userID},
|
||||
bson.M{"$set": bson.M{"lastSeenAt": time.Now().UTC()}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch lastSeenAt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user