OAuth是一个关于授权(Authorization)的开放网络标准,目前的版本是2.0版。注意是Authorization(授权),而不是Authentication(认证)。用来做Authentication(认证)的标准叫做openid connect。
一、OAuth2.0理论普及
1、OAuth2.0中的角色说明:
资源拥有者(resource owner):能授权访问受保护资源的一个实体,可以是一个人,那我们称之为最终用户;如新浪微博用户zhangsan;
资源服务器(resource server):存储受保护资源,客户端通过access token请求资源,资源服务器响应受保护资源给客户端;存储着用户zhangsan的微博等信息。
授权服务器(authorization server):成功验证资源拥有者并获取授权之后,授权服务器颁发授权令牌(Access Token)给客户端。
客户端(client):如新浪微博客户端weico、微格等第三方应用,也可以是它自己的官方应用;其本身不存储资源,而是资源拥有者授权通过后,使用它的授权(授权令牌)访问受保护资源,然后客户端把相应的数据展示出来/提交到服务器。“客户端”术语不代表任何特定实现(如应用运行在一台服务器、桌面、手机或其他设备)。
2、OAuth2.0客户端的授权模式:
2.1、Oautho2.0为客户端定义了4种授权模式:
1)授权码模式
2)简化模式
3)密码模式
4)客户端模式
2.2、授权码模式:
授权码模式是功能最完整、流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与"服务提供商"的认证服务器进行互动。
授权码模式的认证流程:
(A)用户访问客户端,后者将前者导向认证服务器。
(B)用户选择是否给予客户端授权。
(C)假设用户给予授权,认证服务器首先生成一个授权码,并返回给用户,认证服务器将用户导向客户端事先指定的"重定向URI"(redirection URI),同时附上一个授权码。
(D)客户端收到授权码,附上早先的"重定向URI",向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。
(E)认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。
注意:(C)和(D)中两个重定向URI是不一样的,(C)中的重定向URI是用来核对的,这个是服务器事先指定并保存在数据库里面。而(D)中的重定向URI指的是生成access_token的url。
3、选择合适的OAuth模式打造自己的webApi认证服务
场景:你自己实现了一套webApi,想供自己的客户端调用,又想做认证。
这种场景下你应该选择模式3或者4,特别是当你的的客户端是js+html应该选择3,当你的客户端是移动端(ios应用之类)可以选择3,也可以选择4。
密码模式(resource owner password credentials)的流程:
我在另一篇博客中
《深入聊聊微服务架构的身份认证问题》,详细介绍了各种微服务身份认证的技术方案。
如果觉得以上理论信息意犹未尽的话,请继续关注鄙人博客,或者搜索相关的资料,做进一步的研究。
下面就以授权码授权模式为例,进行代码的实践。
二、OAuth2.0实践
这里是以Asp.Net mvc5为例,具体步骤如下:
首先引用Owin OAuth相关的类库。
Microsoft.AspNet.Identity;
Microsoft.Owin;
Microsoft.Owin.Security.Cookies;
Microsoft.Owin.Security.Infrastructure;
Microsoft.Owin.Security.OAuth;
添加Owin启动类,代码如下:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNet.Identity;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Infrastructure;
using Microsoft.Owin.Security.OAuth;
using Owin;
using System.Security.Claims;
using System.Collections.Concurrent;
/*=================================================================================================
*
* Title:XXXXXXXX
* Author:李朝强
* Description:模块描述
* CreatedBy:lichaoqiang.com
* CreatedOn:2017-8-4 11:16:42
* ModifyBy:暂无...
* ModifyOn:2017-8-4 11:16:42
* Blog:http://www.lichaoqiang.com
* Mark:
*
*================================================================================================*/
[assembly: OwinStartup(typeof(OAuthCode.Startup))]
namespace OAuthCode
{
/// <summary>
/// 应用程序启动类
/// </summary>
public class Startup
{
/// <summary>
/// 用来存放临时授权码 线程安全
/// </summary>
private readonly ConcurrentDictionary<string, string> _authenticationCodes = new ConcurrentDictionary<string, string>(StringComparer.Ordinal);
/// <summary>
/// 配置授权
/// </summary>
/// <param name="app"></param>
public void Configuration(IAppBuilder app)
{
//创建OAuth授权服务器
app.UseOAuthAuthorizationServer(new Microsoft.Owin.Security.OAuth.OAuthAuthorizationServerOptions()
{
AllowInsecureHttp = true,//开启
AuthenticationType = "Bearer",
AuthorizeEndpointPath = new PathString("/OAuth/Authorize"),
TokenEndpointPath = new PathString("/OAuth/Token"),
AccessTokenExpireTimeSpan = TimeSpan.FromMinutes(30),
Provider = new OAuthAuthorizationServerProvider()
{
//授权码 authorization_code
OnGrantAuthorizationCode = ctx =>
{
if (ctx.Ticket != null &&
ctx.Ticket.Identity != null &&
ctx.Ticket.Identity.IsAuthenticated)
{
ctx.Validated(ctx.Ticket.Identity);//
}
return Task.FromResult(0);
},
OnGrantRefreshToken = ctx =>
{
if (ctx.Ticket != null &&
ctx.Ticket.Identity != null &&
ctx.Ticket.Identity.IsAuthenticated)
{
ctx.Validated();
}
return Task.FromResult(0);
},
//OnGrantResourceOwnerCredentials = (context) =>
//{
// context.Validated(context.Ticket.Identity);
// return Task.FromResult(0);
//},
OnValidateAuthorizeRequest = ctx =>
{
ctx.Validated();
return Task.FromResult(ctx);
},
//验证redirect_uri是否合法
OnValidateClientRedirectUri = context =>
{
context.Validated(redirectUri: context.RedirectUri);
return Task.FromResult(context);
},
//用来验证请求中的client_id和client_secret
OnValidateClientAuthentication = context =>
{
string clientId;
string clientSecret;
//这是通过Basic或form的方式,获取client_id和client_secret
if (context.TryGetBasicCredentials(out clientId, out clientSecret) ||
context.TryGetFormCredentials(out clientId, out clientSecret))
{
context.Validated(clientId);
}
return Task.FromResult(context);
},
OnAuthorizeEndpoint = context =>
{
return Task.FromResult(context);
},
OnTokenEndpoint = (context) =>
{
return Task.FromResult(context);
},
//OnGrantClientCredentials = (context) =>
//{
// context.Validated();
// return Task.FromResult(context);
//}
},
//Code授权
AuthorizationCodeProvider = new AuthenticationTokenProvider()
{
OnCreate = context =>
{
context.SetToken(DateTime.Now.Ticks.ToString());
string token = context.Token;
string ticket = context.SerializeTicket();
var redirect_uri = context.Request.Query["redirect_uri"];
context.Response.Redirect(string.Format("{0}?code={1}&state=1", redirect_uri, token));
_authenticationCodes[token] = ticket;//这里存放授权码
},
//当接收到code时
OnReceive = context =>
{
string token = context.Token;
string ticket;
if (_authenticationCodes.TryRemove(token, out ticket))
{
context.DeserializeTicket(ticket);
}
},
},
//(可选)访问令牌
AccessTokenProvider = new AuthenticationTokenProvider()
{
//创建访问令牌
OnCreate = (context) =>
{
string token = context.SerializeTicket();
context.SetToken(token);
},
//接收
OnReceive = (context) =>
{
context.DeserializeTicket(context.Token);
},
},
//刷新令牌
RefreshTokenProvider = new AuthenticationTokenProvider()
{
OnCreate = context =>
{
context.SetToken(context.SerializeTicket());
},
OnReceive = context =>
{
context.DeserializeTicket(context.Token);
},
}
});
//本地Cookie身份认证
app.UseCookieAuthentication(new CookieAuthenticationOptions()
{
LoginPath = new PathString("/Account/Login"),
AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie
});
}
}
}
以上是启动类的所有代码,你也可以在码云中获取。
接下来,我们需要一个登录授权页面,这里有两个控制器
AccountController及OAuthController,分别负责登录及认证授权。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Web;
using System.Web.Mvc;
using Microsoft.AspNet.Identity;
using Microsoft.Owin.Security;
using OAuthCode.Models;
namespace OAuthCode.Controllers
{
public class AccountController : Controller
{
/// <summary>
///
/// </summary>
public IAuthenticationManager AuthenticationManager
{
get { return HttpContext.GetOwinContext().Authentication; }
}
//
// GET: /Account/
public ActionResult Index()
{
return View();
}
/// <summary>
///
/// </summary>
/// <returns></returns>
public ActionResult Login(string returnUrl)
{
ViewBag.returnUrl = Uri.EscapeDataString(returnUrl);
return View();
}
/// <summary>
///
/// </summary>
/// <param name="model"></param>
/// <param name="returnUrl"></param>
/// <returns></returns>
[HttpPost]
public ActionResult Login(LoginViewModel model, string returnUrl)
{
string userId = "1";
//可以在这里将用户所属的role或者Claim添加到此
ClaimsIdentity claims = new ClaimsIdentity(new[] {
new Claim(ClaimTypes.Name, model.account)
,new Claim(ClaimTypes.NameIdentifier,userId)
,new Claim("http://schemas.microsoft.com/accesscontrolservice/2010/07/claims/identityprovider",userId)},
DefaultAuthenticationTypes.ApplicationCookie);
AuthenticationProperties properties = new AuthenticationProperties
{
IsPersistent = true
};
ClaimsPrincipal principal = new ClaimsPrincipal(claims);
//System.Threading.Thread.CurrentPrincipal = principal;
this.AuthenticationManager.SignIn(properties, new[] { claims });
return Redirect(returnUrl);
}
}
}
以上是登录控制器有关的代码。
接下来,我们编写下OAuthController控制器相关的Action.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Claims;
using System.Web;
using System.Web.Http;
using System.Web.Mvc;
using Microsoft.AspNet.Identity;
using Microsoft.Owin.Security;
using System.Threading.Tasks;
using DotNetOpenAuth.OAuth2;
/******************************************************************************************************************
*
*
* 说 明: (版本:Version1.0.0)
* 作 者:李朝强
* 日 期:2015/05/19
* 修 改:
* 参 考:http://my.oschina.net/lichaoqiang/
* 备 注:暂无...
*
*
* ***************************************************************************************************************/
namespace OAuthCode.Controllers
{
/// <summary>
/// <![CDATA[验证授权]]>
/// </summary>
public class OAuthController : Controller
{
/// <summary>
/// <!--授权-->
/// </summary>
/// <returns></returns>
public ActionResult Authorize()
{
//验证是否登录,如果没有,
IAuthenticationManager authentication = HttpContext.GetOwinContext().Authentication;
AuthenticateResult ticket = authentication.AuthenticateAsync(DefaultAuthenticationTypes.ApplicationCookie).Result;
ClaimsIdentity identity = ticket == null ? null : ticket.Identity;
if (identity == null)
{
//如果没有验证通过,则必须先通过身份验证,跳转到验证方法
authentication.Challenge();
return new HttpUnauthorizedResult();
}
identity = new ClaimsIdentity(identity.Claims, "Bearer");
//hardcode添加一些Claim,正常是从数据库中根据用户ID来查找添加
identity.AddClaim(new Claim(ClaimTypes.Role, "Admin"));
identity.AddClaim(new Claim(ClaimTypes.Role, "Normal"));
identity.AddClaim(new Claim("MyType", "MyValue"));
authentication.SignIn(new AuthenticationProperties() { IsPersistent = true }, identity);
return new EmptyResult();
}
/// <summary>
///<![CDATA[获取访问令牌]]>
/// </summary>
/// <returns></returns>
public async Task<ActionResult> GetAccessToken()
{
#region 使用DotNetOpenOAuth获取访问令牌
//var authServer = new AuthorizationServerDescription
//{
// AuthorizationEndpoint = new Uri("http://localhost:3335/OAuth/Authorize"),
// TokenEndpoint = new Uri("http://localhost:3335/OAuth/Token"),
//};
//var autoServerClient = new DotNetOpenAuth.OAuth2.WebServerClient(authServer, clientIdentifier: "fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8", clientSecret: "clientSecret");
//var authorizationState = autoServerClient.ProcessUserAuthorization();
//if (authorizationState != null)
//{
// if (!string.IsNullOrEmpty(authorizationState.AccessToken))
// {
// var token = authorizationState.AccessToken;
// }
//}
#endregion 使用DotNetOpenOAuth获取访问令牌
#region 根据授权码,获取访问令牌 模拟第三方回调地址 redirect_uri
string strCode = Request.QueryString["code"];//访问令牌
if (string.IsNullOrEmpty(strCode) == false)
{
HttpClient client = new HttpClient();
HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Post, "http://localhost:3335/OAuth/Token");
Dictionary<string, string> dict = new Dictionary<string, string>();
dict["grant_type"] = "authorization_code";
dict["client_id"] = "fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8";
dict["client_secret"] = "111111";
dict["code"] = strCode;
dict["redirect_uri"] = "http://localhost:3335/OAuth/GetAccessToken";
dict["scope"] = "scope1";
message.Content = new FormUrlEncodedContent(dict);
var response = await client.SendAsync(message);
string strResponseText = await response.Content.ReadAsStringAsync();
return Content(strResponseText, "text/javascript");
}
#endregion 根据授权码,获取访问令牌
return Content("invalid code!");
}
}
}
其中包含了认证及回调获取令牌的处理逻辑。
以上步骤中,注意的事项比较多,
认证终结点:/OAuth/Authorize
下面是我本地演示的认证地址:
http://localhost:3335//OAuth/Authorize?redirect_uri=http://localhost:3335/OAuth/GetAccessToken&client_id=fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8&client_secret=111111&response_type=code&scope=scope1
授权码模式下需要注意以下事项:
注意:
认证的时候,response_type必须为code,scope可选参数。
授权(获取令牌的)的时候:grant_type必须为authorization_code
完成以上工作,接下来让我们进行尝试,首先,打开请求认证授权的地址,
http://localhost:3335//OAuth/Authorize?redirect_uri=http://localhost:3335/OAuth/GetAccessToken&client_id=fNm0EDIXbfuuDowUpAoq5GTEiywV8eg0TpiIVnV8&client_secret=111111&response_type=code&scope=scope1
它请求的是认证终结点,也就是颁发授权码的入口。
第一次,由于没有登录,于是会出现登录授权的界面。
这个过程就像,第三方登录,如QQ,点击QQ登录,会出现QQ的授权页面,这里只是省略了,可以根据实际情况进行定制。
我们点击登录授权
返回结果如下:
{"access_token":"p8Jd6YwYmBgDkyTt4zTBMWNzTRbZRAM30vO3gfOiqzEw_8dCft-emDrbCC4o6_DGHW2zX0HuQus_4GJ1mYio6meCGeNP4tyEz_la4_zP8vJPsWG0TyXIwzyZth0ioWJ9JJc453MXNMH7EPMevrRsYyQpPG387gEaQFia1Q3EL7EOV7_LIkpmmMyfHGuxaTbevCbekWqR8YJdpigFd4WSwOOlode_PwL23qtneu-ezE3YitFoRIicD4rLk62lCme5pc9gFHBo2d0hRjyu7sHbqiwotWISDm290ddkhlhGlS2cPNJKYJZeCXMb7EPOdTuWMWBoOO1tpFZUsWZDVsbsu2tf42O5SNvQwzNw_o-oDW3riDVwle6aW5IqwFDk2cBIXVU3_ewbNhx13r4HfoeyhzFqUBmOjmUcB2qaER5UaEDsNVf8d0-KukUPEW-MHwl42flCTB_qqFn7ZjiKOIbjZJbVlbVj8vDvzTYjrc3msjc","token_type":"bearer","expires_in":1799,"refresh_token":"bR4OmKey_ex9JJApDP8-3O1gFZ0X9yefaXGS95At5q8NDt_v8CIM825jJklg0hrMd-yvpK_9ZG_ev8jViK78G7XN6jmy882bZPZAgcKT4tf879rKtMR_m7v4SJQAy7Jf1WnDr-U_Ty5s8bAnTCZFj99kK-S0mSoeBbgyepk1Cvez0fsw60jovxH8q_DPJPFfFETGQKYmDWQ34T1MeBllgtfZ2_Ayp5Dd4RBewDQTb_c1-cgmXy4rE_rcHz751aRsYSvhHU07QnzpjtFO6oo0i3bjWD84SCxhoevXEm-5TgBLNeX_WGv1raazwgAoV_7lpbvbGgsVbEcjyAkx05j81wp9RU3NnaKxQOpstOFW3C7ER-jx9niGplwpFS_As7t2L3Z0_ww2XwS1LHyMDXEYU3UPSP3EA7aW12qoNpxfe1ep0Ky-4kc1tFf3qq9syIvTgmXhXjGqxD8m3PvZsxlpHV89RVqFrrbCkTqIH3gm9fw"}
项目源码:https://gitee.com/lichaoqiang/RichCodeBox
RichCodeBox其中包含了代码JWT、客户端模式、授权码模式等。
另外,我们也可以自己定制OAuthAuthorizationServerProvider。我在JWT中有用到,以下代码只做了解代码如下:
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.DataHandler.Encoder;
using Microsoft.Owin.Security.OAuth;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web;
namespace WebApplication3.OAuth
{
/// <summary>
///
/// </summary>
public class CustomAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
/// <summary>
///
/// </summary>
public CustomAuthorizationServerProvider()
{
}
/// <summary>
///
/// </summary>
private TokenValidationParameters _validationParameters;
/// <summary>
///
/// </summary>
/// <param name="validationParameters"></param>
public CustomAuthorizationServerProvider(TokenValidationParameters validationParameters)
{
_validationParameters = validationParameters;
}
/// <summary>
/// <![CDATA[授权资源拥有者证书]]>
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public override Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
var secret = TextEncodings.Base64Url.Decode("IxrAjDoa2FqElO7IhrSrUJELhUckePEPVpaePlS_Xaw");//秘钥
var username = context.UserName;
var password = context.Password;
string userid;
if (!CheckCredential(username, password, out userid))
{
context.SetError("invalid_grant", "The user name or password is incorrect");
context.Rejected();//拒绝访问
return Task.FromResult<object>(context);
}
var ticket = new AuthenticationTicket(SetClaimsIdentity(context, userid, username), new AuthenticationProperties());
context.Validated(ticket);
return Task.FromResult<object>(context);
}
/// <summary>
/// <![CDATA[验证客户端授权]]>
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
string client_id;
string client_secret;
context.TryGetFormCredentials(out client_id, out client_secret);
//验证clientid
if (string.IsNullOrEmpty(context.ClientId) ||
context.ClientId != Constants.Const.OAuth2.CLIENT_ID)
{
context.SetError("ClientId is incorrect!");
context.Rejected();
}
//正常
else
{
context.Validated();
}
return Task.FromResult<object>(context);
}
/// <summary>
///
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public override Task GrantRefreshToken(OAuthGrantRefreshTokenContext context)
{
return Task.FromResult<object>(context);
}
/// <summary>
/// 验证客户端重定向URL
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public override Task ValidateClientRedirectUri(OAuthValidateClientRedirectUriContext context)
{
if (string.IsNullOrEmpty(context.ClientId) ||
context.ClientId != Constants.Const.OAuth2.CLIENT_ID)
{
context.SetError("client_id is incorrect!");
context.Rejected();
}
if (string.IsNullOrEmpty(context.RedirectUri))
{
context.SetError("redirect_uri is null!");
context.Rejected();
}
else
{
context.Validated(context.RedirectUri);
}
return Task.FromResult<object>(0);
}
/// <summary>
/// 验证令牌
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public override Task ValidateTokenRequest(OAuthValidateTokenRequestContext context)
{
context.Validated();
return Task.FromResult<object>(context);
}
#region 私有方法
/// <summary>
/// 设置声明
/// </summary>
/// <param name="context"></param>
/// <param name="userid"></param>
/// <param name="usercode"></param>
/// <returns></returns>
private static ClaimsIdentity SetClaimsIdentity(OAuthGrantResourceOwnerCredentialsContext context, string userid, string usercode)
{
var identity = new ClaimsIdentity("JWT");
identity.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, userid));
identity.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()));
identity.AddClaim(new Claim(ClaimTypes.Name, usercode));
identity.AddClaim(new Claim(ClaimTypes.Role, "1"));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, usercode));
return identity;
}
/// <summary>
/// 检测用户凭证
/// </summary>
/// <param name="usernme"></param>
/// <param name="password"></param>
/// <param name="userid"></param>
/// <returns></returns>
private static bool CheckCredential(string usernme, string password, out string userid)
{
var success = false;
// 用户名和密码验证
if (usernme == "admin" && password == "admin")
{
userid = "1";
success = true;
}
else
{
userid = string.Empty;
}
return success;
}
#endregion 私有方法
}
}
另外,建议在使用Microsoft.Owin.Security.OAuth默认的AccessToken生成类时,在
配置文件中,添加machineKey的有配置,关于machineKey的生成工具,不在讨论范围。