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)
|
||||
}
|
||||
Reference in New Issue
Block a user