1e13f35219
/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
187 lines
6.7 KiB
Go
187 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|