用Abp实现找回密码和密码强制过期策略

news/2024/11/29 5:42:27/

文章目录

    • 重置密码
    • 找回密码
      • 发送验证码
      • 校验验证码
      • 发送重置密码链接
      • 创建接口
    • 密码强制过期策略
      • 改写接口
    • Vue网页端开发
      • 重置密码页面
      • 忘记密码控件
      • 密码过期提示
    • 项目地址

用户找回密码,确切地说是 重置密码,为了保证用户账号安全,原始密码将不再以明文的方式找回,而是通过短信或者邮件的方式发送一个随机的重置校验码(带校验码的页面连接),用户点击该链接,跳转到重置密码页面,输入新的密码。这个重置校验码是一次性的,用户重置密码后立即失效。

用户找回密码是在用户没有登录时进行的,因此需要先校验身份(除用户名+密码外的第二种身份验证方式)。
第二种身份验证的前提是绑定了手机号或者邮箱,如果没有绑定,那么只能通过管理员进行原始密码重置。

密码强制过期策略,是指用户在一段时间内没有修改密码,在下次登录时系统阻止用户登录,直到用户修改了密码后方可继续登录。此策略提高用户账号的安全性。

在这里插入图片描述

找回密码和密码过期重置密码,两种机制有相近的业务逻辑,即密码重置。今天我们来实现这个功能。

重置密码

Abp框架中,AbpUserBase类中已经定义了重置校验码PasswordResetCode属性,以及SetNewPasswordResetCode方法,用于生成新的重置校验码。

[StringLength(328)]
public virtual string PasswordResetCode { get; set; }
public virtual void SetNewPasswordResetCode()
{PasswordResetCode = Guid.NewGuid().ToString("N").Truncate(328);
}

在UserAppService中添加ResetPasswordByCode,用于响应重置密码的请求。
在其参数ResetPasswordByLinkDto中携带了校验信息PasswordResetCode,因此添加了特性[AbpAllowAnonymous],不需要登录认证即可调用此接口

密码更新完成后,立刻将PasswordResetCode重置为null,以防止重复使用。

[AbpAllowAnonymous]
public async Task<bool> ResetPasswordByCode(ResetPasswordByLinkDto input)
{await _userManager.InitializeOptionsAsync(AbpSession.TenantId);var currentUser = await _userManager.GetUserByIdAsync(input.UserId);if (currentUser == null || currentUser.PasswordResetCode.IsNullOrEmpty() || currentUser.PasswordResetCode != input.ResetCode){throw new UserFriendlyException("PasswordResetCode不正确");}var loginAsync = await _logInManager.LoginAsync(currentUser.UserName, input.NewPassword, shouldLockout: false);if (loginAsync.Result == AbpLoginResultType.Success){throw new UserFriendlyException("重置的密码不应与之前密码相同");}if (currentUser.IsDeleted || !currentUser.IsActive){return false;}CheckErrors(await _userManager.ChangePasswordAsync(currentUser, input.NewPassword));currentUser.PasswordResetCode = null;currentUser.LastPasswordModificationTime = DateTime.Now;await this._userManager.UpdateAsync(currentUser);return true;
}

找回密码

发送验证码

使用AbpBoilerplate.Sms作为短信服务库。

之前的项目中,我们定义好了ICaptchaManager接口,已经实现了验证码的发送、验证码校验、解绑手机号、绑定手机号

这4个功能,通过定义用途(purpose)字段以校验区分短信模板

public interface ICaptchaManager
{Task BindAsync(string token);Task UnbindAsync(string token);Task SendCaptchaAsync(long userId, string phoneNumber, string purpose);Task<bool> VerifyCaptchaAsync(string token, string purpose = "IDENTITY_VERIFICATION");
}

添加一个用于重置密码的purpose,在CaptchaPurpose枚举类型中添加RESET_PASSWORD

public class CaptchaPurpose
{...public const string RESET_PASSWORD = "RESET_PASSWORD";}

在SMS服务商管理端后台申请一个短信模板,用于重置密码。

在这里插入图片描述

打开短信验证码的领域服务类SmsCaptchaManager, 添加RESET_PASSWORD对应短信模板的编号

public async Task SendCaptchaAsync(long userId, string phoneNumber, string purpose)
{var captcha = CommonHelper.GetRandomCaptchaNumber();var model = new SendSmsRequest();model.PhoneNumbers = new string[] { phoneNumber };model.SignName = "MatoApp";model.TemplateCode = purpose switch{...CaptchaPurpose.RESET_PASSWORD => "SMS_1587660"    //添加重置密码对应短信模板的编号};...
}

接下来我们创建ResetPasswordManager类,用于处理找回密码和密码过期重置密码的业务逻辑。
注入UserManager,ISmsService,SmsCaptchaManager,EmailCaptchaManager。

public class ResetPasswordManager : ITransientDependency
{private readonly UserManager userManager;private readonly ISmsService smsService;private readonly SmsCaptchaManager smsCaptchaManager;private readonly EmailCaptchaManager emailCaptchaManager;public ResetPasswordManager(UserManager userManager,ISmsService smsService,SmsCaptchaManager smsCaptchaManager,EmailCaptchaManager emailCaptchaManager){this.userManager = userManager;this.smsService = smsService;this.smsCaptchaManager = smsCaptchaManager;this.emailCaptchaManager = emailCaptchaManager;}

在ResetPasswordManager中添加SendForgotPasswordCaptchaAsync方法,用于短信或邮箱方式的身份验证。

public async Task SendForgotPasswordCaptchaAsync(string provider, string phoneNumberOrEmail)
{User user;if (provider == "Email"){user = await userManager.FindByEmailAsync(phoneNumberOrEmail);if (user == null){throw new UserFriendlyException("未找到绑定邮箱的用户");}await emailCaptchaManager.SendCaptchaAsync(user.Id, user.EmailAddress, CaptchaPurpose.RESET_PASSWORD);}else if (provider == "Phone"){user = await userManager.FindByNameOrPhoneNumberAsync(phoneNumberOrEmail);if (user == null){throw new UserFriendlyException("未找到绑定手机号的用户");}await smsCaptchaManager.SendCaptchaAsync(user.Id, user.PhoneNumber, CaptchaPurpose.RESET_PASSWORD);}}

校验验证码

添加VerifyAndSendResetPasswordLinkAsync方法,用于校验验证码,并发送重置密码的链接。

public async Task VerifyAndSendResetPasswordLinkAsync(string token, string provider)
{if (provider == "Email"){EmailCaptchaTokenCacheItem currentItem = await emailCaptchaManager.GetToken(token);if (currentItem == null || currentItem.Purpose != CaptchaPurpose.RESET_PASSWORD){throw new UserFriendlyException("验证码不正确或已过期");}var user = await userManager.GetUserByIdAsync(currentItem.UserId);var emailAddress = currentItem.EmailAddress;await SendEmailResetPasswordLink(user, emailAddress);await emailCaptchaManager.RemoveToken(token);}else if (provider == "Phone"){SmsCaptchaTokenCacheItem currentItem = await smsCaptchaManager.GetToken(token);if (currentItem == null || currentItem.Purpose != CaptchaPurpose.RESET_PASSWORD){throw new UserFriendlyException("验证码不正确或已过期");}var user = await userManager.GetUserByIdAsync(currentItem.UserId);var phoneNumber = currentItem.PhoneNumber;await SendSmsResetPasswordLink(user, phoneNumber);await smsCaptchaManager.RemoveToken(token);}else{throw new UserFriendlyException("验证码提供者错误");}}

发送重置密码链接

创建SendSmsResetPasswordLink,用于对当前用户产生一个NewPasswordResetCode,并发送重置密码的短信链接。

private async Task SendSmsResetPasswordLink(User user, string phoneNumber)
{var model = new SendSmsRequest();user.SetNewPasswordResetCode();var passwordResetCode = user.PasswordResetCode;model.PhoneNumbers = new string[] { phoneNumber };model.SignName = "MatoApp";model.TemplateCode = "SMS_255330989";//for aliyunmodel.TemplateParam = JsonConvert.SerializeObject(new { username = user.UserName, code = passwordResetCode });//for tencent-cloud//model.TemplateParam = JsonConvert.SerializeObject(new string[] { user.UserName, passwordResetCode });var result = await smsService.SendSmsAsync(model);if (string.IsNullOrEmpty(result.BizId) && result.Code != "OK"){throw new UserFriendlyException("验证码发送失败,错误信息:" + result.Message);}
}

创建接口

在UserAppService暴露出SendForgotPasswordCaptcha和VerifyAndSendResetPasswordLink两个接口,

注意这两个接口都需要添加[AbpAllowAnonymous]特性,因为在用户未登录的情况下,也需要使用这两个接口。

[AbpAllowAnonymous]
public async Task SendForgotPasswordCaptcha(ForgotPasswordProviderDto input)
{var provider = input.Provider;var phoneNumberOrEmail = input.ProviderNumber;await forgotPasswordManager.SendForgotPasswordCaptchaAsync(provider, phoneNumberOrEmail);}[AbpAllowAnonymous]
public async Task VerifyAndSendResetPasswordLink(SendResetPasswordLinkDto input)
{var provider = input.Provider;var token = input.Token;await forgotPasswordManager.VerifyAndSendResetPasswordLinkAsync(token, provider);}

这两个接口分别在用户忘记密码的两个阶段调用,

  1. 第一阶段是发送验证码,
  2. 第二阶段是校验验证码并发送重置密码的链接。

在这里插入图片描述

密码强制过期策略

在User实体中添加一个属性,用于记录密码最后修改时间,在登录时验证这个时间至此时的时间跨度,如果超过一定时间(例如90天),强制用户重置密码。

[Required]
public DateTime LastPasswordModificationTime { get; set; }

改写接口

将重置校验码PasswordResetCode添加到AuthenticateResultModel中

public string PasswordResetCode { get; set; }

打开TokenAuthController,注入ResetPasswordManager服务对象

登录验证终节点方法Authenticate中,添加对密码强制过期的逻辑代码

[HttpPost]
public async Task<AuthenticateResultModel> Authenticate([FromBody] AuthenticateModel model)
{var loginResult = await GetLoginResultAsync(model.UserNameOrEmailAddress,model.Password,GetTenancyNameOrNull());...//Password Expiration Checkif (DateTime.Now - loginResult.User.LastPasswordModificationTime > TimeSpan.FromDays(90)){loginResult.User.SetNewPasswordResetCode();return new AuthenticateResultModel{PasswordResetCode = loginResult.User.PasswordResetCode,UserId = loginResult.User.Id,};}}

当登录账号的LastPasswordModificationTime距此时大于90天时,将阻止登录,并提示账户密码已过期,需要修改密码
在这里插入图片描述
在这里插入图片描述

Vue网页端开发

重置密码页面

创建Web端的重置密码页面,用于用户重置密码。
在这里插入图片描述

当用户通过短信或邮箱接收到重置密码的链接后,点击链接,会跳转到重置密码的页面,用户输入新密码后,点击提交,就可以完成密码重置。

连接格式如下

http://localhost:8080/reset-password-sample/reset.html?code=f16b5fbb057d4a04bce5b9e7f24e1d56&userId=1

项目参与实际生产中请加密参数,在此为了简单起见采用明文传递。

<template><div id="app"><div class="title-container center"><h3 class="title">修改密码</h3></div><el-row><el-formref="loginForm":model="input"class="login-form"autocomplete="on"label-position="left"><el-form-item label="验证码"><el-input v-model="input.code" placeholder="请输入验证码" clearable /></el-form-item><el-form-item label="新密码" prop="newPassword"><el-inputv-model="input.newPassword"placeholder="请输入新密码"clearableshow-password/></el-form-item><el-form-item label="新密码(确认)" prop="newPassword2"><el-inputv-model="input.newPassword2"placeholder="请再次输入新密码"clearableshow-password/></el-form-item><el-row type="flex" class="row-bg"><el-col :offset="6" :span="10"><el-buttontype="primary"style="width: 100%"@click.native.prevent="submit">修改</el-button></el-col></el-row></el-form></el-row></div>
</template>

创建页面时会根据url中的参数,获取code和userId。

created: async function () {var url = window.location.href;var reg = /[?&]([^?&#]+)=([^?&#]+)/g;var param = {};var ret = reg.exec(url);while (ret) {param[ret[1]] = ret[2];ret = reg.exec(url);}if ("code" in param) {this.input.code = param["code"];}if ("userId" in param) {this.input.userId = param["userId"];}},

点击修改时会触发submit方法,这个方法会调用ResetPasswordByCode接口,将UserId,newPassword以及resetCode回传。

 async submit() {if ((this.input.newPassword != this.input.newPassword2) == null) {this.$message.warning("两次输入的密码不一致!");return;}await request(`${this.host}${this.prefix}/User/ResetPasswordByCode`,"post",{userId: this.input.userId,newPassword: this.input.newPassword,resetCode: this.input.code,}).catch((re) => {var res = re.response.data;this.errorMessage(res.error.message);}).then(async (res) => {var data = res.data.result;this.successMessage("密码修改成功!");window.location.href = "/reset-password-sample.html";}).finally(() => {setTimeout(() => {this.loading = false;}, 1.5 * 1000);});},

忘记密码控件

在登录页面中,添加忘记密码的控件。

在这里插入图片描述

resetPasswordStage 是判定当前是哪个阶段的变量,
0表示正常用户名密码登录(初始状态),1表示输入手机号或邮箱验证身份,2表示通过验证即将发送重置密码的链接。

默认两种方式,一种是短信验证码,一种是邮箱验证码,这里我们采用了elementUI的tab组件,来实现两种方式的切换。

<template v-else-if="resetPasswordStage == 1"><p>请输入与要找回的账户关联的手机号或邮箱。我们将为你发送密码重置连接</p><el-tabs tab-position="top" v-model="forgotPasswordProvider.provider"><el-tab-pane :lazy="true" label="通过手机号找回" name="Phone"><el-row><el-col :span="24"><el-inputv-model="forgotPasswordProvider.providerNumber":placeholder="'请输入手机号'"tabindex="2"><el-buttonslot="append"@click="sendResetPasswordLink":disabled="forgotPasswordProvider.providerNumber == ''">下一步</el-button></el-input></el-col></el-row></el-tab-pane><el-tab-pane :lazy="true" label="通过邮箱找回" name="Email"><el-row><el-col :span="24"><el-alertv-if="showResetRequireSuccess"title="密码重置连接已发送至登录用户对应的邮箱,请查收"type="info"></el-alert></el-col><el-col :span="24"><p>建设中..</p></el-col></el-row></el-tab-pane></el-tabs>
</template>

不通的阶段,将分别调用不同的接口,sendResetPasswordLink以及verifyAndSendResetPasswordLink。

调用verifyAndSendResetPasswordLink接口完毕时,resetPasswordStage将设置位初始状态,即0。

async sendResetPasswordLink() {await request(`${this.host}${this.prefix}/User/SendForgotPasswordCaptcha`,"post",this.forgotPasswordProvider).catch((re) => {var res = re.response.data;this.errorMessage(res.error.message);}).then(async (re) => {if (re) {this.successMessage("发送验证码成功");this.resetPasswordStage++;}});
},
async verifyAndSendResetPasswordLink() {await request(`${this.host}${this.prefix}/User/VerifyAndSendResetPasswordLink`,"post",{provider: this.forgotPasswordProvider.provider,token: this.captchaToken,}).catch((re) => {var res = re.response.data;this.errorMessage(res.error.message);}).then(async (re) => {if (re) {this.successMessage("发送连接成功");this.resetPasswordStage = 0;}});
},

密码过期提示

主页面中添加对passwordResetCode的响应,当passwordResetCode不为空时,显示一个提示框,提示用户密码已超过90天未修改,请修改密码。

<el-alertv-if="passwordResetCode != null"close-text="点此修改密码"title="密码已超过90天未修改,为了安全,请修改密码"type="info"@close="gotoUrl('/reset-password-sample/reset.html?code=' +passwordResetCode +'&userId=' +userId)">
</el-alert>

在这里插入图片描述
用户点击点此修改密码按钮时将跳转至重置密码页面。

项目地址

Github:matoapp-samples


http://www.ppmy.cn/news/46259.html

相关文章

【通过Cpython3.9源码看看列表到底是咋回事】

列表结构 typedef struct {PyObject_VAR_HEAD/* Vector of pointers to list elements. list[0] is ob_item[0], etc. */PyObject **ob_item;/* ob_item contains space for allocated elements. The number* currently in use is ob_size.* Invariants:* 0 < ob_siz…

ObjectARX中的坐标系以及坐标转换

1 AutoCAD中的坐标系种类 WCS World Coordinate System. The “reference” coordinate system. All other coordinate systems are defined relative to the WCS, which never changes. Values measured relative to the WCS are stable across changes to other coordinate s…

MySQL数据库——MySQL数据类型的选择

MySQL 提供了大量的数据类型&#xff0c;为了优化存储和提高数据库性能&#xff0c;在任何情况下都应该使用最精确的数据类型。 前面主要对 MySQL 中的数据类型及其基本特性进行了描述&#xff0c;包括它们能够存放的值的类型和占用空间等。本节主要讨论创建数据库表时如何选择…

Hive常用命令

1.hive模糊搜索表 show tables like *name*; ANALYZE TABLE tablename [PARTITION(partcol1[val1], partcol2[val2], ...)] COMPUTE STATISTICS [noscan]; 2.查看表结构信息 desc formatted table_name;desc table_name; 3.查看分区信息 show partitions table_name; 4.根…

重要公告 | 首批Moonbeam Accelerator孵化项目官宣

通过Moonbeam Accelerator计划&#xff0c;Arrington Capital和Borderless Capital将Moonbeam作为跨链创新的首选目的地 跨链互连应用的最佳去中心化开发平台Moonbeam很高兴宣布&#xff1a;在Rokk3r的运营之下&#xff0c;入选Moonbeam Accelerator计划的Web3初创公司首次亮相…

ArcGIS制作建设项目使用林地现状图

近年来&#xff0c;随着经济社会的快速发展&#xff0c;各地建设项目不断增多&#xff0c;占用征用林地项目的数量也呈逐年上升的趋势。根据《占用征用林地审核审批管理规范》规定&#xff0c;建设项目申请占用征用林地&#xff0c;应编制的项目使用林地可行性报告&#xff1b;…

【linux】 安装 java 环境

目录 1.检查linux 下是否安装java(jdk)环境2.查看 linux 的操作系统版本3.下载jdk4.新建java文件夹用于安装jdk5.将下载到本地的jdk压缩包上传到linux服务器6.配置环境变量 1.检查linux 下是否安装java(jdk)环境 可通过下面五条命令来查看linux 系统是否安装了java 环境 1、jav…

【云原生概念和技术】1.2 云原生技术概括(中)

如果想了解或者学习云原生的友友们&#xff0c;欢迎订阅哦&#xff5e;&#x1f917;&#xff0c;目前一周三更&#xff0c;努力码字中&#x1f9d1;‍&#x1f4bb;…目前第一章是一些介绍和概念性的知识&#xff0c;可以先在脑海里有一个知识的轮廓&#xff0c;从第二章开始就…