OAuth2.0協議入門(二):OAuth2.0授權服務端從設計到實現

zifangsky發表於2018-09-03

一OAuth2.0授權服務端的設計

上一篇文章中,我介紹了OAuth2.0協議的基本概念以及作為一個第三方應用在請求授權服務端的時候需要做哪些事情。通過上一篇文章中呼叫百度OAuth服務的例子我們可以得知,使用授權碼模式完成OAuth2.0授權的過程需要以下三個步驟:

  1. client請求授權服務端,獲取Authorization Code
  2. client通過Authorization Code再次請求授權服務端,獲取Access Token
  3. client通過服務端返回的Access Token獲取使用者的基本資訊

因此,OAuth2.0授權服務端的設計也就主要圍繞這幾個介面展開,其主要流程是這樣的:

OAuth2.0服務端的主要流程

明白了整個執行流程,那剩下就好辦了。接下來我們需要做的是資料庫的表結構設計。

資料庫的表結構設計

提示:我在下面只介紹一些表的主要欄位,這個Demo中使用的完整的表結構可以參考:gitee.com/zifangsky/O…

(1)auth_client_details

接入的第三方客戶端詳情表。這就跟我們要想使用百度OAuth服務就需要事先在百度開發者中心新建一個應用是一個道理,每個想要接入OAuth2.0授權服務的第三方客戶端都需要事先在服務端這裡“備案”,所以主要需要以下幾個欄位:

  • client_id:每個客戶端的client_id是唯一的,通常是一個隨機生成的字串
  • client_name:客戶端的名稱
  • client_secret:這個祕鑰是客戶端和OAuth2.0服務端共同持有,用於鑑別請求中的身份,通常也是一個隨機生成的字串

(2)auth_scope

使用者資訊範圍表。OAuth2.0服務端在授權第三方客戶端訪問使用者的資訊的時候,通常會把使用者的資訊劃分為幾個級別,比如使用者的基本資訊,使用者密碼、購物記錄等高保密性資訊。這樣劃分主要是讓使用者自主選擇把自己哪種資訊授權給第三方客戶端訪問,所以主要需要以下欄位:

  • scope_name:範圍名稱

(3)auth_access_token

Access Token資訊表。這個表主要體現出哪個使用者授予哪個client何種訪問範圍的令牌,以及這個令牌的結束日期是哪天。所以主要需要以下幾個欄位:

  • access_tokenAccess Token欄位
  • user_id:表明是哪個使用者授予的許可權
  • client_id:表明授予給哪個客戶端
  • expires_in:過期時間戳,表明這個Token在哪一天過期
  • scope:表明可以訪問何種範圍

(4)auth_refresh_token

Refresh Token資訊表。這個表主要用來記錄Refresh Token,在設計表結構的時候需要關聯它對應的auth_access_token表的記錄。所以主要需要以下幾個欄位:

  • refresh_tokenRefresh Token欄位
  • token_id:它對應的auth_access_token表的記錄
  • expires_in:過期時間戳

(5)auth_client_user

使用者對某個接入客戶端的授權資訊表。這個表用於記錄client、scope、使用者之間的關聯關係。所以主要需要以下幾個欄位:

  • auth_client_id:授權對應的auth_client_details表的記錄
  • user_id:授權對應的user表的記錄
  • auth_scope_id:授權對應的auth_scope表的記錄

明白了授權的整個流程,以及設計好後面需要用到的表結構,那麼我們最後就剩下具體程式碼實現了。

二 OAuth2.0授權服務端主要介面的程式碼實現

這個Demo的授權服務端的完整可用原始碼可以參考:gitee.com/zifangsky/O…

(1)客戶端註冊介面:

某個第三方客戶端需要事先在服務端這裡“備案”。在這個Demo中我沒有寫具體的頁面,只提供了一個註冊介面,其中client_idclient_secret都是隨機生成的字串。

介面地址http://127.0.0.1:7000/oauth2.0/clientRegister

引數

{"clientName":"測試客戶端","redirectUri":"http://localhost:7080/login","description":"這是一個測試客戶端服務"}
複製程式碼

客戶端註冊介面

(2)授權頁面:

如果使用者之前沒有給請求的client授權過,那麼在第一次請求Authorization Code的時候會開啟授權頁面,然後使用者手動選擇是否授權:

授權頁面

實現程式碼很簡單,就是在使用者選擇“授權”後,往表auth_client_user插入一條記錄。這裡就不多說了,可以自行參考一下示例原始碼。

(3)獲取Authorization Code:

根據請求的client_idscope生成一個字串——Authorization Code,同時需要將本次請求的授權範圍和所屬的使用者資訊儲存到Redis中(因為後面在請求Access Token的時候是從第三方客戶端的後臺直接請求,屬於一個新的會話,所以需要提前存一下使用者資訊)。

介面地址:http://127.0.0.1:7000/oauth2.0/authorize?client_id=7Ugj6XWmTDpyYp8M8njG3hqx&scope=basic&response_type=code&state=AB1357&redirect_uri=http://192.168.197.130:7080/login

/**
 * 獲取Authorization Code
 * @author zifangsky
 * @date 2018/8/6 17:40
 * @since 1.0.0
 * @param request HttpServletRequest
 * @return org.springframework.web.servlet.ModelAndView
 */
@RequestMapping("/authorize")
public ModelAndView authorize(HttpServletRequest request){
	HttpSession session = request.getSession();
	User user = (User) session.getAttribute(Constants.SESSION_USER);

	//客戶端ID
	String clientIdStr = request.getParameter("client_id");
	//許可權範圍
	String scopeStr = request.getParameter("scope");
	//回撥URL
	String redirectUri = request.getParameter("redirect_uri");
	//status,用於防止CSRF攻擊(非必填)
	String status = request.getParameter("status");

	//生成Authorization Code
	String authorizationCode = authorizationService.createAuthorizationCode(clientIdStr, scopeStr, user);

	String params = "?code=" + authorizationCode;
	if(StringUtils.isNoneBlank(status)){
		params = params + "&status=" + status;
	}

	return new ModelAndView("redirect:" + redirectUri + params);
}
複製程式碼

呼叫的cn/zifangsky/service/impl/AuthorizationServiceImpl.java類裡面的生成邏輯:

@Override
public String createAuthorizationCode(String clientIdStr, String scopeStr, User user) {
	//1. 拼裝待加密字串(clientId + scope + 當前精確到毫秒的時間戳)
	String str = clientIdStr + scopeStr + String.valueOf(DateUtils.currentTimeMillis());

	//2. SHA1加密
	String encryptedStr = EncryptUtils.sha1Hex(str);

	//3.1 儲存本次請求的授權範圍
	redisService.setWithExpire(encryptedStr + ":scope", scopeStr, (ExpireEnum.AUTHORIZATION_CODE.getTime()), ExpireEnum.AUTHORIZATION_CODE.getTimeUnit());
	//3.2 儲存本次請求所屬的使用者資訊
	redisService.setWithExpire(encryptedStr + ":user", user, (ExpireEnum.AUTHORIZATION_CODE.getTime()), ExpireEnum.AUTHORIZATION_CODE.getTimeUnit());

	//4. 返回Authorization Code
	return encryptedStr;
}
複製程式碼

(4)通過Authorization Code獲取Access Token:

在第三方客戶端拿到Authorization Code後,它就可以在後臺呼叫生成Token的介面,生成Access TokenRefresh Token

介面地址http://127.0.0.1:7000/oauth2.0/token?grant_type=authorization_code&code=82ce2bf34f5028d7e8a517ef381f5c87f0139b26&client_id=7Ugj6XWmTDpyYp8M8njG3hqx&client_secret=tur2rlFfywR9OOP3fB5ZbsLTnNuNabI3&redirect_uri=http://192.168.197.130:7080/login

返回如下

{
	"access_token": "1.6659c9d38f5943f97db334874e5229284cdd1523.2592000.1537600367",
	"refresh_token": "2.b19923a01cf35ccab48ddbd687750408bd1cb763.31536000.1566544316",
	"expires_in": 2592000,
	"scope": "basic"
}
複製程式碼
/**
 * 通過Authorization Code獲取Access Token
 * @author zifangsky
 * @date 2018/8/18 15:11
 * @since 1.0.0
 * @param request HttpServletRequest
 * @return java.util.Map<java.lang.String,java.lang.Object>
 */
@RequestMapping(value = "/token", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> token(HttpServletRequest request){
	Map<String,Object> result = new HashMap<>(8);

	//授權方式
	String grantType = request.getParameter("grant_type");
	//前面獲取的Authorization Code
	String code = request.getParameter("code");
	//客戶端ID
	String clientIdStr = request.getParameter("client_id");
	//接入的客戶端的金鑰
	String clientSecret = request.getParameter("client_secret");
	//回撥URL
	String redirectUri = request.getParameter("redirect_uri");

	//校驗授權方式
	if(!GrantTypeEnum.AUTHORIZATION_CODE.getType().equals(grantType)){
		this.generateErrorResponse(result, ErrorCodeEnum.UNSUPPORTED_GRANT_TYPE);
		return result;
	}

	try {
		AuthClientDetails savedClientDetails = authorizationService.selectClientDetailsByClientId(clientIdStr);
		//校驗請求的客戶端祕鑰和已儲存的祕鑰是否匹配
		if(!(savedClientDetails != null && savedClientDetails.getClientSecret().equals(clientSecret))){
			this.generateErrorResponse(result, ErrorCodeEnum.INVALID_CLIENT);
			return result;
		}

		//校驗回撥URL
		if(!savedClientDetails.getRedirectUri().equals(redirectUri)){
			this.generateErrorResponse(result, ErrorCodeEnum.REDIRECT_URI_MISMATCH);
			return result;
		}

		//從Redis獲取允許訪問的使用者許可權範圍
		String scope = redisService.get(code + ":scope");
		//從Redis獲取對應的使用者資訊
		User user = redisService.get(code + ":user");

		//如果能夠通過Authorization Code獲取到對應的使用者資訊,則說明該Authorization Code有效
		if(StringUtils.isNoneBlank(scope) && user != null){
			//過期時間
			Long expiresIn = DateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());

			//生成Access Token
			String accessTokenStr = authorizationService.createAccessToken(user, savedClientDetails, grantType, scope, expiresIn);
			//查詢已經插入到資料庫的Access Token
			AuthAccessToken authAccessToken = authorizationService.selectByAccessToken(accessTokenStr);
			//生成Refresh Token
			String refreshTokenStr = authorizationService.createRefreshToken(user, authAccessToken);

			//返回資料
			result.put("access_token", authAccessToken.getAccessToken());
			result.put("refresh_token", refreshTokenStr);
			result.put("expires_in", expiresIn);
			result.put("scope", scope);
			return result;
		}else{
			this.generateErrorResponse(result, ErrorCodeEnum.INVALID_GRANT);
			return result;
		}
	}catch (Exception e){
		this.generateErrorResponse(result, ErrorCodeEnum.UNKNOWN_ERROR);
		return result;
	}
}
複製程式碼

生成邏輯同樣在cn/zifangsky/service/impl/AuthorizationServiceImpl.java這個類裡面,具體如下:

@Override
public String createAccessToken(User user, AuthClientDetails savedClientDetails, String grantType, String scope, Long expiresIn) {
	Date current = new Date();
	//過期的時間戳
	Long expiresAt = DateUtils.nextDaysSecond(ExpireEnum.ACCESS_TOKEN.getTime(), null);

	//1. 拼裝待加密字串(username + clientId + 當前精確到毫秒的時間戳)
	String str = user.getUsername() + savedClientDetails.getClientId() + String.valueOf(DateUtils.currentTimeMillis());

	//2. SHA1加密
	String accessTokenStr = "1." + EncryptUtils.sha1Hex(str) + "." + expiresIn + "." + expiresAt;

	//3. 儲存Access Token
	AuthAccessToken savedAccessToken = authAccessTokenMapper.selectByUserIdClientIdScope(user.getId()
			, savedClientDetails.getId(), scope);
	//如果存在userId + clientId + scope匹配的記錄,則更新原記錄,否則向資料庫中插入新記錄
	if(savedAccessToken != null){
		savedAccessToken.setAccessToken(accessTokenStr);
		savedAccessToken.setExpiresIn(expiresAt);
		savedAccessToken.setUpdateUser(user.getId());
		savedAccessToken.setUpdateTime(current);
		authAccessTokenMapper.updateByPrimaryKeySelective(savedAccessToken);
	}else{
		savedAccessToken = new AuthAccessToken();
		savedAccessToken.setAccessToken(accessTokenStr);
		savedAccessToken.setUserId(user.getId());
		savedAccessToken.setUserName(user.getUsername());
		savedAccessToken.setClientId(savedClientDetails.getId());
		savedAccessToken.setExpiresIn(expiresAt);
		savedAccessToken.setScope(scope);
		savedAccessToken.setGrantType(grantType);
		savedAccessToken.setCreateUser(user.getId());
		savedAccessToken.setUpdateUser(user.getId());
		savedAccessToken.setCreateTime(current);
		savedAccessToken.setUpdateTime(current);
		authAccessTokenMapper.insertSelective(savedAccessToken);
	}

	//4. 返回Access Token
	return accessTokenStr;
}

@Override
public String createRefreshToken(User user, AuthAccessToken authAccessToken) {
	Date current = new Date();
	//過期時間
	Long expiresIn = DateUtils.dayToSecond(ExpireEnum.REFRESH_TOKEN.getTime());
	//過期的時間戳
	Long expiresAt = DateUtils.nextDaysSecond(ExpireEnum.REFRESH_TOKEN.getTime(), null);

	//1. 拼裝待加密字串(username + accessToken + 當前精確到毫秒的時間戳)
	String str = user.getUsername() + authAccessToken.getAccessToken() + String.valueOf(DateUtils.currentTimeMillis());

	//2. SHA1加密
	String refreshTokenStr = "2." + EncryptUtils.sha1Hex(str) + "." + expiresIn + "." + expiresAt;

	//3. 儲存Refresh Token
	AuthRefreshToken savedRefreshToken = authRefreshTokenMapper.selectByTokenId(authAccessToken.getId());
	//如果存在tokenId匹配的記錄,則更新原記錄,否則向資料庫中插入新記錄
	if(savedRefreshToken != null){
		savedRefreshToken.setRefreshToken(refreshTokenStr);
		savedRefreshToken.setExpiresIn(expiresAt);
		savedRefreshToken.setUpdateUser(user.getId());
		savedRefreshToken.setUpdateTime(current);
		authRefreshTokenMapper.updateByPrimaryKeySelective(savedRefreshToken);
	}else{
		savedRefreshToken = new AuthRefreshToken();
		savedRefreshToken.setTokenId(authAccessToken.getId());
		savedRefreshToken.setRefreshToken(refreshTokenStr);
		savedRefreshToken.setExpiresIn(expiresAt);
		savedRefreshToken.setCreateUser(user.getId());
		savedRefreshToken.setUpdateUser(user.getId());
		savedRefreshToken.setCreateTime(current);
		savedRefreshToken.setUpdateTime(current);
		authRefreshTokenMapper.insertSelective(savedRefreshToken);
	}

	//4. 返回Refresh Token
	return refreshTokenStr;
}
複製程式碼

(5)通過Refresh Token重新整理Access Token:

當第三方客戶端的Access Token失效的時候就可以呼叫這個介面,重新生成一個新的Access Token

介面地址http://127.0.0.1:7000/oauth2.0/refreshToken?refresh_token=2.5c58637a2d51e4470d3e1189978e94da8402785e.31536000.1566283826

返回如下

{
	"access_token": "1.adebb0a4522d5dae9eaf94a5af4fec070c4f3dce.2592000.1537508734",
	"refresh_token": "2.5c58637a2d51e4470d3e1189978e94da8402785e.31536000.1566283826",
	"expires_in": 2592000,
	"scope": "basic"
}
複製程式碼
/**
 * 通過Refresh Token重新整理Access Token
 * @author zifangsky
 * @date 2018/8/22 11:11
 * @since 1.0.0
 * @param request HttpServletRequest
 * @return java.util.Map<java.lang.String,java.lang.Object>
 */
@RequestMapping(value = "/refreshToken", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> refreshToken(HttpServletRequest request){
	Map<String,Object> result = new HashMap<>(8);

	//獲取Refresh Token
	String refreshTokenStr = request.getParameter("refresh_token");

	try {
		AuthRefreshToken authRefreshToken = authorizationService.selectByRefreshToken(refreshTokenStr);

		if(authRefreshToken != null) {
			Long savedExpiresAt = authRefreshToken.getExpiresIn();
			//過期日期
			LocalDateTime expiresDateTime = DateUtils.ofEpochSecond(savedExpiresAt, null);
			//當前日期
			LocalDateTime nowDateTime = DateUtils.now();

			//如果Refresh Token已經失效,則需要重新生成
			if (expiresDateTime.isBefore(nowDateTime)) {
				this.generateErrorResponse(result, ErrorCodeEnum.EXPIRED_TOKEN);
				return result;
			} else {
				//獲取儲存的Access Token
				AuthAccessToken authAccessToken = authorizationService.selectByAccessId(authRefreshToken.getTokenId());
				//獲取對應的客戶端資訊
				AuthClientDetails savedClientDetails = authorizationService.selectClientDetailsById(authAccessToken.getClientId());
				//獲取對應的使用者資訊
				User user = userService.selectByUserId(authAccessToken.getUserId());

				//新的過期時間
				Long expiresIn = DateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
				//生成新的Access Token
				String newAccessTokenStr = authorizationService.createAccessToken(user, savedClientDetails
						, authAccessToken.getGrantType(), authAccessToken.getScope(), expiresIn);

				//返回資料
				result.put("access_token", newAccessTokenStr);
				result.put("refresh_token", refreshTokenStr);
				result.put("expires_in", expiresIn);
				result.put("scope", authAccessToken.getScope());
				return result;
			}
		}else {
			this.generateErrorResponse(result, ErrorCodeEnum.INVALID_GRANT);
			return result;
		}
	}catch (Exception e){
		this.generateErrorResponse(result, ErrorCodeEnum.UNKNOWN_ERROR);
		return result;
	}
}
複製程式碼

(6)通過Access Token獲取使用者資訊:

在通過Access Token獲取使用者資訊的時候,首先需要在攔截器裡校驗請求的Token是否有效,相關程式碼邏輯如下:

介面地址http://127.0.0.1:7000/api/users/getInfo?access_token=1.adebb0a4522d5dae9eaf94a5af4fec070c4f3dce.2592000.1537508734

返回如下

{
	"mobile": "110",
	"id": 1,
	"email": "admin@zifangsky.cn",
	"username": "admin"
}
複製程式碼
package cn.zifangsky.interceptor;

import cn.zifangsky.enums.ErrorCodeEnum;
import cn.zifangsky.model.AuthAccessToken;
import cn.zifangsky.service.AuthorizationService;
import cn.zifangsky.utils.DateUtils;
import cn.zifangsky.utils.JsonUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

/**
 * 用於校驗Access Token是否為空以及Access Token是否已經失效
 *
 * @author zifangsky
 * @date 2018/8/22
 * @since 1.0.0
 */
public class AuthAccessTokenInterceptor extends HandlerInterceptorAdapter{
    @Resource(name = "authorizationServiceImpl")
    private AuthorizationService authorizationService;

    /**
     * 檢查Access Token是否已經失效
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String accessToken = request.getParameter("access_token");

        if(StringUtils.isNoneBlank(accessToken)){
            //查詢資料庫中的Access Token
            AuthAccessToken authAccessToken = authorizationService.selectByAccessToken(accessToken);

            if(authAccessToken != null){
                Long savedExpiresAt = authAccessToken.getExpiresIn();
                //過期日期
                LocalDateTime expiresDateTime = DateUtils.ofEpochSecond(savedExpiresAt, null);
                //當前日期
                LocalDateTime nowDateTime = DateUtils.now();

                //如果Access Token已經失效,則返回錯誤提示
                return expiresDateTime.isAfter(nowDateTime) || this.generateErrorResponse(response, ErrorCodeEnum.EXPIRED_TOKEN);
            }else{
                return this.generateErrorResponse(response, ErrorCodeEnum.INVALID_GRANT);
            }
        }else{
            return this.generateErrorResponse(response, ErrorCodeEnum.INVALID_REQUEST);
        }
    }
    
    /**
     * 組裝錯誤請求的返回
     */
    private boolean generateErrorResponse(HttpServletResponse response,ErrorCodeEnum errorCodeEnum) throws Exception {
        response.setCharacterEncoding("UTF-8");
        response.setHeader("Content-type", "application/json;charset=UTF-8");
        Map<String,String> result = new HashMap<>(2);
        result.put("error", errorCodeEnum.getError());
        result.put("error_description",errorCodeEnum.getErrorDescription());

        response.getWriter().write(JsonUtils.toJson(result));
        return false;
    }

}
複製程式碼

然後再根據這個Access Token被授予的訪問範圍返回相應的使用者資訊:

package cn.zifangsky.controller;

import cn.zifangsky.enums.ErrorCodeEnum;
import cn.zifangsky.model.AuthAccessToken;
import cn.zifangsky.model.User;
import cn.zifangsky.service.AuthorizationService;
import cn.zifangsky.service.UserService;
import cn.zifangsky.utils.JsonUtils;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.Map;

/**
 * 通過Access Token訪問的API服務
 *
 * @author zifangsky
 * @date 2018/8/22
 * @since 1.0.0
 */
@RestController
@RequestMapping("/api")
public class ApiController {

    @Resource(name = "authorizationServiceImpl")
    private AuthorizationService authorizationService;

    @Resource(name = "userServiceImpl")
    private UserService userService;

    @RequestMapping(value = "/users/getInfo", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public String getUserInfo(HttpServletRequest request){
        String accessToken = request.getParameter("access_token");
        //查詢資料庫中的Access Token
        AuthAccessToken authAccessToken = authorizationService.selectByAccessToken(accessToken);

        if(authAccessToken != null){
            User user = userService.selectUserInfoByScope(authAccessToken.getUserId(), authAccessToken.getScope());
            return JsonUtils.toJson(user);
        }else{
            return this.generateErrorResponse(ErrorCodeEnum.INVALID_GRANT);
        }
    }

    /**
     * 組裝錯誤請求的返回
     */
    private String generateErrorResponse(ErrorCodeEnum errorCodeEnum) {
        Map<String,Object> result = new HashMap<>(2);
        result.put("error", errorCodeEnum.getError());
        result.put("error_description",errorCodeEnum.getErrorDescription());

        return JsonUtils.toJson(result);
    }

}
複製程式碼

呼叫的程式碼邏輯如下:

@Override
public User selectUserInfoByScope(Integer userId, String scope) {
	User user = userMapper.selectByPrimaryKey(userId);

	//如果是基礎許可權,則部分資訊不返回
	if(ScopeEnum.BASIC.getCode().equalsIgnoreCase(scope)){
		user.setPassword(null);
		user.setCreateTime(null);
		user.setUpdateTime(null);
		user.setStatus(null);
	}

	return user;
}
複製程式碼

三 接入OAuth2.0授權的第三方客戶端的程式碼邏輯

這個Demo的第三方客戶端的完整可用原始碼可以參考:gitee.com/zifangsky/O…

其實,對於接入的第三方客戶端來說,後臺的程式碼邏輯跟我上篇文章中接入百度OAuth服務的程式碼邏輯是差不多的。示例如下:

package cn.zifangsky.controller;

import cn.zifangsky.common.Constants;
import cn.zifangsky.model.AuthorizationResponse;
import cn.zifangsky.model.User;
import cn.zifangsky.utils.EncryptUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.text.MessageFormat;

/**
 * 登入
 * @author zifangsky
 * @date 2018/7/9
 * @since 1.0.0
 */
@Controller
public class LoginController {

    @Autowired
    private RestTemplate restTemplate;

    @Value("${own.oauth2.client-id}")
    private String clientId;

    @Value("${own.oauth2.scope}")
    private String scope;

    @Value("${own.oauth2.client-secret}")
    private String clientSecret;

    @Value("${own.oauth2.user-authorization-uri}")
    private String authorizationUri;

    @Value("${own.oauth2.access-token-uri}")
    private String accessTokenUri;

    @Value("${own.oauth2.resource.userInfoUri}")
    private String userInfoUri;

    /**
     * 登入驗證(實際登入呼叫認證伺服器)
     * @author zifangsky
     * @date 2018/7/25 16:42
     * @since 1.0.0
     * @param request HttpServletRequest
     * @return org.springframework.web.servlet.ModelAndView
     */
    @RequestMapping("/login")
    public ModelAndView login(HttpServletRequest request){
        //當前系統登入成功之後的回撥URL
        String redirectUrl = request.getParameter("redirectUrl");
        //當前系統請求認證伺服器成功之後返回的Authorization Code
        String code = request.getParameter("code");

        //最後重定向的URL
        String resultUrl = "redirect:";
        HttpSession session = request.getSession();
        //當前請求路徑
        String currentUrl = request.getRequestURL().toString();

        //code為空,則說明當前請求不是認證伺服器的回撥請求,則重定向URL到認證伺服器登入
        if(StringUtils.isBlank(code)){
            //如果存在回撥URL,則將這個URL新增到session
            if(StringUtils.isNoneBlank(redirectUrl)){
                session.setAttribute(Constants.SESSION_LOGIN_REDIRECT_URL,redirectUrl);
            }

            //生成隨機的狀態碼,用於防止CSRF攻擊
            String status = EncryptUtils.getRandomStr1(6);
            session.setAttribute(Constants.SESSION_AUTH_CODE_STATUS, status);
            //拼裝請求Authorization Code的地址
            resultUrl += MessageFormat.format(authorizationUri,clientId,status,currentUrl);
        }else{
            //2. 通過Authorization Code獲取Access Token
            AuthorizationResponse response = restTemplate.getForObject(accessTokenUri, AuthorizationResponse.class
                    ,clientId,clientSecret,code,currentUrl);

            //如果正常返回
            if(StringUtils.isNoneBlank(response.getAccess_token())){
                System.out.println(response);

                //2.1 將Access Token存到session
                session.setAttribute(Constants.SESSION_ACCESS_TOKEN,response.getAccess_token());

                //2.2 再次查詢使用者基礎資訊,並將使用者ID存到session
                User user = restTemplate.getForObject(userInfoUri, User.class
                        ,response.getAccess_token());

                if(StringUtils.isNoneBlank(user.getUsername())){
                    session.setAttribute(Constants.SESSION_USER,user);
                }
            }

            //3. 從session中獲取回撥URL,並返回
            redirectUrl = (String) session.getAttribute(Constants.SESSION_LOGIN_REDIRECT_URL);
            session.removeAttribute("redirectUrl");
            if(StringUtils.isNoneBlank(redirectUrl)){
                resultUrl += redirectUrl;
            }else{
                resultUrl += "/user/userIndex";
            }
        }

        return new ModelAndView(resultUrl);
    }

}
複製程式碼

需要注意的是,我這裡新增了一個狀態碼,用於防止OAuth2.0授權登入過程中的CSRF攻擊攻擊。因此,需要新新增一個攔截器,用於在請求完Authorization Code回撥的時候校驗這個狀態碼。相關程式碼如下:

package cn.zifangsky.interceptor;

import cn.zifangsky.common.Constants;
import cn.zifangsky.enums.ErrorCodeEnum;
import cn.zifangsky.utils.JsonUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.HashMap;
import java.util.Map;

/**
 * 用於校驗OAuth2.0登入中的狀態碼
 *
 * @author zifangsky
 * @date 2018/8/23
 * @since 1.0.0
 */
public class AuthInterceptor extends HandlerInterceptorAdapter{

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession();

        //當前系統請求認證伺服器成功之後返回的Authorization Code
        String code = request.getParameter("code");
        //原樣返回的狀態碼
        String resultStatus = request.getParameter("status");

        //code不為空,則說明當前請求是從認證伺服器返回的回撥請求
        if(StringUtils.isNoneBlank(code)){
            //從session獲取儲存的狀態碼
            String savedStatus = (String) session.getAttribute(Constants.SESSION_AUTH_CODE_STATUS);
            //1. 校驗狀態碼是否匹配
            if(savedStatus != null && resultStatus != null && savedStatus.equals(resultStatus)){
                return true;
            }else{
                response.setCharacterEncoding("UTF-8");
                response.setHeader("Content-type", "application/json;charset=UTF-8");
                Map<String,String> result = new HashMap<>(2);
                result.put("error", ErrorCodeEnum.INVALID_STATUS.getError());
                result.put("error_description",ErrorCodeEnum.INVALID_STATUS.getErrorDescription());

                response.getWriter().write(JsonUtils.toJson(result));
                return false;
            }
        }else{
            return true;
        }
    }
}
複製程式碼

另外,實際上面程式碼中使用到的一些配置就是我們OAuth2.0服務端的介面地址:

own.oauth2.client-id=7Ugj6XWmTDpyYp8M8njG3hqx
own.oauth2.scope=super
own.oauth2.client-secret=tur2rlFfywR9OOP3fB5ZbsLTnNuNabI3
own.oauth2.user-authorization-uri=http://127.0.0.1:7000/oauth2.0/authorize?client_id={0}&response_type=code&scope=super&&status={1}&redirect_uri={2}
own.oauth2.access-token-uri=http://127.0.0.1:7000/oauth2.0/token?client_id={1}&client_secret={2}&grant_type=authorization_code&code={3}&redirect_uri={4}

own.oauth2.resource.userInfoUri=http://127.0.0.1:7000/api/users/getInfo?access_token={1}
複製程式碼

好了,本篇文章到此結束,感興趣的同學可以參考示例原始碼自己手動嘗試下。另外,我將在下篇文章中介紹一下OAuth2.0與單點登入(SSO)之間的區別與聯絡,敬請期待。

相關文章