之前有分享這個項目源碼及簡介,不過因為文字講解太少,被和諧了。我重新總結下:


源碼: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類。系統有很多不足的地方,後續我會抽時間迭代出一個穩定版本,這次畢竟只花了幾天時間。當然如果您有什麼寶貴建議也可以郵件聯系我。