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,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=<ticket>. 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
|
||||
// "<userID>:<userUUID>". 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
|
||||
}
|
||||
Reference in New Issue
Block a user