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