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=. 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 // ":". 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 }