之前有分享這個項目源碼及簡介,不過因為文字講解太少,被和諧了。我重新總結下:
源碼:https://github.com/zhoufeihong/SimpleSSO
OAuth 2.0協議:http://www.rfcreader.com/#rfc6749
-------------------------------------------分割線
記得那個酷熱的夏天,面試官翹著二郎腿問:“知道單點登錄不?”,我毫不遲疑答到:“不就是限制用戶只能在一個地方登錄嗎!”。面試完回家,查資料,也是似懂非懂,COOKIE、跨域、令牌、主站都是些啥玩意!其實我就是個VS都沒摸過幾次的畢業生,單點登錄這種玩意是不是太高級了。
這次就是寫個項目練練手(這兩年手生了太多),想到當初在網上找了半天,關於單點登錄、OAuth 2.0也沒找到個完整的實例(概念、理論倒是比較多),就寫了這個項目。分享出來,希望可以給那些對單點登錄、OAuth 2.0實現比較困惑的C#開發人員一些幫助。同時項目裡面有對於Autofac、AutoMapper、EF等等技術實踐方式(當然復制了很多代碼,我會盡量把源項目的License放上),希望在這些技術上也可以給你一些參考,項目可以直接運行(用戶名:admin密碼:123)。
昨天的文章因為文字講解太少了,被和諧了。不得不佩服博客園管理人員的專業水平,是你們如此細致的工作造就了博客園這麼多優秀的文章,也造就了博客園的今天(拍個馬屁)。其實我就想貼幾張圖,你們看到效果後,自己去看代碼、敲代碼,這樣子會比較好些(其實我就是表達能力不好,怕詞不達意)。
廢話不多說了,這篇文章我簡單介紹下:
SimpleSSO授權第三方應用系統獲取用戶信息(OpenID認證)(類似於我們在新浪上點擊QQ快捷登錄,采用的授權碼模式(authorization code))
SimpleSSO授權基於浏覽器應用系統獲取用戶信息(類似於我們通過微信浏覽器點開第三方應用,采用的簡化模式(implicit))
第三方系統使用用戶名密碼申請獲取用戶令牌,然後用令牌獲取用戶信息(采用的密碼模式(password))
第三方系統申請自己的訪問令牌(類似於微信公眾號用申請令牌訪問自己公眾號信息(采用的客戶端模式client credentials))
第三方系統刷新用戶(本身)令牌(refreshtoken)
OAuth2.0(開放授權)是一個開放標准,允許用戶讓第三方應用訪問該用戶在某一網站上存儲的私密的資源,而無需將用戶名和密碼提供給第三方應用。具體你可以去百度(oauth2.0 阮一峰),文章關於oauth2.0理論的講解非常到位,網上的理論也非常多,之前沒有基礎的可以先去腦補下。
具體場景:QQ用戶在XX網站分享文章到QQ空間
剖析:
關於授權模式如果不太清楚的建議:去百度(oauth2.0 阮一峰),文章關於對於授權模式的講解非常到位。Owin.OAuth的基礎,可以看看dudu寫的在ASP.NET中基於Owin OAuth使用Client Credentials Grant授權發放Token,一篇一篇看下去。
本節主要演示SimpleSSOTest站點通過各種授權模式到SimpleSSO站點申請令牌。如圖:
其中SimpleSSO站點為:http://localhost:8550,SimpleTest站點為:http://localhost:6111,後續會用到。
SimpleSSO關於OAuthAuthorizationServerOptions的配置:
builder.Register(c => new OAuthAuthorizationServerOptions { //授權終結點 /Token TokenEndpointPath = new PathString(EndPointConfig.TokenEndpointPath), Provider = new SimpleSSOOAuthProvider(), // Authorize授權終結點 /GrantCode/Authorize AuthorizeEndpointPath = new PathString(EndPointConfig.AuthorizeEndpointPath), //RefreshToken令牌創建、接收 RefreshTokenProvider = new SimpleAuthenticationTokenProvider() { //令牌類型 TokenType = "RefreshToken", //刷新AccessToken時RefreshToken不需要重新生成 TokenKeepingPredicate = data => data.GrantType == GrantTypes.RefreshToken, //過期時間 ExpireTimeSpan = TimeSpan.FromDays(60) }, // AccessToken令牌創建、接收 AccessTokenProvider = new SimpleAuthenticationTokenProvider() { //令牌類型 TokenType = "AccessToken", //過期時間 ExpireTimeSpan = TimeSpan.FromHours(2) }, // AuthorizationCode令牌創建、接收 AuthorizationCodeProvider = new SimpleAuthenticationTokenProvider() { //令牌類型 TokenType = "AuthorizationCode", //過期時間 ExpireTimeSpan = TimeSpan.FromMinutes(15), //接收令牌,同時移除令牌 RemoveWhenReceive = true }, //在生產模式下設 AllowInsecureHttp = false #if DEBUG AllowInsecureHttp = true #endif }).As<OAuthAuthorizationServerOptions>().SingleInstance();View Code
其中兩個關於OAuth授權的實現類:
令牌生成接收:SimpleAuthenticationTokenProvider
授權總線:SimpleSSOOAuthProvider
1.1、Demo展示:
今天新加了Microsoft.Owin.Security.SimpleSSO組件(感興趣的可以看下Katana項目),主要方便第三方集成SimpleSSO登錄。
SimpleTest集成登錄需要完成如下代碼配置:
public partial class Startup { // 有關配置身份驗證的詳細信息,請訪問 http://go.microsoft.com/fwlink/?LinkId=301864 public void ConfigureAuth(IAppBuilder app) { // 並使用 Cookie 來臨時存儲有關使用第三方登錄提供程序登錄的用戶的信息 app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); //simplesso登錄集成配置 var simpleSSOOption = new SimpleSSOAccountAuthenticationOptions { //客戶端ID ClientId = "3", //客戶端秘鑰 ClientSecret = "123", //登錄回調地址 CallbackPath = new PathString("/login/signin-simplesso"), //SimpleSSO Token授權地址 TokenEndpoint = "http://localhost:8550/token", //SimpleSSO authorization code授權地址 AuthorizationEndpoint = "http://localhost:8550/GrantCode/Authorize", //使用令牌到SimpleSSO獲取用戶信息地址 UserInformationEndpoint = "http://localhost:8550/TicketUser/TicketMessage" }; simpleSSOOption.Scope.Add("user-base"); app.UseSimpleSSOAccountAuthentication(simpleSSOOption); app.UseFacebookAuthentication( appId: "", appSecret: ""); } }View Code
1.2、Demo請求流程(流程圖工具過期了,只能用文字了,省略了很多細節):
1)用戶點擊“使用Microsoft.Owin.Security.SimpleSSO模擬OpenID認證”下進入按鈕,將跳轉到http://localhost:6111/login/authsimplesso
2)authsimplesso接收用戶請求
1>如果用戶已經使用ExternalCookie在登錄,注銷ExternalCookie信息,獲取返回用戶信息。
2>當用戶未登錄,則將http返回狀態改為401,並且創建authenticationType為SimpleSSOAuthentication身份驗證,SimpleSSOAccountAuthenticationHandler將用戶重定向到http://localhost:8550/GrantCode/Authorize?client_id={0}&scope={1}&response_type=code&redirect_uri={2}&state={3}。
SimpleSSOAccountAuthenticationHandler重定向代碼:
protected override Task ApplyResponseChallengeAsync() { if (Response.StatusCode != 401) { return Task.FromResult<object>(null); } AuthenticationResponseChallenge challenge = Helper.LookupChallenge(Options.AuthenticationType, Options.AuthenticationMode); if (challenge != null) { string baseUri = Request.Scheme + Uri.SchemeDelimiter + Request.Host + Request.PathBase; string currentUri = baseUri + Request.Path + Request.QueryString; string redirectUri = baseUri + Options.CallbackPath; AuthenticationProperties extra = challenge.Properties; if (string.IsNullOrEmpty(extra.RedirectUri)) { extra.RedirectUri = currentUri; } // OAuth2 10.12 CSRF GenerateCorrelationId(extra); // OAuth2 3.3 space separated string scope = string.Join(" ", Options.Scope); // LiveID requires a scope string, so if the user didn't set one we go for the least possible. if (string.IsNullOrWhiteSpace(scope)) { scope = "user-base"; } string state = Options.StateDataFormat.Protect(extra); string authorizationEndpoint = Options.AuthorizationEndpoint + "?client_id=" + Uri.EscapeDataString(Options.ClientId) + "&scope=" + Uri.EscapeDataString(scope) + "&response_type=code" + "&redirect_uri=" + Uri.EscapeDataString(redirectUri) + "&state=" + Uri.EscapeDataString(state); var redirectContext = new SimpleSSOAccountApplyRedirectContext( Context, Options, extra, authorizationEndpoint); Options.Provider.ApplyRedirect(redirectContext); } return Task.FromResult<object>(null); }View Code
3)GrantCode/Authorize接收用戶請求
1>如果為可信應用則不需要用戶同意,直接生成code讓用戶跳轉到http://localhost:6111/login/signin-simplesso?code={0}&state={1}
2>如果不是可信應用則跳轉到http://localhost:8550/OAuth/Grant用戶授權頁面,用戶點擊授權時跳轉到
4)http://localhost:6111/login/signin-simplesso?code={0}&state={1}請求處理,由SimpleSSOAccountAuthenticationHandler類處理
SimpleSSOAccountAuthenticationHandler代碼:
internal class SimpleSSOAccountAuthenticationHandler : AuthenticationHandler<SimpleSSOAccountAuthenticationOptions> { private readonly ILogger _logger; private readonly HttpClient _httpClient; public SimpleSSOAccountAuthenticationHandler(HttpClient httpClient, ILogger logger) { _httpClient = httpClient; _logger = logger; } public override async Task<bool> InvokeAsync() { if (Options.CallbackPath.HasValue && Options.CallbackPath == Request.Path) { return await InvokeReturnPathAsync(); } return false; } protected override async Task<AuthenticationTicket> AuthenticateCoreAsync() { AuthenticationProperties properties = null; try { string code = null; string state = null; IReadableStringCollection query = Request.Query; IList<string> values = query.GetValues("code"); if (values != null && values.Count == 1) { code = values[0]; } values = query.GetValues("state"); if (values != null && values.Count == 1) { state = values[0]; } properties = Options.StateDataFormat.Unprotect(state); if (properties == null) { return null; } // OAuth2 10.12 CSRF if (!ValidateCorrelationId(properties, _logger)) { return new AuthenticationTicket(null, properties); } var tokenRequestParameters = new List<KeyValuePair<string, string>>() { new KeyValuePair<string, string>("client_id", Options.ClientId), new KeyValuePair<string, string>("redirect_uri", GenerateRedirectUri()), new KeyValuePair<string, string>("client_secret", Options.ClientSecret), new KeyValuePair<string, string>("code", code), new KeyValuePair<string, string>("grant_type", "authorization_code"), }; var requestContent = new FormUrlEncodedContent(tokenRequestParameters); HttpResponseMessage response = await _httpClient.PostAsync(Options.TokenEndpoint, requestContent, Request.CallCancelled); response.EnsureSuccessStatusCode(); string oauthTokenResponse = await response.Content.ReadAsStringAsync(); JObject oauth2Token = JObject.Parse(oauthTokenResponse); var accessToken = oauth2Token["access_token"].Value<string>(); // Refresh token is only available when wl.offline_access is request. // Otherwise, it is null. var refreshToken = oauth2Token.Value<string>("refresh_token"); var expire = oauth2Token.Value<string>("expires_in"); if (string.IsNullOrWhiteSpace(accessToken)) { _logger.WriteWarning("Access token was not found"); return new AuthenticationTicket(null, properties); } _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); HttpResponseMessage graphResponse = await _httpClient.GetAsync( Options.UserInformationEndpoint); graphResponse.EnsureSuccessStatusCode(); string accountString = await graphResponse.Content.ReadAsStringAsync(); JObject accountInformation = JObject.Parse(accountString); var context = new SimpleSSOAccountAuthenticatedContext(Context, accountInformation, accessToken, refreshToken, expire); context.Identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, context.Id, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType), new Claim(ClaimTypes.Name, context.Name, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType), new Claim("urn:simplesso:id", context.Id, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType), new Claim("urn:simplesso:name", context.Name, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType) }, Options.AuthenticationType, ClaimsIdentity.DefaultNameClaimType, ClaimsIdentity.DefaultRoleClaimType); if (!string.IsNullOrWhiteSpace(context.Email)) { context.Identity.AddClaim(new Claim(ClaimTypes.Email, context.Email, "http://www.w3.org/2001/XMLSchema#string", Options.AuthenticationType)); } await Options.Provider.Authenticated(context); context.Properties = properties; return new AuthenticationTicket(context.Identity, context.Properties); } catch (Exception ex) { _logger.WriteError("Authentication failed", ex); return new AuthenticationTicket(null, properties); } } protected override Task ApplyResponseChallengeAsync() { if (Response.StatusCode != 401) { return Task.FromResult<object>(null); } AuthenticationResponseChallenge challenge = Helper.LookupChallenge(Options.AuthenticationType, Options.AuthenticationMode); if (challenge != null) { string baseUri = Request.Scheme + Uri.SchemeDelimiter + Request.Host + Request.PathBase; string currentUri = baseUri + Request.Path + Request.QueryString; string redirectUri = baseUri + Options.CallbackPath; AuthenticationProperties extra = challenge.Properties; if (string.IsNullOrEmpty(extra.RedirectUri)) { extra.RedirectUri = currentUri; } // OAuth2 10.12 CSRF GenerateCorrelationId(extra); // OAuth2 3.3 space separated string scope = string.Join(" ", Options.Scope); // LiveID requires a scope string, so if the user didn't set one we go for the least possible. if (string.IsNullOrWhiteSpace(scope)) { scope = "user-base"; } string state = Options.StateDataFormat.Protect(extra); string authorizationEndpoint = Options.AuthorizationEndpoint + "?client_id=" + Uri.EscapeDataString(Options.ClientId) + "&scope=" + Uri.EscapeDataString(scope) + "&response_type=code" + "&redirect_uri=" + Uri.EscapeDataString(redirectUri) + "&state=" + Uri.EscapeDataString(state); var redirectContext = new SimpleSSOAccountApplyRedirectContext( Context, Options, extra, authorizationEndpoint); Options.Provider.ApplyRedirect(redirectContext); } return Task.FromResult<object>(null); } public async Task<bool> InvokeReturnPathAsync() { AuthenticationTicket model = await AuthenticateAsync(); if (model == null) { _logger.WriteWarning("Invalid return state, unable to redirect."); Response.StatusCode = 500; return true; } var context = new SimpleSSOReturnEndpointContext(Context, model); context.SignInAsAuthenticationType = Options.SignInAsAuthenticationType; context.RedirectUri = model.Properties.RedirectUri; model.Properties.RedirectUri = null; await Options.Provider.ReturnEndpoint(context); if (context.SignInAsAuthenticationType != null && context.Identity != null) { ClaimsIdentity signInIdentity = context.Identity; if (!string.Equals(signInIdentity.AuthenticationType, context.SignInAsAuthenticationType, StringComparison.Ordinal)) { signInIdentity = new ClaimsIdentity(signInIdentity.Claims, context.SignInAsAuthenticationType, signInIdentity.NameClaimType, signInIdentity.RoleClaimType); } Context.Authentication.SignIn(context.Properties, signInIdentity); } if (!context.IsRequestCompleted && context.RedirectUri != null) { if (context.Identity == null) { // add a redirect hint that sign-in failed in some way context.RedirectUri = WebUtilities.AddQueryString(context.RedirectUri, "error", "access_denied"); } Response.Redirect(context.RedirectUri); context.RequestCompleted(); } return context.IsRequestCompleted; } private string GenerateRedirectUri() { string requestPrefix = Request.Scheme + "://" + Request.Host; string redirectUri = requestPrefix + RequestPathBase + Options.CallbackPath; // + "?state=" + Uri.EscapeDataString(Options.StateDataFormat.Protect(state)); return redirectUri; } }View Code
1>使用code獲取令牌
2>獲取用戶信息
3>SignIn(ExternalCookie)
4>重新跳轉到http://localhost:6111/login/authsimplesso,回到1.2-2)
2.1、Demo展示(這個demo請求實際上是可以跨域的):
2.2、Demo請求流程
1)用戶點擊“通過authorization code授權模式申請令牌”下進入按鈕,使用div加載url地址http://localhost:8550/GrantCode/Authorize?client_id=1&scope=user-base&response_type=code&redirect_uri=http://localhost:6111/api/Code/App1&state={隨機}。如果用戶沒有登錄的情況下請求這個路徑,會跳轉到登錄界面。
2)因為client_id=1應用為可信應用,所以直接生成code,請求http://localhost:6111/api/Code/App1?code=?&state={請求過來的值}
由SimpleSSOOAuthProvider方法AuthorizeEndpoint完成可信應用驗證,用戶令牌信息注冊,SimpleAuthenticationTokenProvider完成code生成
3)/api/Code/App1接收code、state
1)使用code獲取Access_Token
2)使用Access_Token獲取用戶信息
3)使用Refresh_Token刷新Access_Token
4)使用刷新後的Access_Token獲取用戶信息
/api/Code/App1代碼:
[HttpGet] [Route("App1")] public async Task<string> App1(string code = "") { return await AppData(code, "App1", "1", "123"); } private async Task<string> AppData(string code, string appName, string clientID, string clientSecret) { StringBuilder strMessage = new StringBuilder(); if (!string.IsNullOrWhiteSpace(code)) { string accessToken = ""; string codeResult = await AuthorizationCode(appName, clientID, clientSecret, code); var obj = JObject.Parse(codeResult); var refreshToken = obj["refresh_token"].Value<string>(); accessToken = obj["access_token"].Value<string>(); strMessage.Append($"<font color='black'><b>應用{appName}使用</b></font></br>code:{code}獲取到</br>refresh_token:{refreshToken}</br>access_token:{accessToken}"); if (!string.IsNullOrEmpty(accessToken)) { strMessage.Append($"</br><font color='black'><b>使用AccessToken獲取到信息:</b></font>{ await GetTicketMessageData(accessToken) }"); obj = JObject.Parse(await RefreshToken(clientID, clientSecret, refreshToken)); refreshToken = obj["refresh_token"].Value<string>(); accessToken = obj["access_token"].Value<string>(); strMessage.Append($"</br><font color='black'><b>應用{appName}刷新秘鑰獲取到</b></font></br>refresh_token:{refreshToken}</br>access_token:{accessToken}"); strMessage.Append($"</br><font color='black'><b>使用刷新後AccessToken獲取到信息:</b></font>{ await GetTicketMessageData(accessToken) }"); } } else { strMessage.AppendLine("獲取code失敗."); } return await Task.FromResult(strMessage.ToString()); }View Code
3.1、Demo展示:
implicit模式是比較特別一種模式,由基於浏覽器應用訪問用戶信息,所以生成的令牌直接為Access_Token,且Url為http://localhost:6111/TokenClient/ShowUser#access_token={0}&token_type={1}&state={2},浏覽器端需要通過window.location.hash訪問。
3.2、Demo請求流程
1)用戶點擊""下進入,http://localhost:8550/GrantCode/Authorize?client_id=2&redirect_uri=http://localhost:6111/TokenClient/ShowUser&response_type=token&scope=user_base&state={隨機}
2)跳轉到用戶授權頁面,用戶授權後,返回http://localhost:6111/TokenClient/ShowUser#access_token={0}&token_type=bearer&state={2}
3)點擊Try Get Data,js使用access_token請求獲取用戶信息。
其中JS代碼:
$(function () { $("#get_data").click(function () { var hashDiv = getHashStringArgs(); var token = hashDiv["access_token"]; var tokenType = hashDiv["token_type"]; if (token) { var url = "@ViewBag.ServerTicketMessageUrl"; var settings = { type: "GET", url: url, beforeSend: function (request) { request.setRequestHeader("Authorization", tokenType + " " + token); }, success: function (data, textStatus) { alert(JSON.stringify(data)); } }; $.ajax(settings); } }); }); function getHashStringArgs() { var hashStrings = (window.location.hash.length > 0 ? window.location.hash.substring(1) : ""), hashArgs = {}, items = hashStrings.length > 0 ? hashStrings.split("&") : [], item = null, name = null, value = null, i = 0, len = items.length; for (i = 0; i < len; i++) { item = items[i].split("="); name = decodeURIComponent(item[0]); value = decodeURIComponent(item[1]); if (name.length > 0) { hashArgs[name] = value; } } return hashArgs; }View Code
實現代碼:
[HttpGet] [Route("AppPassword")] public async Task<string> AppPassword() { var clientID = "1"; var clientSecret = "123"; var userName = "zfh"; var password = "123"; var parameters = new Dictionary<string, string>(); parameters.Add("grant_type", "password"); parameters.Add("username", userName); parameters.Add("password", password); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(clientID + ":" + clientSecret))); var response = await _httpClient.PostAsync(_serverTokenUrl, new FormUrlEncodedContent(parameters)); var result = await response.Content.ReadAsStringAsync(); var obj = JObject.Parse(result); var refreshToken = obj["refresh_token"].Value<string>(); var accessToken = obj["access_token"].Value<string>(); return $"<font color='black'><b>應用App1獲取到用戶zfh的</b></font></br>refresh_token:{refreshToken}</br>access_token:{accessToken}"; }View Code
實現代碼:
[HttpGet] [Route("AppclientCredentials")] public async Task<string> AppclientCredentials() { var clientID = "1"; var clientSecret = "123"; var parameters = new Dictionary<string, string>(); parameters.Add("grant_type", "client_credentials"); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes(clientID + ":" + clientSecret))); var response = await _httpClient.PostAsync(_serverTokenUrl, new FormUrlEncodedContent(parameters)); var result = await response.Content.ReadAsStringAsync(); var obj = JObject.Parse(result); var refreshToken = obj["refresh_token"].Value<string>(); var accessToken = obj["access_token"].Value<string>(); return $"<font color='black'><b>應用App1獲取到</b></font></br>refresh_token:{refreshToken}</br>access_token:{accessToken}"; }View Code
寫的不夠清晰,建議看看源碼。關於OAuth的實現集中在SimpleSSOOAuthProvider,SimpleAuthenticationTokenProvider類。系統有很多不足的地方,後續我會抽時間迭代出一個穩定版本,這次畢竟只花了幾天時間。當然如果您有什麼寶貴建議也可以郵件聯系我。