feat(auth): add device session tracking with configurable limits

Track authenticated sessions per device (parsing user-agent for desktop
vs mobile via mileusna/useragent), enforce configurable concurrent device
limits (Auth.DeviceLimits), and surface device status and "remove other
devices" management in the account dialog. Adds device/session context to
the auth module stores and exposes limits through the API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 01:01:54 +08:00
parent 875fff825c
commit 58f11302fe
32 changed files with 1783 additions and 253 deletions
+39 -37
View File
@@ -13,67 +13,69 @@ import (
)
type tokenPayload struct {
Subject string `json:"sub"`
Type string `json:"typ,omitempty"`
OpenID string `json:"openid,omitempty"`
UnionID string `json:"unionid,omitempty"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
Subject string `json:"sub"`
Type string `json:"typ,omitempty"`
SessionID string `json:"sid,omitempty"`
OpenID string `json:"openid,omitempty"`
UnionID string `json:"unionid,omitempty"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
}
func signToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
return signTypedToken(userID, "access", secret, ttl, now)
return signTypedToken(userID, "access", "", secret, ttl, now)
}
func signRefreshToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return signTypedToken(userID, "refresh", secret, ttl, now)
return signTypedToken(userID, "refresh", "", secret, ttl, now)
}
func signTypedToken(userID string, tokenType string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
func signSessionToken(userID string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 7 * 24 * time.Hour
}
return signTypedToken(userID, "access", sessionID, secret, ttl, now)
}
func signSessionRefreshToken(userID string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return signTypedToken(userID, "refresh", sessionID, secret, ttl, now)
}
func signTypedToken(userID string, tokenType string, sessionID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
payload := tokenPayload{
Subject: userID,
Type: tokenType,
Issued: now.Unix(),
Expires: now.Add(ttl).Unix(),
Subject: userID,
Type: tokenType,
SessionID: sessionID,
Issued: now.Unix(),
Expires: now.Add(ttl).Unix(),
}
token, err := signPayload(payload, secret)
return token, int64(ttl.Seconds()), err
}
func parseToken(ctx context.Context, token string, secret string, now time.Time) (string, bool) {
if err := ctx.Err(); err != nil {
return "", false
}
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) != 2 {
return "", false
}
expected := signBytes([]byte(parts[0]), secret)
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(expected, actual) {
return "", false
}
data, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
var payload tokenPayload
if err := json.Unmarshal(data, &payload); err != nil {
return "", false
}
if payload.Subject == "" || payload.Expires <= now.Unix() {
return "", false
payload, ok := parseAccessTokenPayload(ctx, token, secret, now)
return payload.Subject, ok
}
func parseAccessTokenPayload(ctx context.Context, token string, secret string, now time.Time) (tokenPayload, bool) {
payload, ok := parsePayload(ctx, token, secret)
if !ok || payload.Subject == "" || payload.Expires <= now.Unix() {
return tokenPayload{}, false
}
if payload.Type != "" && payload.Type != "access" {
return "", false
return tokenPayload{}, false
}
return payload.Subject, true
return payload, true
}
func signWechatBindingToken(identity WechatIdentity, secret string, ttl time.Duration, now time.Time) (string, int64, error) {