Files
backend/internal/service/session_service.go

179 lines
6.3 KiB
Go
Raw Permalink Normal View History

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