基於Springboot的SSO單點登陸系統的登陸操作實戰

一 前言

(1)使用環境:

SpringBoot2.X

MyBatis

基於redis存儲的springSession

(2)基礎學習:

關於SSO的基礎學習可以參考該文章:單點登錄(SSO),從原理到實現

代碼風格使用的是曉風輕的代碼規範,對於其中的AOP實現此處不會給出代碼,具體可以在文章尾部的gitHub上查看:我的編碼習慣 - Controller規範

進階可以參考:單點登錄(一)-----理論-----單點登錄SSO的介紹和CAS+選型

(3)目標

  1. 使用Header認證替換Cookie,避免用戶禁用cookie導致登陸失效的情況
  2. 實現可以運行操作的SSO單點登錄系統

(4)注意:

  1. 此處使用了一個項目來模擬一個Client與一個Server,因爲Server依靠存儲token來判斷用戶是否登陸,而Client依靠Session判斷用戶是否登陸,因此兩者能在同個項目共存。
  2. 由於項目的依賴很多,所以不會事無鉅細地講,只會挑重點的看,具體的可以在文章尾部的GitHub上查看

看完以上文章之後總結一下,在這次簡單實現中我們需要做到的有以下幾點:

  1. Client服務端收到請求,Filter攔截該請求,在Filter中判斷該用戶是否已經登陸,如果已經登陸,就直接進入系統,否則,返回用戶沒有登陸的信息,由前端頁面進行跳轉到SSO服務器的登錄頁面,此時要帶上原頁面的url,下面成爲clientUrl。
  2. 在LoginURL中會獲取到用戶的token,檢驗用戶是否已經在其他相關使用SSO的系統登陸成功。如果已經在其他的系統登陸了,則將請求轉回Client,並且帶回一個token, Client再次發送請求到ValidateURL。否則,系統提示用戶輸入ID和PASSWORD。
  3. 提交後請求到ValidateURL,Server驗證token的有效性。然後返回結果給Client。如果token有效,則Client與用戶之間建立局部會話。否則,重定向到登陸頁面,提示用戶輸入ID和PASSWORD。
  4. 校驗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"
}

操作流程:

  1. 當用戶攜帶token與clientUrl時,需要查詢相應的token是否已登錄,若已登錄則將信息反饋給子系統。
  2. 當用戶沒有攜帶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"
    }
}

四 總結

總的來說登錄邏輯分爲以下幾步:

  1. 用戶在SSO認證中心進行登錄,SSO認證中心將隨機生成的token傳遞給子服務器
  2. 子服務器接收到數據後,對SSO認證中心發起驗證請求。
  3. SSO認證中心接收到驗證請求後判斷token是否合法,若合法則將其加入映射。並將操作結果返回
  4. 子服務器新建Session局部會話,並將x-auth-token返回給SSO認證中心
  5. SSO認證中心將token、x-auth-token、clientUrl返回給用戶。用戶可根據token進行多系統登錄,利用x-auth-token得到與某一子系統的局部會話。

本次的SSO單點登錄先寫到這裏,後面可能會寫一篇關於SSO單點登錄的註銷實現。

項目GitHub地址:https://github.com/attendent/distrubuted

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章