OAuth(开放授权)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。
OAuth 允许用户提供一个令牌,而不是用户名和密码来访问他们存放在特定服务提供者的数据。每一个令牌授权一个特定的网站(例如,视频编辑网站)在特定的时段(例如,接下来的 2 小时内)内访问特定的资源(例如仅仅是某一相册中的视频)。这样,OAuth 让用户可以授权第三方网站访问他们存储在另外服务提供者的某些特定信息,而非所有内容。
以上概念来自:https://zh.wikipedia.org/wiki/OAuth
OAuth 是什么?为什么要使用 OAuth?上面的概念已经很明确了,这里就不详细说明了。
阅读目录:
开源地址:https://github.com/yuezhongxin/OAuth2.Demo
关于 OAuth 2.0 的运行流程(来自 RFC 6749):
这里我们模拟一个场景:用户听落网,但需要登录才能收藏期刊,然后用快捷登录方式,使用微博的账号和密码登录后,落网就可以访问到微博的账号信息等,并且在落网也已登录,最后用户就可以收藏期刊了。
结合上面的场景,详细说下 OAuth 2.0 的运行流程:
图中的名词解释:
其实,我不是很理解 ABC 操作,我觉得 ABC 可以合成一个 C:落网打开微博的授权页面,用户输入微博的账号和密码,请求验证。
OAuth 2.0 四种授权模式:
下面我们使用 ASP.NET WebApi OWIN,分别实现上面的四种授权模式。
简单解释:落网提供一些授权凭证,从微博授权服务获取到 authorization_code,然后根据 authorization_code,再获取到 access_token,落网需要请求微博授权服务两次。
第一次请求授权服务(获取 authorization_code),需要的参数:
第二次请求授权服务(获取 access_token),需要的参数:
第二次请求授权服务(获取 access_token),返回的参数:
ASP.NET WebApi OWIN 需要安装的程序包:
在项目中创建 Startup.cs 文件,添加如下代码:
public partial class Startup
{
public void ConfigureAuth(IAppBuilder app)
{
var OAuthOptions = new OAuthAuthorizationServerOptions
{
AllowInsecureHttp = true,
AuthenticationMode = AuthenticationMode.Active,
TokenEndpointPath = new PathString("/token"), //获取 access_token 授权服务请求地址
AuthorizeEndpointPath=new PathString("/authorize"), //获取 authorization_code 授权服务请求地址
AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10), //access_token 过期时间
Provider = new OpenAuthorizationServerProvider(), //access_token 相关授权服务
AuthorizationCodeProvider = new OpenAuthorizationCodeProvider(), //authorization_code 授权服务
RefreshTokenProvider = new OpenRefreshTokenProvider() //refresh_token 授权服务
};
app.UseOAuthBearerTokens(OAuthOptions); //表示 token_type 使用 bearer 方式
}
}
OpenAuthorizationServerProvider 示例代码:
public class OpenAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
/// <summary>
/// 验证 client 信息
/// </summary>
public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
string clientId;
string clientSecret;
if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
{
context.TryGetFormCredentials(out clientId, out clientSecret);
}
if (clientId != "xishuai")
{
context.SetError("invalid_client", "client is not valid");
return;
}
context.Validated();
}
/// <summary>
/// 生成 authorization_code(authorization code 授权方式)、生成 access_token (implicit 授权模式)
/// </summary>
public override async Task AuthorizeEndpoint(OAuthAuthorizeEndpointContext context)
{
if (context.AuthorizeRequest.IsImplicitGrantType)
{
//implicit 授权方式
var identity = new ClaimsIdentity("Bearer");
context.OwinContext.Authentication.SignIn(identity);
context.RequestCompleted();
}
else if (context.AuthorizeRequest.IsAuthorizationCodeGrantType)
{
//authorization code 授权方式
var redirectUri = context.Request.Query["redirect_uri"];
var clientId = context.Request.Query["client_id"];
var identity = new ClaimsIdentity(new GenericIdentity(
clientId, OAuthDefaults.AuthenticationType));
var authorizeCodeContext = new AuthenticationTokenCreateContext(
context.OwinContext,
context.Options.AuthorizationCodeFormat,
new AuthenticationTicket(
identity,
new AuthenticationProperties(new Dictionary<string, string>
{
{"client_id", clientId},
{"redirect_uri", redirectUri}
})
{
IssuedUtc = DateTimeOffset.UtcNow,
ExpiresUtc = DateTimeOffset.UtcNow.Add(context.Options.AuthorizationCodeExpireTimeSpan)
}));
await context.Options.AuthorizationCodeProvider.CreateAsync(authorizeCodeContext);
context.Response.Redirect(redirectUri + "?code=" + Uri.EscapeDataString(authorizeCodeContext.Token));
context.RequestCompleted();
}
}
/// <summary>
/// 验证 authorization_code 的请求
/// </summary>
public override async Task ValidateAuthorizeRequest(OAuthValidateAuthorizeRequestContext context)
{
if (context.AuthorizeRequest.ClientId == "xishuai" &&
(context.AuthorizeRequest.IsAuthorizationCodeGrantType || context.AuthorizeRequest.IsImplicitGrantType))
{
context.Validated();
}
else
{
context.Rejected();
}
}
/// <summary>
/// 验证 redirect_uri
/// </summary>
public override async Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context)
{
context.Validated(context.RedirectUri);
}
/// <summary>
/// 验证 access_token 的请求
/// </summary>
public override async Task ValidateTokenRequest(OAuthValidateTokenRequestContext context)
{
if (context.TokenRequest.IsAuthorizationCodeGrantType || context.TokenRequest.IsRefreshTokenGrantType)
{
context.Validated();
}
else
{
context.Rejected();
}
}
}
需要注意的是,ValidateClientAuthentication 并不需要对 clientSecret 进行验证,另外,AuthorizeEndpoint 只是生成 authorization_code,并没有生成 access_token,生成操作在 OpenAuthorizationCodeProvider 中的 Receive 方法。
OpenAuthorizationCodeProvider 示例代码:
public class OpenAuthorizationCodeProvider : AuthenticationTokenProvider
{
private readonly ConcurrentDictionary<string, string> _authenticationCodes = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
/// <summary>
/// 生成 authorization_code
/// </summary>
public override void Create(AuthenticationTokenCreateContext context)
{
context.SetToken(Guid.NewGuid().ToString("n") + Guid.NewGuid().ToString("n"));
_authenticationCodes[context.Token] = context.SerializeTicket();
}
/// <summary>
/// 由 authorization_code 解析成 access_token
/// </summary>
public override void Receive(AuthenticationTokenReceiveContext context)
{
string value;
if (_authenticationCodes.TryRemove(context.Token, out value))
{
context.DeserializeTicket(value);
}
}
}
上面 Create 方法是 await context.Options.AuthorizationCodeProvider.CreateAsync(authorizeCodeContext);
的重载方法。
OpenRefreshTokenProvider 示例代码:
public class OpenRefreshTokenProvider : AuthenticationTokenProvider
{
private static ConcurrentDictionary<string, string> _refreshTokens = new ConcurrentDictionary<string, string>();
/// <summary>
/// 生成 refresh_token
/// </summary>
public override void Create(AuthenticationTokenCreateContext context)
{
context.Ticket.Properties.IssuedUtc = DateTime.UtcNow;
context.Ticket.Properties.ExpiresUtc = DateTime.UtcNow.AddDays(60);
context.SetToken(Guid.NewGuid().ToString("n") + Guid.NewGuid().ToString("n"));
_refreshTokens[context.Token] = context.SerializeTicket();
}
/// <summary>
/// 由 refresh_token 解析成 access_token
/// </summary>
public override void Receive(AuthenticationTokenReceiveContext context)
{
string value;
if (_refreshTokens.TryRemove(context.Token, out value))
{
context.DeserializeTicket(value);
}
}
}
refresh_token 的作用就是,在 access_token 过期的时候,不需要再通过一些凭证申请 access_token,而是直接通过 refresh_token 就可以重新申请 access_token。
另外,需要一个 api 来接受 authorization_code(来自 redirect_uri 的回调跳转),实现代码如下:
public class CodesController : ApiController
{
[HttpGet]
[Route("api/authorization_code")]
public HttpResponseMessage Get(string code)
{
return new HttpResponseMessage()
{
Content = new StringContent(code, Encoding.UTF8, "text/plain")
};
}
}
基本上面代码已经实现了,单元测试代码如下:
public class OAuthClientTest
{
private const string HOST_ADDRESS = "http://localhost:8001";
private IDisposable _webApp;
private static HttpClient _httpClient;
public OAuthClientTest()
{
_webApp = WebApp.Start<Startup>(HOST_ADDRESS);
Console.WriteLine("Web API started!");
_httpClient = new HttpClient();
_httpClient.BaseAddress = new Uri(HOST_ADDRESS);
Console.WriteLine("HttpClient started!");
}
private static async Task<TokenResponse> GetToken(string grantType, string refreshToken = null, string userName = null, string password = null, string authorizationCode = null)
{
var clientId = "xishuai";
var clientSecret = "123";
var parameters = new Dictionary<string, string>();
parameters.Add("grant_type", grantType);
if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(password))
{
parameters.Add("username", userName);
parameters.Add("password", password);
}
if (!string.IsNullOrEmpty(authorizationCode))
{
parameters.Add("code", authorizationCode);
parameters.Add("redirect_uri", "http://localhost:8001/api/authorization_code"); //和获取 authorization_code 的 redirect_uri 必须一致,不然会报错
}
if (!string.IsNullOrEmpty(refreshToken))
{
parameters.Add("refresh_token", refreshToken);
}
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(Encoding.ASCII.GetBytes(clientId + ":" + clientSecret)));
var response = await _httpClient.PostAsync("/token", new FormUrlEncodedContent(parameters));
var responseValue = await response.Content.ReadAsStringAsync();
if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine(response.StatusCode);
Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
return null;
}
return await response.Content.ReadAsAsync<TokenResponse>();
}
private static async Task<string> GetAuthorizationCode()
{
var clientId = "xishuai";
var response = await _httpClient.GetAsync($"/authorize?grant_type=authorization_code&response_type=code&client_id={clientId}&redirect_uri={HttpUtility.UrlEncode("http://localhost:8001/api/authorization_code")}");
var authorizationCode = await response.Content.ReadAsStringAsync();
if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine(response.StatusCode);
Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
return null;
}
return authorizationCode;
}
[Fact]
public async Task OAuth_AuthorizationCode_Test()
{
var authorizationCode = GetAuthorizationCode().Result; //获取 authorization_code
var tokenResponse = GetToken("authorization_code", null, null, null, authorizationCode).Result; //根据 authorization_code 获取 access_token
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
var response = await _httpClient.GetAsync($"/api/values");
if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine(response.StatusCode);
Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
}
Console.WriteLine(await response.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Thread.Sleep(10000);
var tokenResponseTwo = GetToken("refresh_token", tokenResponse.RefreshToken).Result; //根据 refresh_token 获取 access_token
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponseTwo.AccessToken);
var responseTwo = await _httpClient.GetAsync($"/api/values");
Assert.Equal(HttpStatusCode.OK, responseTwo.StatusCode);
}
}
Startup 配置的 access_token 过期时间是 10s,线程休眠 10s,是为了测试 refresh_token。
上面单元测试代码,执行成功,当然也可以用 Postman 模拟请求测试。
简单解释:授权码模式的简化版,省略 authorization_code,并且 access_token 以 URL 参数返回(比如 #token=xxxx)。
请求授权服务(只有一次),需要的参数:
需要注意的是,简化模式请求参数并不需要 grant_type,并且可以用 http get 直接请求。
Startup 代码:
public partial class Startup
{
public void ConfigureAuth(IAppBuilder app)
{
var OAuthOptions = new OAuthAuthorizationServerOptions
{
AllowInsecureHttp = true,
AuthenticationMode = AuthenticationMode.Active,
TokenEndpointPath = new PathString("/token"), //获取 access_token 授权服务请求地址
AuthorizeEndpointPath=new PathString("/authorize"), //获取 authorization_code 授权服务请求地址
AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10), //access_token 过期时间
Provider = new OpenAuthorizationServerProvider(), //access_token 相关授权服务
RefreshTokenProvider = new OpenRefreshTokenProvider() //refresh_token 授权服务
};
app.UseOAuthBearerTokens(OAuthOptions); //表示 token_type 使用 bearer 方式
}
}
OpenRefreshTokenProvider、OpenAuthorizationServerProvider 的代码就不贴了,和上面授权码模式一样,只不过在 OpenAuthorizationServerProvider 的 AuthorizeEndpoint 方法中有 IsImplicitGrantType 判断,示例代码:
var identity = new ClaimsIdentity("Bearer");
context.OwinContext.Authentication.SignIn(identity);
context.RequestCompleted();
这段代码执行会直接回调 redirect_uri,并附上 access_token,接受示例代码:
[HttpGet]
[Route("api/access_token")]
public HttpResponseMessage GetToken()
{
var url = Request.RequestUri;
return new HttpResponseMessage()
{
Content = new StringContent("", Encoding.UTF8, "text/plain")
};
}
单元测试代码:
[Fact]
public async Task OAuth_Implicit_Test()
{
var clientId = "xishuai";
var tokenResponse = await _httpClient.GetAsync($"/authorize?response_type=token&client_id={clientId}&redirect_uri={HttpUtility.UrlEncode("http://localhost:8001/api/access_token")}");
//redirect_uri: http://localhost:8001/api/access_token#access_token=AQAAANCMnd8BFdERjHoAwE_Cl-sBAAAAfoPB4HZ0PUe-X6h0UUs2q42&token_type=bearer&expires_in=10
var accessToken = "";//get form redirect_uri
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await _httpClient.GetAsync($"/api/values");
if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine(response.StatusCode);
Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
}
Console.WriteLine(await response.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
回调 redirect_uri 中的 access_token 参数值,因为在 URL 的 # 后,后端不好获取到,所以这里的单元测试只是示例,并不能执行成功,建议使用 Poastman 进行测试。
简单解释:在一开始叙述的 OAuth 授权流程的时候,其实就是密码模式,落网发起授权请求,用户在微博的授权页面填写账号和密码,验证成功则返回 access_token,所以,在此过程中,用户填写的账号和密码,和落网没有半毛钱关系,不会存在账户信息被第三方窃取问题。
请求授权服务(只有一次),需要的参数:
Startup 代码:
public partial class Startup
{
public void ConfigureAuth(IAppBuilder app)
{
var OAuthOptions = new OAuthAuthorizationServerOptions
{
AllowInsecureHttp = true,
AuthenticationMode = AuthenticationMode.Active,
TokenEndpointPath = new PathString("/token"), //获取 access_token 授权服务请求地址
AuthorizeEndpointPath=new PathString("/authorize"), //获取 authorization_code 授权服务请求地址
AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10), //access_token 过期时间
Provider = new OpenAuthorizationServerProvider(), //access_token 相关授权服务
RefreshTokenProvider = new OpenRefreshTokenProvider() //refresh_token 授权服务
};
app.UseOAuthBearerTokens(OAuthOptions); //表示 token_type 使用 bearer 方式
}
}
OpenAuthorizationServerProvider 示例代码:
public class OpenAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
/// <summary>
/// 验证 client 信息
/// </summary>
public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
string clientId;
string clientSecret;
if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
{
context.TryGetFormCredentials(out clientId, out clientSecret);
}
if (clientId != "xishuai")
{
context.SetError("invalid_client", "client is not valid");
return;
}
context.Validated();
}
/// <summary>
/// 生成 access_token(resource owner password credentials 授权方式)
/// </summary>
public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
if (string.IsNullOrEmpty(context.UserName))
{
context.SetError("invalid_username", "username is not valid");
return;
}
if (string.IsNullOrEmpty(context.Password))
{
context.SetError("invalid_password", "password is not valid");
return;
}
if (context.UserName != "xishuai" || context.Password != "123")
{
context.SetError("invalid_identity", "username or password is not valid");
return;
}
var OAuthIdentity = new ClaimsIdentity(context.Options.AuthenticationType);
OAuthIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName));
context.Validated(OAuthIdentity);
}
}
GrantResourceOwnerCredentials 内部可以调用外部服务,以进行对用户账户信息的验证。
单元测试代码:
[Fact]
public async Task OAuth_Password_Test()
{
var tokenResponse = GetToken("password", null, "xishuai", "123").Result; //获取 access_token
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
var response = await _httpClient.GetAsync($"/api/values");
if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine(response.StatusCode);
Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
}
Console.WriteLine(await response.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Thread.Sleep(10000);
var tokenResponseTwo = GetToken("refresh_token", tokenResponse.RefreshToken).Result;
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponseTwo.AccessToken);
var responseTwo = await _httpClient.GetAsync($"/api/values");
Assert.Equal(HttpStatusCode.OK, responseTwo.StatusCode);
}
简单解释:顾名思义,客户端模式就是客户端直接向授权服务发起请求,和用户没什么关系,也就是说落网直接向微博提交授权请求,此类的请求不包含用户信息,一般用作应用程序直接的交互等。
请求授权服务(只有一次),需要的参数:
Startup 代码:
public partial class Startup
{
public void ConfigureAuth(IAppBuilder app)
{
var OAuthOptions = new OAuthAuthorizationServerOptions
{
AllowInsecureHttp = true,
AuthenticationMode = AuthenticationMode.Active,
TokenEndpointPath = new PathString("/token"), //获取 access_token 授权服务请求地址
AuthorizeEndpointPath=new PathString("/authorize"), //获取 authorization_code 授权服务请求地址
AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(10), //access_token 过期时间
Provider = new OpenAuthorizationServerProvider(), //access_token 相关授权服务
RefreshTokenProvider = new OpenRefreshTokenProvider() //refresh_token 授权服务
};
app.UseOAuthBearerTokens(OAuthOptions); //表示 token_type 使用 bearer 方式
}
}
OpenAuthorizationServerProvider 示例代码:
public class OpenAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
/// <summary>
/// 验证 client 信息
/// </summary>
public override async Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
string clientId;
string clientSecret;
if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
{
context.TryGetFormCredentials(out clientId, out clientSecret);
}
if (clientId != "xishuai" || clientSecret != "123")
{
context.SetError("invalid_client", "client or clientSecret is not valid");
return;
}
context.Validated();
}
/// <summary>
/// 生成 access_token(client credentials 授权方式)
/// </summary>
public override async Task GrantClientCredentials(OAuthGrantClientCredentialsContext context)
{
var identity = new ClaimsIdentity(new GenericIdentity(
context.ClientId, OAuthDefaults.AuthenticationType),
context.Scope.Select(x => new Claim("urn:oauth:scope", x)));
context.Validated(identity);
}
}
和其他授权模式不同,客户端授权模式需要对 client_secret 进行验证(ValidateClientAuthentication)。
单元测试代码:
[Fact]
public async Task OAuth_ClientCredentials_Test()
{
var tokenResponse = GetToken("client_credentials").Result; //获取 access_token
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken);
var response = await _httpClient.GetAsync($"/api/values");
if (response.StatusCode != HttpStatusCode.OK)
{
Console.WriteLine(response.StatusCode);
Console.WriteLine((await response.Content.ReadAsAsync<HttpError>()).ExceptionMessage);
}
Console.WriteLine(await response.Content.ReadAsStringAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Thread.Sleep(10000);
var tokenResponseTwo = GetToken("refresh_token", tokenResponse.RefreshToken).Result;
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponseTwo.AccessToken);
var responseTwo = await _httpClient.GetAsync($"/api/values");
Assert.Equal(HttpStatusCode.OK, responseTwo.StatusCode);
}
除了上面四种授权模式之外,还有一种就是更新令牌(refresh token),单元测试代码中已经体现了,需要额外的两个参数:
最后,总结下四种授权模式的应用场景:
开源地址:https://github.com/yuezhongxin/OAuth2.Demo
参考资料: