一 前言
(1)使用環境:
SpringBoot2.X
MyBatis
基於redis存儲的springSession
(2)基礎學習:
關於SSO的基礎學習可以參考該文章:單點登錄(SSO),從原理到實現
代碼風格使用的是曉風輕的代碼規範,對於其中的AOP實現此處不會給出代碼,具體可以在文章尾部的gitHub上查看:我的編碼習慣 - Controller規範
進階可以參考:單點登錄(一)-----理論-----單點登錄SSO的介紹和CAS+選型
(3)目標
- 使用Header認證替換Cookie,避免用戶禁用cookie導致登陸失效的情況
- 實現可以運行操作的SSO單點登錄系統
(4)注意:
- 此處使用了一個項目來模擬一個Client與一個Server,因爲Server依靠存儲token來判斷用戶是否登陸,而Client依靠Session判斷用戶是否登陸,因此兩者能在同個項目共存。
- 由於項目的依賴很多,所以不會事無鉅細地講,只會挑重點的看,具體的可以在文章尾部的GitHub上查看
看完以上文章之後總結一下,在這次簡單實現中我們需要做到的有以下幾點:
- Client服務端收到請求,Filter攔截該請求,在Filter中判斷該用戶是否已經登陸,如果已經登陸,就直接進入系統,否則,返回用戶沒有登陸的信息,由前端頁面進行跳轉到SSO服務器的登錄頁面,此時要帶上原頁面的url,下面成爲clientUrl。
- 在LoginURL中會獲取到用戶的token,檢驗用戶是否已經在其他相關使用SSO的系統登陸成功。如果已經在其他的系統登陸了,則將請求轉回Client,並且帶回一個token, Client再次發送請求到ValidateURL。否則,系統提示用戶輸入ID和PASSWORD。
- 提交後請求到ValidateURL,Server驗證token的有效性。然後返回結果給Client。如果token有效,則Client與用戶之間建立局部會話。否則,重定向到登陸頁面,提示用戶輸入ID和PASSWORD。
- 校驗ID和Password是否匹配。如不匹配,再次要求用戶輸入ID和PASSWORD。否則,Server記錄用戶登陸成功。並向瀏覽器回送token,記錄用戶已經登陸成功。
那麼馬上開始SSO單點登陸之旅
二 基礎工具類
首先來看下基礎工具類,比較多,不想看的朋友可以跳到下一大節,下面看到有不清楚的方法可以搜索回來這裏看。以下在gitHub上都有:
配置redis:
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {
@Bean
<T> RedisTemplate<String, T> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, T> redisTemplate = new RedisTemplate<>();
System.out.println("加載redis配置");
Jackson2JsonRedisSerializer j = new Jackson2JsonRedisSerializer(Object.class);
// value值得序列化採用fastJsonRedisSerializer
redisTemplate.setValueSerializer(j);
redisTemplate.setHashValueSerializer(j);
// key的序列化採用StringRedisSerializer
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(factory);
redisTemplate.setEnableTransactionSupport(true);
return redisTemplate;
}
}
使用SpringSession替代Tomcat內置的Session,並且設置爲Header認證:
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1801)
public class HttpSessionConfig {
@Bean
public HeaderHttpSessionIdResolver httpSessionStrategy() {
return new HeaderHttpSessionIdResolver("x-auth-token");
}
}
此處是一個HttpClient類,使用其架起兩個服務器之間的通信:
@Service("httpClientUtil")
@Slf4j
public class HttpClientUtil {
/**
* 使用Json格式發送Post請求
*
* @param url 發送的URL
* @param requestData 傳給數據
* @return
* @throws IOException
*/
public ResultBean<Data> postAction(String url, RequestBean requestData) throws IOException {
// 將Json對象轉換爲字符串
Gson gson = new Gson();
String strJson = gson.toJson(requestData, requestData.getClass());
log.info("httpClient發送數據:{}", strJson);
//使用幫助類HttpClients創建CloseableHttpClient對象.
CloseableHttpClient client = HttpClients.createDefault();
//HTTP請求類型創建HttpPost實例
HttpPost httpPost = new HttpPost(url);
httpPost.setHeader("Content-Type", "application/json;charset=UTF-8");
//組織數據
StringEntity se = new StringEntity(strJson);
se.setContentType("application/json");
//對於httpPost請求,把請求體填充進HttpPost實體.
httpPost.setEntity(se);
CloseableHttpResponse response = null;
try {
// 執行請求
response = client.execute(httpPost);
HttpEntity entity = response.getEntity();
// 判斷返回狀態是否爲200
if (response.getStatusLine().getStatusCode() == 200) {
strJson = EntityUtils.toString(entity, "UTF-8").trim();
Type type = new TypeToken<ResultBean<Data>>() {
}.getType();
ResultBean<Data> resultBean = VerifyUtil.cast(gson.fromJson(strJson, type));
// 處理子系統局部會話的Header認證
if (null != response.getFirstHeader("x-auth-token")) {
if (null == resultBean.getData()) {
resultBean.setData(new Data());
}
resultBean.getData().setAuthToken((response.getFirstHeader("x-auth-token").toString()).split(" ")[1]);
}
return resultBean;
}
return null;
} finally {
if (response != null) {
response.close();
}
client.close();
}
}
}
一個會經常用到的工具類:
public class VerifyUtil implements Serializable {
/**
* 字符串判空
* @param str 字符串
* @return 不爲空返回true
*/
public static boolean isNotEmpty(String str) {
return (null != str && !str.equals(""));
}
/**
* 字符串判空
* @param args 多個字符串
* @return 全部不爲空返回true
*/
public static boolean isNotEmpty(String... args) {
for (String str : args) {
if (null == str || str.equals("")) {
return false;
}
}
return true;
}
/**
* 檢查字符串是否爲null
* @param arg 多個字符串
* @return 全部不爲null返回true
*/
public static boolean checkNull(String...arg) {
for (String str : arg) {
if (!checkNull(str)) {
return false;
}
}
return true;
}
/**
* 對象判空
* @param object 對象
* @return 不爲空返回true
*/
public static boolean checkNull(Object object) {
return null != object;
}
@SuppressWarnings("unchecked")
public static <T> T cast(Object object) {
return (T)object;
}
}
一個用戶狀態的枚舉類:
@AllArgsConstructor
public enum UserStatusEnum {
PARAMETER_ERROR("parameter_error"),
USER_ACCOUNT_ERROR("user_account_error"),
USER_HAS_REGISTER("user_has_register"),
URL_OR_TOKEN_ERROR("url or token error"),
USER_HAS_NOT_LOGIN("you has not login")
;
private String msg;
public String getMsg() {
return msg;
}
}
系統之間進行交互的實體類:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class RequestBean {
User user;
String token;
String clientUrl;
String authToken;
}
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ResultBean<T> implements Serializable {
private static final long serialVersionUID = 1L;
public static final int NO_LOGIN = -1;
public static final int SUCCESS = 0;
public static final int FAIL = 1;
public static final int NO_PERMISSION = 2;
private String msg = "success";
private int code = SUCCESS;
private T data;
public ResultBean() {
super();
}
public ResultBean(T data) {
super();
this.data = data;
}
public ResultBean(Throwable e) {
super();
this.msg = e.toString();
this.code = FAIL;
}
}
@lombok.Data
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public class Data {
User user;
/**
* 單點登錄令牌
*/
String token;
String clientUrl;
String authToken;
}
三 正文
那麼現在就進入正文了,整個項目還是採用了MVC的架構,下面我們將主要看下controller層與service層:
首先是controller層:
@CrossOrigin
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
UserService userService;
// sso服務端
/**
* 用戶登陸
* @param requestBean user、clientUrl、token
* @return token、clientUrl、authToken
*/
@PostMapping("/login")
public ResultBean<Data> login(@RequestBody RequestBean requestBean) {
return new ResultBean<>(userService.login(requestBean));
}
/**
* 驗證客戶端的token與clientUrl是否合法,若合法則將客戶端的clientUrl註冊到token中
* @param requestBean token、clientUrl
* @return 操作結果,成功data爲null
*/
@PostMapping("/valid")
public ResultBean<Data> validToken(@RequestBody RequestBean requestBean) {
return new ResultBean<>(userService.valid(requestBean));
}
// sso客戶端
/**
* 接收來自服務器的token與clientUrl,
* @param httpSession 操作session
* @param requestBean token、clientUrl
* @return 操作結果,成功data爲帶token與clientUrl
*/
@PostMapping("/token")
public ResultBean<Data> token(HttpSession httpSession, @RequestBody RequestBean requestBean) {
ResultBean<Data> resultBean = new ResultBean<>(userService.token(requestBean));
// 驗證成功
if (resultBean.getCode() == 0) {
// 此處僅僅設置用戶會話,用戶信息的獲取在其他請求處理
System.out.println("設置session");
httpSession.setAttribute("user", new User());
}
return resultBean;
}
}
一共只有三個方法,下面說下三者各自的任務:
在login()中對用戶的登錄狀態進行判斷,驗證用戶的token,若用戶仍未登錄則提示用戶登錄。若用戶處於登錄狀態(無論是以註冊token還是剛使用ID與PASSWORD登錄),則利用請求中的clientUrl + "/user/token"
將token傳輸給Client(子服務器)。注意,在login()中並沒有將clientUrl註冊進token內,clientUrl需要經過Client驗證後方纔可以註冊進token。
在token()中則是Client接收SSO Server傳輸過來的token數據,並且將其發送給serverUrl + "/user/valid"
進行驗證,若驗證通過,則爲用戶設置user
的Session屬性,並且將該session對應的x-auth-token
傳遞回去給SSO Server。
valid()中驗證並將clientUrl註冊進token中。
下面讓我們來繼續深入service層:
public interface UserService {
/**
* 用戶登陸
* @param requestBean user、clientUrl、token
* @return token、clientUrl、authToken
*/
Data login(RequestBean requestBean);
/**
* 驗證客戶端的token與clientUrl是否合法,若合法則將客戶端的clientUrl註冊到token中
* @param requestBean token、clientUrl
* @return 操作結果,成功data爲null
*/
Data valid(RequestBean requestBean);
/**
* 接收來自服務器的token與clientUrl,
* @param requestBean token、clientUrl
* @return 操作結果,成功data爲帶token與clientUrl
*/
Data token(RequestBean requestBean);
}
具體比較重要的實現類,這裏我將按照前面所說的SSO流程來進行說明,即是按照一個請求從頭走到尾的方式。同樣,需要完整代碼的看文末的github:
首先是基礎內容:
@Service
@Slf4j
public class UserServiceImpl implements UserService {
/**
* 用戶的Mysql數據庫操作
*/
@Resource
UserDao userDao;
/**
* 服務器之間的通訊方式
*/
@Resource
HttpClientUtil httpClientUtil;
/**
* redis數據庫操作
*/
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 建立token與clientUrl的映射,系統註銷的時候會用到
*/
private static Map<String, List<String>> tokenAndUrlMap = new HashMap<>();
/**
* 建立token與user信息的映射,子系統請求用戶信息的時候回用到
*/
private static Map<String, User> tokenAndUserMap = new HashMap<>();
/**
* 建立token與sessionID,即x-auth-token之間的映射,用戶請求x-auth-token的時候會用到
*/
private static Map<String, String> tokenAndSessionId = new HashMap<>();
}
首先從登陸開始:
/**
* 驗證用戶是否已登錄、驗證請求參數是否合法
* @param requestBean user、clientUrl、token
* @return token、clientUrl、authToken
*/
@Override
public Data login(RequestBean requestBean) {
String token = requestBean.getToken();
String clientUrl = requestBean.getClientUrl();
log.info("login() : token = {}, clientUrl = {}", token, clientUrl);
// 用戶使用token、clientUrl代表需要查詢是否已登錄
// tokenAndUrlMap中包含key代表已登錄
if (isNotEmpty(token, clientUrl) && tokenAndUrlMap.containsKey(token)) {
log.info("token = {} 用戶已登陸", token);
// 將信息反饋給Client
Data data = transmitToken(token, clientUrl);
tokenAndSessionId.put(token, data.getAuthToken());
return data;
}
// 使用賬號密碼進行登陸
User user = requestBean.getUser();
// 登陸信息爲空,說明用戶意圖不爲登陸,拋出未登錄狀態的異常
if (!checkNull(user)) {
throw new UnloginException(UserStatusEnum.USER_HAS_NOT_LOGIN.getMsg());
}
// 檢驗合法參數,並進入登陸邏輯
if (isNotEmpty(user.getAccount(), user.getPassword(), clientUrl)) {
return loginImpl(user, clientUrl);
}
// 說明參數檢驗不通過,拋出參數錯誤異常
throw new CheckException(UserStatusEnum.PARAMETER_ERROR.getMsg());
}
說下我這裏的實現都是使用X()先對參數進行一些基本的驗證,驗證不通過則直接拋出異常,否則進入XImpl()進行邏輯處理。
/**
* 用戶登錄邏輯實現
* @param user 用戶信息:account、password
* @param clientUrl Client的url
* @return token、clientUrl、authToken
*/
private Data loginImpl(User user, String clientUrl) {
String account = user.getAccount();
String password = user.getPassword();
// 查詢數據庫,若無此用戶數據則拋出賬號錯誤異常
user = userDao.listUserByUAccountAndPassword(account, password);
if (!checkNull(user)) {
throw new CheckException(UserStatusEnum.USER_ACCOUNT_ERROR.getMsg());
}
// 使用uuid生成token並存入tokenMap中,注意此時並沒有註冊clientUrl
String token = UUID.randomUUID().toString();
tokenAndUrlMap.put(token, new ArrayList<>());
tokenAndUserMap.put(token, user);
// 將信息反饋給Client、
Data data = transmitToken(token, clientUrl);
tokenAndSessionId.put(token, data.getAuthToken());
log.info("loginImpl() 登錄成功:token = {}, clientUrl = {}, sesssionId = {}, User = {}", token, clientUrl, tokenAndSessionId.get(token), user);
return data;
}
此處給出login()的數據格式:
{
"user":{
"account":"1",
"password":"1"
},
"clientUrl":"http://localhost:8889",
"token":"b8b51c17-dcb8-414f-af62-e2d1f3f49037"
}
操作流程:
- 當用戶攜帶token與clientUrl時,需要查詢相應的token是否已登錄,若已登錄則將信息反饋給子系統。
- 當用戶沒有攜帶token或者token未登錄時,開始檢驗用戶登錄的賬號密碼參數,檢驗成功則查詢數據庫是否存在符合條件的用戶,若符合條件則將信息反饋給子系統
不過大家應該發現了transmitToken(token, clientUrl)
這個方法,可以猜測到他實現的功能應該是利用clientUrl將token傳遞給子系統,並且帶回authToken,即x-auth-token
。相當於用戶在子系統中局部會話的JESSIONID。
/**
* 用以將生成的token或者以驗證登陸的token傳遞回去給Client
*
* @param token 令牌
* @param clientUrl Client的url
*/
private Data transmitToken(String token, String clientUrl) {
try {
return (httpClientUtil.postAction(clientUrl + "/user/token", new RequestBean().setToken(token).setClientUrl(clientUrl)).getData());
} catch (IOException e) {
e.printStackTrace();
throw new ErrorException("clientUrl error");
}
}
這裏的方法參考上一大節的HttpClient工具類,操作是把RequestBean當做參數,發送請求到clientUrl + "/user/token"
中,並且返回類型爲Data的參數。從clientUrl + "/user/token"
開始大家應該想到這個是服務器與服務器之間的請求了。那麼從controller層進入:
/**
* 接收來自服務器的token與clientUrl,
* @param httpSession 操作session
* @param requestBean token、clientUrl
* @return 操作結果,成功data爲帶token與clientUrl
*/
// sso客戶端
@PostMapping("/token")
public ResultBean<Data> token(HttpSession httpSession, @RequestBody RequestBean requestBean) {
ResultBean<Data> resultBean = new ResultBean<>(userService.token(requestBean));
// 驗證成功
if (resultBean.getCode() == 0) {
// 此處僅僅設置用戶會話,用戶信息的獲取在其他請求處理
System.out.println("設置session");
httpSession.setAttribute("user", new User());
}
return resultBean;
}
從SSO的邏輯中,我們知道當SSO認證中心把token發回給子系統時,子系統需要使用token在SSO認證中心進行註冊,在驗證註冊成功後,設置局部會話,並且需要把設置局部會話(即Session)產生的x-auth-token
傳遞迴給SSO認證中心。
那麼我們繼續看子系統的token方法應該怎樣處理此處的邏輯的:
/**
* 驗證來自服務器的token與clientUrl參數合法性,
* @param requestBean token、clientUrl
* @return 操作結果,成功data爲帶token與clientUrl
*/
@Override
public Data token(RequestBean requestBean) {
String token = requestBean.getToken();
String clientUrl = requestBean.getClientUrl();
if (isNotEmpty(token, clientUrl)) {
return tokenImpl(token, clientUrl);
}
throw new CheckException(UserStatusEnum.PARAMETER_ERROR.getMsg());
}
驗證參數合法性,合法則進入邏輯處理:
private Data tokenImpl(String token, String clientUrl) {
if (remoteValid(token, clientUrl)) {
return new Data().setToken(token).setClientUrl(clientUrl);
}
throw new ErrorException("valid error");
}
/**
* 向SSO發送令牌與本地url,驗證註冊
*
* @param token 令牌
* @param clientUrl 子系統url
* @return true 驗證成功
*/
private boolean remoteValid(String token, String clientUrl) {
try {
// 0爲驗證成功
if ((httpClientUtil.postAction("http://localhost:8889/user/valid", new RequestBean().setToken(token).setClientUrl(clientUrl))).getCode() == 0) {
return true;
}
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
這裏是將SSO認證中心發來的token發回SSO認證中心進行認證註冊,我們在上面用戶登錄的時候並沒有對用戶的clientUrl進行註冊,註冊行爲在此處纔會發生。
這裏我們再次跳轉到SSO認證中心的valid方法,controller層沒有特殊處理,直接看service層:
/**
* 檢驗基本的token、clientUrl參數的合法性
* @param requestBean token、clientUrl
* @return 操作結果,成功data爲null
*/
@Override
public Data valid(RequestBean requestBean) {
String token = requestBean.getToken();
String clientUrl = requestBean.getClientUrl();
if (isNotEmpty(token, clientUrl)) {
return validImpl(token, clientUrl);
}
throw new CheckException(UserStatusEnum.PARAMETER_ERROR.getMsg());
}
/**
* 驗證客戶端的token與clientUrl是否合法,若合法則將客戶端的clientUrl註冊到token中
* @param token 令牌,用戶SSO認證中心登錄的憑證
* @param clientUrl 子系統的url
* @return 操作結果,成功data爲null
*/
private Data validImpl(String token, String clientUrl) {
boolean hasSave = false;
// 驗證數據是否合法且token是否存在
if (tokenAndUrlMap.containsKey(token)) {
List<String> urls = tokenAndUrlMap.get(token);
// 驗證url是否已保存
if (null != urls) {
for (String url : urls) {
if (url.contains(clientUrl)) {
hasSave = true;
}
}
}
if (!hasSave) {
urls.add(clientUrl);
}
// 返回null即可以,默認成功
return null;
} else {
throw new ErrorException("has not exist token");
}
}
可以看到的是我們在這裏纔開始進行了token與clientUrl的註冊,之所以要加進去是爲了以後註銷功能的實現。
此處的功能比較簡單,對token進行驗證,驗證通過之後就將其加入到tokenAndurlMap的映射中。
到這裏就是登錄的邏輯,最後SSO認證中心需要把用戶的token以及用戶在子系統的局部會話的x-auth-token
返回給用戶。
其Json格式爲:
{
"msg": "success",
"code": 0,
"data": {
"user": null,
"token": "d82ea315-1034-48d2-8e0d-5303c65d4e6a",
"clientUrl": "http://localhost:8889",
"authToken": "56e20f4e-f473-4882-8b11-24aa006d1ab3"
}
}
四 總結
總的來說登錄邏輯分爲以下幾步:
- 用戶在SSO認證中心進行登錄,SSO認證中心將隨機生成的token傳遞給子服務器
- 子服務器接收到數據後,對SSO認證中心發起驗證請求。
- SSO認證中心接收到驗證請求後判斷token是否合法,若合法則將其加入映射。並將操作結果返回
- 子服務器新建Session局部會話,並將
x-auth-token
返回給SSO認證中心 - SSO認證中心將token、x-auth-token、clientUrl返回給用戶。用戶可根據token進行多系統登錄,利用x-auth-token得到與某一子系統的局部會話。
本次的SSO單點登錄先寫到這裏,後面可能會寫一篇關於SSO單點登錄的註銷實現。
項目GitHub地址:https://github.com/attendent/distrubuted