You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

.Net Core API基于OpenId Connect实现强制登出方案咨询

服务器端强制登出Token的解决方案(基于.NET Core 6 + OpenIddict)

问题背景

用户点击登出时仅在客户端删除Token,但多标签页场景下存在漏洞:一个标签页完成登出后,另一个标签页保留的Token仍能通过服务器验证并正常调用接口,需要实现服务器端强制使Token失效的机制。

环境说明

  • .NET Core 6 API项目,使用OpenIddict实现登录授权
  • 用户数据存储与身份验证依赖外部API,当前API仅作为包装层,已集成OpenID Connect,登录返回Token的功能正常
  • 现有OpenIddict配置及登出代码如下:

Program.cs 相关配置

builder.Services.AddOpenIddict()
    .AddCore(options =>
    {
        options.UseEntityFrameworkCore()
               .UseDbContext<ApplicationDbContext>();
    })
    .AddServer(options =>
    {
        // 启用令牌端点和登出端点
        options.SetTokenEndpointUris("/Connect/Token").SetLogoutEndpointUris("/Connect/Logout");
        options.AllowPasswordFlow()
               .AllowRefreshTokenFlow();
        options.AcceptAnonymousClients();
        options.AddDevelopmentEncryptionCertificate()
               .AddDevelopmentSigningCertificate();
        options.UseAspNetCore()
               .EnableTokenEndpointPassthrough()
               .EnableLogoutEndpointPassthrough()
               .DisableTransportSecurityRequirement();
    });

现有登出方法

[HttpPost("~/Connect/Logout"), Produces("application/json")]
public async Task<IActionResult> Logout()
{
    return SignOut(
        authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
        properties: new AuthenticationProperties
        {
            RedirectUri = "https://localhost:****/Connect/Token"
        });
}

解决方案

1. 启用Token撤销端点(推荐)

OpenIddict原生支持Token撤销机制,只需开启端点并实现处理逻辑即可:

步骤1:添加撤销端点配置

修改Program.cs中的OpenIddict Server配置,启用撤销端点:

options.SetRevocationEndpointUris("/Connect/Revoke"); // 启用撤销端点
options.UseAspNetCore()
       .EnableTokenEndpointPassthrough()
       .EnableLogoutEndpointPassthrough()
       .EnableRevocationEndpointPassthrough(); // 启用撤销端点透传

步骤2:实现撤销接口

添加撤销端点的处理方法,接收并处理Token撤销请求:

[HttpPost("~/Connect/Revoke"), Produces("application/json")]
public async Task<IActionResult> Revoke()
{
    var revocationRequest = await HttpContext.GetOpenIddictServerRequestAsync();
    if (revocationRequest is null)
    {
        return BadRequest(new OpenIddictResponse
        {
            Error = OpenIddictConstants.Errors.InvalidRequest,
            ErrorDescription = "无效的撤销请求。"
        });
    }

    // 调用OpenIddict内置服务撤销Token
    var result = await HttpContext.RequestServices.GetRequiredService<IOpenIddictTokenManager>()
        .RevokeAsync(revocationRequest.Token, revocationRequest.TokenTypeHint);

    return result switch
    {
        OpenIddictResult.Success => Ok(new OpenIddictResponse()),
        OpenIddictResult.InvalidToken => BadRequest(new OpenIddictResponse
        {
            Error = OpenIddictConstants.Errors.InvalidToken,
            ErrorDescription = "要撤销的Token无效或已过期。"
        }),
        _ => BadRequest(new OpenIddictResponse
        {
            Error = OpenIddictConstants.Errors.ServerError,
            ErrorDescription = "撤销Token时发生服务器错误。"
        })
    };
}

步骤3:前端配合调用

用户点击登出时,除了删除客户端本地Token,还需调用/Connect/Revoke端点,传入当前的访问令牌和刷新令牌,确保服务器端标记Token为已撤销。

2. 实现Token黑名单机制

若需要强制登出用户所有设备,可维护Token黑名单:

步骤1:创建黑名单存储

在ApplicationDbContext中添加Token黑名单实体:

public DbSet<TokenBlacklist> TokenBlacklists { get; set; }

public class TokenBlacklist
{
    public int Id { get; set; }
    public string Token { get; set; } = string.Empty;
    public DateTime ExpiresAt { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

步骤2:添加验证中间件

创建中间件,在每次API请求时检查Token是否在黑名单中:

public class TokenBlacklistMiddleware
{
    private readonly RequestDelegate _next;

    public TokenBlacklistMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context, ApplicationDbContext dbContext)
    {
        var authorizationHeader = context.Request.Headers.Authorization.FirstOrDefault();
        if (!string.IsNullOrEmpty(authorizationHeader) && authorizationHeader.StartsWith("Bearer "))
        {
            var token = authorizationHeader["Bearer ".Length..];
            var isBlacklisted = await dbContext.TokenBlacklists.AnyAsync(t => t.Token == token && t.ExpiresAt > DateTime.UtcNow);
            if (isBlacklisted)
            {
                context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                await context.Response.WriteAsync("Token已被撤销,请重新登录。");
                return;
            }
        }

        await _next(context);
    }
}

// 在Program.cs中注册中间件
app.UseMiddleware<TokenBlacklistMiddleware>();

步骤3:登出时加入黑名单

修改Logout方法,将当前Token加入黑名单:

[HttpPost("~/Connect/Logout"), Produces("application/json")]
public async Task<IActionResult> Logout([FromServices] ApplicationDbContext dbContext)
{
    // 从请求头获取当前Token
    var authorizationHeader = Request.Headers.Authorization.FirstOrDefault();
    if (!string.IsNullOrEmpty(authorizationHeader) && authorizationHeader.StartsWith("Bearer "))
    {
        var token = authorizationHeader["Bearer ".Length..];
        // 解析Token获取过期时间
        var handler = new JwtSecurityTokenHandler();
        if (handler.CanReadToken(token))
        {
            var jwtToken = handler.ReadJwtToken(token);
            var expiresAt = jwtToken.ValidTo;
            
            dbContext.TokenBlacklists.Add(new TokenBlacklist
            {
                Token = token,
                ExpiresAt = expiresAt
            });
            await dbContext.SaveChangesAsync();
        }
    }

    return SignOut(
        authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
        properties: new AuthenticationProperties
        {
            RedirectUri = "https://localhost:****/Connect/Token"
        });
}

3. 缩短Token有效期(辅助措施)

缩短访问令牌的有效期,减少Token被滥用的窗口时间,在OpenIddict配置中添加:

options.SetAccessTokenLifetime(TimeSpan.FromMinutes(15)); // 设置访问令牌有效期为15分钟
options.SetRefreshTokenLifetime(TimeSpan.FromDays(7)); // 刷新令牌有效期可适当延长

内容的提问来源于stack exchange,提问作者Basanta Matia

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.20 13:06:28