feat(auth): 验证码/注册/登录/JWT 鉴权模块 (tsk_2PFfyviECIXh)
实现 server/internal/auth 控制面鉴权底座,对应 doc/02 §4.1、doc/06 §3:
- POST /v1/auth/code:IP+邮箱双维度 Redis 滑窗限频(同邮箱 1/min、同 IP 10/h)
+ 一次性邮箱域黑名单(embed 词表)→ 6 位数字码写 auth:code:{email} TTL 10min
→ 异步发信(SMTP / 开发态 log mailer)。
- POST /v1/auth/register:验码(一次性,删 key;超次数烧码)→ 单事务建号
(uuid + dp_uuid 应用层生成、argon2id)+ 7 天 PRO 试用(source='trial')→ 签发 JWT。
- POST /v1/auth/login:argon2id 校验(失败恒定时、未知用户走 dummy hash);
失败计数限流 rl:login:{email} + 锁定;banned 拒绝。
- POST /v1/auth/refresh:RS256,access 15min + refresh 30d 落 Redis 白名单
jwt:refresh:{jti},旋转时删旧写新;JWT 头带 kid,验证端接受新旧公钥支持轮换。
- RequireAuth 中间件:解析 Bearer access,注入 user id(复用 codes.CtxKeyUserID
避免循环依赖)+ uuid + claims 到 context。
- 错误体统一走 internal/apierr 的 {code, message_zh, message_en},文案双语脱敏。
- 文件拆分:handler/service/password/token/ratelimit/emailcheck/store/mailer/
middleware/errors/keyloader;config 增加可选 JWT PEM 路径/kid 加载。
测试:password/token/ratelimit/emailcheck/service/handler/middleware 单测
(miniredis,含注册全流程、重复邮箱 409、验证码错误/过期/复用/烧码、限频 429
带 Retry-After、登录失败锁定、banned 拒绝、refresh 旋转后旧 token 失效),
go test -race 通过;集成测试 integration_test.go(testcontainers MySQL8+Redis,
register→login→refresh→受保护接口全链路,构建 tag integration)与 codes 模块同构。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -6,12 +6,15 @@ require (
|
||||
github.com/alicebob/miniredis/v2 v2.38.0
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.7.1
|
||||
github.com/redis/go-redis/v9 v9.20.1
|
||||
github.com/testcontainers/testcontainers-go v0.34.0
|
||||
github.com/testcontainers/testcontainers-go/modules/mysql v0.34.0
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.34.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
)
|
||||
|
||||
@@ -100,7 +103,6 @@ require (
|
||||
github.com/gocql/gocql v0.0.0-20210515062232-b7ef815b4556 // indirect
|
||||
github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
@@ -109,7 +111,6 @@ require (
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
@@ -208,7 +209,6 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Disposable / temporary email domains blocklist.
|
||||
# One domain per line, lowercase, no leading dot. Lines starting with '#' and
|
||||
# blank lines are ignored. Extend as new throwaway providers appear.
|
||||
0-mail.com
|
||||
10minutemail.com
|
||||
10minutemail.net
|
||||
20minutemail.com
|
||||
33mail.com
|
||||
guerrillamail.com
|
||||
guerrillamail.net
|
||||
guerrillamail.org
|
||||
guerrillamailblock.com
|
||||
sharklasers.com
|
||||
grr.la
|
||||
spam4.me
|
||||
mailinator.com
|
||||
mailinator.net
|
||||
mailinator2.com
|
||||
maildrop.cc
|
||||
mintemail.com
|
||||
mohmal.com
|
||||
temp-mail.org
|
||||
tempmail.com
|
||||
tempmailo.com
|
||||
tempr.email
|
||||
tempinbox.com
|
||||
throwawaymail.com
|
||||
trashmail.com
|
||||
trashmail.net
|
||||
trashmail.de
|
||||
dispostable.com
|
||||
fakeinbox.com
|
||||
getnada.com
|
||||
nada.email
|
||||
yopmail.com
|
||||
yopmail.net
|
||||
yopmail.fr
|
||||
mailnesia.com
|
||||
emailondeck.com
|
||||
moakt.com
|
||||
mytemp.email
|
||||
tmpmail.org
|
||||
tmpmail.net
|
||||
tmpeml.com
|
||||
inboxkitten.com
|
||||
burnermail.io
|
||||
spamgourmet.com
|
||||
mailcatch.com
|
||||
fakemailgenerator.com
|
||||
mvrht.com
|
||||
discard.email
|
||||
discardmail.com
|
||||
mail-temp.com
|
||||
luxusmail.org
|
||||
1secmail.com
|
||||
1secmail.net
|
||||
1secmail.org
|
||||
emailfake.com
|
||||
tempmailaddress.com
|
||||
mailpoof.com
|
||||
harakirimail.com
|
||||
spambox.us
|
||||
jetable.org
|
||||
@@ -0,0 +1,68 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed disposable_domains.txt
|
||||
var disposableList string
|
||||
|
||||
// disposableDomains is the parsed blocklist, built once at package init.
|
||||
var disposableDomains = parseDisposable(disposableList)
|
||||
|
||||
// parseDisposable turns the embedded wordlist into a lookup set.
|
||||
func parseDisposable(raw string) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(strings.ToLower(line))
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
set[line] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// NormalizeEmail lowercases and trims the address for consistent storage and
|
||||
// rate-limit keying. It does not alter the local part beyond trimming.
|
||||
func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// emailDomain returns the lowercased domain part of an email, or "" if the
|
||||
// address has no single '@'.
|
||||
func emailDomain(email string) string {
|
||||
at := strings.LastIndex(email, "@")
|
||||
if at < 0 || at == len(email)-1 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(email[at+1:])
|
||||
}
|
||||
|
||||
// ValidEmail performs a cheap structural sanity check: exactly one '@', a
|
||||
// non-empty local part, and a domain containing a dot. It is deliberately
|
||||
// permissive — true deliverability is proven by the verification code.
|
||||
func ValidEmail(email string) bool {
|
||||
email = strings.TrimSpace(email)
|
||||
if len(email) < 3 || len(email) > 254 {
|
||||
return false
|
||||
}
|
||||
at := strings.IndexByte(email, '@')
|
||||
if at <= 0 || at != strings.LastIndexByte(email, '@') {
|
||||
return false
|
||||
}
|
||||
domain := email[at+1:]
|
||||
return strings.Contains(domain, ".") && !strings.HasPrefix(domain, ".") && !strings.HasSuffix(domain, ".")
|
||||
}
|
||||
|
||||
// IsDisposable reports whether the email's domain is on the disposable-domain
|
||||
// blocklist.
|
||||
func IsDisposable(email string) bool {
|
||||
domain := emailDomain(email)
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := disposableDomains[domain]
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidEmail(t *testing.T) {
|
||||
valid := []string{"a@b.com", "user.name+tag@sub.example.co", "x@y.io"}
|
||||
for _, e := range valid {
|
||||
if !ValidEmail(e) {
|
||||
t.Errorf("expected %q to be valid", e)
|
||||
}
|
||||
}
|
||||
invalid := []string{"", "no-at", "a@", "@b.com", "a@b", "a@@b.com", "a@.com", "a@b."}
|
||||
for _, e := range invalid {
|
||||
if ValidEmail(e) {
|
||||
t.Errorf("expected %q to be invalid", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDisposable(t *testing.T) {
|
||||
if !IsDisposable("foo@mailinator.com") {
|
||||
t.Error("mailinator.com should be disposable")
|
||||
}
|
||||
if !IsDisposable("foo@MAILINATOR.com") { // case-insensitive
|
||||
t.Error("disposable check should be case-insensitive")
|
||||
}
|
||||
if IsDisposable("foo@gmail.com") {
|
||||
t.Error("gmail.com should not be disposable")
|
||||
}
|
||||
if IsDisposable("no-domain") {
|
||||
t.Error("address without domain should not be flagged disposable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
if got := NormalizeEmail(" User@Example.COM "); got != "user@example.com" {
|
||||
t.Errorf("NormalizeEmail = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import "github.com/wangjia/pangolin/server/internal/apierr"
|
||||
|
||||
// Auth-module error values. Codes use the dotted `auth.*` namespace documented
|
||||
// in the OpenAPI contract (components.schemas.Error). All messages are bilingual
|
||||
// and desensitised (no destination / traffic wording, no enumeration leaks).
|
||||
var (
|
||||
// ErrInvalidRequest — malformed body or failed field validation.
|
||||
ErrInvalidRequest = &apierr.Error{
|
||||
Code: "auth.invalid_request",
|
||||
MessageZH: "请求参数有误,请检查后重试",
|
||||
MessageEn: "Invalid request parameters, please verify and try again",
|
||||
}
|
||||
|
||||
// ErrEmailDisposable — the email domain is on the disposable-domain blocklist.
|
||||
ErrEmailDisposable = &apierr.Error{
|
||||
Code: "auth.email_disposable",
|
||||
MessageZH: "暂不支持该邮箱服务商,请更换邮箱",
|
||||
MessageEn: "This email provider is not supported, please use another address",
|
||||
}
|
||||
|
||||
// ErrCodeInvalid — verification code wrong, expired, or already used.
|
||||
// Intentionally generic to avoid distinguishing the three cases.
|
||||
ErrCodeInvalid = &apierr.Error{
|
||||
Code: "auth.code_invalid",
|
||||
MessageZH: "验证码无效或已过期,请重新获取",
|
||||
MessageEn: "Verification code is invalid or expired, please request a new one",
|
||||
}
|
||||
|
||||
// ErrEmailExists — the email is already registered.
|
||||
ErrEmailExists = &apierr.Error{
|
||||
Code: "auth.email_exists",
|
||||
MessageZH: "该邮箱已注册,请直接登录",
|
||||
MessageEn: "This email is already registered, please sign in",
|
||||
}
|
||||
|
||||
// ErrRateLimited — rate-limit window exceeded. Carries a Retry-After header.
|
||||
ErrRateLimited = &apierr.Error{
|
||||
Code: "auth.rate_limited",
|
||||
MessageZH: "操作过于频繁,请稍后再试",
|
||||
MessageEn: "Too many attempts, please try again later",
|
||||
}
|
||||
|
||||
// ErrInvalidCredentials — wrong email or password (login).
|
||||
ErrInvalidCredentials = &apierr.Error{
|
||||
Code: "auth.invalid_credentials",
|
||||
MessageZH: "邮箱或密码不正确",
|
||||
MessageEn: "Incorrect email or password",
|
||||
}
|
||||
|
||||
// ErrAccountLocked — too many failed logins; temporarily locked.
|
||||
ErrAccountLocked = &apierr.Error{
|
||||
Code: "auth.account_locked",
|
||||
MessageZH: "登录失败次数过多,账户已临时锁定,请稍后再试",
|
||||
MessageEn: "Too many failed sign-in attempts, account temporarily locked",
|
||||
}
|
||||
|
||||
// ErrAccountBanned — the account has been disabled.
|
||||
ErrAccountBanned = &apierr.Error{
|
||||
Code: "auth.account_banned",
|
||||
MessageZH: "该账户已被停用",
|
||||
MessageEn: "This account has been disabled",
|
||||
}
|
||||
|
||||
// ErrInvalidToken — refresh token absent from whitelist, malformed, or expired.
|
||||
ErrInvalidToken = &apierr.Error{
|
||||
Code: "auth.invalid_token",
|
||||
MessageZH: "登录已失效,请重新登录",
|
||||
MessageEn: "Your session has expired, please sign in again",
|
||||
}
|
||||
|
||||
// ErrUnauthorized — missing or invalid bearer access token (middleware).
|
||||
ErrUnauthorized = &apierr.Error{
|
||||
Code: "auth.unauthorized",
|
||||
MessageZH: "请先登录",
|
||||
MessageEn: "Authentication required",
|
||||
}
|
||||
|
||||
// ErrInternal — unexpected server-side failure.
|
||||
ErrInternal = &apierr.Error{
|
||||
Code: "auth.internal",
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// Handler adapts the auth Service to HTTP. Route shapes follow the OpenAPI
|
||||
// contract (POST /v1/auth/{code,register,login,refresh}).
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
// NewHandler builds a Handler.
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
// RegisterRoutes mounts the four public auth endpoints onto r. The caller is
|
||||
// expected to mount this group WITHOUT the bearer-auth middleware.
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/auth/code", h.SendCode)
|
||||
r.Post("/auth/register", h.Register)
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/refresh", h.Refresh)
|
||||
}
|
||||
|
||||
// ---- request/response bodies (mirror openapi.yaml) ----
|
||||
|
||||
type sendCodeRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type tokenPairResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// SendCode handles POST /v1/auth/code.
|
||||
func (h *Handler) SendCode(w http.ResponseWriter, r *http.Request) {
|
||||
var req sendCodeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
retryAfter, apiErr := h.svc.SendCode(r.Context(), req.Email, clientIP(r))
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Register handles POST /v1/auth/register.
|
||||
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req registerRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, apiErr := h.svc.Register(r.Context(), req.Email, req.Code, req.Password)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// Login handles POST /v1/auth/login.
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, retryAfter, apiErr := h.svc.Login(r.Context(), req.Email, req.Password, clientIP(r))
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// Refresh handles POST /v1/auth/refresh.
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req refreshRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
pair, apiErr := h.svc.Refresh(r.Context(), req.RefreshToken)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
}
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// decodeJSON decodes the request body, writing a 400 on malformed input.
|
||||
// Returns false if the caller should stop (error already written).
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst interface{}) bool {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeAPIErr(w, ErrInvalidRequest, 0)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeTokenPair writes a 200 TokenPair body.
|
||||
func writeTokenPair(w http.ResponseWriter, pair *TokenPair) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(tokenPairResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
RefreshToken: pair.RefreshToken,
|
||||
ExpiresIn: pair.ExpiresIn,
|
||||
})
|
||||
}
|
||||
|
||||
// writeAPIErr maps an apierr.Error to the right HTTP status, attaching a
|
||||
// Retry-After header when provided.
|
||||
func writeAPIErr(w http.ResponseWriter, e *apierr.Error, retryAfter time.Duration) {
|
||||
status := statusFor(e)
|
||||
if retryAfter > 0 {
|
||||
secs := int(retryAfter.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
}
|
||||
apierr.WriteJSON(w, status, e)
|
||||
}
|
||||
|
||||
// statusFor maps auth error codes to HTTP status codes.
|
||||
func statusFor(e *apierr.Error) int {
|
||||
switch e.Code {
|
||||
case ErrInvalidRequest.Code, ErrEmailDisposable.Code, ErrCodeInvalid.Code:
|
||||
return http.StatusBadRequest
|
||||
case ErrEmailExists.Code:
|
||||
return http.StatusConflict
|
||||
case ErrRateLimited.Code, ErrAccountLocked.Code:
|
||||
return http.StatusTooManyRequests
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
|
||||
return http.StatusUnauthorized
|
||||
case ErrAccountBanned.Code:
|
||||
return http.StatusForbidden
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP extracts the best-effort client IP, honouring X-Forwarded-For and
|
||||
// X-Real-IP set by the edge proxy. Used only as a rate-limit key — never logged.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
// First entry is the original client.
|
||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
||||
return strings.TrimSpace(xr)
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// newTestHandler builds a Handler + chi router for HTTP-level tests.
|
||||
func newTestHandler(t *testing.T, cfg ServiceConfig) (*Service, http.Handler) {
|
||||
t.Helper()
|
||||
svc, _, _ := newService(t, cfg)
|
||||
h := NewHandler(svc)
|
||||
r := chi.NewRouter()
|
||||
h.RegisterRoutes(r)
|
||||
return svc, r
|
||||
}
|
||||
|
||||
func doJSON(t *testing.T, h http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
_ = json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.RemoteAddr = "203.0.113.5:1234"
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestHandler_SendCode_204(t *testing.T) {
|
||||
_, h := newTestHandler(t, ServiceConfig{})
|
||||
rec := doJSON(t, h, http.MethodPost, "/auth/code", map[string]string{"email": "u@example.com"})
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_SendCode_RateLimited_RetryAfter(t *testing.T) {
|
||||
_, h := newTestHandler(t, ServiceConfig{EmailPerMinute: 1})
|
||||
_ = doJSON(t, h, http.MethodPost, "/auth/code", map[string]string{"email": "u@example.com"})
|
||||
rec := doJSON(t, h, http.MethodPost, "/auth/code", map[string]string{"email": "u@example.com"})
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want 429", rec.Code)
|
||||
}
|
||||
ra := rec.Header().Get("Retry-After")
|
||||
if ra == "" {
|
||||
t.Fatal("missing Retry-After header")
|
||||
}
|
||||
if n, err := strconv.Atoi(ra); err != nil || n <= 0 {
|
||||
t.Fatalf("bad Retry-After %q", ra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_RegisterLoginRefresh_HTTP(t *testing.T) {
|
||||
svc, h := newTestHandler(t, ServiceConfig{})
|
||||
const email = "flow@example.com"
|
||||
|
||||
// Send code, read it from Redis.
|
||||
_ = doJSON(t, h, http.MethodPost, "/auth/code", map[string]string{"email": email})
|
||||
code := codeInRedis(t, svc, email)
|
||||
|
||||
// Register.
|
||||
rec := doJSON(t, h, http.MethodPost, "/auth/register", map[string]string{
|
||||
"email": email, "code": code, "password": "password123",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("register status = %d (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
var reg tokenPairResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), ®)
|
||||
if reg.AccessToken == "" || reg.ExpiresIn != 900 {
|
||||
t.Fatalf("bad register response: %+v", reg)
|
||||
}
|
||||
|
||||
// Login.
|
||||
rec = doJSON(t, h, http.MethodPost, "/auth/login", map[string]string{
|
||||
"email": email, "password": "password123",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
var login tokenPairResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &login)
|
||||
|
||||
// Refresh.
|
||||
rec = doJSON(t, h, http.MethodPost, "/auth/refresh", map[string]string{
|
||||
"refresh_token": login.RefreshToken,
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status = %d (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware_RequireAuth(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
tm := newTokenManager(t, rdb, time.Now)
|
||||
|
||||
// Protected handler that echoes the injected user id.
|
||||
var gotUID int64
|
||||
var gotOK bool
|
||||
protected := RequireAuth(tm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUID, gotOK = UserIDFromContext(r.Context())
|
||||
// Confirm the codes module reads the same value via its exported key.
|
||||
if v, ok := r.Context().Value(codes.CtxKeyUserID).(int64); !ok || v != gotUID {
|
||||
t.Errorf("codes.CtxKeyUserID mismatch: %v", v)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// No token → 401.
|
||||
rec := httptest.NewRecorder()
|
||||
protected.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/me", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no-token status = %d, want 401", rec.Code)
|
||||
}
|
||||
|
||||
// Valid token → 200 with injected uid.
|
||||
pair, _ := tm.Issue(context.Background(), 77, "uuid-77")
|
||||
req := httptest.NewRequest(http.MethodGet, "/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.AccessToken)
|
||||
rec = httptest.NewRecorder()
|
||||
protected.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("valid-token status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !gotOK || gotUID != 77 {
|
||||
t.Fatalf("injected uid = %d ok=%v, want 77", gotUID, gotOK)
|
||||
}
|
||||
|
||||
// Refresh token must be rejected by access middleware.
|
||||
req = httptest.NewRequest(http.MethodGet, "/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+pair.RefreshToken)
|
||||
rec = httptest.NewRecorder()
|
||||
protected.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("refresh-as-access status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// newMiniRedis spins up an in-memory Redis and returns a connected client.
|
||||
func newMiniRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr, err := miniredis.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis: %v", err)
|
||||
}
|
||||
t.Cleanup(mr.Close)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = rdb.Close() })
|
||||
return rdb, mr
|
||||
}
|
||||
|
||||
// newRSAKey generates a 2048-bit RSA key for signing test tokens.
|
||||
func newRSAKey(t *testing.T) *rsa.PrivateKey {
|
||||
t.Helper()
|
||||
k, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("rsa key: %v", err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// newTokenManager builds a TokenManager with the given clock and a single kid.
|
||||
func newTokenManager(t *testing.T, rdb *redis.Client, now func() time.Time) *TokenManager {
|
||||
t.Helper()
|
||||
key := newRSAKey(t)
|
||||
tm, err := NewTokenManager(rdb, TokenConfig{
|
||||
SignKey: key,
|
||||
SignKID: "k1",
|
||||
AccessTTL: 15 * time.Minute,
|
||||
RefreshTTL: 30 * 24 * time.Hour,
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTokenManager: %v", err)
|
||||
}
|
||||
return tm
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// fakeStore — in-memory UserStore for unit tests.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type trialRecord struct {
|
||||
plan string
|
||||
expiresAt time.Time
|
||||
source string
|
||||
}
|
||||
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
byEmail map[string]*User
|
||||
trials map[int64]trialRecord
|
||||
nextID int64
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{byEmail: map[string]*User{}, trials: map[int64]trialRecord{}}
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateUserWithTrial(_ context.Context, email, pwHash string, trialDays int) (*User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if _, ok := f.byEmail[email]; ok {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
f.nextID++
|
||||
u := &User{
|
||||
ID: f.nextID,
|
||||
UUID: uuid.NewString(),
|
||||
Email: email,
|
||||
PwHash: pwHash,
|
||||
DpUUID: uuid.NewString(),
|
||||
Status: "active",
|
||||
}
|
||||
f.byEmail[email] = u
|
||||
f.trials[u.ID] = trialRecord{
|
||||
plan: "pro",
|
||||
expiresAt: time.Now().UTC().AddDate(0, 0, trialDays),
|
||||
source: "trial",
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetUserByEmail(_ context.Context, email string) (*User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
u, ok := f.byEmail[email]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// setStatus mutates a stored user's status (e.g. to "banned") for tests.
|
||||
func (f *fakeStore) setStatus(email, status string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if u, ok := f.byEmail[email]; ok {
|
||||
u.Status = status
|
||||
}
|
||||
}
|
||||
|
||||
// captureMailer records the last code it was asked to send.
|
||||
type captureMailer struct {
|
||||
mu sync.Mutex
|
||||
last map[string]string
|
||||
}
|
||||
|
||||
func newCaptureMailer() *captureMailer { return &captureMailer{last: map[string]string{}} }
|
||||
|
||||
func (m *captureMailer) SendCode(_ context.Context, to, code string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.last[to] = code
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *captureMailer) codeFor(to string) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.last[to]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//go:build integration
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
|
||||
tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
func setupMySQL(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
ctr, err := tcmysql.Run(ctx, "mysql:8.0",
|
||||
tcmysql.WithDatabase("pangolin_test"),
|
||||
tcmysql.WithUsername("root"),
|
||||
tcmysql.WithPassword("test"),
|
||||
)
|
||||
testcontainers.CleanupContainer(t, ctr)
|
||||
if err != nil {
|
||||
t.Fatalf("mysql container: %v", err)
|
||||
}
|
||||
dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'")
|
||||
if err != nil {
|
||||
t.Fatalf("mysql dsn: %v", err)
|
||||
}
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open mysql: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := applyAuthSchema(db); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func setupRedis(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
ctr, err := tcredis.Run(ctx, "redis:7-alpine")
|
||||
testcontainers.CleanupContainer(t, ctr)
|
||||
if err != nil {
|
||||
t.Fatalf("redis container: %v", err)
|
||||
}
|
||||
addr, err := ctr.ConnectionString(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("redis addr: %v", err)
|
||||
}
|
||||
for _, p := range []string{"redis://", "rediss://"} {
|
||||
if len(addr) > len(p) && addr[:len(p)] == p {
|
||||
addr = addr[len(p):]
|
||||
}
|
||||
}
|
||||
rdb := redis.NewClient(&redis.Options{Addr: addr})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return rdb
|
||||
}
|
||||
|
||||
func applyAuthSchema(db *sql.DB) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS plans (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
code ENUM('free','pro','team') NOT NULL UNIQUE,
|
||||
max_devices INT NOT NULL DEFAULT 1,
|
||||
daily_minutes INT NULL,
|
||||
ad_gate BOOLEAN NOT NULL DEFAULT FALSE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
uuid CHAR(36) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
pw_hash VARCHAR(255) NOT NULL,
|
||||
dp_uuid CHAR(36) NOT NULL,
|
||||
status ENUM('active','banned') NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
plan_id BIGINT UNSIGNED NOT NULL,
|
||||
expires_at DATETIME(6) NOT NULL,
|
||||
source ENUM('trial','code') NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
FOREIGN KEY (plan_id) REFERENCES plans(id),
|
||||
INDEX idx_user_exp (user_id, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
|
||||
VALUES ('free',1,10,TRUE),('pro',5,NULL,FALSE),('team',10,NULL,FALSE)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newIntegrationService(t *testing.T, db *sql.DB, rdb *redis.Client) *Service {
|
||||
t.Helper()
|
||||
store := NewSQLStore(db)
|
||||
rl := NewRateLimiter(rdb, nil)
|
||||
key := newRSAKey(t)
|
||||
tm, err := NewTokenManager(rdb, TokenConfig{SignKey: key, SignKID: "k1"})
|
||||
if err != nil {
|
||||
t.Fatalf("token manager: %v", err)
|
||||
}
|
||||
return NewService(store, rdb, rl, tm, NewLogMailer(nil), ServiceConfig{}, nil)
|
||||
}
|
||||
|
||||
// TestIntegration_FullChain exercises register → login → refresh → protected
|
||||
// route against real MySQL 8 and Redis containers.
|
||||
func TestIntegration_FullChain(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
svc := newIntegrationService(t, db, rdb)
|
||||
ctx := context.Background()
|
||||
const email = "integration@example.com"
|
||||
const pw = "password-integration"
|
||||
|
||||
// 1. Send code (read it back from Redis to simulate the user).
|
||||
if _, e := svc.SendCode(ctx, email, "198.51.100.7"); e != nil {
|
||||
t.Fatalf("SendCode: %v", e)
|
||||
}
|
||||
code, err := rdb.Get(ctx, codeKey(email)).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("code not stored: %v", err)
|
||||
}
|
||||
|
||||
// 2. Register → trial subscription must exist for 7 days.
|
||||
pair, apiErr := svc.Register(ctx, email, code, pw)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Register: %v", apiErr)
|
||||
}
|
||||
|
||||
var plan, source string
|
||||
var expires time.Time
|
||||
err = db.QueryRowContext(ctx,
|
||||
`SELECT p.code, s.source, s.expires_at
|
||||
FROM subscriptions s JOIN plans p ON p.id = s.plan_id
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE u.email = ?`, email).Scan(&plan, &source, &expires)
|
||||
if err != nil {
|
||||
t.Fatalf("query trial: %v", err)
|
||||
}
|
||||
if plan != "pro" || source != "trial" {
|
||||
t.Fatalf("trial = %s/%s, want pro/trial", plan, source)
|
||||
}
|
||||
days := time.Until(expires).Hours() / 24
|
||||
if days < 6.5 || days > 7.1 {
|
||||
t.Fatalf("trial length = %.2f days, want ~7", days)
|
||||
}
|
||||
|
||||
// 3. Duplicate email → 409.
|
||||
if _, e := svc.SendCode(ctx, email, ""); e != nil && e.Code != ErrRateLimited.Code {
|
||||
t.Fatalf("second SendCode: %v", e)
|
||||
}
|
||||
// Force a fresh code regardless of rate limit.
|
||||
_ = rdb.Set(ctx, codeKey(email), code, 10*time.Minute).Err()
|
||||
if _, e := svc.Register(ctx, email, code, pw); e == nil || e.Code != ErrEmailExists.Code {
|
||||
t.Fatalf("want email_exists, got %v", e)
|
||||
}
|
||||
|
||||
// 4. Login.
|
||||
loginPair, _, apiErr := svc.Login(ctx, email, pw, "198.51.100.7")
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Login: %v", apiErr)
|
||||
}
|
||||
|
||||
// 5. Refresh rotates.
|
||||
rotated, apiErr := svc.Refresh(ctx, loginPair.RefreshToken)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Refresh: %v", apiErr)
|
||||
}
|
||||
if _, e := svc.Refresh(ctx, loginPair.RefreshToken); e == nil {
|
||||
t.Fatal("old refresh token must be rejected after rotation")
|
||||
}
|
||||
|
||||
// 6. Access a protected route with the rotated access token.
|
||||
protected := RequireAuth(svc.tokens)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok || uid == 0 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Confirm interop with the codes module's context key.
|
||||
if _, ok := r.Context().Value(codes.CtxKeyUserID).(int64); !ok {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+rotated.AccessToken)
|
||||
rec := httptest.NewRecorder()
|
||||
protected.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("protected route status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
// Pair returned at registration is also a valid access token.
|
||||
if _, e := svc.tokens.ParseAccess(pair.AccessToken); e != nil {
|
||||
t.Fatalf("register access token invalid: %v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoadTokenConfig assembles a TokenConfig from PEM files on disk: one private
|
||||
// signing key (privateKeyPath, identified by signKID) and a set of public keys
|
||||
// (publicKeyPaths: kid -> path) accepted for verification, supporting key
|
||||
// rotation. The signing key's public part is added automatically if its kid is
|
||||
// absent from publicKeyPaths.
|
||||
func LoadTokenConfig(privateKeyPath, signKID string, publicKeyPaths map[string]string) (TokenConfig, error) {
|
||||
if privateKeyPath == "" {
|
||||
return TokenConfig{}, fmt.Errorf("auth: JWT private key path is empty")
|
||||
}
|
||||
if signKID == "" {
|
||||
return TokenConfig{}, fmt.Errorf("auth: JWT key id (kid) is empty")
|
||||
}
|
||||
|
||||
privPEM, err := os.ReadFile(privateKeyPath)
|
||||
if err != nil {
|
||||
return TokenConfig{}, fmt.Errorf("auth: read private key: %w", err)
|
||||
}
|
||||
priv, err := LoadPrivateKeyPEM(privPEM)
|
||||
if err != nil {
|
||||
return TokenConfig{}, err
|
||||
}
|
||||
|
||||
verify := map[string]*rsa.PublicKey{}
|
||||
for kid, path := range publicKeyPaths {
|
||||
pubPEM, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return TokenConfig{}, fmt.Errorf("auth: read public key %q: %w", kid, err)
|
||||
}
|
||||
pub, err := LoadPublicKeyPEM(pubPEM)
|
||||
if err != nil {
|
||||
return TokenConfig{}, fmt.Errorf("auth: parse public key %q: %w", kid, err)
|
||||
}
|
||||
verify[kid] = pub
|
||||
}
|
||||
|
||||
return TokenConfig{
|
||||
SignKey: priv,
|
||||
SignKID: signKID,
|
||||
VerifyKeys: verify,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Mailer delivers a verification code to an email address. Implementations must
|
||||
// be safe for concurrent use; Send is typically invoked from a goroutine.
|
||||
type Mailer interface {
|
||||
SendCode(ctx context.Context, to, code string) error
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// SMTP implementation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// SMTPConfig configures the SMTP mailer.
|
||||
type SMTPConfig struct {
|
||||
Host string // SMTP host (no port)
|
||||
Port int // SMTP port, e.g. 587
|
||||
Username string
|
||||
Password string
|
||||
From string // From header, e.g. "Pangolin <no-reply@pangolin.app>"
|
||||
}
|
||||
|
||||
// SMTPMailer sends verification codes over SMTP with STARTTLS (PlainAuth).
|
||||
type SMTPMailer struct {
|
||||
cfg SMTPConfig
|
||||
}
|
||||
|
||||
// NewSMTPMailer builds an SMTPMailer.
|
||||
func NewSMTPMailer(cfg SMTPConfig) *SMTPMailer { return &SMTPMailer{cfg: cfg} }
|
||||
|
||||
// SendCode delivers the verification code. The message body intentionally
|
||||
// contains no product-identifying or destination wording beyond a neutral
|
||||
// account-verification notice.
|
||||
func (m *SMTPMailer) SendCode(_ context.Context, to, code string) error {
|
||||
subject := "Your verification code / 验证码"
|
||||
body := fmt.Sprintf(
|
||||
"Your verification code is %s. It expires in 10 minutes.\r\n"+
|
||||
"您的验证码为 %s,10 分钟内有效。请勿向他人泄露。\r\n",
|
||||
code, code)
|
||||
|
||||
msg := strings.Join([]string{
|
||||
"From: " + m.cfg.From,
|
||||
"To: " + to,
|
||||
"Subject: " + subject,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port)
|
||||
auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
|
||||
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, []byte(msg)); err != nil {
|
||||
return fmt.Errorf("auth: smtp send: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Development log implementation
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// LogMailer is a development-only Mailer that prints the code to the process
|
||||
// log instead of sending an email. It MUST NOT be used in production — it is
|
||||
// the single deliberate exception to the no-secret-in-logs rule, scoped to
|
||||
// local development where no real mailbox exists.
|
||||
type LogMailer struct {
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// NewLogMailer builds a LogMailer. If logger is nil, the standard logger is used.
|
||||
func NewLogMailer(logger *log.Logger) *LogMailer {
|
||||
return &LogMailer{logger: logger}
|
||||
}
|
||||
|
||||
// SendCode logs the code for local development.
|
||||
func (m *LogMailer) SendCode(_ context.Context, to, code string) error {
|
||||
if m.logger != nil {
|
||||
m.logger.Printf("[dev-mailer] verification code for %s: %s", to, code)
|
||||
} else {
|
||||
log.Printf("[dev-mailer] verification code for %s: %s", to, code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/codes"
|
||||
)
|
||||
|
||||
// ctxKey is this package's private context-key type for values other than the
|
||||
// numeric user id (which is shared with the codes module — see below).
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
// ctxKeyUserUUID stores the authenticated user's UUID (the JWT subject).
|
||||
ctxKeyUserUUID ctxKey = "user_uuid"
|
||||
// ctxKeyClaims stores the full *Claims for handlers that need jti/exp.
|
||||
ctxKeyClaims ctxKey = "claims"
|
||||
)
|
||||
|
||||
// The numeric user id is injected under codes.CtxKeyUserID so that the codes
|
||||
// module (and any other module sharing that exported key) reads the same value
|
||||
// without an import cycle — codes deliberately exports the key for this purpose.
|
||||
|
||||
// RequireAuth returns middleware that enforces a valid Bearer access token on
|
||||
// every wrapped route. On success it injects the numeric user id, the user
|
||||
// UUID, and the parsed claims into the request context. This is the auth base
|
||||
// for all protected /v1 routes (everything except the auth group).
|
||||
func RequireAuth(tm *TokenManager) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearerToken(r)
|
||||
if !ok {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
claims, err := tm.ParseAccess(token)
|
||||
if err != nil {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, codes.CtxKeyUserID, claims.UID)
|
||||
ctx = context.WithValue(ctx, ctxKeyUserUUID, claims.Subject)
|
||||
ctx = context.WithValue(ctx, ctxKeyClaims, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken extracts the token from an "Authorization: Bearer <token>" header.
|
||||
func bearerToken(r *http.Request) (string, bool) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return "", false
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimSpace(h[len(prefix):])
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
// UserIDFromContext returns the numeric user id injected by RequireAuth.
|
||||
func UserIDFromContext(ctx context.Context) (int64, bool) {
|
||||
v, ok := ctx.Value(codes.CtxKeyUserID).(int64)
|
||||
return v, ok && v != 0
|
||||
}
|
||||
|
||||
// UserUUIDFromContext returns the user UUID injected by RequireAuth.
|
||||
func UserUUIDFromContext(ctx context.Context) (string, bool) {
|
||||
v, ok := ctx.Value(ctxKeyUserUUID).(string)
|
||||
return v, ok && v != ""
|
||||
}
|
||||
|
||||
// ClaimsFromContext returns the parsed access-token claims injected by RequireAuth.
|
||||
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
||||
v, ok := ctx.Value(ctxKeyClaims).(*Claims)
|
||||
return v, ok
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// argon2id parameters. Tuned for a control-plane login path: ~64 MiB memory,
|
||||
// single pass, 4 lanes. Kept as constants so the encoded hash is self-describing
|
||||
// and parameters can evolve without breaking existing hashes (params are parsed
|
||||
// back out of the stored string at verify time).
|
||||
const (
|
||||
argonMemory uint32 = 64 * 1024 // KiB → 64 MiB
|
||||
argonTime uint32 = 1
|
||||
argonThreads uint8 = 4
|
||||
argonKeyLen uint32 = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
// errInvalidHash is returned when a stored PHC string cannot be parsed.
|
||||
var errInvalidHash = errors.New("auth: invalid argon2id hash format")
|
||||
|
||||
// HashPassword derives an argon2id hash and returns it in the standard PHC
|
||||
// string format: $argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("auth: read salt: %w", err)
|
||||
}
|
||||
return encodeArgon(password, salt, argonTime, argonMemory, argonThreads, argonKeyLen), nil
|
||||
}
|
||||
|
||||
// encodeArgon computes the hash and formats the PHC string.
|
||||
func encodeArgon(password string, salt []byte, t, m uint32, p uint8, keyLen uint32) string {
|
||||
key := argon2.IDKey([]byte(password), salt, t, m, p, keyLen)
|
||||
return fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, m, t, p,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(key),
|
||||
)
|
||||
}
|
||||
|
||||
// VerifyPassword reports whether password matches the encoded argon2id hash.
|
||||
// The comparison is constant-time. A malformed encoded hash returns false with
|
||||
// an error so callers can distinguish "wrong password" from "corrupt record".
|
||||
func VerifyPassword(encoded, password string) (bool, error) {
|
||||
t, m, p, salt, key, err := decodeArgon(encoded)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
other := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(key)))
|
||||
if subtle.ConstantTimeCompare(key, other) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// decodeArgon parses a PHC argon2id string back into its parameters.
|
||||
func decodeArgon(encoded string) (t, m uint32, p uint8, salt, key []byte, err error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, key]
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return 0, 0, 0, nil, nil, errInvalidHash
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err = fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return 0, 0, 0, nil, nil, errInvalidHash
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return 0, 0, 0, nil, nil, errInvalidHash
|
||||
}
|
||||
if _, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
|
||||
return 0, 0, 0, nil, nil, errInvalidHash
|
||||
}
|
||||
if salt, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil {
|
||||
return 0, 0, 0, nil, nil, errInvalidHash
|
||||
}
|
||||
if key, err = base64.RawStdEncoding.DecodeString(parts[5]); err != nil {
|
||||
return 0, 0, 0, nil, nil, errInvalidHash
|
||||
}
|
||||
return t, m, p, salt, key, nil
|
||||
}
|
||||
|
||||
// dummyHash is a pre-computed argon2id hash used to spend roughly the same CPU
|
||||
// time when a login targets a non-existent account, keeping the login response
|
||||
// time constant regardless of whether the email exists. Computed lazily once.
|
||||
var dummyHash = mustDummyHash()
|
||||
|
||||
func mustDummyHash() string {
|
||||
h, err := HashPassword("pangolin-constant-time-placeholder")
|
||||
if err != nil {
|
||||
// Fall back to a static PHC string; verification will simply fail.
|
||||
return "$argon2id$v=19$m=65536,t=1,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// ConstantTimeReject performs a throwaway argon2id verification against a dummy
|
||||
// hash. Login handlers call this when the account does not exist so the timing
|
||||
// profile matches the "account exists, wrong password" branch.
|
||||
func ConstantTimeReject(password string) {
|
||||
_, _ = VerifyPassword(dummyHash, password)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPassword_RoundTrip(t *testing.T) {
|
||||
const pw = "correct horse battery staple"
|
||||
hash, err := HashPassword(pw)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$v=19$") {
|
||||
t.Fatalf("unexpected PHC prefix: %s", hash)
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(hash, pw)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected password to verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword_DistinctSalts(t *testing.T) {
|
||||
h1, _ := HashPassword("same")
|
||||
h2, _ := HashPassword("same")
|
||||
if h1 == h2 {
|
||||
t.Fatal("expected distinct hashes for equal passwords (random salt)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_Wrong(t *testing.T) {
|
||||
hash, _ := HashPassword("right")
|
||||
ok, err := VerifyPassword(hash, "wrong")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected wrong password to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPassword_Malformed(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"not-a-hash",
|
||||
"$argon2id$v=19$bad",
|
||||
"$bcrypt$v=19$m=1,t=1,p=1$aaaa$bbbb",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := VerifyPassword(c, "x"); err == nil {
|
||||
t.Fatalf("expected error for malformed hash %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RateLimiter implements per-scope sliding-window rate limiting and failure
|
||||
// counters on top of Redis. It stores nothing but short-lived counters — no
|
||||
// connection or behaviour logs — per the no-log baseline (doc/06 §4).
|
||||
//
|
||||
// Sliding-window keys are ZSETs named `rl:{scope}:{key}` whose members are
|
||||
// timestamped attempts; failure counters are plain integer keys.
|
||||
type RateLimiter struct {
|
||||
rdb *redis.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRateLimiter builds a RateLimiter. now may be nil (defaults to time.Now).
|
||||
func NewRateLimiter(rdb *redis.Client, now func() time.Time) *RateLimiter {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &RateLimiter{rdb: rdb, now: now}
|
||||
}
|
||||
|
||||
// slidingWindow is a single atomic Lua script:
|
||||
// - drops members older than the window,
|
||||
// - if the live count already reached the limit, returns {0, oldestScore},
|
||||
// - otherwise records the attempt and returns {1, 0}.
|
||||
//
|
||||
// All times are unix-milliseconds.
|
||||
var slidingWindow = redis.NewScript(`
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local window = tonumber(ARGV[2])
|
||||
local limit = tonumber(ARGV[3])
|
||||
local member = ARGV[4]
|
||||
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
|
||||
local count = redis.call('ZCARD', key)
|
||||
if count >= limit then
|
||||
local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
|
||||
return {0, oldest[2]}
|
||||
end
|
||||
redis.call('ZADD', key, now, member)
|
||||
redis.call('PEXPIRE', key, window)
|
||||
return {1, 0}
|
||||
`)
|
||||
|
||||
// rlKey builds the canonical `rl:{scope}:{key}` Redis key.
|
||||
func rlKey(scope, key string) string {
|
||||
return fmt.Sprintf("rl:%s:%s", scope, key)
|
||||
}
|
||||
|
||||
// Allow records an attempt in the sliding window for (scope, key). It returns
|
||||
// allowed=false together with the Retry-After duration when the limit within
|
||||
// window has been reached. limit is the maximum number of attempts permitted
|
||||
// inside window.
|
||||
func (rl *RateLimiter) Allow(ctx context.Context, scope, key string, limit int, window time.Duration) (allowed bool, retryAfter time.Duration, err error) {
|
||||
nowMs := rl.now().UnixMilli()
|
||||
winMs := window.Milliseconds()
|
||||
|
||||
member, err := uniqueMember(nowMs)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
res, err := slidingWindow.Run(ctx, rl.rdb, []string{rlKey(scope, key)},
|
||||
nowMs, winMs, limit, member).Result()
|
||||
if err != nil {
|
||||
return false, 0, fmt.Errorf("auth: ratelimit run: %w", err)
|
||||
}
|
||||
|
||||
vals, ok := res.([]interface{})
|
||||
if !ok || len(vals) != 2 {
|
||||
return false, 0, fmt.Errorf("auth: ratelimit unexpected result %v", res)
|
||||
}
|
||||
ok1, _ := vals[0].(int64)
|
||||
if ok1 == 1 {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
// Denied: compute how long until the oldest attempt leaves the window.
|
||||
oldest := toInt64(vals[1])
|
||||
ra := time.Duration(oldest+winMs-nowMs) * time.Millisecond
|
||||
if ra < time.Second {
|
||||
ra = time.Second
|
||||
}
|
||||
return false, ra, nil
|
||||
}
|
||||
|
||||
// failKey builds the failure-counter key, e.g. `rl:login:{email}`.
|
||||
func failKey(scope, key string) string {
|
||||
return rlKey(scope, key)
|
||||
}
|
||||
|
||||
// RecordFailure increments the failure counter for (scope, key), (re)setting
|
||||
// its TTL to window on every increment so the lock slides forward while abuse
|
||||
// continues. It returns the new count.
|
||||
func (rl *RateLimiter) RecordFailure(ctx context.Context, scope, key string, window time.Duration) (int64, error) {
|
||||
k := failKey(scope, key)
|
||||
pipe := rl.rdb.Pipeline()
|
||||
incr := pipe.Incr(ctx, k)
|
||||
pipe.Expire(ctx, k, window)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return 0, fmt.Errorf("auth: record failure: %w", err)
|
||||
}
|
||||
return incr.Val(), nil
|
||||
}
|
||||
|
||||
// FailureCount returns the current failure count and the remaining TTL (lock
|
||||
// window) for (scope, key). count is 0 when no counter exists.
|
||||
func (rl *RateLimiter) FailureCount(ctx context.Context, scope, key string) (count int64, ttl time.Duration, err error) {
|
||||
k := failKey(scope, key)
|
||||
pipe := rl.rdb.Pipeline()
|
||||
get := pipe.Get(ctx, k)
|
||||
pttl := pipe.PTTL(ctx, k)
|
||||
if _, err := pipe.Exec(ctx); err != nil && err != redis.Nil {
|
||||
return 0, 0, fmt.Errorf("auth: failure count: %w", err)
|
||||
}
|
||||
n, _ := get.Int64()
|
||||
d := pttl.Val()
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
return n, d, nil
|
||||
}
|
||||
|
||||
// ClearFailures removes the failure counter after a successful authentication.
|
||||
func (rl *RateLimiter) ClearFailures(ctx context.Context, scope, key string) error {
|
||||
return rl.rdb.Del(ctx, failKey(scope, key)).Err()
|
||||
}
|
||||
|
||||
// uniqueMember returns a ZSET member that is unique even for attempts that share
|
||||
// the same millisecond timestamp (timestamp prefix keeps ordering stable).
|
||||
func uniqueMember(nowMs int64) (string, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("auth: ratelimit member: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%d-%s", nowMs, hex.EncodeToString(b[:])), nil
|
||||
}
|
||||
|
||||
// toInt64 coerces a Redis Lua return value (which may be int64 or string) to int64.
|
||||
func toInt64(v interface{}) int64 {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
return t
|
||||
case string:
|
||||
var n int64
|
||||
_, _ = fmt.Sscanf(t, "%d", &n)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRateLimiter_SlidingWindow(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
base := time.Unix(1_700_000_000, 0)
|
||||
clock := base
|
||||
rl := NewRateLimiter(rdb, func() time.Time { return clock })
|
||||
ctx := context.Background()
|
||||
|
||||
// limit 1 per minute.
|
||||
ok, _, err := rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("first attempt should pass: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
// Second attempt within the window is denied with a Retry-After.
|
||||
ok, ra, err := rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("second attempt within window should be denied")
|
||||
}
|
||||
if ra <= 0 || ra > time.Minute {
|
||||
t.Fatalf("retry-after = %v, want (0, 1m]", ra)
|
||||
}
|
||||
|
||||
// After the window passes, attempts are allowed again.
|
||||
clock = base.Add(61 * time.Second)
|
||||
ok, _, err = rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("attempt after window should pass: ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_DistinctKeysIndependent(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
rl := NewRateLimiter(rdb, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
if ok, _, _ := rl.Allow(ctx, "code:email", "a@b.com", 1, time.Minute); !ok {
|
||||
t.Fatal("a@b.com first should pass")
|
||||
}
|
||||
if ok, _, _ := rl.Allow(ctx, "code:email", "c@d.com", 1, time.Minute); !ok {
|
||||
t.Fatal("c@d.com first should pass (independent key)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiter_FailureCounter(t *testing.T) {
|
||||
rdb, mr := newMiniRedis(t)
|
||||
rl := NewRateLimiter(rdb, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 1; i <= 3; i++ {
|
||||
n, err := rl.RecordFailure(ctx, "login", "a@b.com", 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordFailure: %v", err)
|
||||
}
|
||||
if n != int64(i) {
|
||||
t.Fatalf("count = %d, want %d", n, i)
|
||||
}
|
||||
}
|
||||
|
||||
count, ttl, err := rl.FailureCount(ctx, "login", "a@b.com")
|
||||
if err != nil {
|
||||
t.Fatalf("FailureCount: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("count = %d, want 3", count)
|
||||
}
|
||||
if ttl <= 0 {
|
||||
t.Fatalf("ttl = %v, want > 0", ttl)
|
||||
}
|
||||
|
||||
// Clear resets the counter.
|
||||
if err := rl.ClearFailures(ctx, "login", "a@b.com"); err != nil {
|
||||
t.Fatalf("ClearFailures: %v", err)
|
||||
}
|
||||
count, _, _ = rl.FailureCount(ctx, "login", "a@b.com")
|
||||
if count != 0 {
|
||||
t.Fatalf("count after clear = %d, want 0", count)
|
||||
}
|
||||
|
||||
// Counter expires after its window.
|
||||
_, _ = rl.RecordFailure(ctx, "login", "x@y.com", 15*time.Minute)
|
||||
mr.FastForward(16 * time.Minute)
|
||||
count, _, _ = rl.FailureCount(ctx, "login", "x@y.com")
|
||||
if count != 0 {
|
||||
t.Fatalf("count after TTL = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// Redis key helpers for verification codes (doc/03 §4: auth:code:{email}).
|
||||
func codeKey(email string) string { return "auth:code:" + email }
|
||||
func codeAttemptsKey(email string) string { return "auth:code:attempts:" + email }
|
||||
|
||||
// Rate-limit scopes.
|
||||
const (
|
||||
scopeCodeEmail = "code:email" // per-email send limit
|
||||
scopeCodeIP = "code:ip" // per-IP send limit
|
||||
scopeLogin = "login" // per-email login-failure counter
|
||||
)
|
||||
|
||||
// ServiceConfig tunes the auth service. Zero values fall back to the documented
|
||||
// defaults (doc/02 §4.1, doc/06 §3).
|
||||
type ServiceConfig struct {
|
||||
CodeTTL time.Duration // verification code lifetime (default 10m)
|
||||
CodeMaxAttempts int // max verify attempts before code is burned (default 5)
|
||||
TrialDays int // auto PRO trial length (default 7)
|
||||
|
||||
EmailPerMinute int // code sends per email per minute (default 1)
|
||||
IPPerHour int // code sends per IP per hour (default 10)
|
||||
LoginFailMax int // failed logins before lock (default 5)
|
||||
LoginLockWindow time.Duration // lock / failure-window length (default 15m)
|
||||
}
|
||||
|
||||
func (c *ServiceConfig) withDefaults() {
|
||||
if c.CodeTTL <= 0 {
|
||||
c.CodeTTL = 10 * time.Minute
|
||||
}
|
||||
if c.CodeMaxAttempts <= 0 {
|
||||
c.CodeMaxAttempts = 5
|
||||
}
|
||||
if c.TrialDays <= 0 {
|
||||
c.TrialDays = 7
|
||||
}
|
||||
if c.EmailPerMinute <= 0 {
|
||||
c.EmailPerMinute = 1
|
||||
}
|
||||
if c.IPPerHour <= 0 {
|
||||
c.IPPerHour = 10
|
||||
}
|
||||
if c.LoginFailMax <= 0 {
|
||||
c.LoginFailMax = 5
|
||||
}
|
||||
if c.LoginLockWindow <= 0 {
|
||||
c.LoginLockWindow = 15 * time.Minute
|
||||
}
|
||||
}
|
||||
|
||||
// Service is the auth business layer: code issuance, registration, login, and
|
||||
// token refresh. It is safe for concurrent use.
|
||||
type Service struct {
|
||||
store UserStore
|
||||
rdb *redis.Client
|
||||
rl *RateLimiter
|
||||
tokens *TokenManager
|
||||
mailer Mailer
|
||||
cfg ServiceConfig
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService wires the auth service. now may be nil (defaults to time.Now).
|
||||
func NewService(store UserStore, rdb *redis.Client, rl *RateLimiter, tokens *TokenManager, mailer Mailer, cfg ServiceConfig, now func() time.Time) *Service {
|
||||
cfg.withDefaults()
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{
|
||||
store: store,
|
||||
rdb: rdb,
|
||||
rl: rl,
|
||||
tokens: tokens,
|
||||
mailer: mailer,
|
||||
cfg: cfg,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// SendCode applies rate-limiting and disposable-domain checks, generates a
|
||||
// 6-digit code, stores it in Redis (TTL CodeTTL), and dispatches it
|
||||
// asynchronously. retryAfter is non-zero only when a rate limit was hit.
|
||||
func (s *Service) SendCode(ctx context.Context, rawEmail, ip string) (retryAfter time.Duration, apiErr *apierr.Error) {
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if !ValidEmail(email) {
|
||||
return 0, ErrInvalidRequest
|
||||
}
|
||||
if IsDisposable(email) {
|
||||
return 0, ErrEmailDisposable
|
||||
}
|
||||
|
||||
// Per-email limit: 1/min by default.
|
||||
ok, ra, err := s.rl.Allow(ctx, scopeCodeEmail, email, s.cfg.EmailPerMinute, time.Minute)
|
||||
if err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
if !ok {
|
||||
return ra, ErrRateLimited
|
||||
}
|
||||
// Per-IP limit: e.g. 10/h. Skipped when IP is unknown.
|
||||
if ip != "" {
|
||||
ok, ra, err = s.rl.Allow(ctx, scopeCodeIP, ip, s.cfg.IPPerHour, time.Hour)
|
||||
if err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
if !ok {
|
||||
return ra, ErrRateLimited
|
||||
}
|
||||
}
|
||||
|
||||
code, err := genNumericCode(6)
|
||||
if err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
|
||||
pipe := s.rdb.Pipeline()
|
||||
pipe.Set(ctx, codeKey(email), code, s.cfg.CodeTTL)
|
||||
pipe.Del(ctx, codeAttemptsKey(email)) // reset attempt counter for the new code
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return 0, ErrInternal
|
||||
}
|
||||
|
||||
// Dispatch asynchronously; the request must not block on SMTP. A detached
|
||||
// context is used so request cancellation doesn't abort delivery.
|
||||
go func(to, c string) {
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = s.mailer.SendCode(sendCtx, to, c)
|
||||
}(email, code)
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Register verifies the code (one-time), creates the account plus a 7-day PRO
|
||||
// trial in a single transaction, and returns a fresh token pair.
|
||||
func (s *Service) Register(ctx context.Context, rawEmail, code, password string) (*TokenPair, *apierr.Error) {
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if !ValidEmail(email) || len(password) < 8 || len(code) != 6 {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
|
||||
// Verify the code with brute-force protection.
|
||||
if apiErr := s.verifyCode(ctx, email, code); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
|
||||
pwHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, ErrInternal
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUserWithTrial(ctx, email, pwHash, s.cfg.TrialDays)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailTaken) {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
return nil, ErrInternal
|
||||
}
|
||||
|
||||
pair, err := s.tokens.Issue(ctx, user.ID, user.UUID)
|
||||
if err != nil {
|
||||
return nil, ErrInternal
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// verifyCode checks the supplied code against Redis. The code is consumed
|
||||
// (deleted) on success, and after CodeMaxAttempts failed tries it is burned to
|
||||
// stop brute forcing the 6-digit space. All failure modes return ErrCodeInvalid
|
||||
// to avoid distinguishing wrong / expired / used.
|
||||
func (s *Service) verifyCode(ctx context.Context, email, code string) *apierr.Error {
|
||||
stored, err := s.rdb.Get(ctx, codeKey(email)).Result()
|
||||
if err == redis.Nil {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return ErrInternal
|
||||
}
|
||||
|
||||
// Count this attempt (TTL bounded by the code lifetime).
|
||||
attempts, err := s.rdb.Incr(ctx, codeAttemptsKey(email)).Result()
|
||||
if err != nil {
|
||||
return ErrInternal
|
||||
}
|
||||
if attempts == 1 {
|
||||
_ = s.rdb.Expire(ctx, codeAttemptsKey(email), s.cfg.CodeTTL).Err()
|
||||
}
|
||||
if attempts > int64(s.cfg.CodeMaxAttempts) {
|
||||
// Burn the code and the counter.
|
||||
_ = s.rdb.Del(ctx, codeKey(email), codeAttemptsKey(email)).Err()
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(stored), []byte(code)) != 1 {
|
||||
return ErrCodeInvalid
|
||||
}
|
||||
|
||||
// Success: consume the code (one-time use).
|
||||
_ = s.rdb.Del(ctx, codeKey(email), codeAttemptsKey(email)).Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login authenticates email+password with constant-time behaviour and a
|
||||
// failure-count lock. retryAfter is non-zero only when the account is locked.
|
||||
func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*TokenPair, time.Duration, *apierr.Error) {
|
||||
_ = ip // IP reserved for future per-IP login throttling; not logged.
|
||||
email := NormalizeEmail(rawEmail)
|
||||
if email == "" || password == "" {
|
||||
return nil, 0, ErrInvalidRequest
|
||||
}
|
||||
|
||||
// Lock check.
|
||||
count, ttl, err := s.rl.FailureCount(ctx, scopeLogin, email)
|
||||
if err != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
if count >= int64(s.cfg.LoginFailMax) && ttl > 0 {
|
||||
return nil, ttl, ErrAccountLocked
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Spend equivalent CPU so timing doesn't reveal account existence.
|
||||
ConstantTimeReject(password)
|
||||
_, _ = s.rl.RecordFailure(ctx, scopeLogin, email, s.cfg.LoginLockWindow)
|
||||
return nil, 0, ErrInvalidCredentials
|
||||
}
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
|
||||
valid, verr := VerifyPassword(user.PwHash, password)
|
||||
if verr != nil || !valid {
|
||||
_, _ = s.rl.RecordFailure(ctx, scopeLogin, email, s.cfg.LoginLockWindow)
|
||||
return nil, 0, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if user.Status == "banned" {
|
||||
return nil, 0, ErrAccountBanned
|
||||
}
|
||||
|
||||
// Success: clear the failure counter and issue tokens.
|
||||
_ = s.rl.ClearFailures(ctx, scopeLogin, email)
|
||||
pair, err := s.tokens.Issue(ctx, user.ID, user.UUID)
|
||||
if err != nil {
|
||||
return nil, 0, ErrInternal
|
||||
}
|
||||
return pair, 0, nil
|
||||
}
|
||||
|
||||
// Refresh validates and rotates a refresh token.
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, *apierr.Error) {
|
||||
if refreshToken == "" {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
pair, err := s.tokens.Refresh(ctx, refreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidTokenSentinel) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
// Parse / signature / expiry failures all map to an opaque invalid-token.
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// genNumericCode returns an n-digit numeric string drawn from crypto/rand.
|
||||
func genNumericCode(n int) (string, error) {
|
||||
const digits = "0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits))))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: gen code: %w", err)
|
||||
}
|
||||
b[i] = digits[idx.Int64()]
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newService wires a Service over a fake store + miniredis for unit tests.
|
||||
// It returns the service, the redis client (to read codes directly), the fake
|
||||
// store, and the miniredis handle (to fast-forward TTLs).
|
||||
func newService(t *testing.T, cfg ServiceConfig) (*Service, *fakeStore, *captureMailer) {
|
||||
t.Helper()
|
||||
rdb, _ := newMiniRedis(t)
|
||||
rl := NewRateLimiter(rdb, nil)
|
||||
tm := newTokenManager(t, rdb, time.Now)
|
||||
store := newFakeStore()
|
||||
mailer := newCaptureMailer()
|
||||
svc := NewService(store, rdb, rl, tm, mailer, cfg, nil)
|
||||
return svc, store, mailer
|
||||
}
|
||||
|
||||
// codeInRedis reads the active verification code straight from Redis.
|
||||
func codeInRedis(t *testing.T, svc *Service, email string) string {
|
||||
t.Helper()
|
||||
c, err := svc.rdb.Get(context.Background(), codeKey(email)).Result()
|
||||
if err != nil {
|
||||
t.Fatalf("no code stored for %s: %v", email, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestService_RegisterFullFlow(t *testing.T) {
|
||||
svc, store, _ := newService(t, ServiceConfig{})
|
||||
ctx := context.Background()
|
||||
const email = "alice@example.com"
|
||||
|
||||
if _, err := svc.SendCode(ctx, email, "1.1.1.1"); err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
code := codeInRedis(t, svc, email)
|
||||
|
||||
pair, apiErr := svc.Register(ctx, email, code, "supersecret")
|
||||
if apiErr != nil {
|
||||
t.Fatalf("Register: %v", apiErr)
|
||||
}
|
||||
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
|
||||
t.Fatalf("bad token pair: %+v", pair)
|
||||
}
|
||||
|
||||
// User exists.
|
||||
u, err := store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
t.Fatalf("user not created: %v", err)
|
||||
}
|
||||
// Trial subscription exists, PRO, ~7 days.
|
||||
tr, ok := store.trials[u.ID]
|
||||
if !ok {
|
||||
t.Fatal("trial subscription not created")
|
||||
}
|
||||
if tr.plan != "pro" || tr.source != "trial" {
|
||||
t.Fatalf("trial = %+v, want pro/trial", tr)
|
||||
}
|
||||
days := time.Until(tr.expiresAt).Hours() / 24
|
||||
if days < 6.9 || days > 7.1 {
|
||||
t.Fatalf("trial length = %.2f days, want ~7", days)
|
||||
}
|
||||
|
||||
// Issued access token authenticates.
|
||||
claims, perr := svc.tokens.ParseAccess(pair.AccessToken)
|
||||
if perr != nil {
|
||||
t.Fatalf("ParseAccess: %v", perr)
|
||||
}
|
||||
if claims.UID != u.ID || claims.Subject != u.UUID {
|
||||
t.Fatalf("claims mismatch: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_DuplicateEmailConflict(t *testing.T) {
|
||||
// Higher send limit so the two registration attempts can each request a code.
|
||||
svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 10})
|
||||
ctx := context.Background()
|
||||
const email = "dup@example.com"
|
||||
|
||||
// First registration.
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1"); e != nil {
|
||||
t.Fatalf("first register: %v", e)
|
||||
}
|
||||
|
||||
// Second: new code, but the email is already taken → 409.
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
_, apiErr := svc.Register(ctx, email, codeInRedis(t, svc, email), "password2")
|
||||
if apiErr == nil || apiErr.Code != ErrEmailExists.Code {
|
||||
t.Fatalf("want email_exists, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CodeWrong(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{})
|
||||
ctx := context.Background()
|
||||
const email = "wrong@example.com"
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
|
||||
_, apiErr := svc.Register(ctx, email, "000000", "password1")
|
||||
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CodeExpired(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{CodeTTL: time.Minute})
|
||||
ctx := context.Background()
|
||||
const email = "expired@example.com"
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
code := codeInRedis(t, svc, email)
|
||||
|
||||
// Expire the code key.
|
||||
svc.rdb.Del(ctx, codeKey(email))
|
||||
|
||||
_, apiErr := svc.Register(ctx, email, code, "password1")
|
||||
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid after expiry, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CodeReuseRejected(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{})
|
||||
ctx := context.Background()
|
||||
const email = "reuse@example.com"
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
code := codeInRedis(t, svc, email)
|
||||
|
||||
if _, e := svc.Register(ctx, email, code, "password1"); e != nil {
|
||||
t.Fatalf("first register: %v", e)
|
||||
}
|
||||
// Re-using the consumed code must fail.
|
||||
_, apiErr := svc.Register(ctx, "other@example.com", code, "password1")
|
||||
if apiErr == nil || apiErr.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("want code_invalid on reuse, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CodeBruteForceBurned(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{CodeMaxAttempts: 3})
|
||||
ctx := context.Background()
|
||||
const email = "brute@example.com"
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
good := codeInRedis(t, svc, email)
|
||||
|
||||
// 3 wrong attempts burn the code.
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, e := svc.Register(ctx, email, "999999", "password1"); e == nil {
|
||||
t.Fatal("wrong code should fail")
|
||||
}
|
||||
}
|
||||
// Even the correct code no longer works.
|
||||
if _, e := svc.Register(ctx, email, good, "password1"); e == nil || e.Code != ErrCodeInvalid.Code {
|
||||
t.Fatalf("burned code should reject correct value, got %v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SendCodeRateLimited(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{EmailPerMinute: 1})
|
||||
ctx := context.Background()
|
||||
const email = "rl@example.com"
|
||||
|
||||
if _, e := svc.SendCode(ctx, email, "9.9.9.9"); e != nil {
|
||||
t.Fatalf("first send: %v", e)
|
||||
}
|
||||
ra, apiErr := svc.SendCode(ctx, email, "9.9.9.9")
|
||||
if apiErr == nil || apiErr.Code != ErrRateLimited.Code {
|
||||
t.Fatalf("want rate_limited, got %v", apiErr)
|
||||
}
|
||||
if ra <= 0 {
|
||||
t.Fatalf("expected positive retry-after, got %v", ra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_SendCodeDisposableBlocked(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{})
|
||||
_, apiErr := svc.SendCode(context.Background(), "x@mailinator.com", "")
|
||||
if apiErr == nil || apiErr.Code != ErrEmailDisposable.Code {
|
||||
t.Fatalf("want email_disposable, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_LoginAndLockout(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{LoginFailMax: 3, LoginLockWindow: 15 * time.Minute})
|
||||
ctx := context.Background()
|
||||
const email = "login@example.com"
|
||||
const pw = "rightpassword"
|
||||
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil {
|
||||
t.Fatalf("register: %v", e)
|
||||
}
|
||||
|
||||
// Correct login works.
|
||||
pair, _, apiErr := svc.Login(ctx, email, pw, "")
|
||||
if apiErr != nil || pair == nil {
|
||||
t.Fatalf("login should succeed: %v", apiErr)
|
||||
}
|
||||
|
||||
// 3 wrong attempts.
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _, e := svc.Login(ctx, email, "wrong", "")
|
||||
if e == nil || e.Code != ErrInvalidCredentials.Code {
|
||||
t.Fatalf("attempt %d want invalid_credentials, got %v", i, e)
|
||||
}
|
||||
}
|
||||
// Now locked, even with the correct password.
|
||||
_, ra, e := svc.Login(ctx, email, pw, "")
|
||||
if e == nil || e.Code != ErrAccountLocked.Code {
|
||||
t.Fatalf("want account_locked, got %v", e)
|
||||
}
|
||||
if ra <= 0 {
|
||||
t.Fatalf("expected positive retry-after on lock, got %v", ra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_LoginUnknownUser(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{})
|
||||
_, _, apiErr := svc.Login(context.Background(), "ghost@example.com", "whatever", "")
|
||||
if apiErr == nil || apiErr.Code != ErrInvalidCredentials.Code {
|
||||
t.Fatalf("want invalid_credentials for unknown user, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_BannedUserRejected(t *testing.T) {
|
||||
svc, store, _ := newService(t, ServiceConfig{})
|
||||
ctx := context.Background()
|
||||
const email = "banned@example.com"
|
||||
const pw = "password1"
|
||||
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
if _, e := svc.Register(ctx, email, codeInRedis(t, svc, email), pw); e != nil {
|
||||
t.Fatalf("register: %v", e)
|
||||
}
|
||||
store.setStatus(email, "banned")
|
||||
|
||||
_, _, apiErr := svc.Login(ctx, email, pw, "")
|
||||
if apiErr == nil || apiErr.Code != ErrAccountBanned.Code {
|
||||
t.Fatalf("want account_banned, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_RefreshRotation(t *testing.T) {
|
||||
svc, _, _ := newService(t, ServiceConfig{})
|
||||
ctx := context.Background()
|
||||
const email = "refresh@example.com"
|
||||
|
||||
_, _ = svc.SendCode(ctx, email, "")
|
||||
pair, e := svc.Register(ctx, email, codeInRedis(t, svc, email), "password1")
|
||||
if e != nil {
|
||||
t.Fatalf("register: %v", e)
|
||||
}
|
||||
|
||||
rotated, apiErr := svc.Refresh(ctx, pair.RefreshToken)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("refresh: %v", apiErr)
|
||||
}
|
||||
// Old refresh token now invalid.
|
||||
if _, e := svc.Refresh(ctx, pair.RefreshToken); e == nil || e.Code != ErrInvalidToken.Code {
|
||||
t.Fatalf("want invalid_token for rotated-out refresh, got %v", e)
|
||||
}
|
||||
// New one works.
|
||||
if _, e := svc.Refresh(ctx, rotated.RefreshToken); e != nil {
|
||||
t.Fatalf("new refresh should work: %v", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// User is the subset of the users row the auth module needs.
|
||||
type User struct {
|
||||
ID int64
|
||||
UUID string
|
||||
Email string
|
||||
PwHash string
|
||||
DpUUID string
|
||||
Status string // "active" | "banned"
|
||||
}
|
||||
|
||||
// Sentinel store errors. Service maps these to API errors.
|
||||
var (
|
||||
// ErrEmailTaken is returned by CreateUserWithTrial on a duplicate email.
|
||||
ErrEmailTaken = errors.New("auth: email already registered")
|
||||
// ErrNotFound is returned when a user lookup yields no row.
|
||||
ErrNotFound = errors.New("auth: user not found")
|
||||
)
|
||||
|
||||
// UserStore is the persistence contract for the auth module. The MySQL
|
||||
// implementation lives in this file; tests substitute an in-memory fake.
|
||||
type UserStore interface {
|
||||
// CreateUserWithTrial atomically inserts a new user and a 7-day PRO trial
|
||||
// subscription (source='trial') in a single transaction. The email UNIQUE
|
||||
// constraint guarantees a single trial per address; a duplicate returns
|
||||
// ErrEmailTaken. trialDays controls the trial length.
|
||||
CreateUserWithTrial(ctx context.Context, email, pwHash string, trialDays int) (*User, error)
|
||||
// GetUserByEmail returns the user for login. ErrNotFound when absent.
|
||||
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||
}
|
||||
|
||||
// SQLStore is the MySQL-backed UserStore.
|
||||
type SQLStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLStore builds a SQLStore.
|
||||
func NewSQLStore(db *sql.DB) *SQLStore { return &SQLStore{db: db} }
|
||||
|
||||
// CreateUserWithTrial implements UserStore.
|
||||
func (s *SQLStore) CreateUserWithTrial(ctx context.Context, email, pwHash string, trialDays int) (*User, error) {
|
||||
userUUID := uuid.NewString()
|
||||
dpUUID := uuid.NewString()
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: begin tx: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, created_at)
|
||||
VALUES (?, ?, ?, ?, 'active', UTC_TIMESTAMP(6))`,
|
||||
userUUID, email, pwHash, dpUUID)
|
||||
if err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return nil, ErrEmailTaken
|
||||
}
|
||||
return nil, fmt.Errorf("auth: insert user: %w", err)
|
||||
}
|
||||
userID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: user last id: %w", err)
|
||||
}
|
||||
|
||||
// Resolve the PRO plan id and insert the trial subscription.
|
||||
var proID int64
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM plans WHERE code='pro'`).Scan(&proID); err != nil {
|
||||
return nil, fmt.Errorf("auth: lookup pro plan: %w", err)
|
||||
}
|
||||
expires := time.Now().UTC().AddDate(0, 0, trialDays)
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at)
|
||||
VALUES (?, ?, ?, 'trial', UTC_TIMESTAMP(6))`,
|
||||
userID, proID, expires); err != nil {
|
||||
return nil, fmt.Errorf("auth: insert trial subscription: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("auth: commit: %w", err)
|
||||
}
|
||||
committed = true
|
||||
|
||||
return &User{
|
||||
ID: userID,
|
||||
UUID: userUUID,
|
||||
Email: email,
|
||||
PwHash: pwHash,
|
||||
DpUUID: dpUUID,
|
||||
Status: "active",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserByEmail implements UserStore.
|
||||
func (s *SQLStore) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var u User
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, uuid, email, pw_hash, dp_uuid, status FROM users WHERE email = ?`,
|
||||
email).Scan(&u.ID, &u.UUID, &u.Email, &u.PwHash, &u.DpUUID, &u.Status)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: get user by email: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// isDuplicateKey reports whether err is a MySQL duplicate-key (1062) error.
|
||||
func isDuplicateKey(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "1062")
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Token type discriminator carried in the `typ` claim so an access token can
|
||||
// never be replayed as a refresh token and vice-versa.
|
||||
const (
|
||||
typAccess = "access"
|
||||
typRefresh = "refresh"
|
||||
)
|
||||
|
||||
// Default lifetimes (doc/02 §4.1): access 15 min, refresh 30 days.
|
||||
const (
|
||||
DefaultAccessTTL = 15 * time.Minute
|
||||
DefaultRefreshTTL = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// refreshKeyPrefix is the Redis whitelist prefix for refresh-token JTIs.
|
||||
const refreshKeyPrefix = "jwt:refresh:"
|
||||
|
||||
// Claims is the JWT payload. Subject holds the user UUID; UID carries the
|
||||
// numeric primary key so downstream middleware/stores avoid a DB round-trip.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
UID int64 `json:"uid"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
// TokenPair is the issued access/refresh pair plus the access lifetime seconds.
|
||||
type TokenPair struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
ExpiresIn int // access_token validity in seconds
|
||||
}
|
||||
|
||||
// TokenManager signs tokens with one RSA private key (identified by kid) and
|
||||
// verifies with a set of public keys keyed by kid — accepting both the current
|
||||
// and previous keys to support zero-downtime key rotation. Refresh tokens are
|
||||
// whitelisted in Redis so logout/ban takes effect immediately.
|
||||
type TokenManager struct {
|
||||
signKey *rsa.PrivateKey
|
||||
signKID string
|
||||
verifyKeys map[string]*rsa.PublicKey // kid -> public key (current + old)
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
rdb *redis.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// TokenConfig configures a TokenManager.
|
||||
type TokenConfig struct {
|
||||
// SignKey is the active RSA private key used to sign new tokens.
|
||||
SignKey *rsa.PrivateKey
|
||||
// SignKID is the key id written into the JWT header.
|
||||
SignKID string
|
||||
// VerifyKeys maps kid -> public key. Must contain SignKID; may contain
|
||||
// additional (older) keys still accepted during rotation. If nil, the
|
||||
// public part of SignKey under SignKID is used.
|
||||
VerifyKeys map[string]*rsa.PublicKey
|
||||
AccessTTL time.Duration
|
||||
RefreshTTL time.Duration
|
||||
// Now is an optional clock override for tests.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// NewTokenManager constructs a TokenManager, validating the key material.
|
||||
func NewTokenManager(rdb *redis.Client, cfg TokenConfig) (*TokenManager, error) {
|
||||
if cfg.SignKey == nil {
|
||||
return nil, errors.New("auth: token manager requires a signing key")
|
||||
}
|
||||
if cfg.SignKID == "" {
|
||||
return nil, errors.New("auth: token manager requires a signing key id (kid)")
|
||||
}
|
||||
verify := cfg.VerifyKeys
|
||||
if verify == nil {
|
||||
verify = map[string]*rsa.PublicKey{}
|
||||
}
|
||||
if _, ok := verify[cfg.SignKID]; !ok {
|
||||
verify[cfg.SignKID] = &cfg.SignKey.PublicKey
|
||||
}
|
||||
accessTTL := cfg.AccessTTL
|
||||
if accessTTL <= 0 {
|
||||
accessTTL = DefaultAccessTTL
|
||||
}
|
||||
refreshTTL := cfg.RefreshTTL
|
||||
if refreshTTL <= 0 {
|
||||
refreshTTL = DefaultRefreshTTL
|
||||
}
|
||||
now := cfg.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &TokenManager{
|
||||
signKey: cfg.SignKey,
|
||||
signKID: cfg.SignKID,
|
||||
verifyKeys: verify,
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
rdb: rdb,
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Issue mints a fresh access+refresh pair for the user and whitelists the
|
||||
// refresh JTI in Redis with the refresh TTL.
|
||||
func (tm *TokenManager) Issue(ctx context.Context, userID int64, userUUID string) (*TokenPair, error) {
|
||||
now := tm.now()
|
||||
|
||||
access, _, err := tm.sign(userID, userUUID, typAccess, tm.accessTTL, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refresh, refreshJTI, err := tm.sign(userID, userUUID, typRefresh, tm.refreshTTL, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tm.whitelist(ctx, refreshJTI, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &TokenPair{
|
||||
AccessToken: access,
|
||||
RefreshToken: refresh,
|
||||
ExpiresIn: int(tm.accessTTL.Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sign builds and signs one token, returning the compact string and its JTI.
|
||||
func (tm *TokenManager) sign(userID int64, userUUID, typ string, ttl time.Duration, now time.Time) (string, string, error) {
|
||||
jti := uuid.NewString()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userUUID,
|
||||
ID: jti,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||
},
|
||||
UID: userID,
|
||||
Typ: typ,
|
||||
}
|
||||
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tok.Header["kid"] = tm.signKID
|
||||
signed, err := tok.SignedString(tm.signKey)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("auth: sign token: %w", err)
|
||||
}
|
||||
return signed, jti, nil
|
||||
}
|
||||
|
||||
// keyfunc resolves the verification key from the token's kid header and rejects
|
||||
// any algorithm other than RS256.
|
||||
func (tm *TokenManager) keyfunc(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("auth: unexpected signing method %q", t.Header["alg"])
|
||||
}
|
||||
kid, _ := t.Header["kid"].(string)
|
||||
if kid == "" {
|
||||
return nil, errors.New("auth: token missing kid")
|
||||
}
|
||||
pub, ok := tm.verifyKeys[kid]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("auth: unknown kid %q", kid)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// parse validates signature, expiry, and the expected token type. Expiry is
|
||||
// checked manually against tm.now so tests can inject a clock without mutating
|
||||
// the package-global jwt.TimeFunc.
|
||||
func (tm *TokenManager) parse(tokenStr, wantTyp string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
parser := jwt.NewParser(
|
||||
jwt.WithValidMethods([]string{"RS256"}),
|
||||
jwt.WithoutClaimsValidation(), // we validate exp/iat ourselves below
|
||||
)
|
||||
if _, err := parser.ParseWithClaims(tokenStr, claims, tm.keyfunc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := tm.now()
|
||||
if claims.ExpiresAt == nil || now.After(claims.ExpiresAt.Time) {
|
||||
return nil, errors.New("auth: token expired")
|
||||
}
|
||||
if claims.IssuedAt != nil && now.Add(time.Minute).Before(claims.IssuedAt.Time) {
|
||||
return nil, errors.New("auth: token used before issued")
|
||||
}
|
||||
if claims.Typ != wantTyp {
|
||||
return nil, fmt.Errorf("auth: token type %q, want %q", claims.Typ, wantTyp)
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ParseAccess validates an access token and returns its claims.
|
||||
func (tm *TokenManager) ParseAccess(tokenStr string) (*Claims, error) {
|
||||
return tm.parse(tokenStr, typAccess)
|
||||
}
|
||||
|
||||
// Refresh validates a refresh token against the Redis whitelist, then rotates:
|
||||
// the old JTI is deleted and a brand-new access+refresh pair is issued, so the
|
||||
// presented refresh token can never be replayed.
|
||||
func (tm *TokenManager) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
claims, err := tm.parse(refreshToken, typRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Whitelist check + single-use rotation: DEL returns the number of keys
|
||||
// removed; 0 means the JTI was absent (already rotated, logged out, or
|
||||
// banned) → reject.
|
||||
removed, err := tm.rdb.Del(ctx, refreshKeyPrefix+claims.ID).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: refresh whitelist del: %w", err)
|
||||
}
|
||||
if removed == 0 {
|
||||
return nil, ErrInvalidTokenSentinel
|
||||
}
|
||||
|
||||
return tm.Issue(ctx, claims.UID, claims.Subject)
|
||||
}
|
||||
|
||||
// Revoke removes a single refresh JTI from the whitelist (logout).
|
||||
func (tm *TokenManager) Revoke(ctx context.Context, jti string) error {
|
||||
return tm.rdb.Del(ctx, refreshKeyPrefix+jti).Err()
|
||||
}
|
||||
|
||||
// whitelist stores the refresh JTI with the refresh TTL.
|
||||
func (tm *TokenManager) whitelist(ctx context.Context, jti string, userID int64) error {
|
||||
if err := tm.rdb.Set(ctx, refreshKeyPrefix+jti, userID, tm.refreshTTL).Err(); err != nil {
|
||||
return fmt.Errorf("auth: refresh whitelist set: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrInvalidTokenSentinel is returned by Refresh when the token is structurally
|
||||
// valid but no longer whitelisted. Callers map it to ErrInvalidToken.
|
||||
var ErrInvalidTokenSentinel = errors.New("auth: refresh token not in whitelist")
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// PEM loading helpers (used by wiring/config to build a TokenManager)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// LoadPrivateKeyPEM parses a PEM-encoded RSA private key (PKCS#1 or PKCS#8).
|
||||
func LoadPrivateKeyPEM(pemBytes []byte) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, errors.New("auth: no PEM block in private key")
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: parse private key: %w", err)
|
||||
}
|
||||
rsaKey, ok := parsed.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("auth: private key is not RSA")
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
// LoadPublicKeyPEM parses a PEM-encoded RSA public key (PKIX or PKCS#1).
|
||||
func LoadPublicKeyPEM(pemBytes []byte) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return nil, errors.New("auth: no PEM block in public key")
|
||||
}
|
||||
if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil {
|
||||
if rsaPub, ok := pub.(*rsa.PublicKey); ok {
|
||||
return rsaPub, nil
|
||||
}
|
||||
return nil, errors.New("auth: public key is not RSA")
|
||||
}
|
||||
rsaPub, err := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: parse public key: %w", err)
|
||||
}
|
||||
return rsaPub, nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestToken_IssueAndParseAccess(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
tm := newTokenManager(t, rdb, time.Now)
|
||||
|
||||
pair, err := tm.Issue(context.Background(), 42, "uuid-42")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
if pair.ExpiresIn != 900 {
|
||||
t.Errorf("ExpiresIn = %d, want 900", pair.ExpiresIn)
|
||||
}
|
||||
|
||||
claims, err := tm.ParseAccess(pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAccess: %v", err)
|
||||
}
|
||||
if claims.UID != 42 || claims.Subject != "uuid-42" {
|
||||
t.Errorf("claims = %+v", claims)
|
||||
}
|
||||
if claims.Typ != typAccess {
|
||||
t.Errorf("typ = %q", claims.Typ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_RefreshCannotBeUsedAsAccess(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
tm := newTokenManager(t, rdb, time.Now)
|
||||
pair, _ := tm.Issue(context.Background(), 1, "u1")
|
||||
if _, err := tm.ParseAccess(pair.RefreshToken); err == nil {
|
||||
t.Fatal("refresh token must not parse as access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_AccessExpiry(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
base := time.Now()
|
||||
clock := base
|
||||
tm := newTokenManager(t, rdb, func() time.Time { return clock })
|
||||
|
||||
pair, _ := tm.Issue(context.Background(), 1, "u1")
|
||||
clock = base.Add(16 * time.Minute) // past 15-min access TTL
|
||||
if _, err := tm.ParseAccess(pair.AccessToken); err == nil {
|
||||
t.Fatal("expected expired access token to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_RefreshRotationInvalidatesOld(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
tm := newTokenManager(t, rdb, time.Now)
|
||||
ctx := context.Background()
|
||||
|
||||
pair, _ := tm.Issue(ctx, 7, "u7")
|
||||
|
||||
rotated, err := tm.Refresh(ctx, pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh: %v", err)
|
||||
}
|
||||
if rotated.RefreshToken == pair.RefreshToken {
|
||||
t.Fatal("rotation should produce a new refresh token")
|
||||
}
|
||||
|
||||
// Old refresh token is now invalid.
|
||||
if _, err := tm.Refresh(ctx, pair.RefreshToken); err == nil {
|
||||
t.Fatal("old refresh token must be rejected after rotation")
|
||||
}
|
||||
|
||||
// New refresh token still works.
|
||||
if _, err := tm.Refresh(ctx, rotated.RefreshToken); err != nil {
|
||||
t.Fatalf("new refresh token should work: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_RevokeRefresh(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
tm := newTokenManager(t, rdb, time.Now)
|
||||
ctx := context.Background()
|
||||
|
||||
pair, _ := tm.Issue(ctx, 3, "u3")
|
||||
claims, err := tm.parse(pair.RefreshToken, typRefresh)
|
||||
if err != nil {
|
||||
t.Fatalf("parse refresh: %v", err)
|
||||
}
|
||||
if err := tm.Revoke(ctx, claims.ID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
if _, err := tm.Refresh(ctx, pair.RefreshToken); err == nil {
|
||||
t.Fatal("revoked refresh token must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_KeyRotationAcceptsOldKey(t *testing.T) {
|
||||
rdb, _ := newMiniRedis(t)
|
||||
|
||||
// Old manager signs with k1.
|
||||
oldKey := newRSAKey(t)
|
||||
oldTM, err := NewTokenManager(rdb, TokenConfig{SignKey: oldKey, SignKID: "k1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, _ := oldTM.Issue(context.Background(), 9, "u9")
|
||||
|
||||
// New manager signs with k2 but still accepts k1 for verification.
|
||||
newKey := newRSAKey(t)
|
||||
newTM, err := NewTokenManager(rdb, TokenConfig{
|
||||
SignKey: newKey,
|
||||
SignKID: "k2",
|
||||
VerifyKeys: map[string]*rsa.PublicKey{
|
||||
"k1": &oldKey.PublicKey,
|
||||
"k2": &newKey.PublicKey,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := newTM.ParseAccess(pair.AccessToken); err != nil {
|
||||
t.Fatalf("token signed with old key must still verify: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -37,6 +38,16 @@ type Config struct {
|
||||
// WebhookNonceTTL is how long a webhook nonce is kept in Redis to prevent replay.
|
||||
// Should be > 2 * WebhookTimestampTolerance. Default: 15 minutes.
|
||||
WebhookNonceTTL time.Duration
|
||||
|
||||
// ── Auth / JWT (RS256) ────────────────────────────────────────────────
|
||||
// JWTPrivateKeyPath is the PEM file holding the active RS256 signing key.
|
||||
JWTPrivateKeyPath string
|
||||
// JWTKeyID is the `kid` written into the JWT header (identifies the signing key).
|
||||
JWTKeyID string
|
||||
// JWTPublicKeys maps kid -> PEM public-key file path. It must include the
|
||||
// active key's kid and may carry previous keys still accepted during
|
||||
// rotation. Parsed from JWT_PUBLIC_KEYS="kid1:/path1,kid2:/path2".
|
||||
JWTPublicKeys map[string]string
|
||||
}
|
||||
|
||||
// FromEnv reads configuration from environment variables.
|
||||
@@ -52,6 +63,9 @@ func FromEnv() (*Config, error) {
|
||||
RedeemLockDuration: time.Hour,
|
||||
WebhookTimestampTolerance: 5 * time.Minute,
|
||||
WebhookNonceTTL: 15 * time.Minute,
|
||||
JWTPrivateKeyPath: os.Getenv("JWT_PRIVATE_KEY_PATH"),
|
||||
JWTKeyID: os.Getenv("JWT_KEY_ID"),
|
||||
JWTPublicKeys: parseKeyMap(os.Getenv("JWT_PUBLIC_KEYS")),
|
||||
}
|
||||
|
||||
if c.DSN == "" {
|
||||
@@ -69,3 +83,24 @@ func getEnvDefault(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// parseKeyMap parses a "kid1:/path1,kid2:/path2" string into a map. Empty input
|
||||
// yields a nil map. Malformed entries (missing ':') are skipped.
|
||||
func parseKeyMap(raw string) map[string]string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
m := map[string]string{}
|
||||
for _, pair := range strings.Split(raw, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
i := strings.IndexByte(pair, ':')
|
||||
if i <= 0 || i == len(pair)-1 {
|
||||
continue
|
||||
}
|
||||
m[strings.TrimSpace(pair[:i])] = strings.TrimSpace(pair[i+1:])
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user