feat(auth): implement session-based token management with revocation support
Add Session model, SessionService, and SessionRepository to track user login sessions and enable token revocation on auth-critical events. - Introduce explicit TokenType (access/refresh) in JWT claims to prevent refresh token misuse via access token endpoints - Add SessionID field to JWT claims, enabling stateless JWT validation against revoked sessions - Replace legacy Auth middleware with RequireAuth/OptionalAuth pipeline that validates token type, account status, and session validity - Implement session revocation on password change, reset, user ban/inactive, and explicit logout - Add Principal cache with active invalidation for banned/role-changed users - Fix IDOR vulnerability: GetMessagesByCursor now validates currentUserID is conversation participant via GetParticipantStrict - Add group member visibility checks: announcements, group info, member list now require group membership - Simplify Casbin policy: remove g grouping, use r.sub == p.sub matcher with globMatch; user_roles table is single source of truth for roles - Add migration logic to clean legacy casbin g rules and migrate old p rules from path-style to admin/<domain> resource naming
This commit is contained in:
179
internal/service/session_service.go
Normal file
179
internal/service/session_service.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
apperrors "with_you/internal/errors"
|
||||
"with_you/internal/model"
|
||||
"with_you/internal/repository"
|
||||
)
|
||||
|
||||
// SessionRefreshExpireGracePeriod 会话过期判断的容差:以 ExpiresAt 为准,避免边界条件。
|
||||
const (
|
||||
SessionRefreshExpireGracePeriod = 1 * time.Second
|
||||
)
|
||||
|
||||
// SessionService 会话服务
|
||||
//
|
||||
// 负责会话生命周期:签发、校验、轮换、撤销。
|
||||
// access/refresh token 在 JWT 层只做签名/过期/类型校验,
|
||||
// 真正的撤销能力(登出/封禁后旧 token 立即失效)由会话表提供:
|
||||
// - Issue:创建会话,返回写入了 sid 的 access/refresh token 与会话记录。
|
||||
// - Validate:在 refresh 端点校验 refresh token 对应会话是否仍有效。
|
||||
// - Rotate:刷新时撤销旧 refresh、签发新会话与令牌对。
|
||||
// - Revoke/RevokeAllByUser:登出/封禁时撤销。
|
||||
type SessionService interface {
|
||||
Issue(ctx context.Context, userID, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error)
|
||||
Validate(ctx context.Context, refreshToken string) (*model.Session, error)
|
||||
Rotate(ctx context.Context, oldRefreshToken, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error)
|
||||
Revoke(ctx context.Context, sessionID string) error
|
||||
RevokeAllByUser(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
type sessionService struct {
|
||||
sessionRepo repository.SessionRepository
|
||||
jwtService JWTService
|
||||
}
|
||||
|
||||
// NewSessionService 创建会话服务。
|
||||
func NewSessionService(sessionRepo repository.SessionRepository, jwtService JWTService) SessionService {
|
||||
return &sessionService{
|
||||
sessionRepo: sessionRepo,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
}
|
||||
|
||||
// hashRefreshToken 对 refresh token 明文做 SHA256 哈希。
|
||||
//
|
||||
// 不使用 bcrypt:refresh token 本身已是高熵随机串,SHA256 足够防泄露;
|
||||
// bcrypt 会引入额外成本并使按 hash 反查会话不可行。
|
||||
func hashRefreshToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Issue 创建会话并签发令牌对。
|
||||
func (s *sessionService) Issue(ctx context.Context, userID, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error) {
|
||||
now := time.Now()
|
||||
expireAt := now.Add(refreshExpire)
|
||||
|
||||
// 先签发 refresh token(sid 留空待补,避免一次性生成两个 JWT 与 session id 不一致)。
|
||||
// 实际实现:先创建 session 得到 ID,再用 sid 签发两个 token。
|
||||
session = &model.Session{
|
||||
UserID: userID,
|
||||
UserAgent: userAgent,
|
||||
IP: ip,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: expireAt,
|
||||
}
|
||||
if err = s.sessionRepo.Create(ctx, session); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
accessToken, err = s.jwtService.GenerateAccessTokenWithSession(userID, username, session.ID)
|
||||
if err != nil {
|
||||
_ = s.sessionRepo.Revoke(ctx, session.ID)
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
refreshToken, err = s.jwtService.GenerateRefreshTokenWithSession(userID, username, session.ID)
|
||||
if err != nil {
|
||||
_ = s.sessionRepo.Revoke(ctx, session.ID)
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
session.RefreshTokenHash = hashRefreshToken(refreshToken)
|
||||
if uerr := s.sessionRepo.Update(ctx, session); uerr != nil {
|
||||
// 哈希写入失败时撤销会话,避免出现无 hash 的不可轮换会话。
|
||||
_ = s.sessionRepo.Revoke(ctx, session.ID)
|
||||
return nil, "", "", uerr
|
||||
}
|
||||
|
||||
return session, accessToken, refreshToken, nil
|
||||
}
|
||||
|
||||
// Validate 校验 refresh token 对应会话是否仍有效。
|
||||
func (s *sessionService) Validate(ctx context.Context, refreshToken string) (*model.Session, error) {
|
||||
if refreshToken == "" {
|
||||
return nil, apperrors.ErrInvalidToken
|
||||
}
|
||||
|
||||
// 先解析 JWT 拿到 sid,避免仅靠 hash 反查;同时确保 refresh token 类型正确。
|
||||
claims, err := s.jwtService.ParseRefreshToken(refreshToken)
|
||||
if err != nil {
|
||||
return nil, apperrors.ErrInvalidToken
|
||||
}
|
||||
|
||||
session, err := s.sessionRepo.GetByID(ctx, claims.SessionID)
|
||||
if err != nil {
|
||||
return nil, apperrors.ErrSessionRevoked
|
||||
}
|
||||
|
||||
// 校验 refresh token hash 与会话匹配,防止 sid 复用但 refresh 已轮换。
|
||||
hash := hashRefreshToken(refreshToken)
|
||||
if session.RefreshTokenHash != hash {
|
||||
return nil, apperrors.ErrSessionRevoked
|
||||
}
|
||||
|
||||
if session.IsRevoked() {
|
||||
return nil, apperrors.ErrSessionRevoked
|
||||
}
|
||||
if session.IsExpired(time.Now()) {
|
||||
return nil, apperrors.ErrTokenExpired
|
||||
}
|
||||
|
||||
// 更新最近使用时间,便于审计与会话清理判断。
|
||||
// 失败不阻塞鉴权主路径,仅记日志:LastUsedAt 仅用于审计,不参与鉴权决策。
|
||||
session.LastUsedAt = time.Now()
|
||||
if uerr := s.sessionRepo.Update(ctx, session); uerr != nil {
|
||||
zap.L().Warn("failed to update session last_used_at",
|
||||
zap.String("session_id", session.ID),
|
||||
zap.Error(uerr),
|
||||
)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Rotate 撤销旧 refresh token、签发新会话与令牌对。
|
||||
func (s *sessionService) Rotate(ctx context.Context, oldRefreshToken, username, userAgent, ip string, refreshExpire time.Duration) (session *model.Session, accessToken, refreshToken string, err error) {
|
||||
oldSession, err := s.Validate(ctx, oldRefreshToken)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
// 撤销旧会话(其 refresh token 不再可用)。
|
||||
if rerr := s.sessionRepo.Revoke(ctx, oldSession.ID); rerr != nil {
|
||||
return nil, "", "", rerr
|
||||
}
|
||||
|
||||
newSession, accessToken, refreshToken, err := s.Issue(ctx, oldSession.UserID, username, userAgent, ip, refreshExpire)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return newSession, accessToken, refreshToken, nil
|
||||
}
|
||||
|
||||
// Revoke 撤销指定会话(登出)。
|
||||
func (s *sessionService) Revoke(ctx context.Context, sessionID string) error {
|
||||
if sessionID == "" {
|
||||
return errors.New("session id is required")
|
||||
}
|
||||
return s.sessionRepo.Revoke(ctx, sessionID)
|
||||
}
|
||||
|
||||
// RevokeAllByUser 撤销用户的所有会话(封禁/重置密码)。
|
||||
func (s *sessionService) RevokeAllByUser(ctx context.Context, userID string) error {
|
||||
if userID == "" {
|
||||
return errors.New("user id is required")
|
||||
}
|
||||
// 同时刷新 access tokens 关联账户:access token 仅在 TTL 内可见,会话撤销后 refresh 已立即失效。
|
||||
// 如未来需要 access token 即时失效,可引入 jti 黑名单,但当前 access TTL 较短可接受。
|
||||
return s.sessionRepo.RevokeAllByUser(ctx, userID)
|
||||
}
|
||||
Reference in New Issue
Block a user