58535fda7b
安全: - 渠道 webhook 入站强制令牌鉴权(恒定时间比较+统一文案),企微签名官方算法; - 报表/概览/systemInfo 端点按 usage:read/audit:read/system:manage 授权; - sso_error 固定错误码;个人渠道令牌仅请求头;工具出站 Dialer.Control 消除 DNS rebinding TOCTOU;新增 channel:read/manage 权限;限流倍数上限 10。 并发/一致性: - 任务上报单条条件 UPDATE 防重放双提交;认领回收过期 claimed 任务; - 审批改先开通后落记录(幂等,无嵌套事务);聊天消息单事务落库; - 会话列表校验 AuthVersion;吊销先 Del 后 SRem;删工具保护调用历史; - rejected 冷却 24h;限流被拒补偿;maintenance 清理限流窗口。 前端/菜单: - 修复 gatewayChildren late-append 导致 reports/tenants/channels 菜单不可见; - 聊天改名 PUT 对齐;渠道编辑清空凭据防串写+启用开关; - 聊天响应防串扰;报表本地时区日期。
396 lines
13 KiB
Go
396 lines
13 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
var ErrInvalidSession = errors.New("invalid or expired session")
|
|
|
|
type Principal struct {
|
|
Kind Kind `json:"kind"`
|
|
SubjectID string `json:"subject_id"`
|
|
Login string `json:"login"`
|
|
DisplayName string `json:"display_name"`
|
|
Role string `json:"role,omitempty"`
|
|
Purpose string `json:"purpose"`
|
|
IssuedAt int64 `json:"issued_at"`
|
|
// IP 与 UserAgent 记录签发时的登录环境,供"我的登录设备"展示与登录提醒。
|
|
IP string `json:"ip,omitempty"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
// AuthVersion 是签发会话时账号的凭据版本;凭据变更(改密/2FA 变更)会
|
|
// 递增该版本,旧版本会话在 Authenticate 时被拒绝,被盗会话无法在
|
|
// 凭据轮换后继续存活。
|
|
AuthVersion int64 `json:"auth_version,omitempty"`
|
|
}
|
|
|
|
// authVersionTTL 必须严格大于会话 TTL(上限 7 天,由 config 校验保证):
|
|
// 版本键过期时所有旧会话已自然过期,凭据变更后旧会话不会复活。
|
|
const authVersionTTL = 14 * 24 * time.Hour
|
|
|
|
func authVersionKey(kind Kind, subjectID string) string {
|
|
return "gateway:auth-version:" + string(kind) + ":" + subjectID
|
|
}
|
|
|
|
// AuthVersion 返回账号当前凭据版本;从未变更过则为 0。
|
|
func (s *SessionStore) AuthVersion(ctx context.Context, kind Kind, subjectID string) int64 {
|
|
if s.client == nil {
|
|
return 0
|
|
}
|
|
value, err := s.client.Get(ctx, authVersionKey(kind, subjectID)).Int64()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return value
|
|
}
|
|
|
|
// BumpAuthVersion 使账号的全部既有会话失效(改密、2FA 启用/停用等凭据变更后调用)。
|
|
// Redis 不可用时静默失败:会话仍按 TTL 自然过期,凭据变更的即时失效降级为延迟生效。
|
|
func (s *SessionStore) BumpAuthVersion(ctx context.Context, kind Kind, subjectID string) {
|
|
if s.client == nil {
|
|
return
|
|
}
|
|
key := authVersionKey(kind, subjectID)
|
|
_ = s.client.Incr(ctx, key).Err()
|
|
_ = s.client.Expire(ctx, key, authVersionTTL).Err()
|
|
}
|
|
|
|
type SessionStore struct {
|
|
client *redis.Client
|
|
ttl time.Duration
|
|
}
|
|
|
|
func NewSessionStore(client *redis.Client, ttl time.Duration) *SessionStore {
|
|
return &SessionStore{client: client, ttl: ttl}
|
|
}
|
|
|
|
func (s *SessionStore) Create(ctx context.Context, principal Principal) (string, error) {
|
|
principal.Purpose = "session"
|
|
return s.create(ctx, principal, s.ttl)
|
|
}
|
|
|
|
// CreateWithMeta 签发会话并记录登录环境(IP/UA),供设备管理与登录提醒使用。
|
|
func (s *SessionStore) CreateWithMeta(ctx context.Context, principal Principal, ip, userAgent string) (string, error) {
|
|
principal.Purpose = "session"
|
|
principal.IP = truncate(strings.TrimSpace(ip), 64)
|
|
principal.UserAgent = truncate(strings.TrimSpace(userAgent), 256)
|
|
return s.create(ctx, principal, s.ttl)
|
|
}
|
|
|
|
func (s *SessionStore) CreatePending(ctx context.Context, principal Principal, ttl time.Duration) (string, error) {
|
|
principal.Purpose = "totp_pending"
|
|
return s.create(ctx, principal, ttl)
|
|
}
|
|
|
|
func (s *SessionStore) create(ctx context.Context, principal Principal, ttl time.Duration) (string, error) {
|
|
if s.client == nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
random := make([]byte, 32)
|
|
if _, err := rand.Read(random); err != nil {
|
|
return "", err
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(random)
|
|
principal.IssuedAt = time.Now().Unix()
|
|
principal.AuthVersion = s.AuthVersion(ctx, principal.Kind, principal.SubjectID)
|
|
payload, err := json.Marshal(principal)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := s.client.Set(ctx, sessionKey(token), payload, ttl).Err(); err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
// 维护账号的会话索引(仅正式会话,不含 TOTP 挑战令牌),供
|
|
// "我的登录设备"列出与吊销。索引不做 TTL 管理,列出时惰性清理过期项。
|
|
if principal.Purpose == "session" {
|
|
_ = s.client.SAdd(ctx, sessionIndexKey(principal.Kind, principal.SubjectID), sessionHex(token)).Err()
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func (s *SessionStore) Authenticate(ctx context.Context, authorization string, expected Kind) (Principal, error) {
|
|
if s.client == nil {
|
|
return Principal{}, ErrUnavailable
|
|
}
|
|
token, ok := bearerToken(authorization)
|
|
if !ok {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
payload, err := s.client.Get(ctx, sessionKey(token)).Bytes()
|
|
if errors.Is(err, redis.Nil) {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
if err != nil {
|
|
return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
var principal Principal
|
|
if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "session" {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
// 凭据版本不匹配:改密/2FA 变更后旧会话一律失效。
|
|
if principal.AuthVersion != s.AuthVersion(ctx, principal.Kind, principal.SubjectID) {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
return principal, nil
|
|
}
|
|
|
|
// AuthenticatePending 只读取(不消费)挑战令牌,供 CompleteTOTPLogin 在
|
|
// 验证前解析 principal;验证码输错时令牌保留可重试。
|
|
func (s *SessionStore) AuthenticatePending(ctx context.Context, token string, expected Kind) (Principal, error) {
|
|
if s.client == nil {
|
|
return Principal{}, ErrUnavailable
|
|
}
|
|
payload, err := s.client.Get(ctx, sessionKey(strings.TrimSpace(token))).Bytes()
|
|
if errors.Is(err, redis.Nil) {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
if err != nil {
|
|
return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
var principal Principal
|
|
if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "totp_pending" {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
if principal.AuthVersion != s.AuthVersion(ctx, principal.Kind, principal.SubjectID) {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
return principal, nil
|
|
}
|
|
|
|
// ConsumePending 原子消费挑战令牌(GetDel):验证通过后调用,并发请求用同一
|
|
// 令牌时只有一个能成功,防止一次 2FA 挑战铸出两个会话。
|
|
func (s *SessionStore) ConsumePending(ctx context.Context, token string, expected Kind) (Principal, error) {
|
|
if s.client == nil {
|
|
return Principal{}, ErrUnavailable
|
|
}
|
|
payload, err := s.client.GetDel(ctx, sessionKey(strings.TrimSpace(token))).Bytes()
|
|
if errors.Is(err, redis.Nil) {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
if err != nil {
|
|
return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
var principal Principal
|
|
if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "totp_pending" {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
if principal.AuthVersion != s.AuthVersion(ctx, principal.Kind, principal.SubjectID) {
|
|
return Principal{}, ErrInvalidSession
|
|
}
|
|
return principal, nil
|
|
}
|
|
|
|
func (s *SessionStore) DeleteToken(ctx context.Context, token string) error {
|
|
if s.client == nil {
|
|
return ErrUnavailable
|
|
}
|
|
if err := s.client.Del(ctx, sessionKey(strings.TrimSpace(token))).Err(); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionStore) Delete(ctx context.Context, authorization string) error {
|
|
if s.client == nil {
|
|
return ErrUnavailable
|
|
}
|
|
token, ok := bearerToken(authorization)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := s.client.Del(ctx, sessionKey(token)).Err(); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SessionView 是"我的登录设备"视图。
|
|
type SessionView struct {
|
|
ID string `json:"id"`
|
|
IP string `json:"ip"`
|
|
UserAgent string `json:"user_agent"`
|
|
IssuedAt int64 `json:"issued_at"`
|
|
Current bool `json:"current"`
|
|
}
|
|
|
|
// ListSessions 列出账号的全部有效会话并惰性清理已过期项。
|
|
// currentToken 为当前请求的会话,标记 current=true。
|
|
func (s *SessionStore) ListSessions(ctx context.Context, kind Kind, subjectID, currentAuthorization string) ([]SessionView, error) {
|
|
if s.client == nil {
|
|
return nil, ErrUnavailable
|
|
}
|
|
hexes, err := s.client.SMembers(ctx, sessionIndexKey(kind, subjectID)).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
items := []SessionView{}
|
|
for _, hex := range hexes {
|
|
payload, err := s.client.Get(ctx, sessionKeyFromHex(hex)).Bytes()
|
|
if errors.Is(err, redis.Nil) {
|
|
_ = s.client.SRem(ctx, sessionIndexKey(kind, subjectID), hex).Err()
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
var principal Principal
|
|
if json.Unmarshal(payload, &principal) != nil || principal.Kind != kind || principal.SubjectID != subjectID || principal.Purpose != "session" {
|
|
_ = s.client.SRem(ctx, sessionIndexKey(kind, subjectID), hex).Err()
|
|
continue
|
|
}
|
|
// 凭据版本不匹配的会话(改密/2FA 变更后)实际已失效,不展示并清理索引。
|
|
if principal.AuthVersion != s.AuthVersion(ctx, principal.Kind, principal.SubjectID) {
|
|
_ = s.client.SRem(ctx, sessionIndexKey(kind, subjectID), hex).Err()
|
|
continue
|
|
}
|
|
items = append(items, SessionView{ID: hex, IP: principal.IP, UserAgent: principal.UserAgent, IssuedAt: principal.IssuedAt,
|
|
Current: s.sessionHexMatches(currentAuthorization, hex)})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// ErrRevokeCurrentSession 表示试图吊销当前登录的会话。
|
|
var ErrRevokeCurrentSession = errors.New("不能吊销当前登录的会话")
|
|
|
|
// RevokeSession 吊销指定会话(hex ID);当前会话不得吊销。
|
|
func (s *SessionStore) RevokeSession(ctx context.Context, kind Kind, subjectID, sessionID, currentAuthorization string) error {
|
|
if s.client == nil {
|
|
return ErrUnavailable
|
|
}
|
|
sessionID = strings.TrimSpace(sessionID)
|
|
if len(sessionID) != 64 || !isHex(sessionID) {
|
|
return errors.New("会话标识无效")
|
|
}
|
|
if s.sessionHexMatches(currentAuthorization, sessionID) {
|
|
return ErrRevokeCurrentSession
|
|
}
|
|
if err := s.client.Del(ctx, sessionKeyFromHex(sessionID)).Err(); err != nil {
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
// 先删会话键再删索引:Del 失败时两者都保留(列表仍显示,可重试);
|
|
// SRem 失败只留脏索引,由 ListSessions 惰性清理自愈。
|
|
removed, err := s.client.SRem(ctx, sessionIndexKey(kind, subjectID), sessionID).Result()
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
if removed == 0 {
|
|
return ErrInvalidSession
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionStore) sessionHexMatches(authorization, hex string) bool {
|
|
token, ok := bearerToken(authorization)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return sessionHex(token) == hex
|
|
}
|
|
|
|
func sessionIndexKey(kind Kind, subjectID string) string {
|
|
return "gateway:session-index:" + string(kind) + ":" + subjectID
|
|
}
|
|
|
|
func sessionHex(token string) string {
|
|
digest := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func sessionKeyFromHex(value string) string {
|
|
return "gateway:session:v1:" + value
|
|
}
|
|
|
|
func isHex(value string) bool {
|
|
if value == "" {
|
|
return false
|
|
}
|
|
for _, r := range value {
|
|
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *SessionStore) StoreOneTime(ctx context.Context, namespace string, value any, ttl time.Duration) (string, error) {
|
|
if s.client == nil {
|
|
return "", ErrUnavailable
|
|
}
|
|
random := make([]byte, 32)
|
|
if _, err := rand.Read(random); err != nil {
|
|
return "", err
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(random)
|
|
payload, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := s.client.Set(ctx, oneTimeKey(namespace, token), payload, ttl).Err(); err != nil {
|
|
return "", fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
return token, nil
|
|
}
|
|
|
|
func (s *SessionStore) ConsumeOneTime(ctx context.Context, namespace, token string, target any) error {
|
|
if s.client == nil {
|
|
return ErrUnavailable
|
|
}
|
|
payload, err := s.client.GetDel(ctx, oneTimeKey(namespace, strings.TrimSpace(token))).Bytes()
|
|
if errors.Is(err, redis.Nil) {
|
|
return ErrInvalidSession
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
if err := json.Unmarshal(payload, target); err != nil {
|
|
return ErrInvalidSession
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClaimIdentifier atomically claims a caller-provided replay identifier for
|
|
// the TTL. It is used for signed protocol message IDs, not bearer secrets.
|
|
func (s *SessionStore) ClaimIdentifier(ctx context.Context, namespace, identifier string, ttl time.Duration) (bool, error) {
|
|
if s.client == nil {
|
|
return false, ErrUnavailable
|
|
}
|
|
identifier = strings.TrimSpace(identifier)
|
|
if identifier == "" || len(identifier) > 512 {
|
|
return false, ErrInvalidSession
|
|
}
|
|
claimed, err := s.client.SetNX(ctx, oneTimeKey(namespace, identifier), "1", ttl).Result()
|
|
if err != nil {
|
|
return false, fmt.Errorf("%w: %v", ErrUnavailable, err)
|
|
}
|
|
return claimed, nil
|
|
}
|
|
|
|
func bearerToken(authorization string) (string, bool) {
|
|
authorization = strings.TrimSpace(authorization)
|
|
if len(authorization) <= len("Bearer ") || !strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") {
|
|
return "", false
|
|
}
|
|
token := strings.TrimSpace(authorization[len("Bearer "):])
|
|
return token, token != ""
|
|
}
|
|
|
|
func sessionKey(token string) string {
|
|
digest := sha256.Sum256([]byte(token))
|
|
return "gateway:session:v1:" + hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
func oneTimeKey(namespace, token string) string {
|
|
digest := sha256.Sum256([]byte(token))
|
|
return "gateway:one-time:" + namespace + ":" + hex.EncodeToString(digest[:])
|
|
}
|