用户登录 - 防止重复会话
目录
1. 概述
1.1 为什么需要会话幂等?
用户登录场景下的幂等性主要解决以下问题:
问题场景:
- 用户多次点击"登录"按钮
- 网络超时导致客户端重试
- 多个设备同时登录同一账号
- Token 刷新时的并发请求
后果:
- 创建多个会话,浪费资源
- 用户数据不一致
- 安全审计困难
1.2 会话策略
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 单点登录 | 同一用户只能有一个活跃会话 | 企业系统、银行 |
| 多点登录 | 允许同时多个会话 | 社交应用、邮箱 |
| 限制数量 | 最多 N 个会话 | SaaS 平台 |
| 按设备类型 | 每种设备类型一个会话 | 移动应用 |
2. 会话管理策略
2.1 基于唯一索引的防重
sql
-- 会话表
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
-- 会话信息
session_token VARCHAR(200) NOT NULL UNIQUE, -- 唯一令牌
device_info JSONB, -- 设备信息
ip_address INET, -- IP地址
-- 会话状态
is_active BOOLEAN NOT NULL DEFAULT TRUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
-- 审计字段
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_activity_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- 唯一索引:同一用户只有一个活跃会话(单点登录)
CREATE UNIQUE INDEX idx_user_single_session
ON user_sessions(user_id)
WHERE is_active = TRUE;
-- 普通索引
CREATE INDEX idx_user_sessions_token ON user_sessions(session_token);
CREATE INDEX idx_user_sessions_expires ON user_sessions(expires_at);
COMMENT ON COLUMN user_sessions.session_token IS '会话令牌,全局唯一';
COMMENT ON INDEX idx_user_single_session IS '保证单点登录:同一用户只有一个活跃会话';2.2 多设备登录策略
sql
-- 移除单点登录约束,改为复合唯一索引
CREATE UNIQUE INDEX idx_user_device_session
ON user_sessions(user_id, device_id)
WHERE is_active = TRUE;
-- device_id 存储在 device_info 中
ALTER TABLE user_sessions ADD COLUMN device_id VARCHAR(100);3. 数据库设计
3.1 完整的会话表
sql
CREATE TABLE user_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 会话令牌
session_token VARCHAR(200) NOT NULL UNIQUE,
refresh_token VARCHAR(200) UNIQUE, -- 刷新令牌
-- 设备信息
device_id VARCHAR(100), -- 设备标识
device_type VARCHAR(20), -- 'mobile', 'desktop', 'web'
device_name VARCHAR(200), -- 'iPhone 13', 'Chrome on Windows'
os_info VARCHAR(100), -- 'iOS 15.0', 'Windows 11'
-- 位置信息
ip_address INET,
country VARCHAR(50),
city VARCHAR(100),
-- 会话状态
is_active BOOLEAN NOT NULL DEFAULT TRUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
-- 审计
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
last_activity_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- 约束
CONSTRAINT chk_session_expiry CHECK (expires_at > created_at)
);
-- 索引
CREATE INDEX idx_sessions_user_id ON user_sessions(user_id);
CREATE INDEX idx_sessions_token ON user_sessions(session_token);
CREATE INDEX idx_sessions_refresh_token ON user_sessions(refresh_token);
CREATE INDEX idx_sessions_device_id ON user_sessions(device_id);
CREATE INDEX idx_sessions_active ON user_sessions(user_id, is_active) WHERE is_active = TRUE;
CREATE INDEX idx_sessions_expires ON user_sessions(expires_at) WHERE is_active = TRUE;
-- 注释
COMMENT ON TABLE user_sessions IS '用户会话表';
COMMENT ON COLUMN user_sessions.session_token IS '访问令牌';
COMMENT ON COLUMN user_sessions.refresh_token IS '刷新令牌';
COMMENT ON COLUMN user_sessions.device_id IS '设备唯一标识';4. C# 实现
4.1 会话服务接口
csharp
namespace Idempotency.Auth.Services
{
public interface ISessionService
{
/// <summary>
/// 创建会话(幂等)
/// </summary>
Task<Result<SessionInfo>> CreateSessionAsync(CreateSessionRequest request);
/// <summary>
/// 验证会话
/// </summary>
Task<Result<SessionInfo>> ValidateSessionAsync(string sessionToken);
/// <summary>
/// 刷新会话
/// </summary>
Task<Result<SessionInfo>> RefreshSessionAsync(string refreshToken);
/// <summary>
/// 登出(销毁会话)
/// </summary>
Task<Result> LogoutAsync(string sessionToken);
/// <summary>
/// 获取用户的活跃会话列表
/// </summary>
Task<List<SessionInfo>> GetActiveSessionsAsync(Guid userId);
/// <summary>
/// 强制登出所有会话
/// </summary>
Task<Result> LogoutAllSessionsAsync(Guid userId);
}
public record CreateSessionRequest(
Guid UserId,
string DeviceId,
string DeviceType,
string DeviceName,
string IpAddress
);
public record SessionInfo(
Guid SessionId,
string SessionToken,
string RefreshToken,
Guid UserId,
DateTime ExpiresAt,
string? DeviceName
);
}4.2 会话服务实现
csharp
using Microsoft.EntityFrameworkCore;
using Idempotency.Auth.Models;
namespace Idempotency.Auth.Services
{
public class SessionService : ISessionService
{
private readonly AuthDbContext _dbContext;
private readonly ILogger<SessionService> _logger;
public SessionService(AuthDbContext dbContext, ILogger<SessionService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <summary>
/// 创建会话(幂等:同一设备的登录请求只会创建一个会话)
/// </summary>
public async Task<Result<SessionInfo>> CreateSessionAsync(CreateSessionRequest request)
{
try
{
// 检查是否已有该设备的活跃会话
var existingSession = await _dbContext.UserSessions
.Where(s => s.UserId == request.UserId
&& s.DeviceId == request.DeviceId
&& s.IsActive)
.FirstOrDefaultAsync();
if (existingSession != null)
{
// 复用现有会话
_logger.LogInformation("Reusing existing session for user {UserId}, device {DeviceId}",
request.UserId, request.DeviceId);
return Result.Success(MapToSessionInfo(existingSession));
}
// 如果是单点登录,先禁用其他会话
await DisableOtherSessionsAsync(request.UserId, request.DeviceId);
// 创建新会话
var session = new UserSession
{
Id = Guid.NewGuid(),
UserId = request.UserId,
SessionToken = GenerateSessionToken(),
RefreshToken = GenerateRefreshToken(),
DeviceId = request.DeviceId,
DeviceType = request.DeviceType,
DeviceName = request.DeviceName,
IpAddress = request.IpAddress,
IsActive = true,
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow,
LastActivityAt = DateTime.UtcNow
};
_dbContext.UserSessions.Add(session);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("New session created for user {UserId}, session {SessionId}",
request.UserId, session.Id);
return Result.Success(MapToSessionInfo(session));
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// 并发创建,返回已存在的会话
_logger.LogWarning("Concurrent session creation detected for user {UserId}",
request.UserId);
var existingSession = await _dbContext.UserSessions
.Where(s => s.UserId == request.UserId && s.IsActive)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
return existingSession != null
? Result.Success(MapToSessionInfo(existingSession))
: Result.Fail<SessionInfo>("Failed to create session");
}
}
/// <summary>
/// 验证会话
/// </summary>
public async Task<Result<SessionInfo>> ValidateSessionAsync(string sessionToken)
{
var session = await _dbContext.UserSessions
.Where(s => s.SessionToken == sessionToken && s.IsActive)
.FirstOrDefaultAsync();
if (session == null)
{
return Result.Fail<SessionInfo>("Invalid or expired session");
}
if (session.ExpiresAt < DateTime.UtcNow)
{
// 会话过期,标记为非活跃
session.IsActive = false;
await _dbContext.SaveChangesAsync();
return Result.Fail<SessionInfo>("Session expired");
}
// 更新最后活动时间
session.LastActivityAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
return Result.Success(MapToSessionInfo(session));
}
/// <summary>
/// 刷新会话
/// </summary>
public async Task<Result<SessionInfo>> RefreshSessionAsync(string refreshToken)
{
var session = await _dbContext.UserSessions
.Where(s => s.RefreshToken == refreshToken && s.IsActive)
.FirstOrDefaultAsync();
if (session == null)
{
return Result.Fail<SessionInfo>("Invalid refresh token");
}
// 生成新的令牌
session.SessionToken = GenerateSessionToken();
session.RefreshToken = GenerateRefreshToken();
session.ExpiresAt = DateTime.UtcNow.AddDays(7);
session.LastActivityAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
return Result.Success(MapToSessionInfo(session));
}
/// <summary>
/// 登出
/// </summary>
public async Task<Result> LogoutAsync(string sessionToken)
{
var session = await _dbContext.UserSessions
.Where(s => s.SessionToken == sessionToken && s.IsActive)
.FirstOrDefaultAsync();
if (session == null)
{
return Result.Fail("Session not found");
}
session.IsActive = false;
await _dbContext.SaveChangesAsync();
_logger.LogInformation("User logged out. Session: {SessionId}", session.Id);
return Result.Success();
}
/// <summary>
/// 获取活跃会话列表
/// </summary>
public async Task<List<SessionInfo>> GetActiveSessionsAsync(Guid userId)
{
var sessions = await _dbContext.UserSessions
.Where(s => s.UserId == userId && s.IsActive)
.OrderByDescending(s => s.LastActivityAt)
.ToListAsync();
return sessions.Select(MapToSessionInfo).ToList();
}
/// <summary>
/// 强制登出所有会话
/// </summary>
public async Task<Result> LogoutAllSessionsAsync(Guid userId)
{
var sessions = await _dbContext.UserSessions
.Where(s => s.UserId == userId && s.IsActive)
.ToListAsync();
foreach (var session in sessions)
{
session.IsActive = false;
}
await _dbContext.SaveChangesAsync();
_logger.LogInformation("All sessions logged out for user {UserId}. Count: {Count}",
userId, sessions.Count);
return Result.Success();
}
/// <summary>
/// 禁用其他会话(单点登录)
/// </summary>
private async Task DisableOtherSessionsAsync(Guid userId, string currentDeviceId)
{
var otherSessions = await _dbContext.UserSessions
.Where(s => s.UserId == userId
&& s.DeviceId != currentDeviceId
&& s.IsActive)
.ToListAsync();
foreach (var session in otherSessions)
{
session.IsActive = false;
}
if (otherSessions.Any())
{
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Disabled {Count} other sessions for user {UserId}",
otherSessions.Count, userId);
}
}
private bool IsUniqueViolation(DbUpdateException ex)
{
return ex.InnerException is NpgsqlException npgEx && npgEx.SqlState == "23505";
}
private string GenerateSessionToken()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
}
private string GenerateRefreshToken()
{
return Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
}
private SessionInfo MapToSessionInfo(UserSession session)
{
return new SessionInfo(
session.Id,
session.SessionToken,
session.RefreshToken,
session.UserId,
session.ExpiresAt,
session.DeviceName
);
}
}
}5. Redis 会话存储
5.1 Redis 会话缓存
csharp
public class RedisSessionStore
{
private readonly IDatabase _redis;
private readonly TimeSpan _defaultTtl = TimeSpan.FromDays(7);
public async Task StoreSessionAsync(SessionInfo session)
{
var key = $"session:{session.SessionToken}";
var value = JsonSerializer.Serialize(session);
await _redis.StringSetAsync(key, value, _defaultTtl);
// 建立用户到会话的映射
var userKey = $"user_sessions:{session.UserId}";
await _redis.SetAddAsync(userKey, session.SessionToken);
await _redis.KeyExpireAsync(userKey, _defaultTtl);
}
public async Task<SessionInfo?> GetSessionAsync(string sessionToken)
{
var key = $"session:{sessionToken}";
var value = await _redis.StringGetAsync(key);
if (value.HasValue)
{
return JsonSerializer.Deserialize<SessionInfo>(value!);
}
return null;
}
public async Task RemoveSessionAsync(string sessionToken)
{
var session = await GetSessionAsync(sessionToken);
if (session == null) return;
var key = $"session:{sessionToken}";
await _redis.KeyDeleteAsync(key);
// 从用户会话列表中移除
var userKey = $"user_sessions:{session.UserId}";
await _redis.SetRemoveAsync(userKey, sessionToken);
}
public async Task<List<string>> GetUserSessionsAsync(Guid userId)
{
var userKey = $"user_sessions:{userId}";
var tokens = await _redis.SetMembersAsync(userKey);
return tokens.Select(t => t.ToString()).ToList();
}
}5.2 双存储策略
csharp
public class HybridSessionService
{
private readonly AuthDbContext _dbContext; // 持久化存储
private readonly RedisSessionStore _redisStore; // 缓存
public async Task<Result<SessionInfo>> CreateSessionAsync(CreateSessionRequest request)
{
// 1. 在数据库中创建会话
var session = await CreateInDatabaseAsync(request);
// 2. 缓存到 Redis
await _redisStore.StoreSessionAsync(session);
return Result.Success(session);
}
public async Task<Result<SessionInfo>> ValidateSessionAsync(string sessionToken)
{
// 1. 先从 Redis 查询
var session = await _redisStore.GetSessionAsync(sessionToken);
if (session != null)
{
// 命中缓存
return Result.Success(session);
}
// 2. 缓存未命中,从数据库查询
var dbResult = await ValidateInDatabaseAsync(sessionToken);
if (dbResult.IsSuccess && dbResult.Data != null)
{
// 回填缓存
await _redisStore.StoreSessionAsync(dbResult.Data);
}
return dbResult;
}
}6. 最佳实践
6.1 安全的 Token 生成
csharp
// ✅ 推荐:加密安全的随机数
public string GenerateSecureToken(int size = 32)
{
var bytes = RandomNumberGenerator.GetBytes(size);
return Convert.ToBase64String(bytes)
.Replace("+", "-")
.Replace("/", "_")
.Replace("=", "");
}
// ❌ 避免:可预测的 Token
public string GenerateWeakToken()
{
return Guid.NewGuid().ToString(); // 虽然唯一,但可预测
}6.2 会话超时策略
csharp
// 分级超时
public TimeSpan GetSessionTtl(string deviceType)
{
return deviceType.ToLower() switch
{
"mobile" => TimeSpan.FromDays(30), // 移动设备长期有效
"desktop" => TimeSpan.FromDays(7), // 桌面设备一周
"web" => TimeSpan.FromHours(24), // Web 浏览器一天
_ => TimeSpan.FromHours(12)
};
}6.3 滑动过期
csharp
public async Task ExtendSessionAsync(string sessionToken)
{
var session = await GetSessionAsync(sessionToken);
if (session == null) return;
// 如果会话即将过期(剩余时间 < 24小时),延长有效期
if (session.ExpiresAt - DateTime.UtcNow < TimeSpan.FromHours(24))
{
session.ExpiresAt = DateTime.UtcNow.AddDays(7);
await UpdateSessionAsync(session);
}
}6.4 监控异常登录
csharp
public class LoginMonitoringService
{
public async Task DetectSuspiciousLoginAsync(Guid userId, string ipAddress)
{
// 检查地理位置变化
var lastLocation = await GetLastLoginLocationAsync(userId);
if (lastLocation != null && IsFarAway(lastLocation, ipAddress))
{
_logger.LogWarning(
"Suspicious login detected! User {UserId} logged in from {IpAddress}, " +
"but last login was from {LastIp}",
userId, ipAddress, lastLocation.IpAddress);
// 发送告警通知用户
await SendSecurityAlertAsync(userId, ipAddress);
}
}
}总结
用户登录的会话管理需要考虑安全性和用户体验的平衡:
核心要点
- 幂等性:同一设备的重复登录请求只创建一个会话
- 唯一索引:数据库层面保证会话不重复
- Redis 缓存:提升会话验证性能
- 安全策略:合理的超时和刷新机制
最佳实践
- ✅ 使用加密安全的 Token
- ✅ 实现滑动过期提升体验
- ✅ 监控异常登录行为
- ✅ 提供"登出所有设备"功能
通过完善的会话管理,可以为用户提供安全、流畅的登录体验。