ASP.NET Core实现OAuth2.0的AuthorizationCode模式
前言
在上一篇中实现了resource owner password credentials和client credentials模式:http://www.cnblogs.com/skig/p/6079457.html ,而这篇介绍实现AuthorizationCode模式。
OAuth2.0授权框架文档说明参考:https://tools.ietf.org/html/rfc6749 ;
ASP.NET Core开发OAuth2的项目使用了IdentityServer4,参考:https://identityserver4.readthedocs.io/en/dev/,源码:https://github.com/IdentityServer ;
.NET中开发OAuth2可使用OWIN,可参考:https://www.asp.net/aspnet/overview/owin-and-katana/owin-oauth-20-authorization-server
ASP.NET Core实现OAuth2的AuthorizationCode模式
授权服务器
Program.cs --> Main方法中:需要调用UseUrls设置IdentityServer4授权服务的IP地址
1 var host = new WebHostBuilder() 2 .UseKestrel() 3 //IdentityServer4的使用需要配置UseUrls 4 .UseUrls("http://localhost:5114") 5 .UseContentRoot(Directory.GetCurrentDirectory()) 6 .UseIISIntegration() 7 .UseStartup<Startup>() 8 .Build();
Startup.cs -->ConfigureServices方法中的配置:
1 //RSA:证书长度2048以上,否则抛异常 2 //配置AccessToken的加密证书 3 var rsa = new RSACryptoServiceProvider(); 4 //从配置文件获取加密证书 5 rsa.ImportCspBlob(Convert.FromBase64String(Configuration["SigningCredential"])); 6 //配置IdentityServer4 7 services.AddSingleton<IClientStore, MyClientStore>(); //注入IClientStore的实现,可用于运行时校验Client 8 services.AddSingleton<IScopeStore, MyScopeStore>(); //注入IScopeStore的实现,可用于运行时校验Scope 9 //注入IPersistedGrantStore的实现,用于存储AuthorizationCode和RefreshToken等等,默认实现是存储在内存中, 10 //如果服务重启那么这些数据就会被清空了,因此可实现IPersistedGrantStore将这些数据写入到数据库或者NoSql(Redis)中 11 services.AddSingleton<IPersistedGrantStore, MyPersistedGrantStore>(); 12 services.AddIdentityServer() 13 .AddSigningCredential(new RsaSecurityKey(rsa)); 14 //.AddTemporarySigningCredential() //生成临时的加密证书,每次重启服务都会重新生成 15 //.AddInMemoryScopes(Config.GetScopes()) //将Scopes设置到内存中 16 //.AddInMemoryClients(Config.GetClients()) //将Clients设置到内存中
Startup.cs --> Configure方法中的配置:
1 //使用IdentityServer4 2 app.UseIdentityServer(); 3 //使用Cookie模块 4 app.UseCookieAuthentication(new CookieAuthenticationOptions 5 { 6 AuthenticationScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme, 7 AutomaticAuthenticate = false, 8 AutomaticChallenge = false 9 });
Client配置
方式一:
.AddInMemoryClients(Config.GetClients()) //将Clients设置到内存中,IdentityServer4从中获取进行验证
方式二(推荐):
services.AddSingleton<IClientStore, MyClientStore>(); //注入IClientStore的实现,用于运行时获取和校验Client
IClientStore的实现
1 public class MyClientStore : IClientStore 2 { 3 readonly Dictionary<string, Client> _clients; 4 readonly IScopeStore _scopes; 5 public MyClientStore(IScopeStore scopes) 6 { 7 _scopes = scopes; 8 _clients = new Dictionary<string, Client>() 9 { 10 { 11 "auth_clientid", 12 new Client 13 { 14 ClientId = "auth_clientid", 15 ClientName = "AuthorizationCode Clientid", 16 AllowedGrantTypes = new string[] { GrantType.AuthorizationCode }, //允许AuthorizationCode模式 17 ClientSecrets = 18 { 19 new Secret("secret".Sha256()) 20 }, 21 RedirectUris = { "http://localhost:6321/Home/AuthCode" }, 22 PostLogoutRedirectUris = { "http://localhost:6321/" }, 23 //AccessTokenLifetime = 3600, //AccessToken过期时间, in seconds (defaults to 3600 seconds / 1 hour) 24 //AuthorizationCodeLifetime = 300, //设置AuthorizationCode的有效时间,in seconds (defaults to 300 seconds / 5 minutes) 25 //AbsoluteRefreshTokenLifetime = 2592000, //RefreshToken的最大过期时间,in seconds. Defaults to 2592000 seconds / 30 day 26 AllowedScopes = (from l in _scopes.GetEnabledScopesAsync(true).Result select l.Name).ToList(), 27 } 28 } 29 }; 30 } 31 32 public Task<Client> FindClientByIdAsync(string clientId) 33 { 34 Client client; 35 _clients.TryGetValue(clientId, out client); 36 return Task.FromResult(client); 37 } 38 }
Scope配置
方式一:
.AddInMemoryScopes(Config.GetScopes()) //将Scopes设置到内存中,IdentityServer4从中获取进行验证
方式二(推荐):
services.AddSingleton<IScopeStore, MyScopeStore>(); //注入IScopeStore的实现,用于运行时获取和校验Scope
IScopeStore的实现
1 public class MyScopeStore : IScopeStore 2 { 3 readonly static Dictionary<string, Scope> _scopes = new Dictionary<string, Scope>() 4 { 5 { 6 "api1", 7 new Scope 8 { 9 Name = "api1", 10 DisplayName = "api1", 11 Description = "My API", 12 } 13 }, 14 { 15 //RefreshToken的Scope 16 StandardScopes.OfflineAccess.Name, 17 StandardScopes.OfflineAccess 18 }, 19 }; 20 21 public Task<IEnumerable<Scope>> FindScopesAsync(IEnumerable<string> scopeNames) 22 { 23 List<Scope> scopes = new List<Scope>(); 24 if (scopeNames != null) 25 { 26 Scope sc; 27 foreach (var sname in scopeNames) 28 { 29 if (_scopes.TryGetValue(sname, out sc)) 30 { 31 scopes.Add(sc); 32 } 33 else 34 { 35 break; 36 } 37 } 38 } 39 //返回值scopes不能为null 40 return Task.FromResult<IEnumerable<Scope>>(scopes); 41 } 42 43 public Task<IEnumerable<Scope>> GetScopesAsync(bool publicOnly = true) 44 { 45 //publicOnly为true:获取public的scope;为false:获取所有的scope 46 //这里不做区分 47 return Task.FromResult<IEnumerable<Scope>>(_scopes.Values); 48 } 49 }
资源服务器
资源服务器的配置在上一篇中已介绍(http://www.cnblogs.com/skig/p/6079457.html ),详情也可参考源代码。
测试
AuthorizationCode模式的流程图(来自:https://tools.ietf.org/html/rfc6749):
流程实现
步骤A
第三方客户端页面简单实现:
点击AccessToken按钮进行访问授权服务器,就是流程图中步骤A:
1 //访问授权服务器 2 return Redirect(OAuthConstants.AuthorizationServerBaseAddress + OAuthConstants.AuthorizePath + "?" 3 + "response_type=code" 4 + "&client_id=" + OAuthConstants.Clientid 5 + "&redirect_uri=" + OAuthConstants.AuthorizeCodeCallBackPath 6 + "&scope=" + OAuthConstants.Scopes 7 + "&state=" + OAuthConstants.State);
步骤B
授权服务器接收到请求后,会判断用户是否已经登陆,如果未登陆那么跳转到登陆页面(如果已经登陆,登陆的一些相关信息会存储在cookie中):
1 /// <summary> 2 /// 登陆页面 3 /// </summary> 4 [HttpGet] 5 public async Task<IActionResult> Login(string returnUrl) 6 { 7 var context = await _interaction.GetAuthorizationContextAsync(returnUrl); 8 var vm = BuildLoginViewModel(returnUrl, context); 9 return View(vm); 10 } 11 12 /// <summary> 13 /// 登陆账号验证 14 /// </summary> 15 [HttpPost] 16 [ValidateAntiForgeryToken] 17 public async Task<IActionResult> Login(LoginInputModel model) 18 { 19 if (ModelState.IsValid) 20 { 21 //账号密码验证 22 if (model.Username == "admin" && model.Password == "123456") 23 { 24 AuthenticationProperties props = null; 25 //判断是否 记住登陆 26 if (model.RememberLogin) 27 { 28 props = new AuthenticationProperties 29 { 30 IsPersistent = true, 31 ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(1) 32 }; 33 }; 34 //参数一:Subject,可在资源服务器中获取到,资源服务器通过User.Claims.Where(l => l.Type == "sub").FirstOrDefault();获取 35 //参数二:账号 36 await HttpContext.Authentication.SignInAsync("admin", "admin", props); 37 //验证ReturnUrl,ReturnUrl为重定向到授权页面 38 if (_interaction.IsValidReturnUrl(model.ReturnUrl)) 39 { 40 return Redirect(model.ReturnUrl); 41 } 42 return Redirect("~/"); 43 } 44 ModelState.AddModelError("", "Invalid username or password."); 45 } 46 //生成错误信息的LoginViewModel 47 var vm = await BuildLoginViewModelAsync(model); 48 return View(vm); 49 }
登陆成功后,重定向到授权页面,询问用户是否授权,就是流程图的步骤B了:
1 /// <summary> 2 /// 显示用户可授予的权限 3 /// </summary> 4 /// <param name="returnUrl"></param> 5 /// <returns></returns> 6 [HttpGet] 7 public async Task<IActionResult> Index(string returnUrl) 8 { 9 var vm = await BuildViewModelAsync(returnUrl); 10 if (vm != null) 11 { 12 return View("Index", vm); 13 } 14 15 return View("Error", new ErrorViewModel 16 { 17 Error = new ErrorMessage { Error = "Invalid Request" }, 18 }); 19 }
步骤C
授权成功,重定向到redirect_uri(步骤A传递的)所指定的地址(第三方端),并且会把Authorization Code也设置到url的参数code中:
1 /// <summary> 2 /// 用户授权验证 3 /// </summary> 4 [HttpPost] 5 [ValidateAntiForgeryToken] 6 public async Task<IActionResult> Index(ConsentInputModel model) 7 { 8 //解析returnUrl 9 var request = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl); 10 if (request != null && model != null) 11 { 12 if (ModelState.IsValid) 13 { 14 ConsentResponse response = null; 15 //用户不同意授权 16 if (model.Button == "no") 17 { 18 response = ConsentResponse.Denied; 19 } 20 //用户同意授权 21 else if (model.Button == "yes") 22 { 23 //设置已选择授权的Scopes 24 if (model.ScopesConsented != null && model.ScopesConsented.Any()) 25 { 26 response = new ConsentResponse 27 { 28 RememberConsent = model.RememberConsent, 29 ScopesConsented = model.ScopesConsented 30 }; 31 } 32 else 33 { 34 ModelState.AddModelError("", "You must pick at least one permission."); 35 } 36 } 37 else 38 { 39 ModelState.AddModelError("", "Invalid Selection"); 40 } 41 if (response != null) 42 { 43 //将授权的结果设置到identityserver中 44 await _interaction.GrantConsentAsync(request, response); 45 //授权成功重定向 46 return Redirect(model.ReturnUrl); 47 } 48 } 49 //有错误,重新授权 50 var vm = await BuildViewModelAsync(model.ReturnUrl, model); 51 if (vm != null) 52 { 53 return View(vm); 54 } 55 } 56 return View("Error", new ErrorViewModel 57 { 58 Error = new ErrorMessage { Error = "Invalid Request" }, 59 }); 60 }
步骤D
授权成功后重定向到指定的第三方端(步骤A所指定的redirect_uri),然后这个重定向的地址中去实现获取AccessToken(就是由第三方端实现):
1 public IActionResult AuthCode(AuthCodeModel model) 2 { 3 GrantClientViewModel vmodel = new GrantClientViewModel(); 4 if (model.state == OAuthConstants.State) 5 { 6 //通过Authorization Code获取AccessToken 7 var client = new HttpClientHepler(OAuthConstants.AuthorizationServerBaseAddress + OAuthConstants.TokenPath); 8 client.PostAsync(null, 9 "grant_type=" + "authorization_code" + 10 "&code=" + model.code + //Authorization Code 11 "&redirect_uri=" + OAuthConstants.AuthorizeCodeCallBackPath + 12 "&client_id=" + OAuthConstants.Clientid + 13 "&client_secret=" + OAuthConstants.Secret, 14 hd => hd.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-www-form-urlencoded"), 15 rtnVal => 16 { 17 var jsonVal = JsonConvert.DeserializeObject<dynamic>(rtnVal); 18 vmodel.AccessToken = jsonVal.access_token; 19 vmodel.RefreshToken = jsonVal.refresh_token; 20 }, 21 fault => _logger.LogError("Get AccessToken Error: " + fault.ReasonPhrase), 22 ex => _logger.LogError("Get AccessToken Error: " + ex)).Wait(); 23 } 24 25 return Redirect("~/Home/Index?" 26 + nameof(vmodel.AccessToken) + "=" + vmodel.AccessToken + "&" 27 + nameof(vmodel.RefreshToken) + "=" + vmodel.RefreshToken); 28 }
步骤E
授权服务器对步骤D请求传递的Authorization Code进行验证,验证成功生成AccessToken并返回:
其中,点击RefreshToken进行刷新AccessToken:
1 //刷新AccessToken 2 var client = new HttpClientHepler(OAuthConstants.AuthorizationServerBaseAddress + OAuthConstants.TokenPath); 3 client.PostAsync(null, 4 "grant_type=" + "refresh_token" + 5 "&client_id=" + OAuthConstants.Clientid + 6 "&client_secret=" + OAuthConstants.Secret + 7 "&refresh_token=" + model.RefreshToken, 8 hd => hd.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/x-www-form-urlencoded"), 9 rtnVal => 10 { 11 var jsonVal = JsonConvert.DeserializeObject<dynamic>(rtnVal); 12 vmodel.AccessToken = jsonVal.access_token; 13 vmodel.RefreshToken = jsonVal.refresh_token; 14 }, 15 fault => _logger.LogError("RefreshToken Error: " + fault.ReasonPhrase), 16 ex => _logger.LogError("RefreshToken Error: " + ex)).Wait();
点击CallResources访问资源服务器:
1 //访问资源服务 2 var client = new HttpClientHepler(OAuthConstants.ResourceServerBaseAddress + OAuthConstants.ResourcesPath); 3 client.GetAsync(null, 4 hd => hd.Add("Authorization", "Bearer " + model.AccessToken), 5 rtnVal => vmodel.Resources = rtnVal, 6 fault => _logger.LogError("CallResources Error: " + fault.ReasonPhrase), 7 ex => _logger.LogError("CallResources Error: " + ex)).Wait();
点击Logout为注销登陆:
1 //访问授权服务器,注销登陆 2 return Redirect(OAuthConstants.AuthorizationServerBaseAddress + OAuthConstants.LogoutPath + "?" 3 + "logoutId=" + OAuthConstants.Clientid);
授权服务器的注销实现代码:
1 /// <summary> 2 /// 注销登陆页面(因为账号的一些相关信息会存储在cookie中的) 3 /// </summary> 4 [HttpGet] 5 public async Task<IActionResult> Logout(string logoutId) 6 { 7 if (User.Identity.IsAuthenticated == false) 8 { 9 //如果用户并未授权过,那么返回 10 return await Logout(new LogoutViewModel { LogoutId = logoutId }); 11 } 12 //显示注销提示, 这可以防止攻击, 如果用户签署了另一个恶意网页 13 var vm = new LogoutViewModel 14 { 15 LogoutId = logoutId 16 }; 17 return View(vm); 18 } 19 20 /// <summary> 21 /// 处理注销登陆 22 /// </summary> 23 [HttpPost] 24 [ValidateAntiForgeryToken] 25 public async Task<IActionResult> Logout(LogoutViewModel model) 26 { 27 //清除Cookie中的授权信息 28 await HttpContext.Authentication.SignOutAsync(); 29 //设置User使之呈现为匿名用户 30 HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity()); 31 Client logout = null; 32 if (model != null && !string.IsNullOrEmpty(model.LogoutId)) 33 { 34 //获取Logout的相关信息 35 logout = await _clientStore.FindClientByIdAsync(model.LogoutId); 36 } 37 var vm = new LoggedOutViewModel 38 { 39 PostLogoutRedirectUri = logout?.PostLogoutRedirectUris?.FirstOrDefault(), 40 ClientName = logout?.ClientName, 41 }; 42 return View("LoggedOut", vm); 43 }
注意
1. 授权服务器中生成的RefreshToken和AuthorizationCode默认是存储在内存中的,因此如果服务重启这些数据就失效了,那么就需要实现IPersistedGrantStore接口对这些数据的存储,将这些数据写入到数据库或者NoSql(Redis)中,实现代码可参考源代码;
2.资源服务器在第一次解析AccessToken的时候会先到授权服务器获取配置数据(例如会访问:http://localhost:5114/.well-known/openid-configuration 获取配置的,http://localhost:5114/.well-known/openid-configuration/jwks 获取jwks)),之后解析AccessToken都会使用第一次获取到的配置数据,因此如果授权服务的配置更改了(加密证书等等修改了),那么应该重启资源服务器使之重新获取新的配置数据;
3.调试IdentityServer4框架的时候应该配置好ILogger,因为授权过程中的访问(例如授权失败等等)信息都会调用ILogger进行日志记录,可使用NLog,例如:
在Startup.cs --> Configure方法中配置:loggerFactory.AddNLog();//添加NLog
源码:https://files.cnblogs.com/files/skig/OAuth2AuthorizationCode.zip