解决 IdentityServer 授权与登录分离的问题

园子 open api (api.cnblogs.com) 的授权服务器(authorization server,oauth.cnblogs.com)基于 IdentyServer 实现,而登录则通过单独的登录中心(account.cnblogs.com)完成,这样的场景对 IdentyServer 来说似乎是非典型应用场景,在网上几乎找不到相关的参考资料。

IdentyServer 的典型应用场景是授权(authorization)与登录验证(authentication)在同样一个应用中,登录验证通过后,调用 IdentityServer 提供的 HttpContext.SignInAsync 方法保存一些 IdentityServer 所需的 Claims,就大功告成了。

var isUser = new IdentityServerUser(userId.ToString());
await HttpContext.SignInAsync(isUser.CreatePrincipal());

而我们的非典型应用场景需要在请求 //oauth.cnblogs.com/connect/authorize 时重定向到统一登录中心(account.cnblogs.com)进行登录验证,登录成功后再通过 //oauth.cnblogs.com/connect/authorize/callback 跳转回来,问题来了,在哪里保存 IdentityServer 所需的 Claims

开始我们采用的一个变通方法是在授权服务器的 SignIn Action 方法中添加下面的实现代码

[Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)]
public async Task<IActionResult> SignIn(string returnUrl)
{
    var user = new IdentityServerUser(User.GetUserIdString());
    await HttpContext.SignInAsync(user);

    return Redirect(returnUrl);
}

上面的代码实现的效果:

  • 用户请求 //oauth.cnblogs.com/connect/authorize 时未登录
  • 跳转到 //oauth.cnblogs.com/users/signin 进行登录,returnUrl 是 /connect/authorize/callback
  • [Authorize] 触发跳转到登录中心 account.cnblogs.com 进行登录,returnUrl 是 /users/signin
  • 登录成功后跳转回 /users/signin
  • 在 SignIn Action 中设置 IdentityServer 所需的 Claims
  • 跳转回 /connect/authorize/callback

后来发现这个变通方法存在一个问题,由于 IdentyServer 的登录与网站默认登录用的是同一个 Authentication Scheme,如果用户在通过授权服务器授权之前已经在网站上处于登录状态,点击授权时会跳过登录过程,直接完成授权。虽然可以通过启用 IdentyServer 在登录成功后的授权同意页面避免这个问题带来的影响,但是经过考虑,决定采用更保险的方法 —— 将来自 IdentyServer 的登录与网站默认登录进行隔离。

于是,解决 IdentityServer 授权与登录分离的问题就变成了 —— 如何通过 Authentication Scheme 实现登录验证隔离?下面分享一下在写这篇博文时采用的解决方法。

登录中心部分的实现

首先,确定一个专门用于 IdentityServer 登录验证的 Scheme —— CnblogsIdsrv 以及统一的 cookie name —— .Cnblogs.AspNetCore.Idsrv

public static class IdentityServerAuthentication
{
    public const string DefaultScheme = "CnblogsIdsrv";
}

接着,在 Startup 中注册这个 Scheme

authenticationBuilder.AddCookie(
    IdentityServerAuthentication.DefaultScheme,
    options =>
    {
        options.Cookie.Name = ".Cnblogs.AspNetCore.Idsrv";
        options.Cookie.Domain = ".cnblogs.com";
        options.ExpireTimeSpan = TimeSpan.FromMinutes(2);
    });

然后,登录中心在登录成功后根据 url 查询参数 sheme 判断是是否是来自 IdentyServer 授权服务器的登录,如果是,则将 IdentyServer 所需的 Claims 写入对应于专用 Authentication Scheme 的登录 Cookie 中(参考自 IdentyServer 的源码

if (IdentityServerAuthentication.IsDefaultScheme(scheme))
{
    var isu = new IdentityServerUser(userId.ToString());
    isu.IdentityProvider = IdentityServerConstants.LocalIdentityProvider;
    isu.AuthenticationMethods.Add(OidcConstants.AuthenticationMethods.Password);
    isu.AuthenticationTime = DateTime.UtcNow;

    await HttpContext.SignInAsync(
        IdentityServerAuthentication.DefaultScheme,
        isu.CreatePrincipal());

    return;
}

注:上面的代码需要安装 nuget 包 IdentityServer4

IdentityServer 授权服务器部分的实现

Startup 中注册 Scheme 为 CnblogsIdsrv 的 CookieAuthentication

services.AddAuthentication("CnblogsIdsrv")
    .AddCookie("CnblogsIdsrv", options =>
    {
        options.Cookie.Name = ".Cnblogs.AspNetCore.Idsrv";
        options.Cookie.Domain = ".cnblogs.com";
        options.ExpireTimeSpan = TimeSpan.FromMinutes(2);
        options.LoginPath = "/users/signin";
        options.LogoutPath = "/users/signout";
    });

Startup 中将 IdentityServer 所使用的 CookieAuthenticationScheme 设置为 CnblogsIdsrv

services.AddIdentityServer(options =>
    {
        options.Authentication.CookieAuthenticationScheme = "CnblogsIdsrv";
    });

修改 SignIn Action 登录跳转代码

public IActionResult SignIn(string returnUrl)
{
    if (!returnUrl.StartsWith("http"))
    {
        returnUrl = "https://" + Request.Host.Host + returnUrl;
    }

    return Redirect($"{_signInUrl}?scheme={Config.AuthenticationScheme}&returnUrl={WebUtility.UrlEncode(returnUrl)}");
}

搞定,分享到此,解决这个问题的过程中走的最大的弯路是开始没有及时看 IdentityServer HttpContext.SignInAsync 部分的实现源码

posted @ 2021-02-15 15:01  dudu  阅读(1486)  评论(1编辑  收藏  举报