欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

OAuth2 源码分析(二.授权码模式源码)

程序员文章站 2022-06-13 19:59:05
...

上一章介绍了与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.授权码模式流程图

OAuth2 源码分析(二.授权码模式源码)

没了解原理前是看着挺费劲的,一般的验证只需要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中的一个。

                                                                   OAuth2 源码分析(二.授权码模式源码)

2.页面跳转至QQ授权页面,url地址是

https://graph.qq.com/oauth2.0/show?which=Login&display=pc&response_type=code&client_id=100270989&redirect_uri=https%3A%2F%2Fpassport.csdn.net%2Faccount%2Flogin%3Foauth_provider%3DQQProvider&state=test

    里面有3个关键的参数,response_type,client_id以及redirect_uri。点击了下图中授权并登录的按钮后,页面自动跳转到了csdn主页,且获取到了QQ相关信息。看似很简单的跳转其实包含了很多步骤。

                           OAuth2 源码分析(二.授权码模式源码)

                                                                      OAuth2 源码分析(二.授权码模式源码)

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,这里不做赘述。

                                                       OAuth2 源码分析(二.授权码模式源码)

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的继承类中,找到存储在缓存中的用户名密码,填写完毕。

                                    OAuth2 源码分析(二.授权码模式源码)

点击“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 总结

OAuth2 源码分析(二.授权码模式源码)

github地址:https://github.com/lexburner/oauth2-demo