验证码这东西,有人喜欢有人不喜欢。对于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-Migration 和Update-Database 命令在数据库添加实体对应的表了。
有了实体后,就要添加服务,以便将图片返回客户端。一般情况下,通过继承CrudAppService 或AsyncCrudAppService 类就可以很简单的实现一个实体的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();
在Get 方法内,先调用VerifyCodeCore 的CreateVerifyCode 方法创建验证码,再调用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 类是登录时用来接收登录数据的模型类。在该类中,添加了VerifyCode 和Key 两个属性用来接收验证码和与验证码相关的搜索值,并添加了自定义验证的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)
{
代码主要添加了一个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({
在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,就判断结果是否包含error 和error.message 两个数据,如果包含,说明有错误信息,就在窗口上使用Ext.window.Toast 来输出信息。如果是其他情况,调用ajax 方法来处理错误信息。
在最后,还需要打开application.js文件,在onAjaxBeforeRequest 方法中,将options.jsonData = true; 这句删除,不然表单提交的时候不会提交任何数据。
至此,验证码功能已经实现了。
|
请发表评论