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:
@@ -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