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
179 lines
6.3 KiB
Go
179 lines
6.3 KiB
Go
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)
|
||
} |