Implement email magic-link authentication
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>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user