Files
backend/internal/service/session_service.go
lafay eb931bf1c6
All checks were successful
Build Backend / build (push) Successful in 2m19s
Build Backend / build-docker (push) Successful in 46s
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
2026-07-05 18:28:08 +08:00

179 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 哈希。
//
// 不使用 bcryptrefresh 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 tokensid 留空待补,避免一次性生成两个 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)
}