@
使用者找回密碼,確切地說是重置密碼,為了保證使用者賬號安全,原始密碼將不再以明文的方式找回,而是透過簡訊或者郵件的方式傳送一個隨機的重置校驗碼(帶校驗碼的頁面連線),使用者點選該連結,跳轉到重置密碼頁面,輸入新的密碼。這個重置校驗碼是一次性的,使用者重置密碼後立即失效。
使用者找回密碼是在使用者沒有登入時進行的,因此需要先校驗身份(除使用者名稱+密碼外的第二種身份驗證方式)。
第二種身份驗證的前提是繫結了手機號或者郵箱,如果沒有繫結,那麼只能透過管理員進行原始密碼重置。
密碼強制過期策略,是指使用者在一段時間內沒有修改密碼,在下次登入時系統阻止使用者登入,直到使用者修改了密碼後方可繼續登入。此策略提高使用者賬號的安全性。
找回密碼和密碼過期重置密碼,兩種機制有相近的業務邏輯,即密碼重置。今天我們來實現這個功能。
重置密碼
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 aliyun
model.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);
}
這兩個介面分別在使用者忘記密碼的兩個階段呼叫,
- 第一階段是傳送驗證碼,
- 第二階段是校驗驗證碼併傳送重置密碼的連結。
密碼強制過期策略
在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 Check
if (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-form
ref="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-input
v-model="input.newPassword"
placeholder="請輸入新密碼"
clearable
show-password
/>
</el-form-item>
<el-form-item label="新密碼(確認)" prop="newPassword2">
<el-input
v-model="input.newPassword2"
placeholder="請再次輸入新密碼"
clearable
show-password
/>
</el-form-item>
<el-row type="flex" class="row-bg">
<el-col :offset="6" :span="10">
<el-button
type="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-input
v-model="forgotPasswordProvider.providerNumber"
:placeholder="'請輸入手機號'"
tabindex="2"
>
<el-button
slot="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-alert
v-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-alert
v-if="passwordResetCode != null"
close-text="點此修改密碼"
title="密碼已超過90天未修改,為了安全,請修改密碼"
type="info"
@close="
gotoUrl(
'/reset-password-sample/reset.html?code=' +
passwordResetCode +
'&userId=' +
userId
)
"
>
</el-alert>
使用者點選點此修改密碼
按鈕時將跳轉至重置密碼頁面。