springboot2+Shiro+Oltu+JSP全註解搭建基於OAuth2授權碼模式授權平臺示例

OAuth2簡單介紹

    OAuth 2 是一個授權框架,它通過一些協議約定,可以使第三方應用程序對服務器的資源、用戶信息有一定的訪問權限。OAuth 2 通過將用戶身份驗證委派給用戶帳戶的服務方以及通過服務方提供方授權給客戶端,從而客戶端可以訪問用戶帳戶信息。具體的介紹參考OAuth2官網,協議規範可參考http://tools.ietf.org/html/rfc6749

  1. OAuth角色
    資源所有者: 能夠授予對受保護資源的訪問權限的實體。 當資源所有者是一個人時,它被稱爲最終用戶。
    資源服務器:託管受保護資源的服務器,能夠接受並使用訪問令牌響應受保護的資源請求。
    客戶端:代表受保護的資源請求的應用程序資源所有者及其授權。
    授權服務器:服務器校驗客戶端身份成功後向客戶端發出訪問令牌(accessToken)
    
    OAuth2.0協議流程圖如下:
    在這裏插入圖片描述
    解釋說明,這裏用微信登錄舉例,可參考微信官方文檔
        1.客戶端從資源擁有者那請求授權,如某app需要用微信登錄;
        2.用戶點擊同意登錄之後,app得到一個授權許可,會返回一個code參數;
        3.app拿着code去請求授權服務器驗證;
        4.身份驗證成功後,授權服務器會返回一個資源訪問令牌(accessToken)給app;
        5.app使用accessToken去資源服務器去請求需要的資源(如,微信用戶的名稱,頭像,性別等)
        6.資源服務器在身份驗證成功後返回相應的資源給客戶端

Oltu介紹

    Apache Oltu是Java中的OAuth協議實現,對常用的方法、需要用到的工具進行封裝,使用方法可參考:https://cwiki.apache.org/confluence/display/OLTU/Documentation

運行演示及其說明

  • 將server端和client分別打成war包放入tomcat運行後,在瀏覽輸入 http://localhost:9080/oauth2-client/ 則會出現如下界面:在這裏插入圖片描述
  • 點擊登錄,則會跳轉到如下界面在這裏插入圖片描述
    同時,觀察控制檯,可看出瀏覽器一共發了兩次請求在這裏插入圖片描述
  • 輸入賬號密碼
    【用戶名:admin 密碼:123456 】 即可登錄成功
    在這裏插入圖片描述
    觀察控制檯, 瀏覽器又發了兩次請求在這裏插入圖片描述
  • UML時序圖及解釋說明在這裏插入圖片描述
    說明:
        1. 用戶點擊【點擊登錄】時,由於在shiro的配置,會被OAuth2AuthenticationFilter進行攔截,由於身份認證沒有通過,則會重定向到shiro配置的loginUrl(這裏配置的是server端的地址)進行登錄;
        2. 當用戶輸入賬號密碼後,點擊【登錄並授權】,則會帶上client_id等參數去服務器進行身份驗證;
        3. 服務器將驗證客戶端身份驗證的結果會通過回調地址redirect_uri通知客戶端,如果驗證成功,則會給客戶返回一個code(每次都不同);
        4. 客戶拿着code去交換訪問令牌(access_token);
        5. 當服務器校驗客戶端的code合法,則會將access_token返回;
        6. 客戶端拿着access_token去訪問受保護的資源。

代碼部分

    說明:由於springboot整合JSP,所以需要打成war放在外部的tomcat進行運行,具體做法:springboot打成war包放入外部tomcat運行
    篇幅所限,完整代碼已上傳至GitHub:https://github.com/AmVilCres/others

  • 服務端核心代碼
  1. 服務端目錄結構
                      在這裏插入圖片描述

  2. ShirServerConfig類

@Configuration
public class ShiroServerConfig {
	
	// 注意: /r/n前不能有空格
	private static final String CRLF = "\r\n";

	private static final String MD5 = "md5";
		
	@Bean(name="hashedCredMatcher")
	public HashedCredentialsMatcher createHashCredentialsMatcher() {
		RetryLimitHashedCredentialsMatcher hcm = new RetryLimitHashedCredentialsMatcher();
		hcm.setHashAlgorithmName(MD5);
		hcm.setStoredCredentialsHexEncoded(true);
        hcm.setCacheManager(createEhcacheManager());
		return hcm;
	}

	@Bean(name="userAuthc")
	public UserRealm getUserAuthcRealm() {
		UserRealm uar = new UserRealm();
		uar.setCredentialsMatcher(createHashCredentialsMatcher());
		return uar;
	}
	
	@Bean(name="rememberCookies")
	public SimpleCookie createSimpleCookies() {
		SimpleCookie sc = new SimpleCookie("rememberMe");
		sc.setHttpOnly(true);
		sc.setMaxAge(5);
		return sc;
	}
	
	@Bean(name="cookieRemmberMananger")
	public CookieRememberMeManager createCookieRemmberMananger() {
		CookieRememberMeManager crm = new CookieRememberMeManager();
		crm.setCookie(createSimpleCookies());
		return crm;
	}
		
	@Bean(name="cacheManager")
	public EhCacheManager createEhcacheManager() {
		EhCacheManager cacheManager = new EhCacheManager();
		String path = "classpath:ehcache.xml";
		cacheManager.setCacheManagerConfigFile(path);
		return cacheManager;
	}
	
	@Bean(name="securityMananger")
	public DefaultWebSecurityManager createSecurityManager() {
		DefaultWebSecurityManager securityMananger = new DefaultWebSecurityManager();
		securityMananger.setRealm(getUserAuthcRealm());
		securityMananger.setCacheManager(createEhcacheManager());
		securityMananger.setRememberMeManager(createCookieRemmberMananger());
		return securityMananger;
	}

	/**
	 * 自定義的shiro相關的Filter在這裏做統一處理
	 * */
	public Map<String, Filter> createFilterChainMap() {
		Map<String, Filter> filters = new LinkedHashMap<>();
		return filters;
	}
	
	public String loadFilterChainDefinitions() {
		StringBuffer sb = new StringBuffer();
		sb.append("/ = anon").append(CRLF);
        sb.append("/login = authc").append(CRLF);
        sb.append("/logout = logout").append(CRLF);
        sb.append("/authorize=anon").append(CRLF);
        sb.append("/accessToken=anon").append(CRLF);
        sb.append("/userInfo=anon").append(CRLF);
        sb.append("/** = user").append(CRLF);

		return sb.toString();
	}
	

	
	 @Bean(name="shiroFilter")
	 public ShiroFilterFactoryBean createShiroSecurityFilterFactory() {
		 ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
		 shiroFilter.setSecurityManager(createSecurityManager());
		 shiroFilter.setLoginUrl("/login");
		 //shiroFilter.setFilters(createFilterChainMap());
         shiroFilter.setFilterChainDefinitions(loadFilterChainDefinitions());
		 return shiroFilter;
	 }
	
}

  1. 授權處理類AuthorizeController核心代碼
@RequestMapping("/authorize")
    public Object authroize(Model model, HttpServletRequest request) throws OAuthSystemException, URISyntaxException {

       try{
           // 構建OAuth 授權請求
           OAuthAuthzRequest oauthRequest = new OAuthAuthzRequest(request);

           // 檢查傳入的客戶端ID是否正確
           if(!oAuthService.checkClientId(oauthRequest.getClientId())){
               log.error("校驗客戶端ID失敗,ClientID="+oauthRequest.getClientId());
               OAuthResponse response = OAuthResponse.errorResponse(HttpServletResponse.SC_BAD_REQUEST)
                       .setError(OAuthError.TokenResponse.INVALID_CLIENT)
                       .setErrorDescription(Constants.INVALID_CLIENT_DESCRIPTION)
                       .buildBodyMessage();
               return new ResponseEntity<>(response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));
           }

           Subject subject = SecurityUtils.getSubject();

           // 如果用戶沒有登錄,跳轉登錄頁面
           if(!subject.isAuthenticated()){
               if(!login(subject,request)){ // 登錄失敗
                   model.addAttribute("client", clientService.findByClientId(oauthRequest.getClientId()));
                   return "oauth2login";
               }
           }

           String username = ((User) subject.getPrincipal()).getUsername();
           // 生成授權碼
           String authorizationCode = null;
           // resopnseType 目前僅支持CODE, 另外還有TOKEN
           String responseType = oauthRequest.getParam(OAuth.OAUTH_RESPONSE_TYPE);

           if(responseType.equals(ResponseType.CODE.toString())){
               OAuthIssuerImpl oAuthIssuer = new OAuthIssuerImpl(new MD5Generator()) ;
               authorizationCode = oAuthIssuer.authorizationCode();
               log.info("服務端生成的授權碼="+authorizationCode);
               oAuthService.addAuthCode(authorizationCode,username);
           }

           // 構建OAuth響應
           OAuthASResponse.OAuthAuthorizationResponseBuilder builder =
                   OAuthASResponse.authorizationResponse(request,HttpServletResponse.SC_FOUND);

           // 設置授權碼
           builder.setCode(authorizationCode);
           // 得到 到客戶端請求地址中的redirect_uri重定向地址
           String redirectURI = oauthRequest.getParam(OAuth.OAUTH_REDIRECT_URI);

           // 構建響應
           OAuthResponse response = builder.location(redirectURI).buildQueryMessage();

           //根據OAuthResponse返回ResponseEntity響應
           HttpHeaders headers = new HttpHeaders();
           headers.setLocation(new URI(response.getLocationUri()));
           return new ResponseEntity<>(headers,HttpStatus.valueOf(response.getResponseStatus()));
       }catch (OAuthProblemException e){
           // 處理出錯
           String redirectUri = e.getRedirectUri();
           if(OAuthUtils.isEmpty(redirectUri)){
               // 告訴客戶端沒有傳入回調地址
               return new ResponseEntity<>("OAuth callback url needs to be provider by client!!",HttpStatus.NOT_FOUND);
           }
           // 返回消息錯誤(如?error=)
           OAuthResponse response =
                   OAuthResponse.errorResponse(HttpServletResponse.SC_FOUND)
                   .error(e).location(redirectUri).buildQueryMessage();
           HttpHeaders headers = new HttpHeaders();
           headers.setLocation(new URI(response.getLocationUri()));
           return new ResponseEntity<>(headers,HttpStatus.valueOf(response.getResponseStatus()));
       }
  1. 令牌生成類AccessTokenController 核心代碼
@RequestMapping("/accessToken")
    public HttpEntity token(HttpServletRequest request) throws OAuthSystemException {
        try{
            // 轉爲OAuth請求
            OAuthTokenRequest oauthRequest = new OAuthTokenRequest(request);
            //檢查提交的客戶端ID是否正確
            if(!oAuthService.checkClientId(oauthRequest.getClientId())){
                log.error("獲取accessToken時,客戶端ID錯誤 client=" + oauthRequest.getClientId());
                OAuthResponse response = OAuthResponse
                        .errorResponse(HttpServletResponse.SC_BAD_REQUEST)
                        .setError(OAuthError.TokenResponse.INVALID_CLIENT)
                        .setErrorDescription(Constants.INVALID_CLIENT_DESCRIPTION)
                        .buildJSONMessage();
                return new ResponseEntity(response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));
            }

            // 檢查客戶端安全KEY是否正確
            if(!oAuthService.checkClientSecret(oauthRequest.getClientSecret())){
                log.error("ClientSecret不合法 client_secret="+oauthRequest.getClientSecret());
                OAuthResponse response = OAuthResponse
                        .errorResponse(HttpServletResponse.SC_UNAUTHORIZED)
                        .setError(OAuthError.TokenResponse.UNAUTHORIZED_CLIENT)
                        .setErrorDescription(Constants.INVALID_CLIENT_DESCRIPTION)
                        .buildJSONMessage();
                return new ResponseEntity(response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));
            }

            String authCode = oauthRequest.getParam(OAuth.OAUTH_CODE);

            /**
             * 此處只校驗 AUTHORIZATION_CODE 類型,其他的還有PASSWORD 、REFRESH_TOKEN 和 CLIENT_CREDENTIALS
             * 具體查看 {@link GrantType}
             * */
            if(oauthRequest.getParam(OAuth.OAUTH_GRANT_TYPE).equals(GrantType.AUTHORIZATION_CODE.toString())){
                if(!oAuthService.checkAuthCode(authCode)){
                    OAuthResponse response = OAuthResponse
                            .errorResponse(HttpServletResponse.SC_BAD_REQUEST)
                            .setError(OAuthError.TokenResponse.INVALID_GRANT)
                            .setErrorDescription(Constants.INVALID_CODE_DESCRIPTION)
                            .buildJSONMessage();
                    return new ResponseEntity(response.getBody(), HttpStatus.valueOf(response.getResponseStatus()));
                }
            }

            // 生成Access Token
            OAuthIssuerImpl oAuthIssuer = new OAuthIssuerImpl(new MD5Generator()) ;
            String accessToken = oAuthIssuer.accessToken();
            log.info("服務器生成的accessToken="+accessToken);
            oAuthService.addAccessToken(accessToken,oAuthService.getUsernameByAuthCode(authCode));
            // 移除code,每個code只能使用一次
            String username = oAuthService.removeAuthCode(authCode);
            log.info("服務器移除auth_code="+authCode+" username="+username);

            // 生成OAuth響應
            OAuthResponse response = OAuthASResponse
                    .tokenResponse(HttpServletResponse.SC_OK)
                    .setAccessToken(accessToken)
                    .setExpiresIn(String.valueOf(oAuthService.getExpireIn()))
                    .buildJSONMessage();

            // 根據OAuthResponse 生成ResponseEntity
            return new ResponseEntity(response.getBody(),HttpStatus.valueOf(response.getResponseStatus()));

        }catch (OAuthProblemException e){
            log.error("獲取accessToken發生異常e=",e);
            // 構建錯誤響應
            OAuthResponse response = OAuthASResponse
                    .errorResponse(HttpServletResponse.SC_BAD_REQUEST).error(e)
                    .buildJSONMessage();
            return new ResponseEntity(response.getBody(),HttpStatus.valueOf(response.getResponseStatus()));
        }
    }
  • 客戶端核心代碼
  1. 客戶端目錄結構
                      ClientCodeStruct
  2. ShiroClientConfig類,只不過多了一個自定義Filter的配置
        與Server端配置類似,其他配置詳情參照GitHub
 public Map<String, Filter> createFilterChainMap() {
        Map<String, Filter> filters = new LinkedHashMap<>();
        OAuth2AuthenticationFilter oAuth2AuthenticationFilter =
                new OAuth2AuthenticationFilter();
        oAuth2AuthenticationFilter.setAuthcCodeParam(OAuth.OAUTH_CODE);
        oAuth2AuthenticationFilter.setResponseType(OAuth.OAUTH_CODE);
        oAuth2AuthenticationFilter.setFailureUrl("/oauth2Failure");
        filters.put("oauth2Authc",oAuth2AuthenticationFilter);
        return filters;
    }
  1. OAuth2Token類
@Data
public class OAuth2Token implements AuthenticationToken {

    private String authCode;
    private String principal;

    public OAuth2Token(String authCode) {
        this.authCode = authCode;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }

    @Override
    public Object getCredentials() {
        return authCode;
    }
}

  1. OAuth2AuthenticationFilter類
@Data
@Slf4j
public class OAuth2AuthenticationFilter extends AuthenticatingFilter {

    //oauth2 authc code參數名
    private String authcCodeParam;
    //客戶端id
    private String clientId;
    //服務器端登錄成功/失敗後重定向到的客戶端地址
    private String redirectUrl;
    //oauth2服務器響應類型
    private String responseType;

    private String failureUrl;


    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String code = httpRequest.getParameter(authcCodeParam);
        log.info("OAuth2AuthenticationFilter中拿到的auth_code = " + code);
        return new OAuth2Token(code);
    }

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        return false;
    }

    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        String error = request.getParameter("error");
        String errorDescription = request.getParameter("error_description");
        if(!StringUtils.isEmpty(error)){
            log.info("error = "+error+"  errorDesc="+errorDescription);
            WebUtils.issueRedirect(request,response,failureUrl+"?error="+error+"&error_description="+errorDescription);
            return false;
        }
        Subject subject = getSubject(request,response);

        log.info("subject.isAuthenticated() == "+subject.isAuthenticated());
        if(!subject.isAuthenticated()){
            if(StringUtils.isEmpty(request.getParameter(authcCodeParam))){
                // 如果沒有身份認證,且沒有authCode,則重定向到服務端授權
                saveRequestAndRedirectToLogin(request,response);
                return false;
            }
        }
        // 執行父類的登錄邏輯
        String successURL = getSuccessUrl();
        return executeLogin(request, response);
    }

    @Override
    protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) {
        Subject subject = getSubject(request,response);
        log.info("是否驗證:subject.isAuthenticated() == " + subject.isAuthenticated());
        log.info("是否記住:subject.isRemembered() == " + subject.isRemembered());
        if(subject.isAuthenticated() || subject.isRemembered()){
            // 重定向到成功頁面
            try {
                log.info("重定向到成功頁面success。。。。");
                issueSuccessRedirect(request,response);
            } catch (Exception e1) {
                log.info("重定向到成功頁面異常=",e1);
            }
        }else{
            try {
                log.info("重定向到失敗頁面failure。。。。");
                WebUtils.issueRedirect(request,response,failureUrl);
            } catch (IOException e1) {
                log.info("重定向到失敗頁面異常=",e1);
            }
        }
        return false;
    }

    @Override
    protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception {
        WebUtils.issueRedirect(request,response,getSuccessUrl());
        return false; // 表示執行鏈結束
    }
  1. OAuth2Realm類核心代碼
/**
     * 身份認證
     * */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        OAuth2Token oAuth2Token = (OAuth2Token) token;
        String code = oAuth2Token.getAuthCode(); //獲取 auth code
        log.info("客戶端Relam中獲取到的auth_code=" + code);
        String username = getUsernameByCode(code); // 獲取用戶名
        SimpleAuthenticationInfo authenticationInfo =
                new SimpleAuthenticationInfo(username, code, getName());
        return authenticationInfo;
    }

    private String getUsernameByCode(String code) {
        try {
            OAuthClient oAuthClient = new OAuthClient(new URLConnectionClient());
            OAuthClientRequest accessTokenRequest = OAuthClientRequest
                    .tokenLocation(accessTokenUrl)
                    .setGrantType(GrantType.AUTHORIZATION_CODE)
                    .setClientId(clientId).setClientSecret(clientSecret)
                    .setCode(code).setRedirectURI(redirectUrl)
                    .buildQueryMessage();
            //獲取 access token
            log.info("clientId="+clientId+"   clientsecret="+clientSecret);
            OAuthAccessTokenResponse oAuthResponse =
                    oAuthClient.accessToken(accessTokenRequest, OAuth.HttpMethod.POST);
            String accessToken = oAuthResponse.getAccessToken();
            log.info("客戶端Realm中獲取到accessToken="+accessToken);
            Long expiresIn = oAuthResponse.getExpiresIn(); // 獲取過期時間,此處暫時沒用到
            //獲取 user info
            OAuthClientRequest userInfoRequest =
                    new OAuthBearerClientRequest(userInfoUrl)
                            .setAccessToken(accessToken).buildQueryMessage();
            OAuthResourceResponse resourceResponse = oAuthClient.resource(
                    userInfoRequest, OAuth.HttpMethod.GET, OAuthResourceResponse.class);
            String username = resourceResponse.getBody();
            return username;
        } catch (Exception e) {
            log.info("獲取用戶名發生異常e=",e);
            throw new OAuth2AuthenticationException(e);
        }
    }
核心代碼完畢,完整代碼查看GitHub:https://github.com/AmVilCres/others
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章