簡介Oauth2.0授權步驟
授權碼模式的基本步驟
原文鏈接地址
(A)用戶訪問客戶端,后者將前者導向認證服務器。
(B)用戶選擇是否給予客戶端授權。
(C)假設用戶給予授權,認證服務器將用戶導向客戶端事先指定的"重定向URI"(redirection URI),同時附上一個授權碼。
(D)客戶端收到授權碼,附上早先的"重定向URI",向認證服務器申請令牌。這一步是在客戶端的后台的服務器上完成的,對用戶不可見。
(E)認證服務器核對了授權碼和重定向URI,確認無誤后,向客戶端發送訪問令牌(access token)和更新令牌(refresh token)。
各步驟詳細解析
A步驟中,客戶端申請認證的URI,包含以下參數:
- response_type:表示授權類型,必選項,此處的值固定為"code"
- client_id:表示客戶端的ID,必選項
- redirect_uri:表示重定向URI,可選項
- scope:表示申請的權限范圍,可選項
- state:表示客戶端的當前狀態,可以指定任意值,認證服務器會原封不動地返回這個值。
- 下面是一個例子。
/authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
C步驟中,服務器回應客戶端的URI,包含以下參數:
- code:表示授權碼,必選項。該碼的有效期應該很短,通常設為10分鍾,客戶端只能使用該碼一次,否則會被授權服務器拒絕。該碼與客戶端ID和重定向URI,是一一對應關系。
- state:如果客戶端的請求中包含這個參數,認證服務器的回應也必須一模一樣包含這個參數
- 下面是一個例子。 https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
&state=xyz
D步驟中,客戶端向認證服務器申請令牌的HTTP請求,包含以下參數:
- grant_type:表示使用的授權模式,必選項,此處的值固定為"authorization_code"。
- code:表示上一步獲得的授權碼,必選項。
- redirect_uri:表示重定向URI,必選項,且必須與A步驟中的該參數值保持一致。
- client_id:表示客戶端ID,必選項。
- 下面是一個例子。 /token?grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
E步驟中,認證服務器發送的HTTP回復,包含以下參數:
- access_token:表示訪問令牌,必選項。
- token_type:表示令牌類型,該值大小寫不敏感,必選項,可以是bearer類型或mac類型。
- expires_in:表示過期時間,單位為秒。如果省略該參數,必須其他方式設置過期時間。
- refresh_token:表示更新令牌,用來獲取下一次的訪問令牌,可選項。
- scope:表示權限范圍,如果與客戶端申請的范圍一致,此項可省略。
- 下面是一個例子。{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}
項目實戰總結
項目背景簡介
- 項目用的spring全家桶+shiro+oauth2.0,不懂shiro也沒關系
- A系統為門戶系統,需要用戶登錄;
- B系統是一個業務系統,嵌在A系統里面,所以不需要再重新登陸,不提供相關登陸接口;
具體代碼及流程
先介紹B網站登陸認證流程
第一步
- 有一個filter,繼承自shiro的【UserFilter】,onAccessDenied方法(即未登錄狀態下訪問B接口)
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
0. 獲取當前請求的requestUri=requestUri()
1. 生成uuid
2. 將request保存到redis,有效期30秒,redis.set(uuid,request)
3. loginUrl = getLoginUrl()="/B/login?oauth";配置文件
4. 重定向到loginUrl?redirect_uri=/B/proxy?_oauth=uuid
即/B/login?oauth&redirect_uri=/B/proxy?_oauth=uuid
總結:單純系統內攔截未登錄的請求,並將請求信息保存下來,然后跳轉到B系統的登陸url,附加了一些參數,方便回調
}
第二步
@RequestMapping("/login")
public void login(
HttpServletRequest request,
HttpServletResponse response
) throws URISyntaxException, IOException{
String code=request.getParameter("code");
if(StringUtils.isNotBlank(code)){
// ...
// 判斷code的先省略,一會再看
// code不為空,代表是從A系統授權過來的請求
// ....
}else{
else 部分的方法見下面
}
// else 部分的方法
// 獲取loginUrl=http://localhost:8080/B/login相當於是寫死的
1.loginUrl=String.format("%s://%s:%d%s/%s",request.getScheme(),request.getServerName(),request.getServerPort(),request.getContextPath(),"login");
2.取第一步中的傳過來的參數redirect_uri,拼接到loginUrl的后面,
3.新的loginUrl=http://localhost:8080/B/login?redirect_uri=/B/proxy?_oauth=b54896bc-2c26-4763-a2a6-7cbfb9223c76
// 以上都只是在自己的系統中拼裝參數之類的
// 接下來就是應用Oauth2相關的協議了
我們在B系統的配置文件中配置了以下配置項
oauth2.url=http://localhost:8080/A
oauth2.loginUrl=http://localhost/A
oauth2.resource.charset=utf-8
oauth2.authorize=/oauth2/authorize
oauth2.access_token=/oauth2/access_token
oauth2.resource=/oauth2/resource
oauth2.client_id=c3991093-f25c-11e6-b7ab-524f72a73ffc
oauth2.client_secret=d02153b8-f25c-11e6-b7ab-524f72a73ffc
4.接下來就要調用/A系統中的方法授權方法了,取{oauth2.loginUrl}和{oauth2.authorize}倆個參數,拼裝成:http://localhost/portal.web/oauth2/authorize。
5.接下來再拼裝Oauth的參數
4和5倆不代碼如下
URIBuilder uriBuilder=new URIBuilder((StringUtils.isBlank(loginUrl)?url:loginUrl)+authorizeUrl);
uriBuilder.addParameter("response_type", "code");
uriBuilder.addParameter("client_id", clientId);
//參數logoutUrl的獲取方式與本方法的loginUrl獲取方式一致
uriBuilder.addParameter("logout_uri",logoutUrl);
uriBuilder.addParameter("state", "portal");
// reUrl就是本方法步驟3生成的loginUrl
uriBuilder.addParameter("redirect_uri", reUrl);
6.最后一步就是跳轉到A系統請求授權redirect(URIBuilder.toString)
}
第三步
- A系統的方法
- A系統同樣有一個filter用來攔截請求,繼承自shiro的UserFilter,當遇到沒有登陸的請求時,
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 獲取請求
String requestURI = getPathWithinApplication(request);
// 判斷請求,如果是登陸請求則直接攔截(意味着shiro會將重定向到登陸頁面);如果不是登陸請求,則保存請求,然后重定向
boolean isLogin=requestURI.indexOf(getLoginUrl())>-1;
if(!isLogin){
String url=request.getRequestURI().toLowerCase();
if(url.indexOf("?")>0){ //去掉?后面的參數
url=url.substring(0,url.indexOf("?"));
}
if(url.endsWith(DEFAULT_REQUEST_JSON) || isJsonRequest(request)){
forwardResponseJson(request, response);
}else if(url.endsWith(DEFAULT_REQUEST_JSONP)){
forwardResponseJsonp(request, response);
}else if(url.endsWith(DEFAULT_REQUEST_XML)){
forwardResponseXml(request, response);
}else{
//判斷是否是oauth2請求,否則返回局部界面
String requestURI = getPathWithinApplication(request);
if(requestURI.equals("/oauth2/authorize")) {
// 第二步中發起的授權請求會進入此處
saveRequestAndRedirectToLogin(request, response);
}else{
redirectToLogin(request, response);
}
}
}
return isLogin;
}
第3.1步 saveRequestAndRedirectToLogin方法
// shiro的方法
@Override
protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
saveRequest(request);
redirectToLogin(request, response);
}
第4步
經過3.1步后,此時系統跳轉到A系統的登陸頁面,用戶輸入賬號密碼后,登陸成功后判斷是否是非登陸的請求,是的話則跳轉到之前的請求。登陸代碼略過,
private void addRedirectUrl(LoginVo lv) {
Subject subject =SecurityUtils.getSubject();
SavedRequest savedRequest=(SavedRequest)subject.getSession().getAttribute(WebUtils.SAVED_REQUEST_KEY);
if(savedRequest!=null) {
// 此時uri = /portal.web/oauth2/authorize,就是3.1步中保存下來的請求
String uri=savedRequest.getRequestURI();
if(uri.endsWith("/oauth2/authorize")){
// 取即將跳轉的url
// url=/portal.web/oauth2/authorize?response_type=code&client_id=c3991093-f25c-11e6-b7ab-524f72a73ffc&logout_uri=http%3A%2F%2Flocalhost%3A8080%2Fcrm-web%2Flogout&state=portal&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcrm-web%2Flogin%3Fredirect_uri%3D%2Fcrm-web%2Fproxy%3F_oauth%3D9041035e-8cd1-48fb-9c90-adc46f9abcae
//
String url=savedRequest.getRequestUrl();
// 這里就是關鍵了,前端會根據這個字段來判斷跳轉到哪里,如果不為空就會跳轉到url所在的url。前端代碼
lv.setRedirectUrl(url);
// 再從shiro的session中移除該請求的信息
Subject subject =SecurityUtils.getSubject();
subject.getSession().removeAttribute(WebUtils.SAVED_REQUEST_KEY);
}
}
}
第5步
此時肯定是已經登陸了A系統,即將訪問A系統的/oauth2/authorize方法如下
這里先把第3/4步的請求本方法的參數打開,方便觀看,攜帶的參數如下
{
response_type=code,
client_id=c3991093-f25c-11e6-b7ab-524f72a73ffc,
logout_uri=http://localhost:8080/crm-web/logout,
state=portal,
redirect_uri=redirect_uri=http://localhost:8080/crm-web/login?redirect_uri=/crm-web/proxy?_oauth=9041035e-8cd1-48fb-9c90-adc46f9abcae
}
這個redirect_uri參數注意一下,它還接着一個redirect_uri,帶着了一會登陸B系統后再跳轉到后接着的這個redirect_uri參數
@RequestMapping(value = "/authorize",method = RequestMethod.GET)
protected void authorize(HttpServletRequest request,HttpServletResponse response){
OAuthResponse resp=null;
OAuthAuthzRequest oauthRequest = oauthRequest = new OAuthAuthzRequest(request);
// 判斷clientId是否存在
if(exists(oauthRequest.getClientId())){
// 生成一個授權code
MD5Generator generator=new MD5Generator();
OAuthIssuer oauthIssuer = new OAuthIssuerImpl(generator);
String authCode = oauthIssuer.authorizationCode();
// OAuth.OAUTH_REDIRECT_URI = "redirect_uri"
String redirectUri=oauthRequest.getParam(OAuth.OAUTH_REDIRECT_URI);
// 然后將剛生成的code與redirect_uri拼在一起生成一個新的code
String code=generator.generateValue(String.format("%s@%s",authCode,redirectUri));
// 將新生成的code封裝成code參數拼裝到redirectUri的后面
redirectUri=String.format("%s&code=%s",redirectUri,code);
// 這一串看着挺嚇人,就是bean的builder模式,簡單說就是根據屬性值生成一個bean而已
resp = OAuthASResponse
.setCode(code)
.setParam("rs-platform-id", String.valueOf(platId))
.location(redirectUri)
.buildQueryMessage();
//將clientId,code,redirectUri, user封成一個對象,存儲到redis
redis.save(clientId,code,redirectUri, user)
// 跳轉到resp.getLocationUri()
// http://localhost:8080/B/login?redirect_uri=/B/proxy?_oauth=549c9e2e-f74a-4a53-8029-b8f8ceb144cb&code=224d33890c31c78070e151897c6f2882&code=224d33890c31c78070e151897c6f2882&state=portal
//至此,終於又跳回到B系統的login方法了,相比第一次,多了一個code參數
response.sendRedirect(resp.getLocationUri());
}
}
第6步
補全B系統的login方法
@RequestMapping("/login")
public void login(
HttpServletRequest request,
HttpServletResponse response
) throws URISyntaxException, IOException{
// 補全判斷code相關的方法,其余方法見第2步
String code=request.getParameter("code");
if(StringUtils.isNotBlank(code)){
String host=request.getRemoteHost();
String reutrnUri=request.getParameter(OAuth2Utils.OAUTH_RETURN_URI);
RunsaOAuthToken token=new RunsaOAuthToken(code,false,host);
ResourceVo resource=new ResourceVo();
try {
// 用code換access_token
OAuth2Response oauthResponse = OAuth2Utils.accessToken(code);
// 用access_token換資源,我們項目中只用到了免登陸,所以取的是登陸信息
resource=OAuth2Utils.resource(oauthResponse);
token.setPrincipal(new PrincipalVo(resource.getUser(),resource.getAuthz()));
// 系統B的登陸
subject.login(token);
//調轉到訪問路徑
if(StringUtils.isNotBlank(reutrnUri)){
response.sendRedirect(reutrnUri);
}
}catch (Exception e) {
logger.error(ExceptionUtils.getStackTrace(e));
OutputStream outputStream=response.getOutputStream();
IOUtils.write(ExceptionUtils.getStackTrace(e), outputStream);
IOUtils.closeQuietly(outputStream);
}
}else{
// 剩下的方法詳細代碼見第2步
}
}
到這里整個大概的流程就結束
下面介紹用code換取token和用token換取source這倆個方法。在我們項目中不太重視這個,我們倆個A/B倆個項目用的一個數據庫,想取啥數據直接數據庫取就好了。只是免登陸,減少用戶操作罷了,沒有用到oauth的精髓。我們后來又有了一個項目C,就沒有再用oauth這種重量級的東西來僅僅做一個免登陸功能了,用的jwt。用戶登錄A后將登陸信息生成一個jwt放在cookie里,請求C接口時帶上jwt就好了。僅僅一個免登陸就要用oauth2來實現,有點大材小用的感覺。當然這不妨礙我們學習。我們項目還自寫了類似mybatis的東西;為了一個搜索,還引入了es,可惜最后沒有投入使用,只進行了一半。(es做一半離職了)炫技的結果
第7步 code換access_token
@RequestMapping(value = "/access_token",method = RequestMethod.POST)
protected void token(HttpServletRequest request,
HttpServletResponse response) throws OAuthSystemException, IOException {
OAuthTokenRequest oauthRequest = null;
OAuthResponse oauthResponse=null;
OAuthIssuer oauthIssuerImpl = new OAuthIssuerImpl(new MD5Generator());
try {
oauthRequest = new OAuthTokenRequest(request);
String authzCode = oauthRequest.getCode();
OAuth2Secret oAuth2Secret=oAuth2Service.selectSecretById(oauthRequest.getClientId(), oauthRequest.getClientSecret());
if(null!=oAuth2Secret){
OAuth2Vo vo=oAuthTokenUtils.select(authzCode);
if(null!=vo){
// 生成令牌,簡單理解其實就是uuid
String accessToken = oauthIssuerImpl.accessToken();
String refreshToken = oauthIssuerImpl.refreshToken();
// 拼裝一個json
oauthResponse = OAuthASResponse
.tokenResponse(HttpServletResponse.SC_OK)
.setAccessToken(accessToken)
.setExpiresIn("3600")
.setTokenType("Bearer")
.setRefreshToken(refreshToken)
.buildJSONMessage();
// 安全考慮,場景限制,只請求一次就足夠了,用完就刪
oAuthTokenUtils.clear(authzCode);
oAuthTokenUtils.save(oauthRequest.getClientId(),
oauthRequest.getClientSecret(),
accessToken,
refreshToken,
vo.getUsers(),
oAuth2Secret.getMeId());
}
}
} catch (OAuthProblemException ex) {
oauthResponse = OAuthResponse
.errorResponse(HttpServletResponse.SC_UNAUTHORIZED)
.error(ex)
.buildJSONMessage();
}finally{
response.setStatus(oauthResponse.getResponseStatus());
OutputStream outputStream=response.getOutputStream();
IOUtils.write(oauthResponse.getBody(), outputStream);
IOUtils.closeQuietly(outputStream);
}
}
第8步 access_token換source
場景限制,只能訪問這一種資源,所有直接寫成一個方法了,否則肯定要隔離
@RequestMapping("/resource")
protected void resource(HttpServletRequest request, HttpServletResponse response)throws OAuthSystemException, IOException{
try {
OAuthAccessResourceRequest oauthRequest = new OAuthAccessResourceRequest(request, ParameterStyle.HEADER);
String accessToken =decodeBase64(oauthRequest.getAccessToken());
// 之前用code換取access_token時保存在redis里的
OAuth2Vo vo=oAuthTokenUtils.select(accessToken);
if(null!=vo){
Map<String, Object> map= 各種操作
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
OutputStream outputStream=response.getOutputStream();
IOUtils.write(JSON.toJSONString(map), outputStream);
IOUtils.closeQuietly(outputStream);
// 老規矩,用完就刪
oAuthTokenUtils.clear(accessToken);
}else{
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.addHeader(OAuth.HeaderType.WWW_AUTHENTICATE, "failure access_token");
}
} catch(OAuthProblemException e) {
logger.error(e.getMessage());
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.addHeader(OAuth.HeaderType.WWW_AUTHENTICATE, "authorization header error");
}
}
項目總結
- 只用來做免登陸有點可惜;
- 因場景限制,項目中流程有一定的局限性;
- 后面會考慮寫一個demo放到github上,
個人demo實例
前言
為了更好的理解oauth2,有了這個demo,
主要功能:
- 支持動態添加合作網站
- 支持token的級別隔離,目前常見的都只有一種token,嘗試打造支持多級別的token
