您当前的位置: 首页 > 排行 >
用Abp实现两步验证(Two-Factor Authentication,2FA)登录(三):免登录验证-精彩看点
来源:博客园      时间:2023-04-12 21:24:18

@

目录原理修改请求报文配置JwtBearerOptions生成Token校验Token修改认证EndPoint修改前端登录登出最终效果项目地址

免登录验证是用户在首次两步验证通过后,在常用的设备(浏览器)中,在一定时间内不需要再次输入验证码直接登录。

常见的网页上提示“7天免登录验证”或“信任此设备,7天内无需两步验证”等内容。这样可以提高用户的体验。但同时也会带来一定的安全风险,因此需要用户自己决定是否开启。


【资料图】

原理

常用的实现方式是在用户登录成功后,生成一个随机的字符串Token,将此Token保存在用户浏览器的 cookie 中,同时将这个字符串保存在用户的数据库中。当用户再次访问时,如果 cookie 中的字符串和数据库中的字符串相同,则免登录验证通过。流程图如下:

为了安全,Token采用对称加密传输存储,同时参与校验的还有用户Id,以进一步验证数据一致性。Token存储于数据库中并设置过期时间(ExpireDate)认证机制由JSON Web Token(JWT)实现,通过自定义Payload声明中添加Token和用户Id字段,实现校验。

下面来看代码实现:

修改请求报文

项目添加对Microsoft.AspNetCore.Authentication.JwtBearer包的引用

在Authenticate方法参数AuthenticateModel中添加RememberClient和RememberClientToken属性,

当首次登录时,若用户选择免登录,RememberClient为true,非首次登录时,系统校验RememberClientToken合法性,是否允许跳过两步验证。

public class AuthenticateModel{  ..    public bool RememberClient { get; set; }    public string RememberClientToken { get; set; }}

同时返回值中添加RememberClientToken,用于首次登录生成的Token

public class AuthenticateResultModel{    ...    public string RememberClientToken { get; set; }}
配置JwtBearerOptions

在TokenAuthController的Authenticate方法中,添加validation参数:

var validationParameters = new TokenValidationParameters{    ValidAudience = _configuration.Audience,    ValidIssuer = _configuration.Issuer,    IssuerSigningKey = _configuration.SecurityKey};

在默认的AbpBoilerplate模板项目中已经为我们生成了默认配置

"Authentication": {    "JwtBearer": {      "IsEnabled": "true",      "SecurityKey": "MatoAppSample_C421AAEE0D114E9C",      "Issuer": "MatoAppSample",      "Audience": "MatoAppSample"    }  },
生成Token

在TokenAuthController类中

添加自定义Payload声明类型

public const string USER_IDENTIFIER_CLAIM = "USER_IDENTIFIER_CLAIM";public const string REMEMBER_CLIENT_TOKEN = "REMEMBER_CLIENT_TOKEN";

添加生成Token的方法CreateAccessToken,它将根据自定义Payload声明,validationParameters生成经过SHA256加密的Token,过期时间即有效期为7天:

private string CreateAccessToken(IEnumerable claims, TokenValidationParameters validationParameters){    var now = DateTime.UtcNow;    var expiration = TimeSpan.FromDays(7);    var signingCredentials = new SigningCredentials(validationParameters.IssuerSigningKey, SecurityAlgorithms.HmacSha256);    var jwtSecurityToken = new JwtSecurityToken(        issuer: validationParameters.ValidIssuer,        audience: validationParameters.ValidAudience,        claims: claims,        notBefore: now,        expires: now.Add(expiration),        signingCredentials: signingCredentials    );    return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);}

更改方法TwoFactorAuthenticateAsync的签名,添加rememberClient和validationParameters形参

在该方法中添加生成Token的代码

if (rememberClient){    if (await settingManager.GetSettingValueAsync(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))    {        var expiration = TimeSpan.FromDays(7);        var tokenValidityKey = Guid.NewGuid().ToString("N");        var accessToken = CreateAccessToken(new[]            {                new Claim(USER_IDENTIFIER_CLAIM, user.ToUserIdentifier().ToString()),                new Claim(REMEMBER_CLIENT_TOKEN, tokenValidityKey)            }, validationParameters        );        await _userManager.AddTokenValidityKeyAsync(user, tokenValidityKey,        DateTime.Now.Add(expiration));        return accessToken;    }}
校验Token

添加校验方法TwoFactorClientRememberedAsync,它表示校验结果是否允许跳过两步验证

public async Task TwoFactorClientRememberedAsync(UserIdentifier userIdentifier, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters){    if (!await settingManager.GetSettingValueAsync(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))    {        return false;    }    if (string.IsNullOrWhiteSpace(TwoFactorRememberClientToken))    {        return false;    }    try    {        var tokenHandler = new JwtSecurityTokenHandler();        if (tokenHandler.CanReadToken(TwoFactorRememberClientToken))        {            try            {                SecurityToken validatedToken;                var principal = tokenHandler.ValidateToken(TwoFactorRememberClientToken, validationParameters, out validatedToken);                var userIdentifierString = principal.Claims.First(c => c.Type == TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM);                if (userIdentifierString == null)                {                    throw new SecurityTokenException(TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM + " invalid");                }                var tokenValidityKeyInClaims = principal.Claims.First(c => c.Type == TwoFactorAuthorizationManager.REMEMBER_CLIENT_TOKEN);                var currentUserIdentifier = UserIdentifier.Parse(userIdentifierString.Value);                var user = _userManager.GetUserById(currentUserIdentifier.UserId);                var isValidityKetValid = AsyncHelper.RunSync(() => _userManager.IsTokenValidityKeyValidAsync(user, tokenValidityKeyInClaims.Value));                if (!isValidityKetValid)                {                    throw new SecurityTokenException(REMEMBER_CLIENT_TOKEN + " invalid");                }                return userIdentifierString.Value == userIdentifier.ToString();            }            catch (Exception ex)            {                LogHelper.LogException(ex);            }        }    }    catch (Exception ex)    {        LogHelper.LogException(ex);    }    return false;}

更改方法IsTwoFactorAuthRequiredAsync添加twoFactorRememberClientToken和validationParameters形参

添加对TwoFactorClientRememberedAsync的调用

public async Task IsTwoFactorAuthRequiredAsync(AbpLoginResult loginResult, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters){    if (!await settingManager.GetSettingValueAsync(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEnabled))    {        return false;    }    if (!loginResult.User.IsTwoFactorEnabled)    {        return false;    }    if ((await _userManager.GetValidTwoFactorProvidersAsync(loginResult.User)).Count <= 0)    {        return false;    }    if (await TwoFactorClientRememberedAsync(loginResult.User.ToUserIdentifier(), TwoFactorRememberClientToken, validationParameters))    {        return false;    }    return true;}
修改认证EndPoint

在TokenAuthController的Authenticate方法中,找到校验代码片段,对以上两个方法的调用传入实参

...await userManager.InitializeOptionsAsync(loginResult.Tenant?.Id);string twoFactorRememberClientToken = null;if (await twoFactorAuthorizationManager.IsTwoFactorAuthRequiredAsync(loginResult, model.RememberClientToken, validationParameters)){    if (string.IsNullOrEmpty(model.TwoFactorAuthenticationToken))    {        return new AuthenticateResultModel        {            RequiresTwoFactorAuthenticate = true,            UserId = loginResult.User.Id,            TwoFactorAuthenticationProviders = await userManager.GetValidTwoFactorProvidersAsync(loginResult.User),        };    }    else    {        twoFactorRememberClientToken = await twoFactorAuthorizationManager.TwoFactorAuthenticateAsync(loginResult.User, model.TwoFactorAuthenticationToken, model.TwoFactorAuthenticationProvider, model.RememberClient, validationParameters);    }}

完整的TwoFactorAuthorizationManager代码如下:

public class TwoFactorAuthorizationManager : ITransientDependency{    public const string USER_IDENTIFIER_CLAIM = "USER_IDENTIFIER_CLAIM";    public const string REMEMBER_CLIENT_TOKEN = "REMEMBER_CLIENT_TOKEN";    private readonly UserManager _userManager;    private readonly ISettingManager settingManager;    private readonly SmsCaptchaManager smsCaptchaManager;    private readonly EmailCaptchaManager emailCaptchaManager;    public TwoFactorAuthorizationManager(        UserManager userManager,        ISettingManager settingManager,        SmsCaptchaManager smsCaptchaManager,        EmailCaptchaManager emailCaptchaManager)    {        this._userManager = userManager;        this.settingManager = settingManager;        this.smsCaptchaManager = smsCaptchaManager;        this.emailCaptchaManager = emailCaptchaManager;    }    public async Task IsTwoFactorAuthRequiredAsync(AbpLoginResult loginResult, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)    {        if (!await settingManager.GetSettingValueAsync(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsEnabled))        {            return false;        }        if (!loginResult.User.IsTwoFactorEnabled)        {            return false;        }        if ((await _userManager.GetValidTwoFactorProvidersAsync(loginResult.User)).Count <= 0)        {            return false;        }        if (await TwoFactorClientRememberedAsync(loginResult.User.ToUserIdentifier(), TwoFactorRememberClientToken, validationParameters))        {            return false;        }        return true;    }    public async Task TwoFactorClientRememberedAsync(UserIdentifier userIdentifier, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)    {        if (!await settingManager.GetSettingValueAsync(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))        {            return false;        }        if (string.IsNullOrWhiteSpace(TwoFactorRememberClientToken))        {            return false;        }        try        {            var tokenHandler = new JwtSecurityTokenHandler();            if (tokenHandler.CanReadToken(TwoFactorRememberClientToken))            {                try                {                    SecurityToken validatedToken;                    var principal = tokenHandler.ValidateToken(TwoFactorRememberClientToken, validationParameters, out validatedToken);                    var userIdentifierString = principal.Claims.First(c => c.Type == TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM);                    if (userIdentifierString == null)                    {                        throw new SecurityTokenException(TwoFactorAuthorizationManager.USER_IDENTIFIER_CLAIM + " invalid");                    }                    var tokenValidityKeyInClaims = principal.Claims.First(c => c.Type == TwoFactorAuthorizationManager.REMEMBER_CLIENT_TOKEN);                    var currentUserIdentifier = UserIdentifier.Parse(userIdentifierString.Value);                    var user = _userManager.GetUserById(currentUserIdentifier.UserId);                    var isValidityKetValid = AsyncHelper.RunSync(() => _userManager.IsTokenValidityKeyValidAsync(user, tokenValidityKeyInClaims.Value));                    if (!isValidityKetValid)                    {                        throw new SecurityTokenException(REMEMBER_CLIENT_TOKEN + " invalid");                    }                    return userIdentifierString.Value == userIdentifier.ToString();                }                catch (Exception ex)                {                    LogHelper.LogException(ex);                }            }        }        catch (Exception ex)        {            LogHelper.LogException(ex);        }        return false;    }    public async Task TwoFactorAuthenticateAsync(User user, string token, string provider, bool rememberClient, TokenValidationParameters validationParameters)    {        if (provider == "Email")        {            var isValidate = await emailCaptchaManager.VerifyCaptchaAsync(token, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION);            if (!isValidate)            {                throw new UserFriendlyException("验证码错误");            }        }        else if (provider == "Phone")        {            var isValidate = await smsCaptchaManager.VerifyCaptchaAsync(token, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION);            if (!isValidate)            {                throw new UserFriendlyException("验证码错误");            }        }        else        {            throw new UserFriendlyException("验证码提供者错误");        }        if (rememberClient)        {            if (await settingManager.GetSettingValueAsync(AbpZeroSettingNames.UserManagement.TwoFactorLogin.IsRememberBrowserEnabled))            {                var expiration = TimeSpan.FromDays(7);                var tokenValidityKey = Guid.NewGuid().ToString("N");                var accessToken = CreateAccessToken(new[]                    {                        new Claim(USER_IDENTIFIER_CLAIM, user.ToUserIdentifier().ToString()),                        new Claim(REMEMBER_CLIENT_TOKEN, tokenValidityKey)                    }, validationParameters                );                await _userManager.AddTokenValidityKeyAsync(user, tokenValidityKey,                DateTime.Now.Add(expiration));                return accessToken;            }        }        return null;    }    private string CreateAccessToken(IEnumerable claims, TokenValidationParameters validationParameters)    {        var now = DateTime.UtcNow;        var expiration = TimeSpan.FromDays(7);        var signingCredentials = new SigningCredentials(validationParameters.IssuerSigningKey, SecurityAlgorithms.HmacSha256);        var jwtSecurityToken = new JwtSecurityToken(            issuer: validationParameters.ValidIssuer,            audience: validationParameters.ValidAudience,            claims: claims,            notBefore: now,            expires: now.Add(expiration),            signingCredentials: signingCredentials        );        return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);    }    public async Task SendCaptchaAsync(long userId, string provider)    {        var user = await _userManager.FindByIdAsync(userId.ToString());        if (user == null)        {            throw new UserFriendlyException("找不到用户");        }        if (provider == "Email")        {            if (!user.IsEmailConfirmed)            {                throw new UserFriendlyException("未绑定邮箱");            }            await emailCaptchaManager.SendCaptchaAsync(user.Id, user.EmailAddress, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION);        }        else if (provider == "Phone")        {            if (!user.IsPhoneNumberConfirmed)            {                throw new UserFriendlyException("未绑定手机号");            }            await smsCaptchaManager.SendCaptchaAsync(user.Id, user.PhoneNumber, CaptchaPurpose.TWO_FACTOR_AUTHORIZATION);        }        else        {            throw new UserFriendlyException("验证码提供者错误");        }    }}

至此我们就完成了后端部分的开发

修改前端登录

在两步验证的页面中添加一个checkbox,用于选择是否记住客户端

    7天内不再要求两步验证

JavaScript部分添加对rememberClientToken的处理,存储于cookie中,即便在网页刷新后也能保持免两步验证的状态

const rememberClientTokenKey = "main_rememberClientToken";const setRememberClientToken = (rememberClientToken: string) =>  Cookies.set(rememberClientTokenKey, rememberClientToken);const cleanRememberClientToken = () => Cookies.remove(rememberClientTokenKey);const getRememberClientToken = () => Cookies.get(rememberClientTokenKey);

在请求body中添加rememberClientToken, rememberClient的值

var rememberClientToken = getRememberClientToken();var rememberClient=this.loginForm.rememberClient;userNameOrEmailAddress = userNameOrEmailAddress.trim();await request(`${this.host}api/TokenAuth/Authenticate`, "post", {    userNameOrEmailAddress,    password,    twoFactorAuthenticationToken,    twoFactorAuthenticationProvider,    rememberClientToken,    rememberClient})

请求成功后,返回报文中包含rememberClientToken,将其存储于cookie中

setRememberClientToken(data.rememberClientToken);
登出

登出的逻辑不用做其他的修改,只需要将页面的两步验证的token清空即可,

this.loginForm.twoFactorAuthenticationToken = "";this.loginForm.password = "";

rememberClientToken是存储于cookie中的,当用户登出时不需要清空cookie中的rememberClientToken,以便下次登录跳过两步验证

除非在浏览器设置中清空cookie,下次登录时,rememberClientToken就会失效。

最终效果项目地址

Github:matoapp-samples

标签:

上一篇:

下一篇:

X 关闭

X 关闭