2026-05-12 09:53:20 +08:00

207 lines
8.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 auth
import (
"context"
"database/sql"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
func (r *Repository) CreateSession(ctx context.Context, session authdomain.Session) error {
// refresh token 只保存 hash原文不会写入数据库。
_, err := r.db.ExecContext(ctx, `
INSERT INTO auth_sessions (app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, NULL, '', '', '', ?, ?)
`, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.CreatedAtMs, session.UpdatedAtMs)
if err != nil {
// session_id 或 refresh hash 冲突统一映射成 auth 领域冲突。
return mapAuthDuplicateError(err)
}
return nil
}
// FindActiveSessionByRefreshHash 查找未过期且未吊销的 session。
func (r *Repository) FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error) {
// 查到 session 后再判断 revoked/expired给调用方稳定 reason。
session, err := r.findSessionByRefreshHash(ctx, refreshTokenHash)
if err != nil {
return authdomain.Session{}, err
}
if session.RevokedAtMs > 0 {
// 已吊销 session 不能继续 refresh。
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
}
if session.ExpiresAtMs <= nowMs {
// 过期 session 与吊销 session 使用不同 reason。
return authdomain.Session{}, xerr.New(xerr.SessionExpired, "session expired")
}
return session, nil
}
// FindActiveSessionByID 查找未过期且未吊销的 session。
func (r *Repository) FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error) {
// onboarding 完成后只需要重签 access token不能要求客户端再提交 refresh token 原文。
session, err := r.findSessionByID(ctx, sessionID)
if err != nil {
return authdomain.Session{}, err
}
if session.RevokedAtMs > 0 {
// 已吊销 session 不能继续签发任何 access token。
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
}
if session.ExpiresAtMs <= nowMs {
// session 过期后只能重新登录,不能用完成注册接口续命。
return authdomain.Session{}, xerr.New(xerr.SessionExpired, "session expired")
}
return session, nil
}
// ReplaceSession 原子吊销旧 session 并创建新 session。
func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error {
// refresh 轮换必须原子吊销旧 session 并插入新 session。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var oldRevokedAt sql.Null[int64]
err = tx.QueryRowContext(ctx, `
SELECT revoked_at_ms
FROM auth_sessions
WHERE app_code = ? AND session_id = ?
FOR UPDATE
`, appcode.FromContext(ctx), oldSessionID).Scan(&oldRevokedAt)
if err == sql.ErrNoRows || oldRevokedAt.Valid {
// 旧 session 不存在或已经吊销时,不能再次轮换。
return xerr.New(xerr.SessionRevoked, "session revoked")
}
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
UPDATE auth_sessions
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ?
WHERE app_code = ? AND session_id = ?
`, revokedAtMs, "REFRESH_ROTATED", "", "refresh_token", revokedAtMs, appcode.FromContext(ctx), oldSessionID)
if err != nil {
return err
}
if err := insertSession(ctx, tx, newSession); err != nil {
// 插入新 session 失败会回滚旧 session 吊销。
return err
}
return tx.Commit()
}
// RevokeSession 按 session_id 或 refresh token hash 吊销 session。
func (r *Repository) RevokeSession(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error) {
if sessionID == "" && refreshTokenHash != "" {
// 只传 refresh token 时先反查 session_id。
session, err := r.findSessionByRefreshHash(ctx, refreshTokenHash)
if err != nil {
return false, err
}
sessionID = session.SessionID
}
return r.revokeSessionByID(ctx, sessionID, revokedAtMs, "USER_LOGOUT", "", "user_logout")
}
func (r *Repository) RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error) {
return r.revokeSessionByID(ctx, sessionID, revokedAtMs, reason, requestID, revokedBy)
}
func (r *Repository) revokeSessionByID(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE auth_sessions
SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ?
WHERE app_code = ? AND session_id = ? AND revoked_at_ms IS NULL
`, revokedAtMs, reason, requestID, revokedBy, revokedAtMs, appcode.FromContext(ctx), sessionID)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
// affected=false 表示没有可吊销 session调用方按幂等结果处理。
return affected > 0, nil
}
// FindThirdPartyIdentity 查找 provider + subject 绑定。
func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) {
// refresh token hash 是唯一索引,最多命中一条 session。
var session authdomain.Session
var revokedAt sql.Null[int64]
err := r.db.QueryRowContext(ctx, `
SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms
FROM auth_sessions
WHERE app_code = ? AND refresh_token_hash = ?
`, appcode.FromContext(ctx), refreshTokenHash).Scan(&session.AppCode, &session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &revokedAt, &session.RevokedReason, &session.RevokedRequestID, &session.RevokedBy, &session.CreatedAtMs, &session.UpdatedAtMs)
if err == sql.ErrNoRows {
// 找不到 hash 按 revoked 返回,避免暴露 token 是否存在。
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
}
if err != nil {
return authdomain.Session{}, err
}
if revokedAt.Valid {
// sql.Null[int64] 转换成领域零值语义。
session.RevokedAtMs = revokedAt.V
}
return session, nil
}
func (r *Repository) findSessionByID(ctx context.Context, sessionID string) (authdomain.Session, error) {
// session_id 来自已校验 access token 的 sid claim仍必须回查服务端 session 状态。
var session authdomain.Session
var revokedAt sql.Null[int64]
err := r.db.QueryRowContext(ctx, `
SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms
FROM auth_sessions
WHERE app_code = ? AND session_id = ?
`, appcode.FromContext(ctx), sessionID).Scan(&session.AppCode, &session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &revokedAt, &session.RevokedReason, &session.RevokedRequestID, &session.RevokedBy, &session.CreatedAtMs, &session.UpdatedAtMs)
if err == sql.ErrNoRows {
// 不暴露 session_id 是否存在,统一按 revoked 处理。
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
}
if err != nil {
return authdomain.Session{}, err
}
if revokedAt.Valid {
session.RevokedAtMs = revokedAt.V
}
return session, nil
}
func insertSession(ctx context.Context, tx *sql.Tx, session authdomain.Session) error {
// insertSession 只在已有事务中使用,保证与 session 替换或三方注册同提交。
_, err := tx.ExecContext(ctx, `
INSERT INTO auth_sessions (app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, NULL, '', '', '', ?, ?)
`, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.CreatedAtMs, session.UpdatedAtMs)
if err != nil {
return mapAuthDuplicateError(err)
}
return nil
}