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
+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)
}