OAuth2簡單介紹
OAuth 2 是一個授權框架,它通過一些協議約定,可以使第三方應用程序對服務器的資源、用戶信息有一定的訪問權限。OAuth 2 通過將用戶身份驗證委派給用戶帳戶的服務方以及通過服務方提供方授權給客戶端,從而客戶端可以訪問用戶帳戶信息。具體的介紹參考OAuth2官網,協議規範可參考http://tools.ietf.org/html/rfc6749
-
OAuth角色
OAuth2.0協議流程圖如下:資源所有者: 能夠授予對受保護資源的訪問權限的實體。 當資源所有者是一個人時,它被稱爲最終用戶。 資源服務器:託管受保護資源的服務器,能夠接受並使用訪問令牌響應受保護的資源請求。 客戶端:代表受保護的資源請求的應用程序資源所有者及其授權。 授權服務器:服務器校驗客戶端身份成功後向客戶端發出訪問令牌(accessToken)
解釋說明,這裏用微信登錄舉例,可參考微信官方文檔:
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
-
服務端目錄結構
-
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;
}
}
- 授權處理類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()));
}
- 令牌生成類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()));
}
}
- 客戶端目錄結構
- 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;
}
- 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;
}
}
- 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; // 表示執行鏈結束
}
- 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);
}
}