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:
etalon
2026-06-12 18:31:25 +02:00
parent f2c534f636
commit c1cc781279
25 changed files with 2318 additions and 24 deletions
+25
View File
@@ -57,3 +57,28 @@ Spec-silent choices, recorded as required by the build instructions.
- **Signed file tokens** are `base64url(exp || hmac-sha256(fileID|exp))` with
a key derived as `sha256("bountyboard/file-url/v1" + SESSION_SECRET)` so
file tokens can never collide with other uses of the session secret.
## Phase 3
- **OIDC linking requires `email_verified=true`.** An unverified IdP email
matching an existing local account is rejected (`oidc_email_unverified`)
instead of linking or creating a duplicate — linking on unverified email
would allow account takeover via a rogue IdP account.
- **OIDC provider discovery is lazy** (first login attempt, retried on
failure) so an unreachable IdP cannot block app startup.
- **CSRF protection applies to authenticated mutations only**; login and
register are pre-session and protected by rate limiting + SameSite=Lax.
The CSRF cookie is intentionally JS-readable (double-submit pattern).
- **Password change revokes all other sessions** of the user (recovery
semantics) but keeps the current one. `SetPassword` is deliberately not
version-checked: a password change must never lose an optimistic-locking
race against a profile edit.
- **Forced password change (`mustChange`)** is enforced in `requireAuth` via
a path allowlist (`me`, `logout`, `logout-all`, `change-password`).
- **Sessions slide at most once per hour** (refreshedAt watermark) instead of
updating expiry on every request.
- **Login rate-limit key is `clientIP|email`** (and `register|clientIP` for
registration), using the trusted-proxy-resolved IP.
- **Mongo runs with `--wiredTigerCacheSizeGB 0.5`** in compose — the target
host has 4 GB RAM; default cache (≈50% of RAM) caused a restart under the
integration-test load.
+2 -1
View File
@@ -1,5 +1,6 @@
# Progress
- Phase 1 (scaffold): repo layout, Makefile, .env loader + validated config, slog JSON, ULID pkg, trusted-proxy middleware, /healthz /readyz /metricsz, Dockerfile + compose (mongo+app, healthchecks, localhost-bound 8787), graceful shutdown — build/vet/tests green, smoke PASS.
- Phase 2 (store layer): mongo-driver v2 connection with startup retry, §4.9 indexes idempotent, optimistic-concurrency UpdateVersioned (ErrVersionConflict/ErrNotFound), AES-256-GCM credential crypto, HMAC-signed file URL tokens (1h TTL), GridFS store with MIME sniffing + size cap + sha256, /readyz mongo check (verified 503 on stopped Mongo) — unit + integration tests green.
- Phase 3 (auth): argon2id (PHC, spec params), login/register rate limiting, opaque Mongo sessions (30d sliding, TTL index), CSRF double-submit, bootstrap admin with forced password change, RBAC middleware + matrix tests, OIDC code flow with PKCE + email linking (verified-only), full integration suite incl. fake IdP — all green.
+6 -1
View File
@@ -10,6 +10,7 @@ import (
"syscall"
"time"
"bountyboard/internal/auth"
"bountyboard/internal/config"
httpx "bountyboard/internal/http"
"bountyboard/internal/metrics"
@@ -50,8 +51,12 @@ func run() error {
}
}()
if err := auth.EnsureBootstrapAdmin(ctx, st, cfg, log); err != nil {
return err
}
reg := metrics.NewRegistry()
srv := httpx.New(cfg, log, reg)
srv := httpx.New(cfg, log, reg, st)
srv.AddReadinessCheck(httpx.ReadinessCheck{
Name: "mongo",
Required: true,
+2
View File
@@ -2,6 +2,8 @@ services:
mongo:
image: mongo:7
restart: unless-stopped
# modest cache so the whole stack fits comfortably on small hosts
command: ["mongod", "--wiredTigerCacheSizeGB", "0.5"]
# Mongo is reachable only on the compose network; never published to the
# host (§12).
volumes:
+10 -4
View File
@@ -2,15 +2,21 @@ module bountyboard
go 1.26
require go.mongodb.org/mongo-driver/v2 v2.6.0
require (
github.com/coreos/go-oidc/v3 v3.18.0
go.mongodb.org/mongo-driver/v2 v2.6.0
golang.org/x/crypto v0.53.0
golang.org/x/oauth2 v0.36.0
)
require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
)
+14 -6
View File
@@ -1,5 +1,9 @@
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
@@ -17,29 +21,33 @@ go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+51
View File
@@ -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
}
+116
View File
@@ -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
}
+73
View File
@@ -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
}
+75
View File
@@ -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))
}
}
+70
View File
@@ -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)
}
}
}
+62
View File
@@ -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")
}
}
+18
View File
@@ -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)
}
+18
View File
@@ -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
+82
View File
@@ -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
}
+258
View File
@@ -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(),
})
}
+472
View File
@@ -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, &reg)
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, &reg)
// 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()
}
+93
View File
@@ -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)
}
})
}
}
+173
View File
@@ -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
}
+303
View File
@@ -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, &reg)
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)
}
}
+10 -1
View File
@@ -8,8 +8,10 @@ import (
"net/http"
"time"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/metrics"
"bountyboard/internal/store"
)
// shutdownBudget is the §12 graceful-shutdown drain window.
@@ -19,16 +21,22 @@ type Server struct {
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,
store: st,
oidc: auth.NewOIDCClient(cfg, log),
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
startedAt: time.Now(),
}
@@ -36,6 +44,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry) *Server {
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),
+1 -1
View File
@@ -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 {
+167
View File
@@ -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")
}
+81
View File
@@ -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
}
+126
View File
@@ -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
}