6

开源项目葫芦藤:IdentityServer4的实现及其运用

 3 years ago
source link: https://www.cnblogs.com/fulu/p/14176964.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

开源项目葫芦藤:IdentityServer4的实现及其运用

full-logo.png版权声明:本博客所有文章归武汉福禄网络科技有限公司所有,欢迎转载,转载请注明出处。

本篇文章主要是讲解葫芦藤项目中对IdentityServer的实践使用,为了使您对本篇文章中所讲述的内容有深刻的认识,并且在阅读时避免感到乏味,文中的内容不会涉及太多的基础理论知识,而更多的是采用动手实践的方式进行讲解,所以在阅读此篇文章前假定您已经掌握了OAuth2.0的基础知识,如您事先并未了解OAuth2.0,请参阅一下阮一峰老师的文章《理解OAuth2.0》, ASP.NET Core 认证与授权,可以看看博客 雨夜朦胧,另外IdentityServer的相关文章也可以参考博客 晓晨Master

葫芦藤前端地址:https://account.suuyuu.cn (验证码获取后,输入123456即可)

葫芦藤后端地址:https://account-web.suuyuu.cn

葫芦藤源码地址:https://github.com/fuluteam/fulusso (帮忙点个小星星哦)

团队博文地址:https://www.cnblogs.com/fulu

签名证书(Signing Credential)

IdentityServer支持X.509证书(包括原始文件和对Windows证书存储库的引用)、RSA密钥和EC密钥,用于令牌签名和验证。每个密钥都可以配置一个(兼容的)签名算法,如RS256、RS384、RS512、PS256、PS384、PS512、ES256、ES384或ES512。

通常情况下,我们使用的是针对开发场景创建的临时证书 AddDeveloperSigningCredential,
生产环境怎么办呢?IdentityServer还提供了AddSigningCredential用来装载证书文件,
为此我们需要准备一个X.509证书,下面是在控制台项目中用于生成证书的代码,完整代码请参考项目:https://github.com/fuluteam/ICH.BouncyCastle

//颁发者DN
var issuer = new X509Name(
new ArrayList{X509Name.C,X509Name.O,X509Name.OU,X509Name.L,X509Name.ST}, 
new Hashtable{[X509Name.C] = "CN",[X509Name.O] = "Fulu Newwork",[X509Name.OU] = "Fulu RSA CA 2020",[X509Name.L] = "Wuhan",[X509Name.ST] = "Hubei"});
//使用者DN
var subject = new X509Name(new ArrayList{X509Name.C,X509Name.O,X509Name.CN}, new Hashtable {[X509Name.C] = "CN",[X509Name.O] = "ICH",[X509Name.CN] = "*.fulu.com"});

//生成证书文件
CertificateGenerator.GenerateCertificate(newCertificateGenerator.GenerateCertificateOptions { Path = "mypfx.pfx",Issuer = issuer, Subject = subject });

执行代码后,在项目编译输出目录中,会看到一个mypfx.pfx的文件,此时我们的证书就创建成功啦。
接着怎么使用呢,看下面代码:

var certificate2 = new X509Certificate2("mypfx.pfx", "password", X509KeyStorageFlags.Exportable);
identityServerBuilder.AddSigningCredential(certificate2);

大家可能会问,葫芦藤中怎么不是这么写的呢,其实葫芦藤项目中是将证书文件的流数据转成了二进制字符串,这样就可以写在配置文件中了:

using (var fs = new FileStream(options.Path, FileMode.Open))
{
    var bytes = new byte[fs.Length];
    fs.Read(bytes, 0, bytes.Length);
    var pfxHexString = Hex.ToHexString(bytes);
}

然后在这么使用:

identityServerBuilder.AddSigningCredential(new X509Certificate2(Hex.Decode(appSettings.X509RawCertData), appSettings.X509CertPwd));

客户端存储(Client Store)

在葫芦藤项目中,我们创建了一个ClientStore类,继承自接口IClientStore,实现其方法代码如下:

public class ClientStore : IClientStore
{
    private readonly IClientCacheStrategy _clientInCacheRepository;

    public ClientStore(IClientCacheStrategy clientInCacheRepository)
    {
        _clientInCacheRepository = clientInCacheRepository;
    }
    public async Task<Client> FindClientByIdAsync(string clientId)
    {

        var clientEntity = await _clientInCacheRepository.GetClientByIdAsync(clientId.ToInt32());
        if (clientEntity == null)
        {
            return null;
        }
        return new Client
        {
            ClientId = clientId,
            AllowedScopes = new[] { "api", "get_user_info" },
            ClientSecrets = new[] { new Secret(clientEntity.ClientSecret.Sha256()) },
            AllowedGrantTypes = new[]
            {
                GrantType.AuthorizationCode,    //授权码模式
                GrantType.ClientCredentials,    //客户端模式
                GrantType.ResourceOwnerPassword,    //密码模式
                CustomGrantType.External,   //自定义模式——三方(移动端)模式
                CustomGrantType.Sms //自定义——短信模式
            },
            AllowOfflineAccess = false,
            RedirectUris = string.IsNullOrWhiteSpace(clientEntity.RedirectUri) ? null : clientEntity.RedirectUri.Split(';'),
            RequireConsent = false,
            AccessTokenType = AccessTokenType.Jwt,
            AccessTokenLifetime = 7200,
            ClientClaimsPrefix = "",
            Claims = new[] { new Claim(JwtClaimTypes.Role, "Client") }
        };
    }
}

通过代码可以看到,通过clientId从缓存中读取Client的相关信息构建并返回,这里我们为所有的Client简单的设置了统一的AllowedGrantTypes,这是一种偷懒的做法,应当按需授予GrantType,例如通常情况下我们只应默认给应用分配AuthorizationCode或者ClientCredentials,ResourceOwnerPassword需要谨慎授予(需要用户对Client高度信任)。

资源存储(Resource Store)

由于历史原因,在葫芦藤中,我们并没有通过IdentityServer对api资源进行访问保护(后续会提供我们的实现方式),我们为所有Client设置了相同的Scope。

持久化授权存储(Persisted Grant Store)

葫芦藤中,我们使用了Redis来持久化数据,

通过EntityFramework Core持久化配置和操作数据,请参考
https://www.cnblogs.com/stulzq/p/8120518.html
https://github.com/IdentityServer/IdentityServer4.EntityFramework

IPersistedGrantStore接口中定义了如下6个方法:

/// <summary>Interface for persisting any type of grant.</summary>
public interface IPersistedGrantStore
{
  /// <summary>Stores the grant.</summary>
  /// <param name="grant">The grant.</param>
  /// <returns></returns>
  Task StoreAsync(PersistedGrant grant);

  /// <summary>Gets the grant.</summary>
  /// <param name="key">The key.</param>
  /// <returns></returns>
  Task<PersistedGrant> GetAsync(string key);

  /// <summary>Gets all grants for a given subject id.</summary>
  /// <param name="subjectId">The subject identifier.</param>
  /// <returns></returns>
  Task<IEnumerable<PersistedGrant>> GetAllAsync(string subjectId);

  /// <summary>Removes the grant by key.</summary>
  /// <param name="key">The key.</param>
  /// <returns></returns>
  Task RemoveAsync(string key);

  /// <summary>
  /// Removes all grants for a given subject id and client id combination.
  /// </summary>
  /// <param name="subjectId">The subject identifier.</param>
  /// <param name="clientId">The client identifier.</param>
  /// <returns></returns>
  Task RemoveAllAsync(string subjectId, string clientId);

  /// <summary>
  /// Removes all grants of a give type for a given subject id and client id combination.
  /// </summary>
  /// <param name="subjectId">The subject identifier.</param>
  /// <param name="clientId">The client identifier.</param>
  /// <param name="type">The type.</param>
  /// <returns></returns>
  Task RemoveAllAsync(string subjectId, string clientId, string type);
}

PersistedGrant的结构如下:

/// <summary>A model for a persisted grant</summary>
public class PersistedGrant
{
  /// <summary>Gets or sets the key.</summary>
  /// <value>The key.</value>
  public string Key { get; set; }

  /// <summary>Gets the type.</summary>
  /// <value>The type.</value>
  public string Type { get; set; }

  /// <summary>Gets the subject identifier.</summary>
  /// <value>The subject identifier.</value>
  public string SubjectId { get; set; }

  /// <summary>Gets the client identifier.</summary>
  /// <value>The client identifier.</value>
  public string ClientId { get; set; }

  /// <summary>Gets or sets the creation time.</summary>
  /// <value>The creation time.</value>
  public DateTime CreationTime { get; set; }

  /// <summary>Gets or sets the expiration.</summary>
  /// <value>The expiration.</value>
  public DateTime? Expiration { get; set; }

  /// <summary>Gets or sets the data.</summary>
  /// <value>The data.</value>
  public string Data { get; set; }
}

可以看出主要是针对PersistedGrant对象的操作,通过观察GetAsync和RemoveAsync方法的入参均为key,我们在StoreAsync中将PersistedGrant中的Key作为缓存key,将PersistedGrant对象以hash的方式存入缓存中,并设置过期时间(注意将UTC时间转换为本地时间)

public async Task StoreAsync(PersistedGrant grant)
{
    //var expiresIn = grant.Expiration - DateTimeOffset.UtcNow;
    var db = await _redisCache.GetDatabaseAsync();

    var trans = db.CreateTransaction();

    var expiry = grant.Expiration.Value.ToLocalTime();

    db.HashSetAsync(grant.Key, GetHashEntries(grant));  //GetHashEntries是将对象PersistedGrant转换为HashEntry数组
    db.KeyExpireAsync(grant.Key, expiry);
    await trans.ExecuteAsync();
}

同时,把GetAsync和RemoveAsync的代码填上:

public async Task<PersistedGrant> GetAsync(string key)
{
    var db = await _redisCache.GetDatabaseAsync();
    var items = await db.HashGetAllAsync(key);
    return GetPersistedGrant(items);    //将HashEntry数组转换为PersistedGrant对象
}

public async Task RemoveAsync(string key)
{
    var db = await _redisCache.GetDatabaseAsync();
    await db.KeyDeleteAsync(key);
}

接着,GetAllAsync方法,通过subjectId查询PersistedGrant集合,1对n,因此,我们在StoreAsync中补上这一层关系,以subjectId为缓存key,grant.Key为缓存值存入list集合中;GetAllAsync方法中,通过subjectId取出grant.Key的集合,最终得到PersistedGrant集合。

public async Task StoreAsync(PersistedGrant grant)
{
    //var expiresIn = grant.Expiration - DateTimeOffset.UtcNow;
    var db = await _redisCache.GetDatabaseAsync();

    var trans = db.CreateTransaction();

    var expiry = grant.Expiration.Value.ToLocalTime();

    db.HashSetAsync(grant.Key, GetHashEntries(grant));  //GetHashEntries是将对象PersistedGrant转换为HashEntry数组
    db.KeyExpireAsync(grant.Key, expiry);
    
    db.ListLeftPushAsync(grant.SubjectId, grant.Key);
    db.KeyExpireAsync(grant.SubjectId, expiry);
                
    await trans.ExecuteAsync();
}

public async Task<IEnumerable<PersistedGrant>> GetAllAsync(string subjectId)
{
    if (string.IsNullOrWhiteSpace(subjectId))
        return new List<PersistedGrant>();

    var db = await _redisCache.GetDatabaseAsync();

    var keys = await db.ListRangeAsync(subjectId);

    var list = new List<PersistedGrant>();
    foreach (string key in keys)
    {
        var items = await db.HashGetAllAsync(key);
        list.Add(GetPersistedGrant(items));
    }

    return list;
}

类似的,StoreAsync方法中我们只需StoreAsync方法中根据RemoveAllAsync方法参数组装缓存key,grant.Key为缓存值写入缓存,对应的RemoveAllAsync中根据参数组装的key查询出grant.Key集合,删除缓存即可。

public async Task StoreAsync(PersistedGrant grant)
{
    var db = await _redisCache.GetDatabaseAsync();

    var trans = db.CreateTransaction();

    var expiry = grant.Expiration.Value.ToLocalTime();

    db.HashSetAsync(grant.Key, GetHashEntries(grant));
    db.KeyExpireAsync(grant.Key, expiry);

    if (!string.IsNullOrEmpty(grant.SubjectId))
    {
        db.ListLeftPushAsync(grant.SubjectId, grant.Key);
        db.KeyExpireAsync(grant.SubjectId, expiry);

        var key1 = $"{grant.SubjectId}:{grant.ClientId}";
        db.ListLeftPushAsync(key1, grant.Key);
        db.KeyExpireAsync(key1, expiry);

        var key2 = $"{grant.SubjectId}:{grant.ClientId}:{grant.Type}";
        db.ListLeftPushAsync(key2, grant.Key);
        db.KeyExpireAsync(key2, expiry);
    }

    await trans.ExecuteAsync();
}

public async Task RemoveAllAsync(string subjectId, string clientId)
{
    if (string.IsNullOrEmpty(subjectId) || string.IsNullOrEmpty(clientId))
        return;
    var db = await _redisCache.GetDatabaseAsync();

    var key = $"{subjectId}:{clientId}";
    var keys = await db.ListRangeAsync(key);
    if (!keys.Any()) return;

    var trans = db.CreateTransaction();
    db.KeyDeleteAsync(keys.ToRedisKeys());
    db.KeyDeleteAsync(key);
    await trans.ExecuteAsync();
}

public async Task RemoveAllAsync(string subjectId, string clientId, string type)
{
    if (string.IsNullOrEmpty(subjectId) || string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(type))
        return;
    var db = await _redisCache.GetDatabaseAsync();

    var key = $"{subjectId}:{clientId}:{type}";
    var keys = await db.ListRangeAsync(key);
    if (!keys.Any()) return;

    var trans = db.CreateTransaction();
    db.KeyDeleteAsync(keys.ToRedisKeys());
    db.KeyDeleteAsync(key);
    await trans.ExecuteAsync();
}

至此,持久化的代码填写完毕;启动并调试项目,可以看到PersistedGrant对象如下:

资源拥有者验证器(Resource Owner Validator)

如果要使用OAuth 2.0 密码模式(Resource Owner Password Credentials Grant),则需要实现并注册IResourceOwnerPasswordValidator接口:

public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
{
    var result = await _userService.LoginByPasswordAsync(context.UserName, context.Password);
    if (result.Code == 0)
    {
        var claims = await _userService.SaveSuccessLoginInfo(context.Request.ClientId.ToInt32(), result.Data.Id,
         _contextAccessor.HttpContext.GetIp(), UserLoginModel.Password);
        context.Result = new GrantValidationResult(result.Data.Id, OidcConstants.AuthenticationMethods.Password, claims);
    }
    else
    {
        context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, result.Message);
    }
}

重定向地址验证器(Redirect Uri Validator)

用于验证重定向(授权码模式)和注销后重定向Uri的校验,葫芦藤项目中重定向地址验证只验证域名(不验证完整的requestedUri地址),且未进行注销重定向Uri的校验。

public class RedirectUriValidator : IRedirectUriValidator
{
    public Task<bool> IsRedirectUriValidAsync(string requestedUri, Client client)
    {
        if (client.RedirectUris == null || !client.RedirectUris.Any())
        {
            return Task.FromResult(false);
        }
        var uri = new Uri(requestedUri);
        return Task.FromResult(client.RedirectUris.Any(x => x.Contains(uri.Host)));
    }

    public Task<bool> IsPostLogoutRedirectUriValidAsync(string requestedUri, Client client)
    {
        return Task.FromResult(true);
    }
}

扩展授权验证器(Extension Grant Validator)

在IdentityServer4中,通过实现IExtensionGrantValidator接口,可以实现自定义授权。在葫芦藤项目中,我们有两个场景需要用到自定义授权:

  • 通过第三方(QQ、微信)的用户标识(OpenId)进行登录(颁发用户令牌)
  • 通过短信验证码进行登录(颁发用户令牌)

在IdentityServer4中实现短信验证码授权模式,我们创建了一个SmsGrantValidator类,继承自IExtensionGrantValidator接口,然后给属性GrantType取一个名字,此处名称为“sms”,实现ValidateAsync方法,方法内进行入参校验,然后验证短信验证码,验证通过后取出用户信息,下面代码中,当用户不存在时也可以自动注册。代码如下:

public class SmsGrantValidator : IExtensionGrantValidator
{
    private readonly IHttpContextAccessor _contextAccessor;
    private readonly IValidationComponent _validationComponent;
    private readonly IUserService _userService;

    public SmsGrantValidator(IHttpContextAccessor contextAccessor, IValidationComponent validationComponent, IUserService userService)
    {
        _contextAccessor = contextAccessor;
        _validationComponent = validationComponent;
        _userService = userService;
        GrantType = CustomGrantType.Sms;
    }

    public async Task ValidateAsync(ExtensionGrantValidationContext context)
    {
        var phone = context.Request.Raw.Get("phone");
        var code = context.Request.Raw.Get("code");
        if (string.IsNullOrEmpty(phone) || Regex.IsMatch(phone, RegExp.PhoneNumber) == false)
        {
            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "phone is not valid");
            return;
        }
        if (string.IsNullOrEmpty(code))
        {
            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "code is not valid");
            return;
        }

        try
        {
            var validSms = await _validationComponent.ValidSmsAsync(phone, code);
            if (!validSms.Data)
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, validSms.Message);
                return;
            }

            var userEntity = await _userService.GetUserByPhoneAsync(phone);
            if (userEntity == null)
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "用户不存在或未注册");
                return;
            }
            if (userEntity.Enabled == false)
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, "您的账号已被禁止登录");
                return;
            }

            await _userService.SaveSuccessLoginInfo(context.Request.ClientId.ToInt32(), userEntity.Id, _contextAccessor.HttpContext.GetIp(),
                 UserLoginModel.SmsCode);
        }
        catch (Exception ex)
        {
            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest, ex.Message);
        }
    }

    public string GrantType { get; }
}

OAuth2.0的实践运用场景

基于角色的授权(role-based authorization)

基于角色的授权检查是声明性的,开发人员将其嵌入到代码中、控制器或控制器内的操作,指定当前用户必须是其成员的角色才能访问请求的资源,文档参考《ASP.NET Core 中的基于角色的授权》。

葫芦藤中定义了两种角色Claim(声明),客户端和用户,使用客户端授权模式(client credentials)颁发的令牌,ClaimRole为Client,使用授权码模式(authorization code)、密码模式(resource owner password credentials)、自定义授权模式(短信、第三方)颁发的用户令牌,ClaimRole为User

public static class ClaimRoles
{
    /// <summary>
    /// 客户端
    /// </summary>
    public const string Client = "Client";
    /// <summary>
    /// 用户
    /// </summary>
    public const string User = "User";
}

在ClientStore中增加返回Client的Claims,JwtClaimTypes.Role为ClaimRoles.Client,下面是客户端令牌,可以看到 "role":"Client"

{"alg":"RS256","kid":"99AA0C1236097972F29789562761D38AAE301918","typ":"JWT","x5t":"maoMEjYJeXLyl4lWJ2HTiq4wGRg"}
{"nbf":1608522625,"exp":1608529825,"iss":"http://localhost:80","aud":"api","client_id":"10000001","role":"Client","scope":["api","get_user_info"]}

在用户登录成功后返回的Claims中增加JwtClaimTypes.Role为ClaimRoles.User,下面是用户令牌,可以看到 "role":"User"

{"alg":"RS256","kid":"99AA0C1236097972F29789562761D38AAE301918","typ":"JWT","x5t":"maoMEjYJeXLyl4lWJ2HTiq4wGRg"}
{"nbf":1608522576,"exp":1608529776,"iss":"http://localhost:80","aud":"api","client_id":"10000001","sub":"df09efff-0074-4dca-91c3-e38180c5e4ac","auth_time":1608522576,"idp":"local","id":"df09efff-0074-4dca-91c3-e38180c5e4ac","open_id":"07E8E30B56D256EF8C440019AB6AAA89","name":"1051dfd1-73e5-4e6f-9326-3423bc9b71a3","nickname":"laowang","phone_number":"18627131390","email":"","role":"User","login_ip":"0.0.0.1","login_address":"保留地址","last_login_ip":"0.0.0.1","last_login_address":"保留地址","scope":["api","get_user_info"],"amr":["pwd","mfa"]}

在项目Fulu.Passport.API的Startup文件中,添加对组件Fulu.Service.Authorize的服务注入

services.AddServiceAuthorize(o =>...代码省略...);
services.AddAuthentication(x =>...代码省略...).AddJwtBearer(o =>
{
    ...代码省略...
    o.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = JwtClaimTypes.Name,
        RoleClaimType = ClaimTypes.Role,    //注意,这里不能使用JwtClaimTypes.Role
    ...代码省略...
    }
}

接着,只需在Controller或Action上指定属性即可

[Route("api/[controller]/[action]")]
[ApiController]
[Authorize(Roles = ClaimRoles.Client)]
public class ClientController : ControllerBase
{
    ...省略部分代码...
    /// <summary>
    /// 获取应用列表
    /// </summary>
    /// <returns></returns>
    [HttpGet]
    [ProducesResponseType(typeof(ActionObjectResult<List<ClientEntity>, Statistic>), 200)]
    public async Task<IActionResult> GetClients()
    {
        var clients = await _clientRepository.TableNoTracking.Where(c => c.Enabled).ToListAsync();
        return ObjectResponse.Ok(clients);
    }
    ...省略部分代码...

客户端授权模式(client credentials)

通过客户端授权模式颁发的令牌,可以实现对服务资源进行保护。步骤如下:

(A)客户端10000001向葫后进行身份认证,并要求一个访问令牌。

(B)葫后验证客户端身份后,向客户端10000001提供访问令牌。

A步骤中,客户端10000001发出的HTTP请求,包含以下参数:

  • grant_type:表示授权类型,此处的值固定为"clientcredentials",必选项。
  • client_id:表示客户端的ID,必选项。
  • client_secret:表示客户端密钥,必选项。
POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1
Host: www.xxx.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=10000001&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w

B步骤中,葫芦藤向客户端10000001发放令牌,下面是一个例子。

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store, no-cache, max-age=0
Pragma: no-cache

{
    "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk5QUEwQzEyMzYwOTc5NzJGMjk3ODk1NjI3NjFEMzhBQUUzMDE5MTgiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJtYW9NRWpZSmVYTHlsNGxXSjJIVGlxNHdHUmcifQ.eyJuYmYiOjE2MDc0MTQ2MjUsImV4cCI6MTYwNzQyMTgyNSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwicm9sZSI6IkNsaWVudCIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IkNsaWVudCIsInNjb3BlIjpbImFwaSIsImdldF91c2VyX2luZm8iXX0.ilu1qMxDiXVxsqU6aO-xuyYaLvvj2mxONjYkXtpMs46K7O3_Qc5VsY0ZZaYPoLROAqPulxsWWpxjEiQd10OdRh4IziGAcpYfAfoD80CZxrcuWrWloB5aWncv_PMZcjzKw7Vt3G3g-WkJl4amTta498hZJ3B-N-ReLhl-3ICSMFU8PU_ZVtEB-2lRx93rVyPIaQu_DWmpyW4Bdf2ocYm4RPQAEsvBToEFObbWPG6paLWIjrSN2aQPvsRWziorvlIhyFV5L6oyFIGIrZxdLJTOsvRQaevpV1sbv9pD_Z9PZDbSQiQDbWQv0MfrYB0Npc6VQlIMkL2GPNlQ8NgwyGT1sQ",
    "expires_in": 7200,
    "token_type": "Bearer",
    "scope": "api get_user_info"
}

授权码模式(authorization code)

葫芦藤项目通过授权码模式(authorization code)实现了单点登录,通过授权码模式拿到用户令牌。目前葫芦藤只有一个应用(葫芦藤安全中心),这里为了不把概念搞混淆,我们假定百度(客户端10000002,redirect_uri 为 http://www.baidu.com)接入了咱们的授权体系,当然,百度的前端肯定没有写如何构造请求步骤的逻辑代码,因此,我们下面通过人工模拟请求步骤。

(A)用户访问“百前”,“百前”将用户导向“葫后”。
(B)“葫后”检查用户是否需要登录(是否携带了有效的登录Cookie),如需登录跳转到“葫前”。
(C)用户登录后,“葫后”将用户导向百度事先指定的"重定向URI"(redirection URI),同时附上一个授权码。
(D)“百前”收到授权码,附上早先的"重定向URI",向“百后”申请令牌,“百后”拿到授权码之后携带密钥client_secret向“葫后”申请令牌。
(E)“葫后”核对了授权码和重定向URI,确认无误后,向“百后”颁发访问令牌(access token)。
(F)“百后”将令牌返回给“百前”。

A步骤中,构造的请求地址包含以下参数:

  • response_type:表示授权类型,必选项,此处的值固定为"code"
  • client_id:表示客户端的ID,必选项
  • redirect_uri:表示重定向URI,可选项
  • scope:表示申请的权限范围,可选项
  • state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值

步骤A中开发人员需向前端人员提供client_id,即上面的client_id,下面是一个例子。

构造如下地址,复制到浏览器地址栏中并回车,如果跳转到登录页,请进行登录。

https://account-web.suuyuu.cn/connect/authorize?client_id=10000002&redirect_uri=https%3A%2F%2Fwww.baidu.com&response_type=code&scope=api&state=STATE

登录后会重定向redirect_uri到如下地址:

https://www.baidu.com/?code=1MlxrvXuD7TfH-s4dLzcw9ymO0SKDbf5xAlh3ZEHlMo&scope=api&state=STATE

D步骤中,我们通过临时授权码向“葫后”索取令牌,包含以下参数:

  • grant_type:表示使用的授权模式,必选项,此处的值固定为"authorization_code"。
  • code:表示上一步获得的授权码,必选项。
  • redirect_uri:表示重定向URI,必选项,且必须与A步骤中的该参数值保持一致。
  • client_id:表示应用ID,必选项。
  • client_secret:表示应用密钥,必选项。
POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1
Host: account-web.suuyuu.cn
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=1MlxrvXuD7TfH-s4dLzcw9ymO0SKDbf5xAlh3ZEHlMo&redirect_uri=https%3A%2F%2Fwww.baidu.com&client_id=10000002&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w
{
    "access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjcwQzQ3OUY1QUIyQTFERjM2QzE0MkNEQjQ3NjQ1QkEwMzQ1MTg1NUEiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJjTVI1OWFzcUhmTnNGQ3piUjJSYm9EUlJoVm8ifQ.eyJuYmYiOjE2MDc0MjY0MjcsImV4cCI6MTYwNzQzMzYyNywiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwic3ViIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwiYXV0aF90aW1lIjoxNjA3NDI2MTk2LCJpZHAiOiJsb2NhbCIsImlkIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwib3Blbl9pZCI6IjA3RThFMzBCNTZEMjU2RUY4QzQ0MDAxOUFCNkFBQTg5IiwibmFtZSI6IjEwNTFkZmQxLTczZTUtNGU2Zi05MzI2LTM0MjNiYzliNzFhMyIsIm5pY2tuYW1lIjoibGFvd2FuZyIsInBob25lX251bWJlciI6IjE4NjI3MTMxMzkwIiwiZW1haWwiOiIiLCJyb2xlIjoiVXNlciIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IlVzZXIiLCJsb2dpbl9pcCI6IjExMy41Ny4xMTguNTEiLCJsb2dpbl9hZGRyZXNzIjoi5rmW5YyX55yB5q2m5rGJ5biCIiwibGFzdF9sb2dpbl9pcCI6IjExMy41Ny4xMTguNTEiLCJsYXN0X2xvZ2luX2FkZHJlc3MiOiLmuZbljJfnnIHmrabmsYnluIIiLCJzY29wZSI6WyJhcGkiXSwiYW1yIjpbIm1mYSJdfQ.ElnHr5Niknq7kzGL8iv1TH0F6NQ21yPrswzSTIZuvetUxztYgQpD-RfgBW2HL6b_rRyQxFjE23gU4lBIEayM8k3M9_sUzZq8E_dFT8LwpsU76-CxepxHft4hn1YG0a5C6QRyjFQoSFVUZXIp663Es7vwRQ6PgsfkHZKXxAqXL-obHj_QLbv6OeciTIRGwYrL9-1_SDQ4esFR2n8LkGGOug55j9QuQEKMCufQLJ-nB3y7A2-0mnNoiuF2BBYSPLamcvMcLe8LbhCITLrHkcUSc6tsSdnEeisS6BMIoiyRq-LR2jJwDD30swTPFd85v6kUBJ3ZnWjeCqsluGGKHrwDLA",
    "expires_in":7200,
    "token_type":"Bearer",
    "scope":"api"
}

密码模式(resource owner password credentials)

密码模式主要用于给可信应用颁发用户令牌,此类应用有个性化的登录页(不依赖单点登录,葫芦藤的登录页面),如app、小程序、h5等。

  • grant_type:表示授权类型,此处的值固定为"password",必选项。
  • client_id:表示客户端的ID,必选项。
  • client_secret:表示客户端密钥,必选项。
  • username:用户名,必选项。
  • password:密码,必选项。(基于密码原文的rsa加密串)
POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1
Host: account-web.suuyuu.cn
Content-Type: application/x-www-form-urlencoded

grant_type=password&client_id=10000001&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w&username=18627131390&password=0200f6389afbcbc624811785c9fbbf5c1b6d7b53b1315a1a43021c0733323fab7625bb9e6594cd30758fa700798421bc189dc223bf696d2438530ffab337809b96bb47ee38f3416bf4b57222050d5f4ad66ee052598ea62ff5ec6f991729956cb692f6f48b758564a46aeff86208581cad9063d3ccd71b551fa4b4b4b983fc1a
{
    "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjcwQzQ3OUY1QUIyQTFERjM2QzE0MkNEQjQ3NjQ1QkEwMzQ1MTg1NUEiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJjTVI1OWFzcUhmTnNGQ3piUjJSYm9EUlJoVm8ifQ.eyJuYmYiOjE2MDc1MTE2NTEsImV4cCI6MTYwNzUxODg1MSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwic3ViIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwiYXV0aF90aW1lIjoxNjA3NTExNjUxLCJpZHAiOiJsb2NhbCIsImlkIjoiZGYwOWVmZmYtMDA3NC00ZGNhLTkxYzMtZTM4MTgwYzVlNGFjIiwib3Blbl9pZCI6IjA3RThFMzBCNTZEMjU2RUY4QzQ0MDAxOUFCNkFBQTg5IiwibmFtZSI6IjEwNTFkZmQxLTczZTUtNGU2Zi05MzI2LTM0MjNiYzliNzFhMyIsIm5pY2tuYW1lIjoibGFvd2FuZyIsInBob25lX251bWJlciI6IjE4NjI3MTMxMzkwIiwiZW1haWwiOiIiLCJyb2xlIjoiVXNlciIsImh0dHA6Ly9zY2hlbWFzLm1pY3Jvc29mdC5jb20vd3MvMjAwOC8wNi9pZGVudGl0eS9jbGFpbXMvcm9sZSI6IlVzZXIiLCJsb2dpbl9pcCI6IjExMy41Ny4xMTguNjEiLCJsb2dpbl9hZGRyZXNzIjoi5rmW5YyX55yB5q2m5rGJ5biCIiwibGFzdF9sb2dpbl9pcCI6IjExMy41Ny4xMTguNjEiLCJsYXN0X2xvZ2luX2FkZHJlc3MiOiLmuZbljJfnnIHmrabmsYnluIIiLCJzY29wZSI6WyJhcGkiLCJnZXRfdXNlcl9pbmZvIl0sImFtciI6WyJwd2QiLCJtZmEiXX0.d3qvhX6KSdm5EgWpUzbjJX2bB1OiUo-285nZ1qsGKpqTQJUH1VHQoJogB0NI-uVYdgIV-y3CMBhFY_fDYQJto43zDf0gDvYxa2eWnX5MWL7Augigi59Icp0YvNDCGd2iT5ztAWpxk1Jww815TtCFtFFGiQfQC75bKLrTW9QvdXr8t4VHcFKGmz92m8g3WL-0eWqAyvk0YuSBvxOd8P8zoocEiiOgVKTSylphSIQxuC8B4MFNf2DoFWDQjNZmDCs7PLh7sniMmLdfilo7T7gAlq9qjUrmQmav4wbDMT8WZqa01WY-LsWq6mZUnbCytgSu7Xrr90b6LAEGn-hxdQ5VHg",
    "expires_in": 7200,
    "token_type": "Bearer",
    "scope": "api get_user_info"
}

自定义授权模式(短信、第三方)(extension grant)

客户端通过用户手机号短信验证码或第三方用户(QQ、WeChat)的用户唯一标识(OpenId)向认证服务器索要用户令牌。

以短信验证码方式为例,我们定义的流程如下:

用户向客户端提供自己的手机号和短信验证码。客户端使用这些信息,向认证服务器索要授权。 步骤如下:

(A)用户向客户端提供手机号和短信验证码。
(B)客户端将手机号和短信码发给认证服务器,向后者请求令牌。
(C)认证服务器确认无误后,向客户端提供用户令牌。

B步骤中,客户端发出的HTTP请求,包含以下参数:

  • grant_type:表示授权类型,此处的值固定为"sms",必选项。
  • client_id:表示客户端的ID,必选项。
  • client_secret:表示客户端的密钥,必选项。
  • phone:表示手机号,必选项。
  • code:表示短信验证码,必选项。

下面是一个请求示例。

POST https://account-web.suuyuu.cn/oauth/token HTTP/1.1
Host: account-web.suuyuu.cn
Content-Type: application/x-www-form-urlencoded

grant_type=sms&phone=18627131390&code=123456&client_id=10000001&client_secret=14p9ao1gxu4q3sp8ogk8bq4gkct59t9w
{
    "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk5QUEwQzEyMzYwOTc5NzJGMjk3ODk1NjI3NjFEMzhBQUUzMDE5MTgiLCJ0eXAiOiJKV1QiLCJ4NXQiOiJtYW9NRWpZSmVYTHlsNGxXSjJIVGlxNHdHUmcifQ.eyJuYmYiOjE2MDczOTU4NTIsImV4cCI6MTYwNzQwMzA1MiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MCIsImF1ZCI6ImFwaSIsImNsaWVudF9pZCI6IjEwMDAwMDAxIiwic3ViIjoiMTg2MjcxMzEzOTAiLCJhdXRoX3RpbWUiOjE2MDczOTU4NTIsImlkcCI6ImxvY2FsIiwiaWQiOiJkZjA5ZWZmZi0wMDc0LTRkY2EtOTFjMy1lMzgxODBjNWU0YWMiLCJvcGVuX2lkIjoiMDdFOEUzMEI1NkQyNTZFRjhDNDQwMDE5QUI2QUFBODkiLCJuYW1lIjoiMTA1MWRmZDEtNzNlNS00ZTZmLTkzMjYtMzQyM2JjOWI3MWEzIiwibmlja25hbWUiOiJsYW93YW5nIiwicGhvbmVfbnVtYmVyIjoiMTg2MjcxMzEzOTAiLCJlbWFpbCI6IiIsInJvbGUiOiJVc2VyIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiVXNlciIsImxvZ2luX2lwIjoiMC4wLjAuMSIsImxvZ2luX2FkZHJlc3MiOiLkv53nlZnlnLDlnYAiLCJsYXN0X2xvZ2luX2lwIjoiMC4wLjAuMSIsImxhc3RfbG9naW5fYWRkcmVzcyI6IuS_neeVmeWcsOWdgCIsInNjb3BlIjpbImFwaSIsImdldF91c2VyX2luZm8iXSwiYW1yIjpbInBhc3N3b3JkIiwibWZhIl19.ZQklMJMXObc3vL-gMOWnWIS56ck5_XbDfXjw9Vm6BeYjG4dyz05JTN_YHgU-EIJoM04nmFyjNgGYtqL-28-3MQeHfWhvQf_5dyY1w-DBBCKo1EMEm_ujKTDB1QQTN1XmVTgW7bBkEiv4NK5v3uYqh_s7pv8Csusm4oWZThWPlKLtxWVDtawFzvz4Un-2WATytsLNfluutiLVnpN7INhkdglansTTOCUOdCOLBEEbDzTuLyCnhm00xYtg5GrMAkDohqXLKYD2jSFzIyYTA_oryTFXcJpkGYwIRqRX7bXvAlMR5yE_CTtNWpSnaLJ2GtFv_QFe-YItCtSO-bBd6XQBRA",
    "expires_in": 7200,
    "token_type": "Bearer",
    "scope": "api get_user_info"
}

第三方授权登录的编写与使用

在葫芦藤项目中我们提供了钉钉、微信的OAuth组件,并实现了功能,演示地址在 https://account.suuyuu.cn,下面我们以微信为例简单介绍下如何编写组件及使用。

首先咱们阅读一下网站应用微信登录开发指南,了解一下接入流程。要使用微信登录,先得在微信·开放平台注册成为开发者,并进行资质认证。

微信开放平台帐号的开发者资质认证提供更安全、更严格的真实性认证、也能够更好的保护企业及用户的合法权益
开发者资质认证通过后,微信开放平台帐号下的应用,将获得微信登录、智能接口、第三方平台开发等高级能力
审核费用:中国大陆地区:300元,非中国大陆地区:99美元

然后在管理中心创建网站应用

对照微信开发指南将需要用到的地址定义到WeChatDefaults.cs中

public static class WeChatDefaults
{
    public const string AuthenticationScheme = "wechat";

    public static readonly string DisplayName = "wechat";
    //第一步:请求CODE
    public static readonly string AuthorizationEndpoint = "https://open.weixin.qq.com/connect/qrconnect";
    //第二步:通过code获取access_token
    public static readonly string TokenEndpoint = "https://api.weixin.qq.com/sns/oauth2/access_token";
    //第三步:获取用户个人信息
    public static readonly string UserInformationEndpoint = "https://api.weixin.qq.com/sns/userinfo";
}

此处唯一要注意的地方,ClaimActions集合的参数来自微信返回的字段

public class WeChatOptions : OAuthOptions
{
    /// <summary>
    /// Initializes a new <see cref="WeChatOptions"/>.
    /// </summary>
    public WeChatOptions()
    {
        CallbackPath = new PathString("/signin-wechat");
        AuthorizationEndpoint = WeChatDefaults.AuthorizationEndpoint;
        TokenEndpoint = WeChatDefaults.TokenEndpoint;
        UserInformationEndpoint = WeChatDefaults.UserInformationEndpoint;
        Scope.Add("snsapi_login");

        ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "openid");
        ClaimActions.MapJsonKey(ClaimTypes.Name, "nickname");
    }

    /// <summary>
    /// access_type. Set to 'offline' to request a refresh token.
    /// </summary>
    public string AccessType { get; set; }
}
public static class WeChatExtensions
{
    public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder)
        => builder.AddWeChat(WeChatDefaults.AuthenticationScheme, _ => { });

    public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, Action<WeChatOptions> configureOptions)
        => builder.AddWeChat(WeChatDefaults.AuthenticationScheme, configureOptions);

    public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, string authenticationScheme, Action<WeChatOptions> configureOptions)
        => builder.AddWeChat(authenticationScheme, WeChatDefaults.DisplayName, configureOptions);

    public static AuthenticationBuilder AddWeChat(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action<WeChatOptions> configureOptions)
        => builder.AddOAuth<WeChatOptions, WeChatHandler>(authenticationScheme, displayName, configureOptions);
}

新增一个类WeChatHandler,继承自OAuthHandler

BuildChallengeUrl(构造客户端申请认证的URI)

protected override string BuildChallengeUrl(AuthenticationProperties properties, string redirectUri)
{
    var state = Options.StateDataFormat.Protect(properties);
    var baseUri = $"{Request.Scheme}{Uri.SchemeDelimiter}{Request.Host}{Request.PathBase}";
    var currentUri = $"{baseUri}{Request.Path}{Request.QueryString}";

    if (string.IsNullOrEmpty(properties.RedirectUri))
    {
        properties.RedirectUri = currentUri;
    }

    var queryStrings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        {"response_type", "code"},
        {"appid", Uri.EscapeDataString(Options.ClientId)},
        {"redirect_uri", redirectUri},
        {"state", Uri.EscapeDataString(state)}
    };

    var scope = string.Join(",", Options.Scope);
    queryStrings.Add("scope", Uri.EscapeDataString(scope));

    var authorizationEndpoint = QueryHelpers.AddQueryString(Options.AuthorizationEndpoint, queryStrings);
    return authorizationEndpoint;
}

HandleRemoteAuthenticateAsync(向认证服务器申请令牌获取用户信息并创建票据)

protected override async Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
{

    var state = Request.Query["state"];
    var properties = Options.StateDataFormat.Unprotect(state);

    if (properties == null)
        return HandleRequestResult.Fail("The oauth state was missing or invalid.");
    if (!ValidateCorrelationId(properties))
        return HandleRequestResult.Fail("Correlation failed.", properties);

    var code = Request.Query["code"];
    if (StringValues.IsNullOrEmpty(code))
        return HandleRequestResult.Fail("Code was not found.", properties);

    var redirectUri = !string.IsNullOrEmpty(Options.CallbackPath) ?
        Options.CallbackPath.Value : BuildRedirectUri(Options.CallbackPath);

    var context = new OAuthCodeExchangeContext(properties, code, redirectUri);

    var tokens = await ExchangeCodeAsync(context);

    if (tokens.Error != null)
        return HandleRequestResult.Fail(tokens.Error, properties);
    if (string.IsNullOrEmpty(tokens.AccessToken))
        return HandleRequestResult.Fail("Failed to retrieve access token.", properties);
    var identity = new ClaimsIdentity(ClaimsIssuer);

    if (Options.SaveTokens)
    {
        var authenticationTokenList = new List<AuthenticationToken>
        {
            new AuthenticationToken
            {
                Name = "access_token",
                Value = tokens.AccessToken
            }
        };
        if (!string.IsNullOrEmpty(tokens.RefreshToken))
        {
            authenticationTokenList.Add(new AuthenticationToken
            {
                Name = "refresh_token",
                Value = tokens.RefreshToken
            });
        }

        if (!string.IsNullOrEmpty(tokens.TokenType))
        {
            authenticationTokenList.Add(new AuthenticationToken
            {
                Name = "token_type",
                Value = tokens.TokenType
            });
        }

        if (!string.IsNullOrEmpty(tokens.ExpiresIn) && int.TryParse(tokens.ExpiresIn, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
        {
            var dateTimeOffset = Clock.UtcNow + TimeSpan.FromSeconds(result);
            authenticationTokenList.Add(new AuthenticationToken()
            {
                Name = "expires_at",
                Value = dateTimeOffset.ToString("o", CultureInfo.InvariantCulture)
            });
        }
        properties.StoreTokens(authenticationTokenList);
    }

    var ticket = await CreateTicketAsync(identity, properties, tokens);
    return ticket == null ? HandleRequestResult.Fail("Failed to retrieve user information from remote server.", properties) : HandleRequestResult.Success(ticket);
}

此步骤中包含两个子步骤

ExchangeCodeAsync(交换授权码Code)

protected override async Task<OAuthTokenResponse> ExchangeCodeAsync(OAuthCodeExchangeContext context)
{

    var tokenRequestParameters = new List<KeyValuePair<string, string>>
    {
        new KeyValuePair<string, string>("appid", Options.ClientId),
        new KeyValuePair<string, string>("secret", Options.ClientSecret),
        new KeyValuePair<string, string>("code", context.Code),
        new KeyValuePair<string, string>("grant_type", "authorization_code"),
    };

    var urlEncodedContent = new FormUrlEncodedContent(tokenRequestParameters);

    var response =
        await Backchannel.PostAsync(Options.TokenEndpoint, urlEncodedContent, Context.RequestAborted);

    return response.IsSuccessStatusCode ? OAuthTokenResponse.Success(JsonDocument.Parse(await response.Content.ReadAsStringAsync())) : OAuthTokenResponse.Failed(new Exception("OAuth token failure"));
}

CreateTicketAsync(创建票据)

protected override async Task<AuthenticationTicket> CreateTicketAsync(ClaimsIdentity identity,AuthenticationProperties properties,OAuthTokenResponse tokens)
{
    var openId = tokens.Response.RootElement.GetString("openid");

    var parameters = new Dictionary<string, string>
    {
        {  "openid", openId},
        {  "access_token", tokens.AccessToken }
    };
    var userInfoEndpoint = QueryHelpers.AddQueryString(Options.UserInformationEndpoint, parameters);
    var response = await Backchannel.GetAsync(userInfoEndpoint, Context.RequestAborted);

    if (!response.IsSuccessStatusCode)
    {
        throw new HttpRequestException($"An error occurred when retrieving WeChat user information ({response.StatusCode}). Please check if the authentication information is correct.");
    }

    using (var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync()))
    {
        var context = new OAuthCreatingTicketContext(new ClaimsPrincipal(identity), properties, Context, Scheme,
            Options, Backchannel, tokens, payload.RootElement);

        context.RunClaimActions();
        await Events.CreatingTicket(context);

        context.Properties.ExpiresUtc = DateTimeOffset.Now.AddMinutes(15);
        return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
    }
}

组件写好了,怎么使用呢?在Fulu.Passport.Web项目的Startup.cs文件中添加代码如下:

public void ConfigureServices(IServiceCollection services)
{
    ......省略部分代码......
    
    services.AddAuthentication().AddWeChat(o =>
    {
        o.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
        o.ClientId = Configuration["ExternalWeChat:AppId"];
        o.ClientSecret = Configuration["ExternalWeChat:Secret"];
    })
}

接着,在UserController.cs中添加如下代码:

/// <summary>
/// 外部账号登录
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[HttpGet, AllowAnonymous]
public IActionResult ExternalLogin([FromQuery] ExternalLoginModel model)
{
    var authenticationProperties = new AuthenticationProperties()
    {
        RedirectUri = Url.Action(nameof(ExternalLoginCallback)),
        Items =
        {
            { "returnUrl", model.ReturnUrl },
            { "scheme", model.Provider },
        }
    };

    return Challenge(authenticationProperties, model.Provider);
}

/// <summary>
/// 外部登录回调
/// </summary>
/// <returns></returns>
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> ExternalLoginCallback()
{
    //获取idsrv.external Cookie 对象
    var result = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);

    var returnUrl = result.Properties.Items["returnUrl"];

    if (result.Succeeded == false)
    {
        return await RedirectErrorResult("error", "External authentication error", returnUrl);
    }
    ......省略部分代码......

    //删除 idsrv.external Cookie
    await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
    //写入 .AspNetCore.Cookies
    await SignIn(userEntity, UserLoginModel.External);

    return Redirect(returnUrl);
}

footer-banner.png福禄ICH·架构组
福禄娃


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK