Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
270 lines
7.7 KiB
Go
270 lines
7.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/go-chi/jwtauth/v5"
|
|
)
|
|
|
|
var TokenAuth *jwtauth.JWTAuth
|
|
|
|
// AuthResponse is the response structure for login/refresh endpoints.
|
|
// RefreshToken is a 90-day opaque, DB-hashed, single-use credential; the
|
|
// client stores it and presents it (Bearer) to POST /api/refresh-token in
|
|
// exchange for a fresh access token + a rotated refresh token. It is returned
|
|
// in JSON so the SPA can persist it — omitting it would make the refresh flow
|
|
// unusable — but it is never logged and never returned by any other endpoint.
|
|
type AuthResponse struct {
|
|
Token string `json:"token"`
|
|
JTI string `json:"jti"`
|
|
RefreshToken string `json:"refreshToken,omitempty"`
|
|
}
|
|
|
|
// generateJTI generates a UUID v4 string using crypto/rand
|
|
func generateJTI() (string, error) {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("failed to generate JTI: %w", err)
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
|
|
b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
|
}
|
|
|
|
// RevokeJTI adds a JTI to the revoked set in PostgreSQL.
|
|
// Returns an error if the operation fails.
|
|
func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) error {
|
|
if db.Conn == nil {
|
|
return fmt.Errorf("revoke JTI: db.Conn is nil")
|
|
}
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke JTI: begin transaction: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
_, err = tx.Exec(ctx,
|
|
`INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2)
|
|
ON CONFLICT (jti) DO NOTHING`,
|
|
jti, expiresAt)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke JTI %s: %w", jti, err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("revoke JTI: commit transaction: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsJTIRevoked checks if a JTI is in the revoked set via PostgreSQL.
|
|
// Returns false if the DB is not initialized (unit tests, startup) — treating
|
|
// the token as valid is the safer default for availability over security during
|
|
// startup, and revoked checks are quickly re-evaluated on each request.
|
|
func IsJTIRevoked(ctx context.Context, jti string) bool {
|
|
if db.Conn == nil {
|
|
return false
|
|
}
|
|
var exists bool
|
|
err := db.Conn.QueryRow(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM revoked_jtis WHERE jti = $1 AND expires_at > NOW())`,
|
|
jti).Scan(&exists)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return exists
|
|
}
|
|
|
|
// CleanupRevokedJTIs removes expired entries from PostgreSQL and returns the count of deleted rows.
|
|
func CleanupRevokedJTIs(ctx context.Context) (int, error) {
|
|
if db.Conn == nil {
|
|
return 0, nil
|
|
}
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
log.Printf("WARN: Failed to begin transaction for JTI cleanup: %v", err)
|
|
return 0, err
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
tag, err := tx.Exec(ctx,
|
|
`DELETE FROM revoked_jtis WHERE expires_at < NOW()`)
|
|
if err != nil {
|
|
log.Printf("WARN: Failed to cleanup revoked JTIs: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
log.Printf("WARN: Failed to commit transaction for JTI cleanup: %v", err)
|
|
return 0, err
|
|
}
|
|
|
|
return int(tag.RowsAffected()), nil
|
|
}
|
|
|
|
func InitJWT(secret string) {
|
|
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
|
}
|
|
|
|
// GenerateToken creates a JWT with user_id, role, and a unique jti claim
|
|
// Returns the token string, the JTI, and any error
|
|
func GenerateToken(userID string, role string) (string, string, error) {
|
|
jti, err := generateJTI()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
_, tokenString, err := TokenAuth.Encode(map[string]any{
|
|
"user_id": userID,
|
|
"role": role,
|
|
"jti": jti,
|
|
"exp": clock.Now().Add(1 * time.Hour).Unix(), // 1 hour
|
|
})
|
|
return tokenString, jti, err
|
|
}
|
|
|
|
// VerifyToken validates JWT and returns user_id, role, and jti
|
|
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, jti string, err error) {
|
|
token, err := jwtauth.VerifyToken(TokenAuth, tokenString)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
var uidVal any
|
|
if err := token.Get("user_id", &uidVal); err != nil {
|
|
return "", "", "", fmt.Errorf("invalid user_id claim")
|
|
}
|
|
userID, ok := uidVal.(string)
|
|
if !ok {
|
|
return "", "", "", fmt.Errorf("invalid user_id claim")
|
|
}
|
|
|
|
var roleVal any
|
|
if err := token.Get("role", &roleVal); err != nil {
|
|
return "", "", "", fmt.Errorf("invalid role claim")
|
|
}
|
|
role, ok = roleVal.(string)
|
|
if !ok {
|
|
return "", "", "", fmt.Errorf("invalid role claim")
|
|
}
|
|
|
|
var jtiVal any
|
|
if err := token.Get("jti", &jtiVal); err != nil {
|
|
return "", "", "", fmt.Errorf("invalid jti claim")
|
|
}
|
|
jti, ok = jtiVal.(string)
|
|
if !ok || jti == "" {
|
|
return "", "", "", fmt.Errorf("invalid jti claim")
|
|
}
|
|
|
|
if IsJTIRevoked(ctx, jti) {
|
|
return "", "", "", fmt.Errorf("token revoked")
|
|
}
|
|
|
|
return userID, role, jti, nil
|
|
}
|
|
|
|
// generateRefreshTokenString creates a cryptographically random opaque refresh token
|
|
func generateRefreshTokenString() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", fmt.Errorf("failed to generate refresh token: %w", err)
|
|
}
|
|
return fmt.Sprintf("%x", b), nil
|
|
}
|
|
|
|
// GenerateRefreshToken creates a refresh token stored in the database
|
|
// Returns the opaque token string to return to the client
|
|
func GenerateRefreshToken(ctx context.Context, userID string, role string) (string, error) {
|
|
token, err := generateRefreshTokenString()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Store hashed version in DB with 90-day expiry
|
|
query := `
|
|
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at)
|
|
VALUES ($1, encode(sha256($2::bytea), 'hex'), $3, NOW() + INTERVAL '90 days')
|
|
RETURNING id`
|
|
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
var tokenID int64
|
|
err = tx.QueryRow(ctx, query, userID, token, role).Scan(&tokenID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to store refresh token: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return "", fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
return token, nil
|
|
}
|
|
|
|
// VerifyRefreshToken checks a refresh token and returns user details if valid
|
|
// The token is consumed (deleted) upon successful verification, implementing rotation.
|
|
func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string, role string, err error) {
|
|
query := `
|
|
DELETE FROM refresh_tokens
|
|
WHERE token_hash = encode(sha256($1::bytea), 'hex')
|
|
AND expires_at > NOW()
|
|
AND NOT revoked
|
|
RETURNING user_id, role`
|
|
|
|
tx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(ctx); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
slog.Error("failed to rollback transaction", "err", err)
|
|
}
|
|
}()
|
|
|
|
err = tx.QueryRow(ctx, query, tokenString).Scan(&userID, &role)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return "", "", fmt.Errorf("invalid or expired refresh token")
|
|
}
|
|
return "", "", fmt.Errorf("failed to verify refresh token: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
// Token was consumed (DELETE returned it) — this is rotation
|
|
// If a token is used twice, the second DELETE returns no rows = invalid
|
|
return userID, role, nil
|
|
}
|