Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之验证码

时间:2023-03-10 01:21:11
Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之验证码

验证码这东西,有人喜欢有人不喜欢。对于WebApi是否需要验证码,没去研究过,只是原来的SimpleCMS有,就加上吧。

在WeiApi上使用验证码,关键的地方在于WeiApi是没有状态的,也就是说,不能使用Session来保存验证码。因而,在WebApi上使用验证码。首先需要解决的是保存的问题。刚开始先测试AbpSession了,但发现机制和习惯的不同,无法使用,那就只能保存到数据库了。保存到数据库最大的麻烦是如何判断当前用户对应的验证码是那个,也就是需要一个唯一值来寻找验证码,而且这个唯一值必须在客户端刷新图片的时候一起返回到客户端,以便提交时,将该值一并提交到服务器。要将图片和唯一值一起返回客户端,也就是说不能以图片方式返回,不然不好添加唯一值。沿着这个思路想到了将图片转换为BASE64代码再返回这方式,这样,返回的就是字符串,可以携带其他信息返回了。后来,再想想,居然都已经转换为字符串了,何不直接将图片字符串做个MD5提交到服务器,然后把这个MD5作为搜索值,这样也省了处理多个数值的问题。使用MD5作为唯一值,唯一的思虑是,能确保图片都是唯一的么?对于这个问题,笔者觉得,能同时出现相同的图片,几乎比中彩票还难,26个字母加上10个数字的6位数排列已经是上十亿的可能,再加上字体、燥点和字体颜色等因素,真的中了,那你不是买彩票都对不起自己了。

思路明确后就可以开始工作了。先在Core项目创建VerifyCode的实体,代码如下:

    [Table("AppVerifyCodes")]
public class VerifyCode :Entity<long>
{
public const int MaxCodeLength = 6;
public const int MaxMd5KeyLength = 32; [Required]
[MaxLength(MaxMd5KeyLength)]
public string Md5Key { get; set; } [Required]
[MaxLength(MaxCodeLength)]
public string Code { get; set; } [Required]
public DateTime Expired { get; set; } }

在实体中添加Expired属性的作用是为验证码设置一个过期时间,以避免这个验证码比重复利用。在练习中,我将过期时间设置为了10分钟,在实际使用时,可以设置为30秒或者更小,不行就让用户刷新验证码就行。

创建实体后,将它添加到SimpleCmsWithAbpDbContext,然后就可执行Add-MigrationUpdate-Database命令在数据库添加实体对应的表了。

有了实体后,就要添加服务,以便将图片返回客户端。一般情况下,通过继承CrudAppServiceAsyncCrudAppService类就可以很简单的实现一个实体的CRUD操作,在没有特殊操作的情况下,基本不需要编写任何代码就能实现实体的CRUD操作了。由于验证码不需要完整的CRUD操作,只需要一个返回图片的操作,因而可以从IApplicationService接口、ApplicationService类或模版提供的SimpleCmsWithAbpAppServiceBase类等继承。SimpleCmsWithAbpAppServiceBase类是在ApplicationService的基础上添加了GetCurrentUserAsync方法用来返回当前用户,添加了GetCurrentTenantAsync方法用来返回当前租户。如果不需要这两方法,可以直接从ApplicationService基础。

了解了如何添加服务后,先在Application项目添加一个名为VerifyCodes的文件夹,并在该文件夹下创建一个名为Dto的文件夹。在Dto文件夹下, 先创建一个名为GetVerifyCodeOutput的类,代码如下:

    public class GetVerifyCodeOutput
{
public string Image { get; set; }
}

GetVerifyCodeOutput类将作为验证码的Get服务的返回对象。

在VerifyCodes文件夹下,创建一个名为IVerifyCodeAppService的接口,代码如下:

    public interface IVerifyCodeAppService : IApplicationService
{
Task<GetVerifyCodeOutput> Get();
}

还要创建一个名为VerifyCodeAppService的类,代码如下:

    public class VerifyCodeAppService:SimpleCmsWithAbpAppServiceBase,IVerifyCodeAppService
{
private readonly IRepository<VerifyCode, long> _repository; public VerifyCodeAppService(IRepository<VerifyCode, long> repository)
{
_repository = repository;
} public async Task<GetVerifyCodeOutput> Get()
{
var v = new VerifyCodeCore();
var code = v.CreateVerifyCode(); //取随机码
v.Padding = 10;
var bytes = v.CreateImage(code);
var image = $"data:{MimeTypeNames.ImageJpeg};base64,{Convert.ToBase64String(bytes)}";
var verifyCode = new VerifyCode()
{
Md5Key = GetMd5Key(image),
Code = code,
Expired = Clock.Now.AddMinutes(10)
};
await _repository.InsertAsync(verifyCode);
var output = new GetVerifyCodeOutput()
{
Image = image
};
return output;
} private static string GetMd5Key(string input)
{
var md5 = new MD5CryptoServiceProvider();
var inputBytes = System.Text.Encoding.ASCII.GetBytes(input);
var hashBytes = md5.ComputeHash(inputBytes); // Convert the byte array to hexadecimal string
var sb = new StringBuilder();
foreach (var t in hashBytes)
{
sb.Append(t.ToString("X2"));
}
return sb.ToString();
}
}

Get方法内,先调用VerifyCodeCoreCreateVerifyCode方法创建验证码,再调用CreateImage方法创建图像的二进制代码,并将二进制代码转换为BASE64代码。接下来是创建一个VerifyCode实体并通过存储的InsertAsync方法将实体添加到数据库。在这里调用了GetMd5Key方法将图片字符串转换为了MD5字符串。最后,创建GetVerifyCodeOutput的实体并返回。

在这里要注意的是,由于在.net core 2中并不包含System.Drawing对象,不能处理Bitmap对象,在使用VerifyCodeCore类的时候会出错,因而,需要在Application项目中添加System.Drawing.Common包,这个包目前还是预览版状态,需要在NuGet管理页中将包括预发行版选上才能找到。

重新生成解决方案,就可在swagger页的底部看到VerifyCode服务了,打开访问地址并单击Try it out!按钮就可看到以下的返回数据:

{
"result": {
"image": "data:image/jpeg;base64,此处省略图片输出"
},
"targetUrl": null,
"success": true,
"error": null,
"unAuthorizedRequest": false,
"__abp": true
}

这说明返回验证码没有问题了。下面要修改验证码的验证问题了。切换到Web.Core项目,在Models文件夹下,打开AuthenticateModel.cs文件,并将代码修改为以下代码:

    public class AuthenticateModel : ICustomValidate
{
[Required]
[StringLength(AbpUserBase.MaxEmailAddressLength)]
public string UserNameOrEmailAddress { get; set; } [Required]
[StringLength(AbpUserBase.MaxPlainPasswordLength)]
public string Password { get; set; } [Required]
[StringLength(6)]
public string VerifyCode { get; set; } [Required]
[StringLength(32)]
public string Key { get; set; } public bool RememberClient { get; set; } public void AddValidationErrors(CustomValidationContext context)
{
var verifyCodeRepository = context.IocResolver.Resolve<IRepository<VerifyCode, long>>();
var localizationManager = context.IocResolver.Resolve<ILocalizationManager>();
var record = verifyCodeRepository.FirstOrDefault(m =>m.Md5Key == Key.ToUpper());
if (record == null || (record.Code.ToUpper() != VerifyCode.ToUpper() || record.Expired < Clock.Now))
{
context.Results.Add(new ValidationResult(
localizationManager.GetString(SimpleCmsWithAbpConsts.LocalizationSourceName, "verifyCodeInvalid"),
new List<string>() {"VerifyCode"}));
}
else
{
verifyCodeRepository.Delete(record);
}
}
}

AuthenticateModel类是登录时用来接收登录数据的模型类。在该类中,添加了VerifyCodeKey两个属性用来接收验证码和与验证码相关的搜索值,并添加了自定义验证的AddValidationErrors方法来验证验证码。在AddValidationErrors内,先通过Resolve方法获取到VerifyCode实体的存储和本地化资源管理接口ILocalizationManager,再调用存储的FirstOrDefault方法来获取与验证码相关的实体,然后进行验证。如果记录不存在,或者记录的验证码不对,或者已经超时,就返回验证错误,否则删除实体,并继续执行后续的验证的操作。

在实现这个的时候,经历了一些波折,在刚开始的时候,笔者习惯使用Equals方法来验证字段与提交值是否相等,但得到的都是错误的结果,这就奇怪了。于是,笔者就查看日志到底是怎么回事,但是日志并没有记录查询时的SQL语句,这就麻烦了。在没有使用ABP框架时,要记录实体查询时的SQL语句很简单,只要调用UseLoggerFactory方法添加工厂类就行了,但是经过搜索,发现ABP框架使用的日志包castle.windsor并没有跟上时代的步伐,为这提供相应的支持,为此,ABP框架的人还去GitHub和castle.windsor的项目负责人进行了交流,最后也没啥结果。没办法,只能自己来解决这个问题了。先在EntityFrameworkCore包添加Microsoft.Extensions.Logging.Log4Net.AspNetCore包,然后打开SimpleCmsWithAbpDbContextConfigurer.cs文件,并将代码修改为以下代码:

    public static class SimpleCmsWithAbpDbContextConfigurer
{ public static readonly LoggerFactory MyLoggerFactory
= new LoggerFactory(new[]
{
new Log4NetProvider("log4net.config",new Func<object, Exception, string>((o, exception) =>exception.Message ))
});
public static void Configure(DbContextOptionsBuilder<SimpleCmsWithAbpDbContext> builder, string connectionString)
{
//builder.UseSqlServer(connectionString);
builder.UseLoggerFactory(MyLoggerFactory).UseMySql(connectionString);
} public static void Configure(DbContextOptionsBuilder<SimpleCmsWithAbpDbContext> builder, DbConnection connection)
{
//builder.UseSqlServer(connection);
builder.UseLoggerFactory(MyLoggerFactory).UseMySql(connection);
}
}

代码主要添加了一个LoggerFactory实例,用于记录实体的操作日志。代码里一定要将UseLoggerFactory方法放在UseMySql的前面,不然不起任何作用。好了,现在可以在日志中记录SQL 语句了。通过查看日志,发现使用Equals方法不能将查询值传递给SQL语句,这就奇怪了,不知道是ABP问题还是Entity Framework Core的问题了。不管了,还成==就没问题了。

使用带有 module-zero的模版,本地化信息可存储在数据库,也可保存在XML文件中。笔者是将信息保存在数据库中,例如AddValidationErrors使用到的verifyCodeInvalid信息,可在abplanguagetexts表中添加一条记录,记录的内容如下: - Key:verifyCodeInvalid - LanguageName:zh-CN - Source:SimpleCmsWithAbp - Value:验证码错误

这里要注意的是CreationTime字段的值不能为0,不然会出现错误,随便添加个时间就行了。还有就是关于Source的值,如果要自定义源的话,需要将源添加到本地化管理中,不然会提示找不到源。为了简便起见,使用SimpleCmsWithAbpConsts.LocalizationSourceName常数指定的源挺好,在本项目里,LocalizationSourceName的值是SimpleCmsWithAbp,因而Source的值为SimpleCmsWithAbp

重新生成解决方案,验证码验证功能就已经可用了。最后要修改的是客户端。

由于默认的服务访问接口都有前缀api/services/app,为了能方便处理这种情况,需要先修改SimpleCMS.util.Url类,将get方法修改成以下代码:

    defaultPath: '/api/services/app',

    get: function(controller, action, notDefaultPath) {
var me = this;
if (!Ext.isString(controller) || Ext.isEmpty(controller)) Ext.raise('非法的控制器名称');
if (!Ext.isString(action) && !Ext.isNumber(action)) Ext.raise('非法的操作名称');
return Ext.String.format(me.urlFormat, ROOTPATH + (notDefaultPath ? '' : me.defaultPath), controller, me.defaultActions[action] || me.actions[action] || action);
},

方法主要添加了一个notDefaultPath参数,用来指定是否添加默认路径,如果不设置该值,则添加,否则就不添加。

在客户端需要一个MD5类用来将图片的字符串转换为MD5字符串,在Sencha官方论坛找到了这个类,具体地址为Ext.util.MD5。类下载后,在app\util\文件夹下添加一个名为MD5.js的文件,然后将下载的代码粘贴到类里,将类名修改为SimpleCMS.util.MD5 ,并在app.js中添加对它的引用,build一次就能用了。

使用WebApi,表单就不能直接提交了,需要将表单内的数据转换为JSON格式提交,而要实现这个,只要在表单中将jsonSubmit设置为true就行了,但是每次都要设置就太麻烦了,通过重写方式可一劳永逸的解决这个问题,但尝试重写Ext.form.Basic发现不起作用,重写Ext.form.action.Submit才行。

数据是能以JSON提交,但发现WebApi在验证错误的时候,返回的是400错误,而登录失败返回的是500错误,而且,验证错误的返回格式与Ext JS的默认格式也不同,这些都需要通过重写Ext.form.action.Submit来实现,完成的后代码如下:

Ext.define('Overrides.form.action.Submit', {
override: "Ext.form.action.Submit", jsonSubmit: true, onFailure: function(response) {
var me = this,
form = me.form,
formActive = form && !form.destroying && !form.destroyed,
result; me.response = response;
//this.failureType = Ext.form.action.Action.CONNECT_FAILURE; if (response.status === 400) {
result = me.processResponse(response);
if (result.error.validationErrors) {
me.form.markInvalid(me.processValidationErrors(result.error.validationErrors));
me.failureType = "validationErrors";
}
} else {
me.failureType = Ext.form.Action.CONNECT_FAILURE;
} if (formActive) {
form.afterAction(me, false);
}
}, processValidationErrors: function(errors) {
var result = {},
ln = errors.length,
i = 0,
error, j, jn, fields, field;
for (i; i < ln; i++) {
error = errors[i];
fields = error.members;
jn = fields.length;
for (j = 0; j < jn; j++) {
field = result[fields[j]];
if (!field) field = result[fields[j]] = [];
field.push(error['message']);
}
}
return result;
} });

以上的代码参考了Sencha官方论坛的Aren’t Http Status Codes enough?这个帖子,不过,帖子中重写的是failure方法,不起左右,要重写onFailure方法才行。

onFailure方法内,如果返回的状态码是400,则判断是否存在error.validationErrors的数据,如果存在,是否是验证错误,需要从error.validationErrors中,将数据提取出来,将数据转换为Ext JS认识的错误格式。转换过程主要是从返回的每个错误中的members中获取字段名称,在新的对象中以字段名称作为属性名称,message的值作为错误信息数组中的一个值。

重写类写好以后,需要build一次以加载重写类。完成build后,打开登录视图app\view\authentication\Login.js,将里面的字段的name都修改为与AuthenticateModel类中属性对应的名称。修改完name后,打开app\view\authentication\AuthenticationController.js文件,修改verifyCodeUrl属性、onLoginButton方法和onRefrestVcode方法,具体代码如下:

    onLoginButton: function () {
var me = this,
view = me.getView(),
f = view.getForm(),
src = view.down('image').getSrc();
if (f.isValid()) {
f.submit({
//jsonSubmit:true,
params:{key: SimpleCMS.util.MD5(src)},
url: URI.get('api/TokenAuth', 'Authenticate', true),
waitMsg: I18N.LoginSubmitWaitMsg,
waitTitle: I18N.LoginSubmitWaitTitle,
success: function (form, action) {
window.location.reload();
},
failure: function(form,action){
this.onRefrestVcode();
FAILED.form(form,action);
},
scope: me
});
}
}, verifyCodeUrl: URI.get('VerifyCode', 'Get'),
onRefrestVcode: function () {
var me = this,
view = me.getView(),
img = view.down('image');
Ext.Ajax.request({
url: me.verifyCodeUrl,
scope: me,
success: function(response, opts) {
var obj = Ext.decode(response.responseText),
view = this.getView();
if(view && obj.success && obj.result && obj.result.image){
view.down('image').setSrc(obj.result.image);
}
},
failure: function(response, opts) {
TOAST.toast('获取验证码失败', this.getView().el, 'bl' );
}
})
},

在onLoginButton方法中,主要修改的地方是在提交前,先获取Ext.Img组件的src属性的值,调用SimpleCMS.util.MD5方法将图片字符串转换为MD5字符,并作为Key值提交到服务器。由于提交地址为/api/TokenAuth/Authenticate,不是默认的WebApi提交地址,因而需要在调用get方法时添加第3个参数。在failure的回调中,只有出现错误,就刷新一次验证码,不能再使用旧的验证码,因为旧的验证码已经删除了。

onRefrestVcode方法中,主要修改的地方就是需要通过Ajax的方式来获取验证码,而不能直接使用修改访问地址的方式来刷新验证码。在获取到验证码后,将返回的字符串值作为图片的src值就行了。

由于登录失败都是以500错误返回的,因而需要修改SimpleCMS.util.Failed以处理这种情况,具体修改代码如下:

    form: function(form, action) {
if (action.failureType === 'validationErrors') return;
if (action.response.status === 500) {
var result = Ext.decode(action.response.responseText);
if (result.error && result.error.message) {
TOAST.toast(
result.error.message + (result.error.details ? result.error.details : ''),
form.owner.el,
'bl'
);
}
return;
}
FAILED.ajax(action.response);
}

代码先判断failureType是否为验证错误,如果是,说明已经处理过了,不用处理,直接返回。如果状态码为500,就判断结果是否包含errorerror.message两个数据,如果包含,说明有错误信息,就在窗口上使用Ext.window.Toast来输出信息。如果是其他情况,调用ajax方法来处理错误信息。

在最后,还需要打开application.js文件,在onAjaxBeforeRequest方法中,将options.jsonData = true;这句删除,不然表单提交的时候不会提交任何数据。

至此,验证码功能已经实现了。