From 1e13f35219de36764582b0631e49e93cd3aa56a7 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Mon, 6 Jul 2026 23:54:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20SSO=20=E6=8D=A2=E7=A5=A8?= =?UTF-8?q?=E7=AB=AF=E7=82=B9(App=E2=86=92Web=20=E5=85=8D=E7=99=BB?= =?UTF-8?q?=E6=8F=A1=E6=89=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u --- server/cmd/server/main.go | 7 + server/internal/auth/errors.go | 9 ++ server/internal/auth/handler.go | 71 ++++++++- server/internal/auth/web_ticket.go | 130 +++++++++++++++++ server/internal/auth/web_ticket_test.go | 186 ++++++++++++++++++++++++ 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 server/internal/auth/web_ticket.go create mode 100644 server/internal/auth/web_ticket_test.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 7435c28..89efac4 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -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) diff --git a/server/internal/auth/errors.go b/server/internal/auth/errors.go index cce6842..abea305 100644 --- a/server/internal/auth/errors.go +++ b/server/internal/auth/errors.go @@ -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", + } ) diff --git a/server/internal/auth/handler.go b/server/internal/auth/handler.go index fde1423..24389e6 100644 --- a/server/internal/auth/handler.go +++ b/server/internal/auth/handler.go @@ -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:///sso?t=`). 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 diff --git a/server/internal/auth/web_ticket.go b/server/internal/auth/web_ticket.go new file mode 100644 index 0000000..f27a8fb --- /dev/null +++ b/server/internal/auth/web_ticket.go @@ -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=. 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 +// ":". 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 +} diff --git a/server/internal/auth/web_ticket_test.go b/server/internal/auth/web_ticket_test.go new file mode 100644 index 0000000..0f884aa --- /dev/null +++ b/server/internal/auth/web_ticket_test.go @@ -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) + } +}