Abp vNext自定义OpenIddict登录

发布时间 2023-12-06 11:09:13作者: .NET好耶

Abp vNext自定义OpenIdDict登录

使用Abp vNext 6.0

我是打算给登录加一个验证码或者手机登录什么的,所以要自定义登录
这方面官方文档写的不多,所以只能翻源码了

源码分析

首先就是去翻登录的api,用abp官方的angularDemo来看登录的路由,有三个网络请求

/.well-known/openid-configuration
/.well-known/jwks
/connect/token

在源码中前两个都被注释掉了,应该就是connect/token这个路由对应的函数

查找到的就是TokenController这个控制器,粗略看一下,我们后续再分析

[Route("connect/token")]
[IgnoreAntiforgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public partial class TokenController : AbpOpenIdDictControllerBase
{
    [HttpGet, HttpPost, Produces("application/json")]
    public virtual async Task<IActionResult> HandleAsync()
    {
        var request = await GetOpenIddictServerRequestAsync(HttpContext);

        if (request.IsPasswordGrantType())
        {
            return await HandlePasswordAsync(request);
        }

        if (request.IsAuthorizationCodeGrantType() )
        {
            return await HandleAuthorizationCodeAsync(request);
        }

        if (request.IsRefreshTokenGrantType() )
        {
            return await HandleRefreshTokenAsync(request);
        }

        if (request.IsDeviceCodeGrantType() )
        {
            return await HandleDeviceCodeAsync(request);
        }

        if (request.IsClientCredentialsGrantType())
        {
            return await HandleClientCredentialsAsync(request);
        }

        var extensionGrantsOptions = HttpContext.RequestServices.GetRequiredService<IOptions<AbpOpenIddictExtensionGrantsOptions>>();
        var extensionTokenGrant = extensionGrantsOptions.Value.Find<ITokenExtensionGrant>(request.GrantType);
        if (extensionTokenGrant != null)
        {
            return await extensionTokenGrant.HandleAsync(new ExtensionGrantContext(HttpContext, request));
        }

        throw new AbpException(string.Format(L["TheSpecifiedGrantTypeIsNotImplemented"], request.GrantType));
    }
}

看完这片源码,你会发现GrantType这个单词出现了很多次,并且判断完GrantType就执行返回函数了,后续就报TheSpecifiedGrantTypeIsNotImplemented异常,简单翻译过来就是对应的GrantType没有实现,所以应该就是这个没错了

当然,不止这个地方,connect/token的搜索结果还有个地方,就是OpenIddict配置的地方,官方文档就有提及这里,说明是在我们的代码中可配置的,可以利用

builder
    .AllowAuthorizationCodeFlow()
    .AllowHybridFlow()
    .AllowImplicitFlow()
    .AllowPasswordFlow()
    .AllowClientCredentialsFlow()
    .AllowRefreshTokenFlow()
    .AllowDeviceCodeFlow()
    .AllowNoneFlow();

这片配置中我们也发现了眼熟的东西,貌似与GrantType判断对应上了,应该属于官方文档提及的OpenIddictServerBuilder这个类,那么可以写扩展方法来配置了
这片扩展方法你会发现搜索不到,因为这个在OpenIddict的源码里,后续再说

刚才TokenController中有一个比较可疑的地方

var extensionGrantsOptions = HttpContext.RequestServices.GetRequiredService<IOptions<AbpOpenIddictExtensionGrantsOptions>>();
var extensionTokenGrant = extensionGrantsOptions.Value.Find<ITokenExtensionGrant>(request.GrantType);
if (extensionTokenGrant != null)
{
    return await extensionTokenGrant.HandleAsync(new ExtensionGrantContext(HttpContext, request));
}

这里根据GrantType获取了ITokenExtensionGrant接口,然后执行了HandleAsync,应该就是我们的目标了

实现

其实你源码里就有一段示例,搜索ITokenExtensionGrant就有一个示例,那就简单了
假设我需要写一个手机号码+密码的登录验证,也不一定是密码,可能是验证码之类的,或者账号+密码+验证码之类的,反正就多个参数演示嘛
新建一个类PhoneTokenExtensionGrant,实现ITokenExtensionGrant接口

public class PhoneTokenExtensionGrant : ITokenExtensionGrant
{
    public string Name => throw new System.NotImplementedException();

    public Task<IActionResult> HandleAsync(ExtensionGrantContext context)
    {
        throw new System.NotImplementedException();
    }
}

这里是需要实现的HandleAsync(ExtensionGrantContext context)返回值为Task<IActionResult>,一眼Controller,所以我们当成Controller来写就可以了
当然,还要继承AbpOpenIdDictControllerBase,这样才算Controller

因为/connect/token的参数是x-www-form-urlencoded,所以直接新的参数直接添加到表单就可以了
至于怎么写,可以参考源码TokenControllerHandlePasswordAsync(OpenIddictRequest request)函数
不过参数略有不同,我们自定义的方法在源码里是这样调用的

return await extensionTokenGrant.HandleAsync(new ExtensionGrantContext(HttpContext, request));

参数ExtensionGrantContext context在源码里是这样的

public class ExtensionGrantContext
{
    public HttpContext HttpContext { get; }

    public OpenIddictRequest Request { get; }

    public ExtensionGrantContext(HttpContext httpContext, OpenIddictRequest request)
    {
        HttpContext = httpContext;
        Request = request;
    }
}

当然,源码里有很多多余的东西,比如多租户、分布式缓存、双因素认证、安全日志什么的,如果只是单纯的登录验证,完全可以只用一个读数据库的service
HandlePasswordAsync为例
protected IServiceScopeFactory ServiceScopeFactory:这个是创建作用域用的,就是依赖注入的那个作用域
protected ITenantConfigurationProvider TenantConfigurationProvider:这个是多租户相关的
protected IOptions<AbpIdentityOptions> AbpIdentityOptions:这个是abp实现的Identity管理,似乎是mvc用的
protected IOptions<IdentityOptions> IdentityOptions:这个和AbpIdentityOptions似乎是一套的,源码里面几乎都是同时有这俩
protected IdentitySecurityLogManager IdentitySecurityLogManager:这个是AbpSecurityLogs这个表的service
protected ISettingProvider SettingProvider:这个是读配置用的
protected IdentityDynamicClaimsPrincipalContributorCache IdentityDynamicClaimsPrincipalContributorCache:这个是分布式缓存用的

由于我们写的是webapi,token的处理我们可以放在其它的中间件里面,所以看起来只需要写一个service就够了

关于返回值Forbid

return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

properties是返回给前端的信息,比如这样的

{
    "error": "invalid_grant",
    "error_description": "Invalid username or password!",
    "error_uri": "https://documentation.openiddict.com/errors/ID2024"
}

OpenIddictServerAspNetCoreDefaults.AuthenticationScheme这个在OpenIddict的源码里长这样,似乎是日志记录用的

/// <summary>
/// Exposes the default values used by the OpenIddict server handler.
/// </summary>
public static class OpenIddictServerAspNetCoreDefaults
{
    /// <summary>
    /// Default value for <see cref="AuthenticationScheme.Name"/>.
    /// </summary>
    public const string AuthenticationScheme = "OpenIddict.Server.AspNetCore";
}

至于其它返回值的话,我们自己写Controller只需要Ok()里面加俩token就完事了
但是我们需要生成token,如果要生成abp标准的token,还是用源码里的方法最好,也就是SetSuccessResultAsync这个函数
这个函数需要IdentityUser这个类的实例,问题就在于如果我们自己写一个Service,根据abp规范,在Contracts这个项目中只能引用DTO,就算不按规范来,直接引用Model,Contracts这个项目的版本是.NET Standard 2.0,不能引用其它项目,大概有三个比较符合规范的解决方法

  • 重写一套Repository和Service,使用正确的.net版本,这样就能绕开Contracts,反正是给后台用的Service,但是似乎需要写abp module
  • 直接使用Repository,绕开Service层就相当于绕开Contracts
  • 先查出用户Id,再用abp内置的IdentityUserAppService来查询用户,但是这样就要查两次数据库了
    我是建议重写一套Repository和Service,相当于内部模块嘛,不过这个似乎必须要写abp module,因为这样才能用依赖注入,也不是很麻烦,加了模块类继承AbpModule,然后引用的时候注意DependsOn

然后有一个坑,Grant可能会涉及的报错

{
    "error": "unsupported_grant_type",
    "error_description": "The specified 'grant_type' is not supported.",
    "error_uri": "https://documentation.openiddict.com/errors/ID2032"
}

这个意思是不支持的grant_type,就是没有实现,要么是请求grant_type给错了,要不就是后端错了

{
    "error": "unauthorized_client",
    "error_description": "This client application is not allowed to use the specified grant type.",
    "error_uri": "https://documentation.openiddict.com/errors/ID2064"
}

这个意思是client_id对应的Permissions没有这个grant_type,这个在数据库里OpenIddictApplications表的Permissions字段,手动改一下重启后端就行了,格式就类似gt:XXX这样的
当然,你也可以再添加一个新的ClientId,相当于是不同的平台嘛,这又是一个坑

其实这个就在Domain项目的OpenIddictDataSeedContributor里面,这里就是DbMigrator生成数据库的时候使用的

grantTypes: new List<string>
{
    OpenIddictConstants.GrantTypes.AuthorizationCode,
    OpenIddictConstants.GrantTypes.Password,
    OpenIddictConstants.GrantTypes.ClientCredentials,
    OpenIddictConstants.GrantTypes.RefreshToken,
    PhoneTokenExtensionGrantConsts.GrantType,
},

然后在CreateApplicationAsync的循环里面再加上一段,这样就会在第一次创建数据库的时候把grant_type写到Permissions里面

if (grantType == PhoneTokenExtensionGrantConsts.GrantType)
{
    application.Permissions.Add(OpenIddictConstants.Permissions.Prefixes.GrantType + PhoneTokenExtensionGrantConsts.GrantType);
}

题外话说太多了,还是上正篇吧

首先定义一个类,这里主要是常量,单独写一个常量类也是因为上面提到的项目引用的.net版本不同,这是写在内部基础模块里的,甚至都不是abp module,相当于Domain.Shared,大家都能调用

public static class PhoneTokenExtensionGrantConsts
{
    public const string GrantType = "PhoneTokenExtensionGrant";
    public static readonly ImmutableArray<string> Scopes = ImmutableArray.Create("offline_access");
}

然后就是具体的实现类

[IgnoreAntiforgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public class PhoneTokenExtensionGrant : AbpOpenIdDictControllerBase, ITokenExtensionGrant
{
    public string Name => PhoneTokenExtensionGrantConsts.GrantType;

    protected IIdentityUserAppService IdentityUserAppService => this.LazyServiceProvider.LazyGetRequiredService<IIdentityUserAppService>();

    public virtual async Task<IActionResult> HandleAsync(ExtensionGrantContext context)
    {
        HttpContext httpContext = context.HttpContext;
        OpenIddictRequest request = context.Request;

        this.LazyServiceProvider = httpContext.RequestServices.GetRequiredService<IAbpLazyServiceProvider>();

        string phone = request.GetParameter("phone").ToString();
        string password = request.GetParameter("password").ToString();
        if (true == string.IsNullOrWhiteSpace(phone) || true == string.IsNullOrWhiteSpace(password))
        {
            string errorDescription = "请输入正确数据";
            AuthenticationProperties properties = new AuthenticationProperties(new Dictionary<string, string>
            {
                [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
                [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = errorDescription
            });

            return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        }

        IdentityUser user = await this.IdentityUserAppService.FindUserByPhoneAndPasswordAsync(phone, password);

        if (null == user)
        {
            string errorDescription = "手机号或密码错误";
            AuthenticationProperties properties = new AuthenticationProperties(new Dictionary<string, string>
            {
                [OpenIddictServerAspNetCoreConstants.Properties.Error] = OpenIddictConstants.Errors.InvalidGrant,
                [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = errorDescription
            });

            return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        }

        return await this.SetSuccessResultAsync(request, user);
    }

    protected virtual async Task<IActionResult> SetSuccessResultAsync(OpenIddictRequest request, IdentityUser user)
    {
        ClaimsPrincipal principal = await this.SignInManager.CreateUserPrincipalAsync(user);

        //principal.SetScopes(request.GetScopes());
        //principal.SetResources(await GetResourcesAsync(request.GetScopes()));

        ImmutableArray<string> scopes = PhoneTokenExtensionGrantConsts.Scopes;
        principal.SetScopes(scopes);
        principal.SetResources(await GetResourcesAsync(scopes));

        return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    }
}

这里头的IIdentityUserAppService是我自己在新的abp module里面写的,FindUserByPhoneAndPasswordAsync是根据手机号和密码查数据库
scope我也是写死的,这种东西还是不要给前端乱用比较好

至于这个配置方法
先在PreConfigureServices里添加配置

PreConfigure<OpenIddictServerBuilder>(builder =>
{
   //添加自定义ITokenExtensionGrant
   builder.Configure(openIddictServerOptions =>
   {
       openIddictServerOptions.GrantTypes.Add(PhoneTokenExtensionGrantConsts.GrantType);
   });
});

然后ConfigureServices里添加就可以了

//配置自定义ITokenExtensionGrant
Configure<AbpOpenIddictExtensionGrantsOptions>(options =>
{
    options.Grants.Add(PhoneTokenExtensionGrantConsts.GrantType, new PhoneTokenExtensionGrant());
});

结果图

其实我的操作是不规范的,因为只是测试,所以直接拿数据库的数据,规范操作应该先通过手机号查用户,然后用IdentityUserManagerSignInManager这种专门的类来校验或处理密码,abp的PasswordHash算法不在源码里

Abp vNext自定义OpenIddict登录 结束

这应该算填坑了,去年就想搞这个了,当时还是IdentityServer4,现在才有空,结果换OpenIddict了,也算是赶趟了