OAuth2 源码分析(二.授权码模式源码)
上一章介绍了与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