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

  1. 尝试新的开发组合:Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS
  2. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之配置IdentityServer
  3. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之数据迁移
  4. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之添加实体
  5. Asp.NET Core+ABP框架+IdentityServer4+MySQL+Ext JS之显示登录视图
  6. 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;这句删除,不然表单提交的时候不会提交任何数据。

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

相关文章
相关标签/搜索