merge: auth 模块 [tsk_2PFfyviECIXh]
# Conflicts: # server/go.mod
This commit is contained in:
@@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +38,16 @@ type Config struct {
|
|||||||
// WebhookNonceTTL is how long a webhook nonce is kept in Redis to prevent replay.
|
// WebhookNonceTTL is how long a webhook nonce is kept in Redis to prevent replay.
|
||||||
// Should be > 2 * WebhookTimestampTolerance. Default: 15 minutes.
|
// Should be > 2 * WebhookTimestampTolerance. Default: 15 minutes.
|
||||||
WebhookNonceTTL time.Duration
|
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.
|
// FromEnv reads configuration from environment variables.
|
||||||
@@ -52,6 +63,9 @@ func FromEnv() (*Config, error) {
|
|||||||
RedeemLockDuration: time.Hour,
|
RedeemLockDuration: time.Hour,
|
||||||
WebhookTimestampTolerance: 5 * time.Minute,
|
WebhookTimestampTolerance: 5 * time.Minute,
|
||||||
WebhookNonceTTL: 15 * 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 == "" {
|
if c.DSN == "" {
|
||||||
@@ -69,3 +83,24 @@ func getEnvDefault(key, def string) string {
|
|||||||
}
|
}
|
||||||
return def
|
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