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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user