Abp vNext自定义OpenIddict登录
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
,所以直接新的参数直接添加到表单就可以了
至于怎么写,可以参考源码TokenController
的HandlePasswordAsync(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字段,手动改一下,清空redis缓存,重启后端就行了,格式就类似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", "audience");
}
这里其实还有个坑,OpenIddict是需要验证audience的,所以scope里面要有对应的audience,我这里省略了,因为这是根据项目决定的
然后就是具体的实现类
[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());
});
结果图
其实我的操作是不规范的,因为只是测试,所以直接拿数据库的数据,规范操作应该先通过手机号查用户,然后用IdentityUserManager
或SignInManager
这种专门的类来校验或处理密码
Abp vNext自定义OpenIddict登录 结束
这应该算填坑了,去年就想搞这个了,当时还是IdentityServer4,现在才有空,结果换OpenIddict了,也算是赶趟了