Blazor Server訪問Identity Server 4-手機驗證碼登錄


手機驗證碼登錄是目前非常主流的登錄方式,畢竟,誰記得住那么多賬號密碼呢?

相比傳統的賬號密碼登錄頁面,手機驗證碼登錄頁面前后台交互比較多,有一個動態提示倒計時的功能。用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.EnsureSeedDatacontext.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主頁提供登錄按鈕,顯示登錄用戶信息。

 

 

 

增加手機驗證碼登錄功能模塊Ids4ClientIdentity 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的用戶角色等信息,測試CounterFetch Data頁面授權都是對的。

 

查看Identity Server 4控制台,可以看到客戶端獲取的token的信息,以及用戶資料的信息。

 

寫了這么多代碼,感覺還是很麻煩的,從Identity Server 4服務器拿到了登錄的token和用戶信息,還要自行解析、拼接再寫入瀏覽器cookies,跳來跳去,累死了。如果可以直接用tokenBlazor Server客戶端的razor頁面進行認證和授權,能夠省很多事。

 

DEMO代碼地址:https://gitee.com/woodsun/blzid4

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM