在前兩篇文章中我介紹了OAuth2.0協議的基本概念(www.zifangsky.cn/1309.html)以及OAuth2.0受權服務端從設計到實現(www.zifangsky.cn/1313.html)。這篇文章中我將介紹OAuth2.0受權與單點登陸的區別,這兩個概念看似很類似,實際上卻有很大區別,而不少人每每把兩者混爲一談。html
單點登陸的英文名是 Single Sign On,所以通常簡稱爲SSO。它的用途在於,無論多麼複雜的應用羣,只要在用戶權限範圍內,那麼就能夠作到,用戶只須要登陸一次就能夠訪問權限範圍內的全部應用子系統。對於用戶而言,訪問多個應用子系統只須要登陸一次,一樣在須要註銷的時候也只須要註銷一次。舉個簡單的例子,你在百度首頁登陸成功以後,你再訪問百度百科、百度知道、百度貼吧等網站也會處於登陸狀態了,這就是一個單點登陸的真實案例。java
根據OAuth2.0受權與單點登陸的概念,咱們能夠得知兩者至少存在如下幾點區別:git
對於一個接入單點登陸的子系統而言,進行單點登陸須要如下兩個步驟:web
所以,單點登陸服務端的設計主要圍繞這兩個接口展開,其主要流程是這樣的:spring
提示:我在下面只介紹一些表的主要字段,這個Demo中使用的完整的表結構能夠參考:gitee.com/zifangsky/O…sql
(1)sso_client_details:數據庫
接入單點登陸的子系統詳情表。相似於百度的百度百科、百度知道、百度貼吧等應用子系統,每一個想要接入單點登陸的子系統都須要事先在服務端這裏「備案」。一方面是爲了防止用戶在服務端登陸成功生成的Access Token
被重定向到非法網站,從而致使用戶的Access Token
被竊取;另外一方面是記錄接入的子系統的註銷URL,便於開發單點註銷功能。因此主要須要如下幾個字段:apache
client_name
:子系統的名稱redirect_url
:獲取Access Token
成功後的回調URLlogout_url
:用戶在子系統的註銷URL(用戶登陸狀態能夠分爲:全局登陸——在單點登陸服務端的登陸狀態;局部登陸——在子系統的登陸狀態,註銷的時候須要同時註銷用戶在單點登陸服務端和應用子系統的登陸狀態)(2)sso_access_token:json
單點登陸的Access Token信息表。這個表主要體現出哪一個用戶在哪一個子系統登陸,以及生成的令牌的結束日期是哪天。因此主要須要如下幾個字段:bash
access_token
:Access Token字段user_id
:代表是哪一個用戶登陸client_id
:代表是在哪一個子系統登陸expires_in
:過時時間戳,代表這個Token在哪一天過時(3)sso_refresh_token:
單點登陸的Refresh Token信息表。這個表主要用來記錄Refresh Token
,在設計表結構的時候須要關聯它對應的sso_access_token
表的記錄。因此主要須要如下幾個字段:
refresh_token
:Refresh Token字段token_id
:它對應的sso_access_token
表的記錄expires_in
:過時時間戳這個Demo的單點登陸服務端的完整可用源碼能夠參考:gitee.com/zifangsky/O…
client請求單點登陸服務端,獲取Access Token,獲取完成以後重定向到請求中的回調URL。
接口地址:http://127.0.0.1:7000/sso/token?redirect_uri=http://192.168.197.130:6080/login
/** * 獲取Token * @author zifangsky * @date 2018/8/30 16:30 * @since 1.0.0 * @param request HttpServletRequest * @return org.springframework.web.servlet.ModelAndView */
@RequestMapping("/token")
public ModelAndView authorize(HttpServletRequest request){
HttpSession session = request.getSession();
User user = (User) session.getAttribute(Constants.SESSION_USER);
//過時時間
Long expiresIn = DateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
//回調URL
String redirectUri = request.getParameter("redirect_uri");
//查詢接入客戶端
SsoClientDetails ssoClientDetails = ssoService.selectByRedirectUrl(redirectUri);
//獲取用戶IP
String requestIp = SpringContextUtils.getRequestIp(request);
//生成Access Token
String accessTokenStr = ssoService.createAccessToken(user, expiresIn, requestIp, ssoClientDetails);
//查詢已經插入到數據庫的Access Token
SsoAccessToken ssoAccessToken = ssoService.selectByAccessToken(accessTokenStr);
//生成Refresh Token
String refreshTokenStr = ssoService.createRefreshToken(user, ssoAccessToken);
logger.info(MessageFormat.format("單點登陸獲取Token:username:【{0}】,channel:【{1}】,Access Token:【{2}】,Refresh Token:【{3}】"
,user.getUsername(),ssoClientDetails.getClientName(),accessTokenStr,refreshTokenStr));
String params = "?code=" + accessTokenStr;
return new ModelAndView("redirect:" + redirectUri + params);
}
複製代碼
相應地,調用的cn/zifangsky/service/impl/SsoServiceImpl.java
類裏面的生成邏輯:
@Override
public String createAccessToken(User user, Long expiresIn, String requestIP, SsoClientDetails ssoClientDetails) {
Date current = new Date();
//過時的時間戳
Long expiresAt = DateUtils.nextDaysSecond(ExpireEnum.ACCESS_TOKEN.getTime(), null);
//1. 拼裝待加密字符串(username + 渠道CODE + 當前精確到毫秒的時間戳)
String str = user.getUsername() + ssoClientDetails.getClientName() + String.valueOf(DateUtils.currentTimeMillis());
//2. SHA1加密
String accessTokenStr = "11." + EncryptUtils.sha1Hex(str) + "." + expiresIn + "." + expiresAt;
//3. 保存Access Token
SsoAccessToken savedAccessToken = ssoAccessTokenMapper.selectByUserIdAndClientId(user.getId(), ssoClientDetails.getId());
//若是存在匹配的記錄,則更新原記錄,不然向數據庫中插入新記錄
if(savedAccessToken != null){
savedAccessToken.setAccessToken(accessTokenStr);
savedAccessToken.setExpiresIn(expiresAt);
savedAccessToken.setUpdateUser(user.getId());
savedAccessToken.setUpdateTime(current);
ssoAccessTokenMapper.updateByPrimaryKeySelective(savedAccessToken);
}else{
savedAccessToken = new SsoAccessToken();
savedAccessToken.setAccessToken(accessTokenStr);
savedAccessToken.setUserId(user.getId());
savedAccessToken.setUserName(user.getUsername());
savedAccessToken.setIp(requestIP);
savedAccessToken.setClientId(ssoClientDetails.getId());
savedAccessToken.setChannel(ssoClientDetails.getClientName());
savedAccessToken.setExpiresIn(expiresAt);
savedAccessToken.setCreateUser(user.getId());
savedAccessToken.setUpdateUser(user.getId());
savedAccessToken.setCreateTime(current);
savedAccessToken.setUpdateTime(current);
ssoAccessTokenMapper.insertSelective(savedAccessToken);
}
//4. 返回Access Token
return accessTokenStr;
}
@Override
public String createRefreshToken(User user, SsoAccessToken ssoAccessToken) {
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() + ssoAccessToken.getAccessToken() + String.valueOf(DateUtils.currentTimeMillis());
//2. SHA1加密
String refreshTokenStr = "12." + EncryptUtils.sha1Hex(str) + "." + expiresIn + "." + expiresAt;
//3. 保存Refresh Token
SsoRefreshToken savedRefreshToken = ssoRefreshTokenMapper.selectByTokenId(ssoAccessToken.getId());
//若是存在tokenId匹配的記錄,則更新原記錄,不然向數據庫中插入新記錄
if(savedRefreshToken != null){
savedRefreshToken.setRefreshToken(refreshTokenStr);
savedRefreshToken.setExpiresIn(expiresAt);
savedRefreshToken.setUpdateUser(user.getId());
savedRefreshToken.setUpdateTime(current);
ssoRefreshTokenMapper.updateByPrimaryKeySelective(savedRefreshToken);
}else{
savedRefreshToken = new SsoRefreshToken();
savedRefreshToken.setTokenId(ssoAccessToken.getId());
savedRefreshToken.setRefreshToken(refreshTokenStr);
savedRefreshToken.setExpiresIn(expiresAt);
savedRefreshToken.setCreateUser(user.getId());
savedRefreshToken.setUpdateUser(user.getId());
savedRefreshToken.setCreateTime(current);
savedRefreshToken.setUpdateTime(current);
ssoRefreshTokenMapper.insertSelective(savedRefreshToken);
}
//4. 返回Refresh Tokens
return refreshTokenStr;
}
複製代碼
client在獲取到Access Token後,再次調用單點登陸服務端接口,用於「校驗Access Token,並返回用戶信息」。
接口地址:http://127.0.0.1:7000/sso/verify?access_token=11.ad51132688b5be3f476592356c78aef71d235f07.2592000.1539143183
返回以下:
{
"access_token": "11.ad51132688b5be3f476592356c78aef71d235f07.2592000.1539143183",
"refresh_token": "12.c10cb9001bf0e2c7f808580318715fc089673279.31536000.1568087183",
"expires_in": 2592000,
"user_info": {
"id": 2,
"username": "zifangsky",
"password": "$5$toOBSeX2$hSnSDyhJmVVRpbmKuIY4SxDgyeGRGacQaBYGrtEBnZA",
"mobile": "110",
"email": "admin@zifangsky.cn",
"createTime": "2017-12-31T16:00:00.000+0000",
"updateTime": "2017-12-31T16:00:00.000+0000",
"status": 1,
"roles": [{
"id": 2,
"roleName": "user",
"description": "普通用戶",
"funcs": null
}
]
}
}
複製代碼
首先在一個攔截器裏校驗Access Token是否有效:
package cn.zifangsky.interceptor;
import cn.zifangsky.enums.ErrorCodeEnum;
import cn.zifangsky.model.SsoAccessToken;
import cn.zifangsky.service.SsoService;
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/30 * @since 1.0.0 */
public class SsoAccessTokenInterceptor extends HandlerInterceptorAdapter{
@Resource(name = "ssoServiceImpl")
private SsoService ssoService;
/** * 檢查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
SsoAccessToken ssoAccessToken = ssoService.selectByAccessToken(accessToken);
if(ssoAccessToken != null){
Long savedExpiresAt = ssoAccessToken.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,並返回用戶信息 * @author zifangsky * @date 2018/8/30 16:07 * @since 1.0.0 * @param request HttpServletRequest * @return java.util.Map<java.lang.String,java.lang.Object> */
@RequestMapping(value = "/verify", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public Map<String,Object> verify(HttpServletRequest request) {
Map<String, Object> result = new HashMap<>(8);
//獲取Access Token
String accessToken = request.getParameter("access_token");
try {
//過時時間
Long expiresIn = DateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
//查詢Access Token
SsoAccessToken ssoAccessToken = ssoService.selectByAccessToken(accessToken);
//查詢Refresh Token
SsoRefreshToken ssoRefreshToken = ssoService.selectByTokenId(ssoAccessToken.getId());
//查詢用戶信息
UserBo userBo = userService.selectUserBoByUserId(ssoAccessToken.getUserId());
//組裝返回信息
result.put("access_token", ssoAccessToken.getAccessToken());
result.put("refresh_token", ssoRefreshToken.getRefreshToken());
result.put("expires_in", expiresIn);
result.put("user_info", userBo);
return result;
}catch (Exception e){
logger.error(e.getMessage());
this.generateErrorResponse(result, ErrorCodeEnum.UNKNOWN_ERROR);
return result;
}
}
複製代碼
若是客戶端的Access Token過時了,那麼就能夠經過這個接口刷新Access Token。
接口地址:http://127.0.0.1:7000/sso/refreshToken?refresh_token=12.c10cb9001bf0e2c7f808580318715fc089673279.31536000.1568087183
返回以下:
{
"access_token": "11.40f0270697c37db4570e41e0f6f335bf6c2f8902.2592000.1539164947",
"refresh_token": "12.c10cb9001bf0e2c7f808580318715fc089673279.31536000.1568087183",
"expires_in": 2592000,
"user_info": {
"id": 2,
"username": "zifangsky",
"password": "$5$toOBSeX2$hSnSDyhJmVVRpbmKuIY4SxDgyeGRGacQaBYGrtEBnZA",
"mobile": "110",
"email": "admin@zifangsky.cn",
"createTime": "2017-12-31T16:00:00.000+0000",
"updateTime": "2017-12-31T16:00:00.000+0000",
"status": 1,
"roles": [{
"id": 2,
"roleName": "user",
"description": "普通用戶",
"funcs": null
}
]
}
}
複製代碼
/** * 經過Refresh Token刷新Access Token * @author zifangsky * @date 2018/8/30 16:07 * @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");
//獲取用戶IP
String requestIp = SpringContextUtils.getRequestIp(request);
try {
SsoRefreshToken ssoRefreshToken = ssoService.selectByRefreshToken(refreshTokenStr);
if(ssoRefreshToken != null) {
Long savedExpiresAt = ssoRefreshToken.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
SsoAccessToken ssoAccessToken = ssoService.selectByAccessId(ssoRefreshToken.getTokenId());
//查詢接入客戶端
SsoClientDetails ssoClientDetails = ssoService.selectByPrimaryKey(ssoAccessToken.getClientId());
//獲取對應的用戶信息
User user = userService.selectByUserId(ssoAccessToken.getUserId());
//新的過時時間
Long expiresIn = DateUtils.dayToSecond(ExpireEnum.ACCESS_TOKEN.getTime());
//生成新的Access Token
String newAccessTokenStr = ssoService.createAccessToken(user, expiresIn, requestIp, ssoClientDetails);
//查詢用戶信息
UserBo userBo = userService.selectUserBoByUserId(ssoAccessToken.getUserId());
logger.info(MessageFormat.format("單點登陸從新刷新Token:username:【{0}】,requestIp:【{1}】,old token:【{2}】,new token:【{3}】"
,user.getUsername(),requestIp,ssoAccessToken.getAccessToken(),newAccessTokenStr));
//組裝返回信息
result.put("access_token", newAccessTokenStr);
result.put("refresh_token", ssoRefreshToken.getRefreshToken());
result.put("expires_in", expiresIn);
result.put("user_info", userBo);
return result;
}
}else {
this.generateErrorResponse(result, ErrorCodeEnum.INVALID_GRANT);
return result;
}
}catch (Exception e){
e.printStackTrace();
this.generateErrorResponse(result, ErrorCodeEnum.UNKNOWN_ERROR);
return result;
}
}
/** * 組裝錯誤請求的返回 */
private void generateErrorResponse(Map<String,Object> result, ErrorCodeEnum errorCodeEnum) {
result.put("error", errorCodeEnum.getError());
result.put("error_description",errorCodeEnum.getErrorDescription());
}
複製代碼
在這個Demo項目中,我沒有提供單點註銷功能的示例代碼,可是我能夠簡單說一下單點註銷功能的主要流程,若是須要這個功能能夠自行使用代碼實現:
這個Demo的單點登陸子系統的完整可用源碼能夠參考:gitee.com/zifangsky/O…
其實,對於接入單點登陸的子系統來講,登陸模塊調用單點登陸服務端提供的接口就能夠了。
登陸校驗過濾器:
package cn.zifangsky.interceptor;
import cn.zifangsky.common.Constants;
import cn.zifangsky.common.SpringContextUtils;
import cn.zifangsky.model.bo.UserBo;
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;
/** * 定義一些頁面須要作登陸檢查 * * @author zifangsky * @date 2018/7/26 * @since 1.0.0 */
public class LoginInterceptor extends HandlerInterceptorAdapter{
/** * 檢查是否已經登陸 */
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpSession session = request.getSession();
//獲取session中存儲的用戶信息
UserBo user = (UserBo) session.getAttribute(Constants.SESSION_USER);
if(user != null){
return true;
}else{
//若是token不存在,則跳轉等登陸頁面
response.sendRedirect(request.getContextPath() + "/login?redirectUrl=" + SpringContextUtils.getRequestUrl(request));
return false;
}
}
}
複製代碼
登陸相關的代碼邏輯:
package cn.zifangsky.controller;
import cn.zifangsky.common.Constants;
import cn.zifangsky.model.SsoResponse;
import cn.zifangsky.model.User;
import cn.zifangsky.utils.CookieUtils;
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.HttpServletResponse;
import javax.servlet.http.HttpSession;
/** * 登陸 * @author zifangsky * @date 2018/7/9 * @since 1.0.0 */
@Controller
public class LoginController {
@Autowired
private RestTemplate restTemplate;
@Value("${own.sso.access-token-uri}")
private String accessTokenUri;
@Value("${own.sso.verify-uri}")
private String verifyUri;
/** * 登陸驗證(實際登陸調用認證服務器) * @author zifangsky * @date 2018/8/30 18:02 * @since 1.0.0 * @param request HttpServletRequest * @return org.springframework.web.servlet.ModelAndView */
@RequestMapping("/login")
public ModelAndView login(HttpServletRequest request, HttpServletResponse response){
//當前系統登陸成功以後的回調URL
String redirectUrl = request.getParameter("redirectUrl");
//當前系統請求認證服務器成功以後返回的Token
String code = request.getParameter("code");
//最後重定向的URL
String resultUrl = "redirect:";
HttpSession session = request.getSession();
//1. code爲空,則說明當前請求不是認證服務器的回調請求,則重定向URL到認證服務器登陸
if(StringUtils.isBlank(code)){
//若是存在回調URL,則將這個URL添加到session
if(StringUtils.isNoneBlank(redirectUrl)){
session.setAttribute(Constants.SESSION_LOGIN_REDIRECT_URL,redirectUrl);
}
//拼裝請求Token的地址
resultUrl += accessTokenUri;
}else{
//2. 驗證Token,並返回用戶基本信息、所屬角色、訪問權限等
SsoResponse verifyResponse = restTemplate.getForObject(verifyUri, SsoResponse.class
,code);
//若是正常返回
if(StringUtils.isNoneBlank(verifyResponse.getAccess_token())){
//2.1 將用戶信息存到session
session.setAttribute(Constants.SESSION_USER,verifyResponse.getUser_info());
//2.2 將Access Token和Refresh Token寫到cookie
CookieUtils.addCookie(response,Constants.COOKIE_ACCESS_TOKEN, verifyResponse.getAccess_token(),request.getServerName());
CookieUtils.addCookie(response,Constants.COOKIE_REFRESH_TOKEN, verifyResponse.getRefresh_token(),request.getServerName());
}
//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);
}
}
複製代碼
固然,上面代碼中使用到的一些配置就是咱們單點登陸服務端的接口地址:
own.sso.access-token-uri=http://10.0.5.22:7000/sso/token?redirect_uri=http://192.168.197.130:6080/login
own.sso.verify-uri=http://10.0.5.22:7000/sso/verify?access_token={1}
複製代碼
SsoClientDemo項目
部署在跟ServerDemo項目
不一樣的服務器;SsoClientDemo項目
並訪問須要登陸的頁面,好比:http://192.168.197.130:6080/user/userIndex
;ServerDemo項目
,在服務端登陸成功以後,跳轉到SsoClientDemo項目
的/user/userIndex
,說明客戶端也登陸成功了;SsoClientDemo項目
,並再次訪問http://192.168.197.130:6080/user/userIndex
,能夠發現此次是直接登陸了(固然也能夠把SsoClientDemo項目
部署到多個服務器上面,前後登陸查看效果),這說明單點登陸功能已經實現。本篇文章到此結束,感謝你們的閱讀。
參考: