feat(server): SSO 换票端点(App→Web 免登握手)
/v1/auth/web-ticket(需登录):签发一次性票据(crypto/rand 32B, Redis GETDEL 单用, 60s)。 /v1/auth/web-ticket/exchange(公开):票换与 /auth/login 同款 token pair。镜像 jiu。含 6 测试。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -378,6 +378,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
v1.Post("/auth/login", authHandler.Login)
|
||||
v1.Post("/auth/refresh", authHandler.Refresh)
|
||||
v1.Post("/auth/logout", authHandler.Logout)
|
||||
// App→网页免登录换票的公开一端;票据本身即凭证,无需 Bearer(见下方
|
||||
// 受保护分组里的签票端 /auth/web-ticket)。
|
||||
v1.Post("/auth/web-ticket/exchange", authHandler.WebTicketExchange)
|
||||
if totpHandler != nil {
|
||||
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
|
||||
}
|
||||
@@ -405,6 +408,10 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
||||
}
|
||||
})
|
||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||
if authHandler != nil {
|
||||
// 签票端要求已登录(拿当前 JWT 的 uid/uuid);兑换端见上方公开分组。
|
||||
protected.Post("/auth/web-ticket", authHandler.WebTicket)
|
||||
}
|
||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||
protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP)
|
||||
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
|
||||
|
||||
@@ -83,4 +83,13 @@ var (
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
|
||||
// ErrTicketInvalid — the web-ticket (App→网页免登录一次性票据) is missing,
|
||||
// already used, or expired. Intentionally generic (mirrors ErrCodeInvalid) so
|
||||
// the three cases can't be distinguished from the response.
|
||||
ErrTicketInvalid = &apierr.Error{
|
||||
Code: "auth.ticket_invalid",
|
||||
MessageZH: "登录票据无效或已过期,请重新从 App 打开",
|
||||
MessageEn: "Login ticket is invalid or expired, please reopen from the app",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
r.Post("/auth/login", h.Login)
|
||||
r.Post("/auth/refresh", h.Refresh)
|
||||
r.Post("/auth/logout", h.Logout)
|
||||
r.Post("/auth/web-ticket/exchange", h.WebTicketExchange)
|
||||
}
|
||||
|
||||
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
|
||||
@@ -158,6 +159,74 @@ func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeTokenPair(w, pair)
|
||||
}
|
||||
|
||||
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
|
||||
|
||||
// webTicketRequest / webTicketResponse — POST /v1/auth/web-ticket (auth-required).
|
||||
type webTicketResponse struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// WebTicket handles POST /v1/auth/web-ticket (auth-required, mounted in the
|
||||
// RequireAuth group — see main.go). Mints a one-time ticket for the currently
|
||||
// authenticated user so the app can open the web user-center pre-authenticated
|
||||
// (`https://<host>/sso?t=<ticket>`). Rate-limited to 1/sec/user.
|
||||
func (h *Handler) WebTicket(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
uuid, ok := UserUUIDFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAPIErr(w, ErrUnauthorized, 0)
|
||||
return
|
||||
}
|
||||
ticket, ttl, retryAfter, apiErr := h.svc.IssueWebTicket(r.Context(), uid, uuid)
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, retryAfter)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(webTicketResponse{Ticket: ticket, ExpiresIn: ttl})
|
||||
}
|
||||
|
||||
// webTicketExchangeRequest is the public exchange body.
|
||||
type webTicketExchangeRequest struct {
|
||||
Ticket string `json:"ticket"`
|
||||
Device deviceBody `json:"device"`
|
||||
}
|
||||
|
||||
// WebTicketExchange handles POST /v1/auth/web-ticket/exchange (public, no
|
||||
// bearer auth — the ticket itself is the credential). Validates+consumes the
|
||||
// one-time ticket and issues the SAME token-pair shape as a normal login for
|
||||
// the ticket's user, registering the device like Login does. Invalid, expired,
|
||||
// or already-used tickets all map to a generic 401.
|
||||
func (h *Handler) WebTicketExchange(w http.ResponseWriter, r *http.Request) {
|
||||
var req webTicketExchangeRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Ticket == "" {
|
||||
writeAPIErr(w, ErrInvalidRequest, 0)
|
||||
return
|
||||
}
|
||||
pair, deviceLimit, apiErr := h.svc.LoginWithWebTicket(r.Context(), req.Ticket, clientIP(r), req.Device.toMeta())
|
||||
if apiErr != nil {
|
||||
writeAPIErr(w, apiErr, 0)
|
||||
return
|
||||
}
|
||||
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,
|
||||
DeviceLimit: deviceLimit,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
// decodeJSON decodes the request body, writing a 400 on malformed input.
|
||||
@@ -206,7 +275,7 @@ func statusFor(e *apierr.Error) int {
|
||||
return http.StatusConflict
|
||||
case ErrRateLimited.Code, ErrAccountLocked.Code:
|
||||
return http.StatusTooManyRequests
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code:
|
||||
case ErrInvalidCredentials.Code, ErrInvalidToken.Code, ErrUnauthorized.Code, ErrTicketInvalid.Code:
|
||||
return http.StatusUnauthorized
|
||||
case ErrAccountBanned.Code:
|
||||
return http.StatusForbidden
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
|
||||
//
|
||||
// The app (already holding a valid JWT) mints a one-time ticket via
|
||||
// IssueWebTicket and opens the system browser at .../sso?t=<ticket>. The web
|
||||
// user-center exchanges the ticket via LoginWithWebTicket for a normal token
|
||||
// pair — same shape as /auth/login — without ever seeing the app's own tokens.
|
||||
//
|
||||
// Storage: Redis only (no DB row). Tickets are single-instance, ephemeral
|
||||
// (60s TTL) credentials — a DB table would need its own cleanup job for what
|
||||
// Redis already gives for free via key expiry. Consumption uses GETDEL, which
|
||||
// is atomic server-side: a replayed/concurrent second exchange always loses
|
||||
// the race and gets redis.Nil, so single-use is enforced without a CAS dance.
|
||||
|
||||
const (
|
||||
// webTicketTTL is the ticket lifetime: long enough for the system browser
|
||||
// to open and hit the exchange endpoint, short enough to bound replay risk.
|
||||
webTicketTTL = 60 * time.Second
|
||||
// webTicketPrefix namespaces ticket keys in Redis.
|
||||
webTicketPrefix = "auth:webticket:"
|
||||
// scopeWebTicket is the rate-limit scope for ticket issuance (1/sec/user).
|
||||
scopeWebTicket = "webticket"
|
||||
)
|
||||
|
||||
func webTicketKey(ticket string) string { return webTicketPrefix + ticket }
|
||||
|
||||
// genWebTicket returns a fresh, cryptographically random one-time ticket. 32
|
||||
// bytes (256 bits) of entropy makes guessing infeasible within the 60s TTL.
|
||||
func genWebTicket() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("auth: gen web ticket: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// IssueWebTicket mints a one-time ticket for an already-authenticated user
|
||||
// (userID/userUUID come from the caller's validated JWT claims — see
|
||||
// Handler.WebTicket). Rate-limited to 1/sec/user as a rebuff-abuse backstop
|
||||
// (the endpoint already requires login). The ticket value itself never
|
||||
// carries a real token — only enough to look the user up on exchange.
|
||||
func (s *Service) IssueWebTicket(ctx context.Context, userID int64, userUUID string) (ticket string, ttlSeconds int, retryAfter time.Duration, apiErr *apierr.Error) {
|
||||
ok, ra, err := s.rl.Allow(ctx, scopeWebTicket, strconv.FormatInt(userID, 10), 1, time.Second)
|
||||
if err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
if !ok {
|
||||
return "", 0, ra, ErrRateLimited
|
||||
}
|
||||
|
||||
tk, err := genWebTicket()
|
||||
if err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
val := userID2UUID(userID, userUUID)
|
||||
if err := s.rdb.Set(ctx, webTicketKey(tk), val, webTicketTTL).Err(); err != nil {
|
||||
return "", 0, 0, ErrInternal
|
||||
}
|
||||
return tk, int(webTicketTTL.Seconds()), 0, nil
|
||||
}
|
||||
|
||||
// LoginWithWebTicket validates+atomically-consumes a one-time ticket and
|
||||
// issues a fresh token pair for its user, registering the device exactly like
|
||||
// a normal Login (best-effort — see recordLogin). Any invalid/expired/
|
||||
// already-used ticket collapses to ErrTicketInvalid (no distinguishing info
|
||||
// leaked).
|
||||
func (s *Service) LoginWithWebTicket(ctx context.Context, ticket, ip string, device DeviceMeta) (*TokenPair, *DeviceLimit, *apierr.Error) {
|
||||
if ticket == "" {
|
||||
return nil, nil, ErrTicketInvalid
|
||||
}
|
||||
|
||||
// GETDEL is atomic: concurrent/replayed exchanges of the same ticket race
|
||||
// on a single Redis command, so exactly one caller ever sees the value.
|
||||
val, err := s.rdb.GetDel(ctx, webTicketKey(ticket)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, nil, ErrTicketInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
|
||||
userID, userUUID, ok := splitUserID2UUID(val)
|
||||
if !ok {
|
||||
// Our own format, corrupt only via a Redis-level anomaly — never surface
|
||||
// internals to the (unauthenticated) caller.
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
|
||||
pair, jti, err := s.tokens.IssueWithJTI(ctx, userID, userUUID)
|
||||
if err != nil {
|
||||
return nil, nil, ErrInternal
|
||||
}
|
||||
dl := s.recordLogin(ctx, userID, jti, ip, device)
|
||||
return pair, dl, nil
|
||||
}
|
||||
|
||||
// userID2UUID / splitUserID2UUID encode the ticket's Redis value as
|
||||
// "<userID>:<userUUID>". UUIDs are hyphenated hex (no ':'), so a single
|
||||
// SplitN(2) round-trips unambiguously.
|
||||
func userID2UUID(userID int64, userUUID string) string {
|
||||
return strconv.FormatInt(userID, 10) + ":" + userUUID
|
||||
}
|
||||
|
||||
func splitUserID2UUID(val string) (userID int64, userUUID string, ok bool) {
|
||||
parts := strings.SplitN(val, ":", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
id, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
return id, parts[1], true
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// newWebTicketHandler wires a Handler with BOTH the public routes (via
|
||||
// RegisterRoutes) and the auth-required /auth/web-ticket route, mirroring how
|
||||
// main.go mounts them in two separate groups (public vs RequireAuth).
|
||||
func newWebTicketHandler(t *testing.T, cfg ServiceConfig) (*Service, http.Handler) {
|
||||
t.Helper()
|
||||
svc, _, _ := newService(t, cfg)
|
||||
h := NewHandler(svc)
|
||||
r := chi.NewRouter()
|
||||
h.RegisterRoutes(r) // public: includes /auth/web-ticket/exchange
|
||||
r.Group(func(protected chi.Router) {
|
||||
protected.Use(RequireAuth(svc.tokens))
|
||||
protected.Post("/auth/web-ticket", h.WebTicket)
|
||||
})
|
||||
return svc, r
|
||||
}
|
||||
|
||||
// issueAccessToken mints a real, valid access token for a fresh user id/uuid
|
||||
// pair — bypassing full register/login, since the ticket flow only cares that
|
||||
// RequireAuth's middleware injects a valid uid/uuid into the request context.
|
||||
func issueAccessToken(t *testing.T, svc *Service) (accessToken string, userID int64, userUUID string) {
|
||||
t.Helper()
|
||||
userID = 42
|
||||
userUUID = uuid.NewString()
|
||||
pair, _, err := svc.tokens.IssueWithJTI(context.Background(), userID, userUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("issue access token: %v", err)
|
||||
}
|
||||
return pair.AccessToken, userID, userUUID
|
||||
}
|
||||
|
||||
func doAuthed(t *testing.T, h http.Handler, method, path, token 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"
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestWebTicket_IssueAndExchange covers the full happy path: an authed app
|
||||
// mints a ticket, the (unauthenticated) web side exchanges it for a token
|
||||
// pair shaped exactly like a normal /auth/login response.
|
||||
func TestWebTicket_IssueAndExchange(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, wantUID, wantUUID := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("issue status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var issued webTicketResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
|
||||
t.Fatalf("decode issue response: %v", err)
|
||||
}
|
||||
if issued.Ticket == "" {
|
||||
t.Fatal("empty ticket")
|
||||
}
|
||||
if issued.ExpiresIn != 60 {
|
||||
t.Fatalf("expires_in = %d, want 60", issued.ExpiresIn)
|
||||
}
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket, "device": map[string]string{"platform": "web"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("exchange status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var pair tokenPairResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||
t.Fatalf("decode exchange response: %v", err)
|
||||
}
|
||||
if pair.AccessToken == "" || pair.RefreshToken == "" || pair.ExpiresIn != 900 {
|
||||
t.Fatalf("bad token pair: %+v", pair)
|
||||
}
|
||||
|
||||
// The exchanged access token must authenticate as the SAME user the app
|
||||
// ticket was issued for.
|
||||
claims, err := svc.tokens.ParseAccess(pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("parse exchanged access token: %v", err)
|
||||
}
|
||||
if claims.UID != wantUID || claims.Subject != wantUUID {
|
||||
t.Fatalf("exchanged token identifies uid=%d uuid=%s, want uid=%d uuid=%s",
|
||||
claims.UID, claims.Subject, wantUID, wantUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_SingleUse_ReplayRejected: a second exchange of the same
|
||||
// ticket (replay) must fail 401, even though the first succeeded.
|
||||
func TestWebTicket_SingleUse_ReplayRejected(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
var issued webTicketResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first exchange should succeed, got %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("replayed exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_Expired_Rejected simulates TTL expiry the same way the rest of
|
||||
// this package does (service_test.go TestService_CodeExpired): delete the
|
||||
// Redis key directly rather than fast-forwarding a clock.
|
||||
func TestWebTicket_Expired_Rejected(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
var issued webTicketResponse
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &issued)
|
||||
|
||||
svc.rdb.Del(context.Background(), webTicketKey(issued.Ticket))
|
||||
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": issued.Ticket})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expired ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_Bogus_Rejected: a made-up ticket that was never issued.
|
||||
func TestWebTicket_Bogus_Rejected(t *testing.T) {
|
||||
_, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket/exchange", "",
|
||||
map[string]any{"ticket": "not-a-real-ticket"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("bogus ticket exchange status = %d, want 401 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_RequiresAuth: minting a ticket without a bearer token is
|
||||
// rejected by RequireAuth before it ever reaches the handler.
|
||||
func TestWebTicket_RequiresAuth(t *testing.T) {
|
||||
_, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated issue status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebTicket_RateLimited: a second ticket request within the same second
|
||||
// for the same user is rejected 429 (rebuff-abuse backstop; the endpoint
|
||||
// already requires login).
|
||||
func TestWebTicket_RateLimited(t *testing.T) {
|
||||
svc, h := newWebTicketHandler(t, ServiceConfig{})
|
||||
token, _, _ := issueAccessToken(t, svc)
|
||||
|
||||
rec := doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("first issue status = %d, body %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec = doAuthed(t, h, http.MethodPost, "/auth/web-ticket", token, nil)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second issue status = %d, want 429 (body %s)", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user