手機驗證碼登錄是目前非常主流的登錄方式,畢竟,誰記得住那么多賬號密碼呢?
相比傳統的賬號密碼登錄頁面,手機驗證碼登錄頁面前后台交互比較多,有一個動態提示倒計時的功能。用Blazor Server實現一個手機驗證碼登錄頁面不難,但是如果想要兼容Identity Server 4登錄,還是有點麻煩的。
我曾經嘗試過創建一個Blazor Server項目,集成Identity Server 4類庫,結果發現無法實現登錄跳轉功能,所以還是要基於MVC項目去實現Identity Server 4服務器。但是我不想寫JavaScript代碼,我的子系統都是Blazor Server項目,我可以寫一個登錄頁面組件類庫,讓所有Blazor Server子系統項目去引用即可。這個方案不用寫JavaScript代碼,但是要把GrantTypes.Code認證方式改為自定義驗證登錄。
我的系統解決方案中還有PC軟件和移動APP客戶端,他們也要用手機驗證碼方式訪問Identity Server 4服務器登錄,因此,編寫一個手機驗證碼自定義登錄模塊還是非常有必要的。
准備種子數據
繼續沿用之前的AspNetId4Web項目。
Blazor Server訪問Identity Server 4單點登錄2-集成Asp.Net角色
給種子用戶增加手機號,為了簡單起見,直接刪除AspIdUsers.db,修改Main函數,每次運行都執行SeedData.EnsureSeedData,context.Database.Migrate判斷到沒有AspIdUsers.db就會自動創建數據庫。
//if (seed) { Log.Information("Seeding database..."); var config = host.Services.GetRequiredService<IConfiguration>(); var connectionString = config.GetConnectionString("DefaultConnection"); SeedData.EnsureSeedData(connectionString); Log.Information("Done seeding database."); //return 0; }
給種子數據加手機號
alice = new ApplicationUser { UserName = "alice", Email = "AliceSmith@email.com", EmailConfirmed = true, PhoneNumber = "13512345001", };
增加自定義手機驗證碼認證處理器
自定義手機驗證碼認證處理流程很簡單,就是根據手機號,去MemoryCache查找驗證碼。MemoryCache自帶過期管理,驗證碼有效期可以設置為10分鍾。
/// <summary> /// 自定義手機驗證碼認證處理器 /// </summary> public class PhoneCodeGrantValidator : IExtensionGrantValidator { /// <summary> /// 認證方式 /// </summary> public string GrantType => "PhoneCodeGrantType"; private readonly IMemoryCache _memoryCache; private readonly ApplicationDbContext _context; private readonly ILogger _logger; public PhoneCodeGrantValidator( IMemoryCache memoryCache, ApplicationDbContext context, ILogger<PhoneCodeGrantValidator> logger) { _memoryCache = memoryCache; _context = context; _logger = logger; } /// <summary> /// 驗證自定義授權請求 /// </summary> /// <param name="context"></param> /// <returns></returns> public async Task ValidateAsync(ExtensionGrantValidationContext context) { try { //獲取登錄參數 string phoneNumber = context.Request.Raw["PhoneNumber"]; string verificationCode = context.Request.Raw["VerificationCode"]; //獲取手機號對應的緩存驗證碼 if (!_memoryCache.TryGetValue(phoneNumber, out string cacheVerificationCode)) { //如果獲取不到緩存驗證碼,說明手機號不存在,或者驗證碼過期,但是發送驗證碼時已經驗證過手機號是存在的,所以只能是驗證碼過期 context.Result = new GrantValidationResult() { IsError = true, Error = "驗證碼過期", }; return; } if (verificationCode != cacheVerificationCode) { context.Result = new GrantValidationResult() { IsError = true, Error = "驗證碼錯誤", }; return; } //根據手機號獲取用戶信息 var appUser = await GetUserByPhoneNumberAsync(phoneNumber); if (appUser == null) { context.Result = new GrantValidationResult() { IsError = true, Error = "手機號無效", }; return; } //授權通過返回 context.Result = new GrantValidationResult(appUser.Id.ToString(), "custom"); } catch (Exception ex) { context.Result = new GrantValidationResult() { IsError = true, Error = ex.Message }; } } //根據手機號獲取用戶信息 private async Task<ApplicationUser> GetUserByPhoneNumberAsync(string phoneNumber) { var appUser = await _context.Users.AsNoTracking() .FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber); return appUser; } }
startup增加注冊PhoneCodeGrantValidator。
var builder = services.AddIdentityServer(options => { options.Events.RaiseErrorEvents = true; options.Events.RaiseInformationEvents = true; options.Events.RaiseFailureEvents = true; options.Events.RaiseSuccessEvents = true; // see https://identityserver4.readthedocs.io/en/latest/topics/resources.html options.EmitStaticAudienceClaim = true; }) .AddInMemoryIdentityResources(Config.IdentityResources) .AddInMemoryApiScopes(Config.ApiScopes) .AddInMemoryClients(Config.Clients) .AddExtensionGrantValidator<PhoneCodeGrantValidator>() //.AddInMemoryApiResources(Config.ApiResources) .AddAspNetIdentity<ApplicationUser>();
config增加一個客戶端配置,允許的認證方式就是自定義的手機驗證碼方式。
new Client() { ClientId="PhoneCode", ClientName = "PhoneCode", ClientSecrets=new []{new Secret("PhoneCode.Secret".Sha256())}, AllowedGrantTypes = new string[]{ "PhoneCodeGrantType" }, //效果等同客戶端項目配置options.GetClaimsFromUserInfoEndpoint = true //AlwaysIncludeUserClaimsInIdToken = true, AllowedScopes = { "openid", "profile", "scope1", "role", } },
增加獲取手機驗證碼的Web Api
客戶端需要首先訪問Web Api發送手機驗證碼短信,然后再通過自定義手機驗證碼方式登錄,這里寫一個模擬發送驗證碼短信的功能,創建驗證碼之后丟到MemoryCache里緩存10分鍾。
/// <summary> /// 發送驗證碼到手機號 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> [ProducesResponseType(StatusCodes.Status200OK)] [AllowAnonymous] [HttpGet("SendPhoneCode")] public async Task<string> SendPhoneCode(string phoneNumber) { //根據手機號獲取用戶信息 var appUser = await GetUserByPhoneNumberAsync(phoneNumber); if (appUser == null) { return "手機號無效"; } //發送驗證碼到手機號,需要調用短信服務平台Web Api,這里模擬發送 string verificationCode = (new Random()).Next(1000, 9999).ToString(); //驗證碼緩存10分鍾 _memoryCache.Set(phoneNumber, verificationCode, TimeSpan.FromMinutes(10)); _logger.LogInformation($"發送驗證碼{verificationCode}到手機號{phoneNumber}, 有效期{DateTime.Now.AddMinutes(10)}"); return "發送驗證碼成功"; } //根據手機號獲取用戶信息 private async Task<ApplicationUser> GetUserByPhoneNumberAsync(string phoneNumber) { var appUser = await _context.Users.AsNoTracking() .FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber); return appUser; }
給AspNetId4Web項目增加一個http端口,便於調試,修改launchSettings.json
"applicationUrl": "https://localhost:5001;http://localhost:5000"
F5調試運行AspNetId4Web,打開瀏覽器,測試一下獲取手機驗證碼Web Api
http://localhost:5000/api/PhoneCodeLogin/SendPhoneCode?phoneNumber=13512345001
可以看到AspNetId4Web控制台打印了發送的驗證碼,確認功能正確。
[10:44:24 Information] AspNetId4Web.Controllers.PhoneCodeLoginController
發送驗證碼6745到手機號13512345001, 有效期2021/9/21 10:54:24
至此,Identity Server 4支持手機驗證碼的功能添加完畢。
增加手機驗證碼登錄Blazor Server客戶端
參考之前的BlzWeb1新建Blazor Server網站項目PhoneCodeLoginBlzWeb,在Index主頁提供登錄按鈕,顯示登錄用戶信息。
增加手機驗證碼登錄功能模塊Ids4Client。Identity Server 4提供了IdentityModel類庫,封裝了通過HttpClient訪問Identity Server 4服務器的代碼,可以簡化客戶端代碼。
/// <summary> /// 手機驗證碼登錄功能模塊 /// </summary> public class Ids4Client { private readonly HttpClient _client; public Ids4Client(HttpClient httpClient) { _client = httpClient; } /// <summary> /// 發送驗證碼到手機號 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> public async Task<string> SendPhoneCodeAsync(string phoneNumber) { string url = $"api/PhoneCodeLogin/SendPhoneCode?phoneNumber={phoneNumber}"; string result = await _client.GetStringAsync(url); return result; } /// <summary> /// 手機驗證碼登錄 /// </summary> /// <param name="phoneNumber">手機號</param> /// <param name="verificationCode">驗證碼</param> /// <returns></returns> public async Task<(TokenResponse tokenResponse, string userInfoJson)> PhoneCodeLogin(string phoneNumber, string verificationCode) { var request = new DiscoveryDocumentRequest() { Policy = new DiscoveryPolicy() { //本地調試抓包 RequireHttps = false } }; //發現端點 var discovery = await _client.GetDiscoveryDocumentAsync(request); if (discovery.IsError) { Console.WriteLine($"訪問Identity Server 4服務器失敗, Error={discovery.Error}"); return (null, null); } //填寫登錄參數,必須跟Identity Server 4服務器Config.cs定義一致 var requestParams = new Dictionary<string, string> { ["client_Id"] = "PhoneCode", ["client_secret"] = "PhoneCode.Secret", ["grant_type"] = "PhoneCodeGrantType", ["scope"] = "openid profile scope1 role", ["PhoneNumber"] = phoneNumber, ["VerificationCode"] = verificationCode }; //請求獲取token var tokenResponse = await _client.RequestTokenRawAsync(discovery.TokenEndpoint, requestParams); if (tokenResponse.IsError) { Console.WriteLine($"請求獲取token失敗, Error={tokenResponse.Error}"); return (null, null); } string userInfoJson = ""; //設置Http認證頭 _client.SetBearerToken(tokenResponse.AccessToken); //獲取用戶信息 var userInfoResponse = await _client.GetAsync(discovery.UserInfoEndpoint); if (!userInfoResponse.IsSuccessStatusCode) { //scope必須包含profile才能獲取到用戶信息 //如果客戶端請求scope沒有profile,返回403拒絕訪問 Console.WriteLine($"獲取用戶信息失敗, StatusCode={userInfoResponse.StatusCode}"); } else { // {"sub":"d2f64bb2-789a-4546-9107-547fcb9cdfce","name":"Alice Smith","given_name":"Alice","family_name":"Smith","website":"http://alice.com","role":["Admin","Guest"],"preferred_username":"alice"} userInfoJson = await userInfoResponse.Content.ReadAsStringAsync(); Console.WriteLine($"獲取用戶信息成功, {userInfoJson}"); } return (tokenResponse, userInfoJson); } }
在startup注冊它
//訪問Identity Server 4服務器的HttpClient services.AddHttpClient<Ids4Client>() .ConfigureHttpClient(c => c.BaseAddress = new Uri("http://localhost:5000"));
Startup注冊認證方案只有cookies
//默認采用cookie認證方案 services.AddAuthentication("cookies") //配置cookie認證 .AddCookie("cookies");
添加一個PhoneCodeLogin.razor手機驗證碼登錄頁面,拿到token和用戶信息json之后,需要跳轉到MVC控制器去登錄SignIn,如果直接登錄razor頁面SignIn,會報錯Headers are read-only, response has already started,很煩。
@page "/phonecodelogin" @using PhoneCodeLoginBlzWeb.Data <div class="card" style="width:500px"> <div class="card-header"> <h5> 手機驗證碼登錄 </h5> </div> <div class="card-body"> <div class="form-group form-inline"> <label for="PhoneNumber" class="control-label">手機號</label> <input id="PhoneNumber" @bind="PhoneNumber" class="form-control" placeholder="請輸入手機號" /> </div> <div class="form-group form-inline"> <label for="VerificationCode" class="control-label">驗證碼</label> <input id="VerificationCode" @bind="VerificationCode" class="form-control" placeholder="請輸入驗證碼" /> @if (CanGetVerificationCode) { <button type="button" class="btn btn-link" @onclick="GetVerificationCode"> 獲取驗證碼 </button> } else { <label>@GetVerificationCodeMsg</label> } </div> </div> <div class="card-footer"> <button type="button" class="btn btn-primary" @onclick="Login"> 登錄 </button> </div> </div> @code { [Inject] private Ids4Client ids4Client { get; set; } [Inject] private IJSRuntime jsRuntime { get; set; } private string PhoneNumber; private string VerificationCode; //獲取驗證碼按鈕當前狀態 private bool CanGetVerificationCode = true; private string GetVerificationCodeMsg; //獲取驗證碼 private async void GetVerificationCode() { if (CanGetVerificationCode) { //發送驗證碼到手機號 string result = await ids4Client.SendPhoneCodeAsync(PhoneNumber); if (result != "發送驗證碼成功") return; CanGetVerificationCode = false; //1分鍾倒計時 for (int i = 60; i >= 0; i--) { GetVerificationCodeMsg = $"獲取驗證碼({i})"; await Task.Delay(1000); //通知頁面更新 StateHasChanged(); } CanGetVerificationCode = true; //通知頁面更新 StateHasChanged(); } } //登錄 private async void Login() { //登錄 var (tokenResponse, userInfoJson) = await ids4Client.PhoneCodeLogin(PhoneNumber, VerificationCode); string tokenResponseJson = Newtonsoft.Json.JsonConvert.SerializeObject(tokenResponse); string uri = $"Account/Login?tokenResponseJson={Uri.EscapeDataString(tokenResponseJson)}&userInfoJson={Uri.EscapeDataString(userInfoJson)}"; await jsRuntime.InvokeVoidAsync("window.location.assign", uri); } }
添加AccountController控制器,注意用戶信息Json的用戶名和角色名是需要轉換的,必須使用Asp.Net內置的角色名http://schemas.microsoft.com/ws/2008/06/identity/claims/role,不能使用Identity Server 4簡化的角色名role,否則razor頁面的授權不對。
public class AccountController : Controller { private readonly ILogger _logger; public AccountController(ILogger<AccountController> logger) { _logger = logger; } /// <summary> /// 解析token創建用戶clamis,SignIn到當前會話實現登錄 /// </summary> /// <param name="tokenResponseJson">Identity Server 4服務器返回包含token的響應</param> /// <param name="userInfoJson">用戶信息Json</param> /// <param name="returnUrl">登錄成功后,返回之前的網頁路由</param> /// <returns></returns> [HttpGet] public async Task<IActionResult> Login(string tokenResponseJson, string userInfoJson, string returnUrl = "") { if (string.IsNullOrEmpty(returnUrl)) returnUrl = "/"; //TokenResponse屬性都是只讀的,無法反序列化 //TokenResponse tokenResponse = System.Text.Json.JsonSerializer.Deserialize<TokenResponse>(tokenResponseJson); dynamic tokenResponse = Newtonsoft.Json.JsonConvert.DeserializeObject(tokenResponseJson); var jwtSecurityToken = new JwtSecurityToken($"{tokenResponse.AccessToken}"); var claims = jwtSecurityToken.Claims.ToList(); dynamic userInfo = Newtonsoft.Json.JsonConvert.DeserializeObject(userInfoJson); //提取name //claims.Add(new Claim(JwtClaimTypes.Name, $"{userInfo.name}")); claims.Add(new Claim(ClaimTypes.Name, $"{userInfo.name}")); //提取角色 //id4返回的角色是字符串數組或者字符串,blazor server的角色是字符串,需要轉換,不然無法獲取到角色 var roleElement = userInfo.role; if (roleElement is Newtonsoft.Json.Linq.JArray roleAry) { var roles = roleAry.Select(e => e.ToString()); //claims.AddRange(roles.Select(r => new Claim(JwtClaimTypes.Role, r))); claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); } else { //claims.Add(new Claim(JwtClaimTypes.Role, roleElement.ToString())); claims.Add(new Claim(ClaimTypes.Role, roleElement.ToString())); } var claimsIdentity = new ClaimsIdentity(claims, "Cookies"); var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); var properties = new AuthenticationProperties { //記住登錄狀態 IsPersistent = true, RedirectUri = returnUrl }; await HttpContext.SignInAsync("Cookies", claimsPrincipal, properties); _logger.LogInformation($"token登錄, returnUrl={returnUrl}"); return Redirect(returnUrl); } /// <summary> /// 退出登錄 /// </summary> /// <returns></returns> [HttpGet] public async Task<IActionResult> Logout() { var userName = HttpContext.User.Identity?.Name; _logger.LogInformation($"{userName}退出登錄。"); //刪除登錄狀態cookies await HttpContext.SignOutAsync("Cookies"); return Redirect("/"); } }
修改App.Razor,如果當前頁面需要認證,就自動跳轉到手機驗證碼razor登錄頁面
@inject IJSRuntime _jsRuntime @inject NavigationManager _navManager <CascadingAuthenticationState> <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <NotAuthorized> @if (!context.User.Identity.IsAuthenticated) { //如果用戶未登錄,跳轉到手機驗證碼登錄頁面,發起登錄 _jsRuntime.InvokeVoidAsync("window.location.assign", $"phonecodelogin?returnUrl={Uri.EscapeDataString(_navManager.Uri)}"); } else { <h4 class="text-danger">Sorry</h4> <p>You're not authorized to reach this page.</p> <p>You may need to log in as a different user.</p> <a href="/account/login" class="btn btn-primary">Login</a> } </NotAuthorized> </AuthorizeRouteView> </Found> <NotFound> <LayoutView Layout="@typeof(MainLayout)"> <p>Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> </Router> </CascadingAuthenticationState>
同時運行AspNetId4Web項目和PhoneCodeLoginBlzWeb項目,輸入alice的手機號,獲取驗證碼
然后查看AspNetId4Web項目的控制台信息,獲得模擬的驗證碼,進行登錄。
可以看到,成功獲取都了alice的用戶角色等信息,測試Counter和Fetch Data頁面授權都是對的。
查看Identity Server 4控制台,可以看到客戶端獲取的token的信息,以及用戶資料的信息。
寫了這么多代碼,感覺還是很麻煩的,從Identity Server 4服務器拿到了登錄的token和用戶信息,還要自行解析、拼接再寫入瀏覽器cookies,跳來跳去,累死了。如果可以直接用token對Blazor Server客戶端的razor頁面進行認證和授權,能夠省很多事。
DEMO代碼地址:https://gitee.com/woodsun/blzid4