Files
Crussell/backend/auth/jwt.go
T
popertotsandSisyphus e86248b27c refactor: move auth, GDPR, and booking cleanup to centralized scheduler
Convert CleanupRevokedJTIs to return (int, error) and remove StartJTICleanup goroutine. Add CleanupStaleLoginEntries and CleanupGDPRExportCache for centralized scheduler. Add clock.London timezone location.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-07 00:10:00 +01:00

248 lines
6.8 KiB
Go

package auth
import (
"context"
"crypto/rand"
"errors"
"fmt"
"log"
"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
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
func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) {
if db.Conn == nil {
return
}
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("WARN: Failed to begin transaction for JTI revocation: %v", err)
return
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx,
`INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING`,
jti, expiresAt)
if err != nil {
// Log but don't fail - this is best effort
log.Printf("WARN: Failed to revoke JTI %s: %v", jti, err)
return
}
if err := tx.Commit(ctx); err != nil {
log.Printf("WARN: Failed to commit transaction for JTI revocation: %v", err)
}
}
// 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 tx.Rollback(ctx)
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]interface{}{
"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 := TokenAuth.Decode(tokenString)
if err != nil {
return "", "", "", err
}
var uidVal interface{}
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 interface{}
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 interface{}
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 tx.Rollback(ctx)
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 tx.Rollback(ctx)
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
}