Skip to content

用户登录 - 防止重复会话 ​

目录 ​


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

总结 ​

用户登录的会话管理需要考虑安全性和用户体验的平衡:

核心要点 ​

  1. 幂等性:同一设备的重复登录请求只创建一个会话
  2. 唯一索引:数据库层面保证会话不重复
  3. Redis 缓存:提升会话验证性能
  4. 安全策略:合理的超时和刷新机制

最佳实践 ​

  • ✅ 使用加密安全的 Token
  • ✅ 实现滑动过期提升体验
  • ✅ 监控异常登录行为
  • ✅ 提供"登出所有设备"功能

通过完善的会话管理,可以为用户提供安全、流畅的登录体验。

Released under the MIT License.