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:
wangjia
2026-07-06 23:54:15 +08:00
parent e4de308ba4
commit 1e13f35219
5 changed files with 402 additions and 1 deletions
+70 -1
View File
@@ -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