Skip to content
On this page

Authorization Code

Authorization Code 授权方式适用于保密客户端,如服务器端Web应用,此授权过程会分别对用户和客户端进行双重身份认证,安全性较高。尽管可以在SPA等公开客户端中使用Authorization Code授权方式,但除了一次性使用的Authorization code外,即便需要保密的Access Token也只能保存在公开客户端中,安全性较低,所以在公开客户端更推荐使用Implicit授权方式简化授权过程。

Authorization Code/Implicit两种授权方式都需要借助IBrowser对象(通常为浏览器)引导用户到IdentityServer进行身份认证,所以多用于交互式客户端,如Web应用(Asp.Net,SPA等),桌面应用(如,Fiddler/Skype等),移动App等,以上客户端多是借助浏览器引导用户进行身份认证和授权。

1. Identity Server

本案例中客户端应用会引导用户到IdentityServer UI中进行登录,我们使用IdentityServer4withIn-MemoryStoresandTestUsers模板创建一个新的IdentityServer项目,该模板项目除了IdentityServer4Empty中提供的基础结构外,还提供了一套UI方便我们进行登录和可视化查看数据。本节代码已分享到Github

本节我们只针对Authorization Code授权方式的内容做简单讲解,IdentityServer其它基础内容在之前章节中已做过介绍,不再赘述。

csharp
public static IEnumerable<Client> Clients =>
    new[]
    {
        new Client
        {
            ClientId = "AuthorizationCodeMvcClient",
            ClientSecrets = {new Secret("AuthorizationCodeMvcClient".Sha256())},
            AllowedGrantTypes = GrantTypes.Code,
            AllowedScopes =
            {
                "WeatherApi",
                IdentityServerConstants.StandardScopes.OpenId,
                IdentityServerConstants.StandardScopes.Profile
            },

            RedirectUris = {"https://localhost:7000/signin-oidc"},
            FrontChannelLogoutUri = "https://localhost:7000/signout-oidc",
            PostLogoutRedirectUris = {"https://localhost:7000/signout-callback-oidc"},
            AllowOfflineAccess = true // 允许 Refresh Token 
            // AlwaysIncludeUserClaimsInIdToken = true // 在IdToken中包含所有用户身份声明
        }
    };

通过以上代码注册客户端,RedirectUris/FrontChannelLogoutUri/PostLogoutRedirectUris三个属性分别用户设置登录/前端登出/服务端登出后要重定向的地址,三个地址都是协议标准默认地址,一般只需要将域名部分修改客户端域名即可。AllowOfflineAccess属性设置是否允许Refresh Token

2. Client

这里API项目依然使用Client Credentials中的代码,不再赘述。

Authorization Code授权方式一般应用于机密客户端,这里我们建立一个Asp.Net MVC程序作为客户端,客户端代码已共享至Github,其客户端配置读取方式Resource Owner Password Credentials 案例相同,亦不再赘述。

MVC客户端需要使用IdentityServer需要安装IdentityModelMicrosoft.AspNetCore.Authentication.OpenIdConnect两个Nuget包。

csharp
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews();

   //关闭JWT Claim类型映射,以便返回WellKnown Claims
    JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
    // JwtSecurityTokenHandler.DefaultMapInboundClaims = false;

    var is4Configuration = Configuration.GetSection(nameof(IdentityServerOptions));
    services.Configure<IdentityServerOptions>(is4Configuration);
    var is4Options = is4Configuration.Get<IdentityServerOptions>();
    services
        .AddAuthentication(options =>
        {
            options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
        })
        .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
        {
            options.Authority = is4Options.Address;
            options.ClientId = is4Options.ClientId;
            options.ClientSecret = is4Options.ClientSecret;
            options.ResponseType = OidcConstants.ResponseTypes.Code;

            options.SaveTokens = true; //保存token到cookie
            options.RequireHttpsMetadata = false; //关闭https验证

            options.Scope.Clear();
            // options.Scope.Add(OidcConstants.StandardScopes.OpenId);
            foreach (var scope in is4Options.Scopes)
                options.Scope.Add(scope.Name);

            options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        }); 
}

通过以上代码注册和配置IdentityServer服务。这里在客户端应用中使用基于Cookie的认证方案,并将认证过程委托给IdentityServer接管。

接下来我们在Controller中访问Identity dataAPIOpenIdConnect库为HttpContext对象扩展了GetTokenAsync()方法用于从获取IdentityServer获取AccessToken/IdToken/RefreshToken等。

csharp
[Authorize]
public class HomeController : Controller
{
    private readonly IdentityServerOptions _options;

    public HomeController(IOptions<IdentityServerOptions> options) => _options = options.Value;

    public async Task<IActionResult> Index()
    {
        using var client = new HttpClient();
        var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
        client.SetBearerToken(accessToken);
        var response = await client.GetAsync(_options["WeatherApi"]);
        if (!response.IsSuccessStatusCode)
            return StatusCode((int) response.StatusCode);

        var content = await response.Content.ReadAsStringAsync();
        return View((object) content);
    }

    public async Task<IActionResult> Privacy()
    {
        ViewBag.AccessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
        ViewBag.IdToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.IdToken);
        ViewBag.RefreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);

        return View();
    }
}

3. 网络请求详解

我们使用Fiddler来监测和分析一个Authorization Code Flow的完整网络请求。建议使用Windows环境,Fiddler在其它环境中默认无法捕捉localhost127.0.0.1本地请求.

启动MVC应用访问https://localhost:7000,默认路由到 ~/Home/Index,我们在HomeController上启用了Authorize,MVC应用会执行鉴权,客户端浏览器没有合法票据,服务器会发起质询(Challenge) 并被重定向到IdentityServerMVC客户端认证过程的DefaultChallengeScheme设置成了OpenIdConnectDefaults.AuthenticationScheme并在AddOpenIdConnect方法配置了IdentityServer

MVC主页请求

如上图所示,MVC主页请求(https://localhost:7000)被重定向到IdentityServer的授权地址(https://localhost:5000/connect/authorize)。

connect/authorize

紧接着授权请求被重定向到登录页。

IdentityServer Login

用户可以在此登录并授权给客户端。

用户登录

用户登录成功后会被重定向到(/connect/authorize/callback)。

登录回调

通过上图可以看到登录回调页面加载完成后会自动提交表单到MVC应用(https://localhost:7000/signin-oidc),这个地址在注册客户端时已经设置到RedirectUris属性中。

signin-oidc

如上图所示IdentityServer在回调MVC应用的signin-oidc时会将Authorization Code发送给MVC客户端浏览器。MVC应用服务端处理signin-oidc请求,向IdentityServerToken(/connect/token)节点发送请求,使用client_id/client_secret/code等进行身份认证,认证通过后IdentityServer会返回Id Token/Access Token/Refresh Token等给MVC服务端,MVC程序将令牌写到Cookie并返回302给浏览器将地址重定向到最初我们要访问的主页。

connect/token

客户端浏览器重定向回主页后,携带Cookie令牌再次请求Home/Index,鉴权通过后Index方法会携带Access Token请求被保护的API资源。

请求API

API项目鉴权Access Token合法并返回数据给MVC应用,MVC渲染界面如下。

Weather

最后在我们访问/Home/Privacy并展示Id Token/Access Token/Refresh Token,因为以上令牌已保存在MVC应用所以不会请求IdentityServer。另外,这些令牌可以使用HttpContext.GetTokenAsync()扩展方法方便地获取各令牌内容。

identity-data

4. SignOut

下面我们来简单介绍一下如何在客户端应用和IdentityServer中注销登录登录。

在MVC View_Layout.cshtml的导航栏中通过以下代码添加注销界面入口。

html
@if (User.Identity.IsAuthenticated)
{ 
    <li class="nav-item"><a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="SignOut">SignOut</a></li>
}

通过下面方法分别注销客户端和IdentityServer的登录状态。

csharp
public async Task SignOut()
{
    // 注销 客户端
    await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    // 注销 IdentityServer
    await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
}

调用以上SignOut()注销登录后会页面默认会停留在以下界面。

Identity Server Signout

我们可以在IdentitySever项目/Quickstart/Account/AccountOptions.cs中设置AccountOptionsAutomaticRedirectAfterSignOuttrue以实现注销后自动跳转到我们设定的地址。

Signout oidc

通过Fiddler监测请求可以看到,IdentityServer注销后请求了我们注册客户端时设定的FrontChannelLogoutUri(https://localhost:7000/signout-oidc)PostLogoutRedirectUris(https://localhost:7000/signout-callback-oidc)signout-callback-oidc将浏览器重定向回注销前的主页地址(/),注销后鉴权失败浏览器立即又被引导了IdentityServer登录认证界面。

5. Refresh token

因为Access Token存在有效期,Refresh Token允许才用非用户交互式方式重新获取Access Token

Refresh Token仅支持Authorization code / Hybrid / Resource owner password credential三种授权方式。

5.1 AccessToken 过期检查

IdentitySeverAccess Token默认有效期是一小时。我们可以在注册客户端时修改Access Token有效时间。

csharp
public static IEnumerable<Client> Clients =>
    new[]
    {
        new Client
        {
            ClientId = "AuthorizationCodeMvcClient",
            ClientSecrets = {new Secret("AuthorizationCodeMvcClient".Sha256())},
            AllowedGrantTypes = GrantTypes.Code,
            AllowedScopes =
            {
                "WeatherApi",
                IdentityServerConstants.StandardScopes.OpenId,
                IdentityServerConstants.StandardScopes.Profile
            },

            RedirectUris = {"https://localhost:7000/signin-oidc"},
            FrontChannelLogoutUri = "https://localhost:7000/signout-oidc",
            PostLogoutRedirectUris = {"https://localhost:7000/signout-callback-oidc"},
            AllowOfflineAccess = true,//允许Refresh Token
            AccessTokenLifetime=30 // 设置Access Token 超时时间为30s
        }
    };

通过以上代码设置MVC客户端超时时间为30s,30s后MVC客户端依然可以使用Access Token正常访问API资源,这是因为API项目中未及时验证Access Token的过期情况。

csharp
public void ConfigureServices(IServiceCollection services)
{
    // ...
    services
        .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
        {
            options.Authority = identityServerOptions.Address;
            options.TokenValidationParameters.ValidateAudience = false;

            options.TokenValidationParameters.RequireExpirationTime = true;
            options.TokenValidationParameters.ClockSkew = TimeSpan.FromSeconds(25);
        });
    // ...
}

在API项目中修改注册认证服务代码如上。options.TokenValidationParameters.RequireExpirationTime = true要求客户端AccessToken必须有过期时间。options.TokenValidationParameters.ClockSkew属性用于设置定期检查AccessToken的间隔时间。此处我们设置为25s。

要检查客户端AccessToken过期,通常会将API项目检查AccessToken的间隔时长设置小于客户端AccessToken有效时长,但检查频率过高会消耗更多资源,生产项目中根据实际情况酌情设置即可。

客户端和API是两个完全独立运行的项目,即使按上述规则设置了过期检查时间,仍然会存在令牌过期后仍可以正常使用的情况(时长小于API一个检查周期)。最坏的情况是API刚检查AcessToken正常有效后,AcessToken立即过期,此时使用AcessToken依然可以正常访问API资源,直到API下一次检查AcessToken

API检测到客户端AccessToken过期后会返回Unauthorized(401)状态码。

5.2 Refresh Token

Access Token过期后通过Refresh Token重新获取新的Access Token

csharp
private async Task<string> RefreshTokenAsync()
{
    using var client = new HttpClient();
    var disco = await client.GetDiscoveryDocumentAsync(_options.Address);
    if (disco.IsError)
        throw new Exception(disco.Error);
    //获取当前RefreshToken
    var refreshToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.RefreshToken);
    //请求刷新令牌
    var response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
    {
        Address = disco.TokenEndpoint,
        ClientId = _options.ClientId,
        ClientSecret = _options.ClientSecret,
        Scope = string.Join(" ", _options.Scopes.Select(s => s.Name)), //刷新令牌时可重设Scope按需缩小授权范围
        GrantType = OpenIdConnectGrantTypes.RefreshToken,
        RefreshToken = refreshToken
    });
    if (response.IsError)
        throw new Exception(response.Error);

    //整理更新的令牌
    var tokens = new[]
    {
        new AuthenticationToken
        {
            Name = OpenIdConnectParameterNames.IdToken,
            Value = response.IdentityToken
        },
        new AuthenticationToken
        {
            Name = OpenIdConnectParameterNames.AccessToken,
            Value = response.AccessToken
        },
        new AuthenticationToken
        {
            Name = OpenIdConnectParameterNames.RefreshToken,
            Value = response.RefreshToken
        },
        new AuthenticationToken
        {
            Name = "expires_at",
            Value = DateTime.UtcNow.AddSeconds(response.ExpiresIn).ToString("o", CultureInfo.InvariantCulture)
        }
    };

    //获取 身份认证票据
    var authenticationResult =
        await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    //使用刷新后的令牌更新认证票据
    authenticationResult.Properties.StoreTokens(tokens);
    //重新登录以 重新颁发票据给客户端浏览器
    await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
        authenticationResult.Principal,
        authenticationResult.Properties);
    return response.AccessToken;
}

通过以上代码可以看到,我们可以使用客户端认证数据(ClientId/ClientSecret等)和RefreshToken通过HttpContext.RequestRefreshTokenAsync()方法向IdentityServer获取新的令牌。得到新的令牌后还需要刷新认证票据并重新颁发给客户端。

了解RefreshToken后,我们简单重构一下主页的Action方法在令牌过期后重新刷新令牌。

csharp
public async Task<IActionResult> Index()
{
    using var client = new HttpClient();
    var accessToken = await HttpContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
    client.SetBearerToken(accessToken);
    var response = await client.GetAsync(_options["WeatherApi"]);
    if (!response.IsSuccessStatusCode)
    {
        if (response.StatusCode != HttpStatusCode.Unauthorized)
            return StatusCode((int) response.StatusCode);
        
        await RefreshTokenAsync();
        return RedirectToAction();
    }
    
    var content = await response.Content.ReadAsStringAsync();
    return View((object) content);
}

Released under the MIT License.