IdentityServer4-從資料庫獲取User登入並對Claims授權驗證(五)
本節將在第四節基礎上介紹如何實現IdentityServer4從資料庫獲取User進行驗證,並對Claim進行許可權設定。
一、新建Web API資源服務,命名為ResourceAPI
(1)新建API專案,用來進行user的身份驗證服務。
(2)配置埠為5001
安裝Microsoft.EntityFrameworkCore包
安裝Microsoft.EntityFrameworkCore.SqlServer包
安裝Microsoft.EntityFrameworkCore.Tools包
(3)我們在專案新增一個 Entities資料夾。
新建一個User類,存放使用者基本資訊,其中Claims為一對多的關係。
其中UserId的值是唯一的。
public class User { [Key] [MaxLength(32)] public string UserId { get; set; } [MaxLength(32)] public string UserName { get; set; } [MaxLength(50)]public string Password { get; set; } public bool IsActive { get; set; }//是否可用 public virtual ICollection<Claims> Claims { get; set; } }
新建Claims類
public class Claims { [MaxLength(32)] public int ClaimsId { get; set; } [MaxLength(32)] public string Type { get; set; } [MaxLength(32)] public string Value { get; set; } public virtual User User { get; set; } }
繼續新建 UserContext.cs
public class UserContext:DbContext { public UserContext(DbContextOptions<UserContext> options) : base(options) { } public DbSet<User> Users { get; set; } public DbSet<Claims> UserClaims { get; set; } }
(4)修改startup.cs中的ConfigureServices方法,新增SQL Server配置。
public void ConfigureServices(IServiceCollection services) { var connection = "Data Source=localhost;Initial Catalog=UserAuth;User ID=sa;Password=Pwd"; services.AddDbContext<UserContext>(options => options.UseSqlServer(connection)); // Add framework services. services.AddMvc(); }
完成後在程式包管理器控制檯執行:Add-Migration InitUserAuth
生成遷移檔案。
(5)新增Models資料夾,定義User的model類和Claims的model類。
在Models資料夾中新建User類:
public class User { public string UserId { get; set; } public string UserName { get; set; } public string Password { get; set; } public bool IsActive { get; set; } public ICollection<Claims> Claims { get; set; } = new HashSet<Claims>(); }
新建Claims類:
public class Claims { public Claims(string type,string value) { Type = type; Value = value; } public string Type { get; set; } public string Value { get; set; } }
做Model和Entity之前的對映。
新增類UserMappers:
public static class UserMappers { static UserMappers() { Mapper = new MapperConfiguration(cfg => cfg.AddProfile<UserContextProfile>()) .CreateMapper(); } internal static IMapper Mapper { get; } /// <summary> /// Maps an entity to a model. /// </summary> /// <param name="entity">The entity.</param> /// <returns></returns> public static Models.User ToModel(this User entity) { return Mapper.Map<Models.User>(entity); } /// <summary> /// Maps a model to an entity. /// </summary> /// <param name="model">The model.</param> /// <returns></returns> public static User ToEntity(this Models.User model) { return Mapper.Map<User>(model); } }
類UserContextProfile:
public class UserContextProfile: Profile { public UserContextProfile() { //entity to model CreateMap<User, Models.User>(MemberList.Destination) .ForMember(x => x.Claims, opt => opt.MapFrom(src => src.Claims.Select(x => new Models.Claims(x.Type, x.Value)))); //model to entity CreateMap<Models.User, User>(MemberList.Source) .ForMember(x => x.Claims, opt => opt.MapFrom(src => src.Claims.Select(x => new Claims { Type = x.Type, Value = x.Value }))); } }
(6)在startup.cs中新增初始化資料庫的方法InitDataBase方法,對User和Claim做級聯插入。
public void InitDataBase(IApplicationBuilder app) { using (var serviceScope = app.ApplicationServices.GetService<IServiceScopeFactory>().CreateScope()) { serviceScope.ServiceProvider.GetRequiredService<Entities.UserContext>().Database.Migrate(); var context = serviceScope.ServiceProvider.GetRequiredService<Entities.UserContext>(); context.Database.Migrate(); if (!context.Users.Any()) { User user = new User() { UserId = "1", UserName = "zhubingjian", Password = "123", IsActive = true, Claims = new List<Claims> { new Claims("role","admin") } }; context.Users.Add(user.ToEntity()); context.SaveChanges(); } } }
(7)在startup.cs中新增InitDataBase方法的引用。
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } InitDataBase(app); app.UseMvc(); }
執行程式,這時候資料生成資料庫UserAuth,表Users中有一條UserName=zhubingjian,Password=123的資料。
二、實現獲取User介面,進行身份驗證
(1)先對API進行保護,在Startup.cs的ConfigureServices方法中新增:
//protect API services.AddMvcCore() .AddAuthorization() .AddJsonFormatters(); services.AddAuthentication("Bearer") .AddIdentityServerAuthentication(options => { options.Authority = "http://localhost:5000"; options.RequireHttpsMetadata = false; options.ApiName = "api1"; });
並在Configure中,將UseAuthentication身份驗證中介軟體新增到管道中,以便在每次呼叫主機時自動執行身份驗證。
app.UseAuthentication();
(2)接著,實現獲取User的介面。
在ValuesController控制中,新增如下程式碼:
UserContext context; public ValuesController(UserContext _context) { context = _context; } //只接受role為AuthServer授權服務的請求 [Authorize(Roles = "AuthServer")] [HttpGet("{userName}/{password}")] public IActionResult AuthUser(string userName, string password) { var res = context.Users.Where(p => p.UserName == userName && p.Password == password) .Include(p=>p.Claims) .FirstOrDefault(); return Ok(res.ToModel()); }
好了,資源伺服器獲取User的介面完成了。
(3)接著回到AuthServer專案,把User改成從資料庫進行驗證。
找到AccountController控制器,把從記憶體驗證User部分修改成從資料庫驗證。
主要修改Login方法,程式碼給出了簡要註釋:
public async Task<IActionResult> Login(LoginInputModel model, string button) { // check if we are in the context of an authorization request AuthorizationRequest context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); // the user clicked the "cancel" button if (button != "login") { if (context != null) { // if the user cancels, send a result back into IdentityServer as if they // denied the consent (even if this client does not require consent). // this will send back an access denied OIDC error response to the client. await _interaction.GrantConsentAsync(context, ConsentResponse.Denied); // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null if (await _clientStore.IsPkceClientAsync(context.ClientId)) { // if the client is PKCE then we assume it's native, so this change in how to // return the response is for better UX for the end user. return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); } return Redirect(model.ReturnUrl); } else { // since we don't have a valid context, then we just go back to the home page return Redirect("~/"); } } if (ModelState.IsValid) { //從資料庫獲取User並進行驗證 var client = _httpClientFactory.CreateClient(); //已過時 DiscoveryResponse disco = await DiscoveryClient.GetAsync("http://localhost:5000"); TokenClient tokenClient = new TokenClient(disco.TokenEndpoint, "AuthServer", "secret"); var tokenResponse = await tokenClient.RequestClientCredentialsAsync("api1"); //var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest //{ // Address = "http://localhost:5000", // ClientId = "AuthServer", // ClientSecret = "secret", // Scope = "api1" //}); //if (tokenResponse.IsError) throw new Exception(tokenResponse.Error); client.SetBearerToken(tokenResponse.AccessToken); try { var response = await client.GetAsync("http://localhost:5001/api/values/" + model.Username + "/" + model.Password); if (!response.IsSuccessStatusCode) { throw new Exception("Resource server is not working!"); } else { var content = await response.Content.ReadAsStringAsync(); User user = JsonConvert.DeserializeObject<User>(content); if (user != null) { await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.UserId, user.UserName)); // only set explicit expiration here if user chooses "remember me". // otherwise we rely upon expiration configured in cookie middleware. AuthenticationProperties props = null; if (AccountOptions.AllowRememberLogin && model.RememberLogin) { props = new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration) }; }; // context.Result = new GrantValidationResult( //user.SubjectId ?? throw new ArgumentException("Subject ID not set", nameof(user.SubjectId)), //OidcConstants.AuthenticationMethods.Password, _clock.UtcNow.UtcDateTime, //user.Claims); // issue authentication cookie with subject ID and username await HttpContext.SignInAsync(user.UserId, user.UserName, props); if (context != null) { if (await _clientStore.IsPkceClientAsync(context.ClientId)) { // if the client is PKCE then we assume it's native, so this change in how to // return the response is for better UX for the end user. return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl }); } // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null return Redirect(model.ReturnUrl); } // request for a local page if (Url.IsLocalUrl(model.ReturnUrl)) { return Redirect(model.ReturnUrl); } else if (string.IsNullOrEmpty(model.ReturnUrl)) { return Redirect("~/"); } else { // user might have clicked on a malicious link - should be logged throw new Exception("invalid return URL"); } } await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials")); ModelState.AddModelError("", AccountOptions.InvalidCredentialsErrorMessage); } } catch (Exception ex) { await _events.RaiseAsync(new UserLoginFailureEvent("Resource server", "is not working!")); ModelState.AddModelError("", "Resource server is not working"); } } // something went wrong, show form with error var vm = await BuildLoginViewModelAsync(model); return View(vm); }
可以看到,在IdentityServer4更新後,舊版獲取tokenResponse的方法已過時,但我按官網文件的說明,使用新方法(註釋的程式碼),獲取不到資訊,還望大家指點。
官網連結:https://identitymodel.readthedocs.io/en/latest/client/token.html
所以這裡還是按老方法來獲取tokenResponse。
(4)到這步後,可以把Startup中ConfigureServices方法裡面的AddTestUsers去掉了。
執行程式,已經可以從資料進行User驗證了。
點選進入About頁面時候,出現沒有許可權提示,我們會發現從資料庫獲取的User中的Claims不起作用了。
三、使用資料資料自定義Claim
為了讓獲取的Claims起作用,我們來實現IresourceOwnerPasswordValidator介面和IprofileService介面。
(1)在AuthServer中新增類ResourceOwnerPasswordValidator,繼承IresourceOwnerPasswordValidator介面。
public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator { private readonly IHttpClientFactory _httpClientFactory; public ResourceOwnerPasswordValidator(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context) { try { var client = _httpClientFactory.CreateClient(); //已過時 DiscoveryResponse disco = await DiscoveryClient.GetAsync("http://localhost:5000"); TokenClient tokenClient = new TokenClient(disco.TokenEndpoint, "AuthServer", "secret"); var tokenResponse = await tokenClient.RequestClientCredentialsAsync("api1"); //var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest //{ // Address = "http://localhost:5000", // ClientId = "AuthServer", // ClientSecret = "secret", // Scope = "api1" //}); //if (TokenResponse.IsError) throw new Exception(TokenResponse.Error); client.SetBearerToken(tokenResponse.AccessToken); var response = await client.GetAsync("http://localhost:5001/api/values/" + context.UserName + "/" + context.Password); if (!response.IsSuccessStatusCode) { throw new Exception("Resource server is not working!"); } else { var content = await response.Content.ReadAsStringAsync(); User user = JsonConvert.DeserializeObject<User>(content); //get your user model from db (by username - in my case its email) //var user = await _userRepository.FindAsync(context.UserName); if (user != null) { //check if password match - remember to hash password if stored as hash in db if (user.Password == context.Password) { //set the result context.Result = new GrantValidationResult( subject: user.UserId.ToString(), authenticationMethod: "custom", claims: GetUserClaims(user)); return; } context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Incorrect password"); return; } context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "User does not exist."); return; } } catch (Exception ex) { } } public static Claim[] GetUserClaims(User user) { List<Claim> claims = new List<Claim>(); Claim claim; foreach (var itemClaim in user.Claims) { claim = new Claim(itemClaim.Type, itemClaim.Value); claims.Add(claim); } return claims.ToArray(); } }
(2)ProfileService類實現IprofileService介面:
public class ProfileService : IProfileService { private readonly IHttpClientFactory _httpClientFactory; public ProfileService(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } ////services //private readonly IUserRepository _userRepository; //public ProfileService(IUserRepository userRepository) //{ // _userRepository = userRepository; //} //Get user profile date in terms of claims when calling /connect/userinfo public async Task GetProfileDataAsync(ProfileDataRequestContext context) { try { //depending on the scope accessing the user data. var userId = context.Subject.Claims.FirstOrDefault(x => x.Type == "sub"); //獲取User_Id if (!string.IsNullOrEmpty(userId?.Value) && long.Parse(userId.Value) > 0) { var client = _httpClientFactory.CreateClient(); //已過時 DiscoveryResponse disco = await DiscoveryClient.GetAsync("http://localhost:5000"); TokenClient tokenClient = new TokenClient(disco.TokenEndpoint, "AuthServer", "secret"); var tokenResponse = await tokenClient.RequestClientCredentialsAsync("api1"); //var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest //{ // Address = "http://localhost:5000", // ClientId = "AuthServer", // ClientSecret = "secret", // Scope = "api1" //}); //if (TokenResponse.IsError) throw new Exception(TokenResponse.Error); client.SetBearerToken(tokenResponse.AccessToken); //根據User_Id獲取user var response = await client.GetAsync("http://localhost:5001/api/values/" + long.Parse(userId.Value)); //get user from db (find user by user id) //var user = await _userRepository.FindAsync(long.Parse(userId.Value)); var content = await response.Content.ReadAsStringAsync(); User user = JsonConvert.DeserializeObject<User>(content); // issue the claims for the user if (user != null) { //獲取user中的Claims var claims = GetUserClaims(user); //context.IssuedClaims = claims.Where(x => context.RequestedClaimTypes.Contains(x.Type)).ToList(); context.IssuedClaims = claims.ToList(); } } } catch (Exception ex) { //log your error } } //check if user account is active. public async Task IsActiveAsync(IsActiveContext context) { try { var userId = context.Subject.Claims.FirstOrDefault(x => x.Type == "sub"); if (!string.IsNullOrEmpty(userId?.Value) && long.Parse(userId.Value) > 0) { //var user = await _userRepository.FindAsync(long.Parse(userId.Value)); var client = _httpClientFactory.CreateClient(); //已過時 DiscoveryResponse disco = await DiscoveryClient.GetAsync("http://localhost:5000"); TokenClient tokenClient = new TokenClient(disco.TokenEndpoint, "AuthServer", "secret"); var tokenResponse = await tokenClient.RequestClientCredentialsAsync("api1"); //var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest //{ // Address = "http://localhost:5000", // ClientId = "AuthServer", // ClientSecret = "secret", // Scope = "api1" //}); //if (TokenResponse.IsError) throw new Exception(TokenResponse.Error); client.SetBearerToken(tokenResponse.AccessToken); //根據User_Id獲取user var response = await client.GetAsync("http://localhost:5001/api/values/" + long.Parse(userId.Value)); //get user from db (find user by user id) //var user = await _userRepository.FindAsync(long.Parse(userId.Value)); var content = await response.Content.ReadAsStringAsync(); User user = JsonConvert.DeserializeObject<User>(content); if (user != null) { if (user.IsActive) { context.IsActive = user.IsActive; } } } } catch (Exception ex) { //handle error logging } } public static Claim[] GetUserClaims(User user) { List<Claim> claims = new List<Claim>(); Claim claim; foreach (var itemClaim in user.Claims) { claim = new Claim(itemClaim.Type, itemClaim.Value); claims.Add(claim); } return claims.ToArray(); } }
(3)發現程式碼裡面需要在ResourceAPI專案的ValuesController控制器中
新增根據UserId獲取User的Claims的介面。
Authorize(Roles = "AuthServer")] [HttpGet("{userId}")] public ActionResult<string> Get(string userId) { var user = context.Users.Where(p => p.UserId == userId) .Include(p => p.Claims) .FirstOrDefault(); return Ok(user.ToModel()); }
(4)修改AuthServer中的Config中GetIdentityResources方法,定義從資料獲取的Claims為role的資訊。
public static IEnumerable<IdentityResource> GetIdentityResources() { var customProfile = new IdentityResource( name: "mvc.profile", displayName: "Mvc profile", claimTypes: new[] { "role" }); return new List<IdentityResource> { new IdentityResources.OpenId(), new IdentityResources.Profile(), //new IdentityResource("roles","role",new List<string>{ "role"}), customProfile }; }
(5)在GetClients中把定義的mvc.profile加到Scope配置
(6)最後記得在Startup的ConfigureServices方法加上
.AddResourceOwnerValidator<ResourceOwnerPasswordValidator>()
.AddProfileService<ProfileService>();
執行後,出現熟悉的About頁面(Access Token後面加上去的,原始碼上有新增方法)
本節介紹的IdentityServer4通過訪問介面的形式驗證從資料庫獲取的User資訊。當然,也可以寫成AuthServer授權服務通過連線資料庫進行驗證。
另外,授權服務訪問資源服務API,用的是ClientCredentials模式(服務與服務之間訪問)。
原始碼地址:https://github.com/Bingjian-Zhu/Mvc-HybridFlow.git