用Abp實現找回密碼和密碼強制過期策略

2023-04-14 15:00:33

@


使用者找回密碼,確切地說是重置密碼,為了保證使用者賬號安全,原始密碼將不再以明文的方式找回,而是通過簡訊或者郵件的方式傳送一個隨機的重置校驗碼(帶校驗碼的頁面連線),使用者點選該連結,跳轉到重置密碼頁面,輸入新的密碼。這個重置校驗碼是一次性的,使用者重置密碼後立即失效。

使用者找回密碼是在使用者沒有登入時進行的,因此需要先校驗身份(除使用者名稱+密碼外的第二種身份驗證方式)。
第二種身份驗證的前提是繫結了手機號或者郵箱,如果沒有繫結,那麼只能通過管理員進行原始密碼重置。

密碼強制過期策略,是指使用者在一段時間內沒有修改密碼,在下次登入時系統阻止使用者登入,直到使用者修改了密碼後方可繼續登入。此策略提高使用者賬號的安全性。

找回密碼和密碼過期重置密碼,兩種機制有相近的業務邏輯,即密碼重置。今天我們來實現這個功能。

重置密碼

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);

}

這兩個介面分別在使用者忘記密碼的兩個階段呼叫,

  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 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>


使用者點選點此修改密碼按鈕時將跳轉至重置密碼頁面。

專案地址

Github:matoapp-samples