2026-06-22 13:59:15 +08:00

305 lines
12 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, last_heartbeat_at_ms, last_heartbeat_request_id, 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.LastHeartbeatAtMs, session.LastHeartbeatRequestID, 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) RevokeUserDeviceSessionsForRisk(ctx context.Context, sourceSessionID string, userID int64, revokedAtMs int64, reason string, requestID string, revokedBy string) ([]string, error) {
// IP 风控是异步执行;客户端可能已经用源 session 的 refresh token 轮换出了新 session。
// 这里先锁定源 session拿到设备和创建时间再圈定同一用户、同一设备、同一登录链路之后产生的未过期 session避免误伤同账号其他设备。
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var deviceID string
var sourceUserID int64
var sourceCreatedAtMs int64
err = tx.QueryRowContext(ctx, `
SELECT user_id, device_id, created_at_ms
FROM auth_sessions
WHERE app_code = ? AND session_id = ?
FOR UPDATE
`, appcode.FromContext(ctx), sourceSessionID).Scan(&sourceUserID, &deviceID, &sourceCreatedAtMs)
if err == sql.ErrNoRows {
// 风控任务来自登录成功后的持久 session若源 session 已被异常清理,按空结果幂等结束。
return nil, tx.Commit()
}
if err != nil {
return nil, err
}
if sourceUserID != userID {
// job.user_id 和 session.user_id 不一致说明数据链路被污染,不能扩大吊销范围。
return nil, xerr.New(xerr.SessionRevoked, "risk session owner mismatch")
}
rows, err := tx.QueryContext(ctx, `
SELECT session_id
FROM auth_sessions
WHERE app_code = ?
AND user_id = ?
AND device_id = ?
AND created_at_ms >= ?
AND expires_at_ms > ?
FOR UPDATE
`, appcode.FromContext(ctx), userID, deviceID, sourceCreatedAtMs, revokedAtMs)
if err != nil {
return nil, err
}
var sessionIDs []string
for rows.Next() {
var sessionID string
if err := rows.Scan(&sessionID); err != nil {
_ = rows.Close()
return nil, err
}
sessionIDs = append(sessionIDs, sessionID)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(sessionIDs) == 0 {
return sessionIDs, tx.Commit()
}
for _, sessionID := range sessionIDs {
// 已经被 refresh 轮换的 session 不覆盖原撤销原因,但仍返回给上层写 denylist避免旧 access token 继续通过 gateway。
if _, 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 = ? AND revoked_at_ms IS NULL
`, revokedAtMs, reason, requestID, revokedBy, revokedAtMs, appcode.FromContext(ctx), sessionID); err != nil {
return nil, err
}
}
return sessionIDs, tx.Commit()
}
func (r *Repository) UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error) {
// App 心跳绑定服务端 session不能只按 user_id 宽泛刷新,避免旧 token 或跨设备请求续住错误会话。
result, err := r.db.ExecContext(ctx, `
UPDATE auth_sessions
SET last_heartbeat_at_ms = ?, last_heartbeat_request_id = ?, updated_at_ms = ?
WHERE app_code = ? AND session_id = ? AND user_id = ? AND revoked_at_ms IS NULL AND expires_at_ms > ?
`, heartbeatAtMs, requestID, heartbeatAtMs, appcode.FromContext(ctx), sessionID, userID, heartbeatAtMs)
if err != nil {
return authdomain.Session{}, err
}
affected, err := result.RowsAffected()
if err != nil {
return authdomain.Session{}, err
}
if affected == 0 {
// 未命中只说明 session 不可用或不属于当前用户;对外统一成 session revoked避免泄漏会话存在性。
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
}
return r.findSessionByID(ctx, sessionID)
}
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, last_heartbeat_at_ms, last_heartbeat_request_id, 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, &session.LastHeartbeatAtMs, &session.LastHeartbeatRequestID, &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, last_heartbeat_at_ms, last_heartbeat_request_id, 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, &session.LastHeartbeatAtMs, &session.LastHeartbeatRequestID, &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, last_heartbeat_at_ms, last_heartbeat_request_id, 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.LastHeartbeatAtMs, session.LastHeartbeatRequestID, session.CreatedAtMs, session.UpdatedAtMs)
if err != nil {
return mapAuthDuplicateError(err)
}
return nil
}