internal/auth/ provides: - TokenStore: 32-byte cryptographically random one-time tokens. Only the SHA-256 hash is persisted (so a DB leak doesn't grant active sessions). Comparison uses subtle.ConstantTimeCompare. Single-use is enforced via UPDATE ... WHERE used_at IS NULL. - Signer: HS256 JWTs with 24h lifetime, jwt.WithValidMethods to reject alg=none and other downgrade attacks. - LogMailer (dev) and SMTPMailer (prod via net/smtp) behind a Mailer interface. - RateLimiter: DB-backed fixed window per email; default 5 per 15 min for the magic-link flow. - Service: orchestrates RequestLogin (auto-creates user on first login, generates token, emails magic link) and Verify (consumes token, updates last_login, issues JWT). - Handlers: POST /auth/login and GET/POST /auth/verify. HandleLogin returns 202 even on validation failure to avoid account enumeration; rate-limit hits surface as 429. Schema additions: magic_tokens (with FK + cascade) and login_attempts. UserStore.SetStoragePath added for completeness. Tests cover: token issue/consume, single-use, expiry, rate limit, JWT round-trip, alg=none rejection, signature tampering, purge, HTTP handlers (login + verify, missing/invalid token paths). Closes #9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
1.9 KiB
Go
72 lines
1.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// SessionLifetime is how long an issued JWT remains valid.
|
|
const SessionLifetime = 24 * time.Hour
|
|
|
|
// ErrInvalidJWT is returned when a JWT fails parsing, signature
|
|
// verification, or claim validation.
|
|
var ErrInvalidJWT = errors.New("invalid jwt")
|
|
|
|
// Claims is the JWT payload we sign for authenticated sessions.
|
|
type Claims struct {
|
|
UserID string `json:"sub"`
|
|
Email string `json:"email"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// Signer issues and verifies session JWTs using HS256.
|
|
type Signer struct {
|
|
secret []byte
|
|
}
|
|
|
|
// NewSigner builds a Signer from a shared secret (>= 32 bytes recommended).
|
|
func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} }
|
|
|
|
// Issue creates a signed JWT for the given user.
|
|
func (s *Signer) Issue(userID, email string) (string, error) {
|
|
now := time.Now().UTC()
|
|
claims := Claims{
|
|
UserID: userID,
|
|
Email: email,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(SessionLifetime)),
|
|
NotBefore: jwt.NewNumericDate(now),
|
|
Issuer: "librenotes",
|
|
Subject: userID,
|
|
},
|
|
}
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
signed, err := tok.SignedString(s.secret)
|
|
if err != nil {
|
|
return "", fmt.Errorf("sign: %w", err)
|
|
}
|
|
return signed, nil
|
|
}
|
|
|
|
// Verify parses and validates a JWT, returning the claims on success.
|
|
func (s *Signer) Verify(token string) (*Claims, error) {
|
|
claims := &Claims{}
|
|
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
|
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
|
}
|
|
return s.secret, nil
|
|
}, jwt.WithValidMethods([]string{"HS256"}))
|
|
if err != nil || !parsed.Valid {
|
|
return nil, ErrInvalidJWT
|
|
}
|
|
if claims.UserID == "" {
|
|
return nil, ErrInvalidJWT
|
|
}
|
|
return claims, nil
|
|
}
|