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) }