Files
moteva/server/internal/modules/auth/token.go
T
root 58f11302fe 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>
2026-07-11 01:02:05 +08:00

199 lines
5.8 KiB
Go

package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
type tokenPayload struct {
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)
}
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)
}
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,
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) {
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 tokenPayload{}, false
}
return payload, true
}
func signWechatBindingToken(identity WechatIdentity, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
if ttl <= 0 {
ttl = 5 * time.Minute
}
if strings.TrimSpace(identity.OpenID) == "" {
return "", 0, fmt.Errorf("%w: wechat identity is incomplete", ErrInvalidCredentials)
}
payload := tokenPayload{
Type: "wechat_binding",
OpenID: strings.TrimSpace(identity.OpenID),
UnionID: strings.TrimSpace(identity.UnionID),
Issued: now.Unix(),
Expires: now.Add(ttl).Unix(),
}
token, err := signPayload(payload, secret)
return token, int64(ttl.Seconds()), err
}
func parseWechatBindingToken(ctx context.Context, token string, secret string, now time.Time) (WechatIdentity, bool) {
payload, ok := parsePayload(ctx, token, secret)
if !ok {
return WechatIdentity{}, false
}
if payload.Type != "wechat_binding" || payload.OpenID == "" || payload.Expires <= now.Unix() {
return WechatIdentity{}, false
}
return WechatIdentity{OpenID: payload.OpenID, UnionID: payload.UnionID}, true
}
func signPayload(payload tokenPayload, secret string) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
encodedPayload := base64.RawURLEncoding.EncodeToString(data)
signature := signBytes([]byte(encodedPayload), secret)
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}
func parsePayload(ctx context.Context, token string, secret string) (tokenPayload, bool) {
if err := ctx.Err(); err != nil {
return tokenPayload{}, false
}
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) != 2 {
return tokenPayload{}, false
}
expected := signBytes([]byte(parts[0]), secret)
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(expected, actual) {
return tokenPayload{}, false
}
data, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return tokenPayload{}, false
}
var payload tokenPayload
if err := json.Unmarshal(data, &payload); err != nil {
return tokenPayload{}, false
}
return payload, true
}
func signState(secret string, ttl time.Duration, now time.Time) (string, int64, error) {
stateID := newID()
token, expiresIn, err := signToken(stateID, secret, ttl, now)
if err != nil {
return "", 0, err
}
return token, expiresIn, nil
}
func verifyState(ctx context.Context, state string, secret string, now time.Time) error {
if strings.TrimSpace(state) == "" {
return fmt.Errorf("%w: state is required", ErrInvalidCredentials)
}
if _, ok := parseToken(ctx, state, secret, now); !ok {
return fmt.Errorf("%w: state is invalid", ErrInvalidCredentials)
}
return nil
}
func hashVerificationCode(code string, target string, secret string) string {
value := strings.TrimSpace(target) + ":" + strings.TrimSpace(code)
return base64.RawURLEncoding.EncodeToString(signBytes([]byte(value), secret))
}
func signBytes(data []byte, secret string) []byte {
secret = strings.TrimSpace(secret)
if secret == "" {
secret = "local-dev-change-me"
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write(data)
return mac.Sum(nil)
}
func bearerToken(header string) (string, bool) {
header = strings.TrimSpace(header)
if header == "" {
return "", false
}
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", false
}
return parts[1], true
}
func normalizeTokenErr(err error) error {
if err == nil {
return nil
}
if errors.Is(err, ErrInvalidCredentials) {
return err
}
return fmt.Errorf("%w: %v", ErrInvalidCredentials, err)
}