@
免登入驗證是使用者在首次兩步驗證透過後,在常用的裝置(瀏覽器)中,在一定時間內不需要再次輸入驗證碼直接登入。
常見的網頁上提示“7天免登入驗證”或“信任此裝置,7天內無需兩步驗證”等內容。
這樣可以提高使用者的體驗。但同時也會帶來一定的安全風險,因此需要使用者自己決定是否開啟。
原理
常用的實現方式是在使用者登入成功後,生成一個隨機的字串Token,將此Token儲存在使用者瀏覽器的 cookie 中,同時將這個字串儲存在使用者的資料庫中。當使用者再次訪問時,如果 cookie 中的字串和資料庫中的字串相同,則免登入驗證透過。流程圖如下:
為了安全,Token採用對稱加密傳輸儲存,同時參與校驗的還有使用者Id,以進一步驗證資料一致性。Token儲存於資料庫中並設定過期時間(ExpireDate)
認證機制由JSON Web Token(JWT)實現,透過自定義Payload宣告中新增Token和使用者Id欄位,實現校驗。
下面來看程式碼實現:
修改請求報文
專案新增對Microsoft.AspNetCore.Authentication.JwtBearer
包的引用
<packagereference include="Microsoft.AspNetCore.Authentication.JwtBearer" version="7.0.4">
在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<claim> 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<bool>(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<bool> TwoFactorClientRememberedAsync(UserIdentifier userIdentifier, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
{
if (!await settingManager.GetSettingValueAsync<bool>(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<bool> IsTwoFactorAuthRequiredAsync(AbpLoginResult<tenant, user=""> loginResult, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
{
if (!await settingManager.GetSettingValueAsync<bool>(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<bool> IsTwoFactorAuthRequiredAsync(AbpLoginResult<tenant, user=""> loginResult, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
{
if (!await settingManager.GetSettingValueAsync<bool>(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<bool> TwoFactorClientRememberedAsync(UserIdentifier userIdentifier, string TwoFactorRememberClientToken, TokenValidationParameters validationParameters)
{
if (!await settingManager.GetSettingValueAsync<bool>(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<string> 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<bool>(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<claim> 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,用於選擇是否記住客戶端
<el-checkbox v-model="loginForm.rememberClient">
7天內不再要求兩步驗證
</el-checkbox>
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</tenant,></tenant,>