本节将在第四节基础上介绍如何实现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 ICollectionClaims { 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(DbContextOptionsoptions) : base(options) { } public DbSet Users { get; set; } public DbSet 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(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 ICollectionClaims { get; set; } = new HashSet (); }
新建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()) .CreateMapper(); } internal static IMapper Mapper { get; } /// /// Maps an entity to a model. /// /// The entity. /// public static Models.User ToModel(this User entity) { return Mapper.Map (entity); } /// /// Maps a model to an entity. /// /// The model. /// public static User ToEntity(this Models.User model) { return Mapper.Map (model); } }
类UserContextProfile:
public class UserContextProfile: Profile { public UserContextProfile() { //entity to model CreateMap(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 (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().CreateScope()) { serviceScope.ServiceProvider.GetRequiredService ().Database.Migrate(); var context = serviceScope.ServiceProvider.GetRequiredService (); context.Database.Migrate(); if (!context.Users.Any()) { User user = new User() { UserId = "1", UserName = "zhubingjian", Password = "123", IsActive = true, Claims = new List { 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 TaskLogin(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"); DiscoveryResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000"); var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { Address = disco.TokenEndpoint, 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 (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.htm
(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"); DiscoveryResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000"); var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { Address = disco.TokenEndpoint, 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(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 claims = new List (); 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"); DiscoveryResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000"); var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { Address = disco.TokenEndpoint, 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(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"); //ar tokenResponse = await tokenClient.RequestClientCredentialsAsync("api1"); DiscoveryResponse disco = await client.GetDiscoveryDocumentAsync("http://localhost:5000"); var tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest { Address = disco.TokenEndpoint, 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 (content); if (user != null) { if (user.IsActive) { context.IsActive = user.IsActive; } } } } catch (Exception ex) { //handle error logging } } public static Claim[] GetUserClaims(User user) { List claims = new List (); 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 IEnumerableGetIdentityResources() { var customProfile = new IdentityResource( name: "mvc.profile", displayName: "Mvc profile", claimTypes: new[] { "role" }); return new List { new IdentityResources.OpenId(), new IdentityResources.Profile(), //new IdentityResource("roles","role",new List { "role"}), customProfile }; }
(5)在GetClients中把定义的mvc.profile加到Scope配置
(6)最后记得在Startup的ConfigureServices方法加上
.AddResourceOwnerValidator
.AddProfileService
运行后,出现熟悉的About页面(Access Token后面加上去的,源码上有添加方法)
本节介绍的IdentityServer4通过访问接口的形式验证从数据库获取的User信息。当然,也可以写成AuthServer授权服务通过连接数据库进行验证。
另外,授权服务访问资源服务API,用的是ClientCredentials模式(服务与服务之间访问)。
参考博客:https://stackoverflow.com/questions/35304038/identityserver4-register-userservice-and-get-users-from-database-in-asp-net-core
源码地址:https://github.com/Bingjian-Zhu/Mvc-HybridFlow.git