https://blog.csdn.net/qq_30905661/article/details/82424552
上一章介绍了与OAuth2相关的核心类,让我们再复习一遍,如果有遗忘的地方请移步到上一章查看。
- 四大角色:ResouceServer AuthorizationServer client user
- OAuth2AccessToken OAuth2Authentiaction
- OAuth2Request TokenRequest AuthorizationRequest
- TokenGranter TokenStore TokenExtractor DefaultTokenServices
- ResourceServerConfigurerAdapter AuthorizationServerConfigurerAdapter
- TokenEndPoint(/oauth/token) AuthorizationEndPoint(/oauth/authorize)
上面介绍的全是乐高积木的小部件,如何把积木拼起来才是关键。说到oauth2不可避免的就要聊到5种授权模式。
(1)授权码模式(Authorization Code)
(2)授权码简化模式(Implicit)
(3)Pwd模式(Resource Owner Password Credentials)
(4)Client模式(Client Credentials)
(5)扩展模式(Extension)
无论哪一种,核心思想都是client向authorization server发出请求,请求参数有client_id,client_secret,scope,response_type或redirect_uri等,authorization server经过验证后,返回client一个access_token。凭借这个access_token,client再去resource server中获取资源。这章只介绍授权码模式的流程
1.授权码模式流程图
没了解原理前是看着挺费劲的,一般的验证只需要client发出一次请求就能获得access_token,但授权码模式多了一个Authorization code。这样就分两步走,第一步请求/oauth/authorize获得Authorization code,第二步请求/oauth/token获得access_token。
这里就以登录csdn为例来解释授权码模式。
1.打开csdn登录页面,选择QQ登录。此时client为csdn,qq为authroization server和resouce server。qq授权服务器里存储了很多client信息,csdn只是众多client中的一个。
2.页面跳转至QQ授权页面,url地址是
里面有3个关键的参数,response_type,client_id以及redirect_uri。点击了下图中授权并登录的按钮后,页面自动跳转到了csdn主页,且获取到了QQ相关信息。看似很简单的跳转其实包含了很多步骤。
3.用户填写用户名、密码后,点击授权并登录,首先访问qq授权服务器的/login路径,spring security验证username和password后给用户发放JSessionId的cookie,session中存储了Authentication。
4.再访问qq授权服务器/oauth/authorize,请求参数有response_type,redirect_uri,client_id,验证通过后请求重定向到redirect_uri,且传递Authorization code。
5.redirect_uri路径指向的是client中的一个endpoint,client接收到了code,表明client信息已经在QQ授权服务器验证成功。再凭借这个code值外加client_id,client_secret,grant_type=authorization_code,code,redirect_uri等参数,去访问QQ的/oauth/token,返回access_token。
6.获得access_token后,client再去找qq的资源服务器要资源。
一句话概括,就是按顺序依次获得authentication ---> Authorization code ----> access_token。
2. 源码分析
为了方便理解,这里先给出来自github lexburner的例子,项目地址是https://github.com/lexburner/oauth2-demo
项目内有Aiqiyi和qq两个服务,分别是client和authorization server,操作说明详见readme.md,这里不做赘述。
2.1 第一次请求/oauth/authorize
请求的完整url如下,有参数client_id,response_type,redirect_uri。
http://localhost:8080/oauth/authorize?client_id=aiqiyi&response_type=code&redirect_uri=http://localhost:8081/aiqiyi/qq/redirect
访问AuthorizationEndPoint中的/oauth/authorize,里面会判断client信息和用户信息,如果user没有Authentication,则会报错,跳转到ExceptionTranslationFilter类中,请求转发到/login路径,并将现请求路径存储到session的saverequest中。
-
@RequestMapping(value =
"/oauth/authorize")
-
public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
-
SessionStatus sessionStatus, Principal principal) {
-
-
AuthorizationRequest authorizationRequest = getOAuth2RequestFactory().createAuthorizationRequest(parameters);
-
-
Set<String> responseTypes = authorizationRequest.getResponseTypes();
-
-
if (!responseTypes.contains(
"token") && !responseTypes.contains(
"code")) {
-
throw
new UnsupportedResponseTypeException(
"Unsupported response types: " + responseTypes);
-
}
-
// 判断clientId是否为空
-
if (authorizationRequest.getClientId() ==
null) {
-
throw
new InvalidClientException(
"A client id must be provided");
-
}
-
-
try {
-
// 判断user是否认证,认证失败则跳转到/login路径
-
if (!(principal
instanceof Authentication) || !((Authentication) principal).isAuthenticated()) {
-
throw
new InsufficientAuthenticationException(
-
"User must be authenticated with Spring Security before authorization can be completed.");
-
}
-
-
// 验证client信息
-
ClientDetails client = getClientDetailsService().loadClientByClientId(authorizationRequest.getClientId());
-
-
...
-
}
-
}
2.2 ExceptionTranslationFilter
-
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
-
throws IOException, ServletException {
-
HttpServletRequest request = (HttpServletRequest) req;
-
HttpServletResponse response = (HttpServletResponse) res;
-
-
try {
-
chain.doFilter(request, response);
-
-
logger.debug(
"Chain processed normally");
-
}
-
catch (IOException ex) {
-
throw ex;
-
}
-
catch (Exception ex) {
-
// Try to extract a SpringSecurityException from the stacktrace
-
Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
-
RuntimeException ase = (AuthenticationException) throwableAnalyzer
-
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
-
-
if (ase ==
null) {
-
ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(
-
AccessDeniedException.class, causeChain);
-
}
-
-
if (ase !=
null) {
-
// 进入到此方法中
-
handleSpringSecurityException(request, response, chain, ase);
-
}
-
...
-
}
-
}
-
-
-
-
private void handleSpringSecurityException(HttpServletRequest request,
-
HttpServletResponse response, FilterChain chain, RuntimeException exception)
-
throws IOException, ServletException {
-
if (exception
instanceof AuthenticationException) {
-
logger.debug(
-
"Authentication exception occurred; redirecting to authentication entry point",
-
exception);
-
// 没有验证身份信息,跳转到/login界面
-
sendStartAuthentication(request, response, chain,
-
(AuthenticationException) exception);
-
}
-
...
-
}
-
-
-
protected void sendStartAuthentication(HttpServletRequest request,
-
HttpServletResponse response, FilterChain chain,
-
AuthenticationException reason)
throws ServletException, IOException {
-
-
SecurityContextHolder.getContext().setAuthentication(
null);
-
// 将saveRequest存到session中,方便身份验证成功后调用
-
requestCache.saveRequest(request, response);
-
logger.debug(
"Calling Authentication entry point.");
-
// 请求重定向到/login
-
authenticationEntryPoint.commence(request, response, reason);
-
}
-
2.3 requestCache.saveRequest(request,response)
requestCache的常用的实现类是HttpSessionRequestCache,一般是访问url时系统判断用户未获得授权,ExceptionTranslationFilter会存储savedRequest到session中,名为“SPRING_SECURITY_SAVED_REQUEST”。
SavedRequest里面包含原先访问的url地址、cookie、header、parameter等信息,一旦Authentication认证成功,successHandler.onAuthenticationSuccess(SavedRequestAwareAuthenticationSuccessHandler)会从session中抽取savedRequest,继续访问原先的url。
-
public
class HttpSessionRequestCache implements RequestCache {
-
static
final String SAVED_REQUEST =
"SPRING_SECURITY_SAVED_REQUEST";
-
/**
-
* HttpSessionRequestCache Stores the current request, provided the configuration properties allow it.
-
*/
-
public void saveRequest(HttpServletRequest request, HttpServletResponse response) {
-
if (requestMatcher.matches(request)) {
-
DefaultSavedRequest savedRequest =
new DefaultSavedRequest(request,
-
portResolver);
-
-
if (createSessionAllowed || request.getSession(
false) !=
null) {
-
// Store the HTTP request itself. Used by
-
// AbstractAuthenticationProcessingFilter
-
// for redirection after successful authentication (SEC-29)
-
request.getSession().setAttribute(
this.sessionAttrName, savedRequest);
-
logger.debug(
"DefaultSavedRequest added to Session: " + savedRequest);
-
}
-
}
-
else {
-
logger.debug(
"Request not saved as configured RequestMatcher did not match");
-
}
-
}
-
}
2.4 重定向到/login
由于是第一次访问qq认证服务器,所以需要用户登录校验身份。在WebSecurityConfigurerAdapter的继承类中,找到存储在缓存中的用户名密码,填写完毕。
点击“Sign In”按钮后,post请求/login路径,按照FilterChainProxy的filter链运行到UsernamePasswordAuthenticationFilter,验证通过后执行successHandler.onAuthenticationSuccess(request, response, authResult),获取session中的savedrequest,重定向到原先的地址/oauth/authorize,并附带完整请求参数。
-
public
class SavedRequestAwareAuthenticationSuccessHandler extends
-
SimpleUrlAuthenticationSuccessHandler {
-
protected
final Log logger = LogFactory.getLog(
this.getClass());
-
-
private RequestCache requestCache =
new HttpSessionRequestCache();
-
-
@Override
-
public void onAuthenticationSuccess(HttpServletRequest request,
-
HttpServletResponse response, Authentication authentication)
-
throws ServletException, IOException {
-
// HttpSessionRequestCache.getRequest ,找名为SPRING_SECURITY_SAVED_REQUEST的session
-
SavedRequest savedRequest = requestCache.getRequest(request, response);
-
-
if (savedRequest ==
null) {
-
super.onAuthenticationSuccess(request, response, authentication);
-
-
return;
-
}
-
String targetUrlParameter = getTargetUrlParameter();
-
if (isAlwaysUseDefaultTargetUrl()
-
|| (targetUrlParameter !=
null && StringUtils.hasText(request
-
.getParameter(targetUrlParameter)))) {
-
requestCache.removeRequest(request, response);
-
super.onAuthenticationSuccess(request, response, authentication);
-
-
return;
-
}
-
-
clearAuthenticationAttributes(request);
-
-
// Use the DefaultSavedRequest URL
-
// 获得原先存储在SavedRequest中的redirectUrl,即/oauth/authorize
-
String targetUrl = savedRequest.getRedirectUrl();
-
logger.debug(
"Redirecting to DefaultSavedRequest Url: " + targetUrl);
-
getRedirectStrategy().sendRedirect(request, response, targetUrl);
-
}
-
-
public void setRequestCache(RequestCache requestCache) {
-
this.requestCache = requestCache;
-
}
-
}
2.5 第二次请求/oauth/authorize
这次请求就硬气多了,请求中携带了Authentication的session,系统验证通过,生成授权码,存储在InMemoryAuthorizationCodeServices中的concurrenthashmap中,且返回给请求参数中的redirect_uri,即http://localhost:8081/aiqiyi/qq/redirect。
http://localhost:8080/oauth/authorize?client_id=aiqiyi&response_type=code&redirect_uri=http://localhost:8081/aiqiyi/qq/redirect
-
@RequestMapping(value =
"/oauth/authorize")
-
public ModelAndView authorize(Map<String, Object> model, @RequestParam Map<String, String> parameters,
-
SessionStatus sessionStatus, Principal principal) {
-
...
-
if (authorizationRequest.isApproved()) {
-
if (responseTypes.contains(
"token")) {
-
return getImplicitGrantResponse(authorizationRequest);
-
}
-
if (responseTypes.contains(
"code")) {
-
// 返回code给redirect_uri
-
return
new ModelAndView(getAuthorizationCodeResponse(authorizationRequest,
-
(Authentication) principal));
-
}
-
}
-
...
-
}
-
public
class InMemoryAuthorizationCodeServices extends RandomValueAuthorizationCodeServices {
-
-
protected
final ConcurrentHashMap<String, OAuth2Authentication> authorizationCodeStore =
new ConcurrentHashMap<String, OAuth2Authentication>();
-
-
@Override
-
protected void store(String code, OAuth2Authentication authentication) {
-
this.authorizationCodeStore.put(code, authentication);
-
}
-
-
@Override
-
public OAuth2Authentication remove(String code) {
-
OAuth2Authentication auth =
this.authorizationCodeStore.remove(code);
-
return auth;
-
}
-
-
}
到了这里我们总结下刚才都发生了什么。首先aiqiyi向qq发出/oauth/authorize的请求,qq服务器的AuthorizationEndPoint判断用户是否登录,如果没有登录则先跳转到/login界面,同时存储首次request的信息,保存在session中。用户登录并授权后,程序自动获取刚存储在session中的savedrequest,再次访问/oauth/authorize。验证client信息和user信息成功后,重定向到redirect_uri,并传参数code。
aiqiyi接到code后,再附带client_id,client_secret,grant_type,redirect_uri等信息post请求/oauth/token,从而获得access_token。
2.6 client接收code并向oauth server请求/oauth/token
以下代码自行写在client的controller里,用于接收qq服务端传递来的code,并请求/oauth/token。
-
@RequestMapping(
"/aiqiyi/qq/redirect")
-
public String getToken(@RequestParam String code){
-
log.info(
"receive code {}",code);
-
HttpHeaders headers =
new HttpHeaders();
-
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
-
MultiValueMap<String, String> params=
new LinkedMultiValueMap<>();
-
params.add(
"grant_type",
"authorization_code");
-
params.add(
"code",code);
-
params.add(
"client_id",
"aiqiyi");
-
params.add(
"client_secret",
"secret");
-
params.add(
"redirect_uri",
"http://localhost:8081/aiqiyi/qq/redirect");
-
HttpEntity<MultiValueMap<String, String>> requestEntity =
new HttpEntity<>(params, headers);
-
ResponseEntity<String> response = restTemplate.postForEntity(
"http://localhost:8080/oauth/token", requestEntity, String.class);
-
String token = response.getBody();
-
log.info(
"token => {}",token);
-
return token;
-
}
2.7 TokenEndPoint生成access_token
具体代码在上一章已介绍,这里不做详述。注意的是会从InMemoryAuthorizationCodeServices中提取hashmap验证code是否正确。
3 总结
github地址:https://github.com/lexburner/oauth2-demo
<li class="tool-item tool-active is-like "><a href="javascript:;"><svg class="icon" aria-hidden="true"> <use xlink:href="#csdnc-thumbsup"></use> </svg><span class="name">点赞</span> <span class="count">3</span> </a></li> <li class="tool-item tool-active is-collection "><a href="javascript:;" data-report-click="{"mod":"popu_824"}"><svg class="icon" aria-hidden="true"> <use xlink:href="#icon-csdnc-Collection-G"></use> </svg><span class="name">收藏</span></a></li> <li class="tool-item tool-active is-share"><a href="javascript:;" data-report-click="{"mod":"1582594662_002"}"><svg class="icon" aria-hidden="true"> <use xlink:href="#icon-csdnc-fenxiang"></use> </svg>分享</a></li> <!--打赏开始--> <!--打赏结束--> <li class="tool-item tool-more"> <a> <svg t="1575545411852" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5717" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><defs><style type="text/css"></style></defs><path d="M179.176 499.222m-113.245 0a113.245 113.245 0 1 0 226.49 0 113.245 113.245 0 1 0-226.49 0Z" p-id="5718"></path><path d="M509.684 499.222m-113.245 0a113.245 113.245 0 1 0 226.49 0 113.245 113.245 0 1 0-226.49 0Z" p-id="5719"></path><path d="M846.175 499.222m-113.245 0a113.245 113.245 0 1 0 226.49 0 113.245 113.245 0 1 0-226.49 0Z" p-id="5720"></path></svg> </a> <ul class="more-box"> <li class="item"><a class="article-report">文章举报</a></li> </ul> </li> </ul> </div> </div> <div class="person-messagebox"> <div class="left-message"><a href="https://blog.csdn.net/qq_30905661"> <img src="https://profile.csdnimg.cn/1/1/D/3_qq_30905661" class="avatar_pic" username="qq_30905661"> <img src="https://g.csdnimg.cn/static/user-reg-year/1x/5.png" class="user-years"> </a></div> <div class="middle-message"> <div class="title"><span class="tit"><a href="https://blog.csdn.net/qq_30905661" data-report-click="{"mod":"popu_379"}" target="_blank">炸天总指挥</a></span> </div> <div class="text"><span>发布了30 篇原创文章</span> · <span>获赞 18</span> · <span>访问量 2万+</span></div> </div> <div class="right-message"> <a href="https://im.csdn.net/im/main.html?userName=qq_30905661" target="_blank" class="btn btn-sm btn-red-hollow bt-button personal-letter">私信 </a> <a class="btn btn-sm bt-button personal-watch" data-report-click="{"mod":"popu_379"}">关注</a> </div> </div> </div> </article>