如何設計一套單點登錄系統
本文轉載自微信公眾號「Java極客技術」,作者鴨血粉絲 。轉載本文請聯系Java極客技術公眾號。
一、介紹
昨天介紹了API接口設計token鑒權方案,其實token鑒權最佳的實踐場景就是在單點登錄系統上。
在企業發展初期,使用的后臺管理系統還比較少,一個或者兩個。
以電商系統為例,在起步階段,可能只有一個商城下單系統和一個后端管理產品和庫存的系統。
隨著業務量越來越大,此時的業務系統會越來越復雜,項目會劃分成多個組,每個組負責各自的領域,例如:A組負責商城系統的開發,B組負責支付系統的開發,C組負責庫存系統的開發,D組負責物流跟蹤系統的開發,E組負責每日業績報表統計的開發...等等。
規模變大的同時,人員也會逐漸的增多,以研發部來說,大致的人員就有這么幾大類:研發人員、測試人員、運維人員、產品經理、技術支持等等。
他們會頻繁的登錄各自的后端業務系統,然后進行辦公。
此時,我們可以設想一下,如果每個組都自己開發一套后端管理系統的登錄,假如有10個這樣的系統,同時一個新入職的同事需要每個系統都給他開放一個權限,那么我們可能需要給他開通10個賬號。
隨著業務規模的擴大,大點的公司,可能高達一百多個業務系統,那豈不是要配置一百多個賬號,讓人去做這種操作,豈不傷天害理。
面對這種繁瑣而且又無效的工作,IT大佬們想到一個辦法,那就是開發一套登錄系統,所有的業務系統都認可這套登錄系統,那么就可以實現只需要登錄一次,就可以訪問其他相互信任的應用系統。
這個登錄系統,我們把它稱為:單點登錄系統。
好了,言歸正傳,下面我們從兩個方面來介紹單點登錄系統的實現。
- 方案設計
- 項目實踐
二、方案設計
2.1、單體后端系統登錄
在傳統的單體后端系統中,簡單點的操作,我們一般都會這么玩,用戶使用賬號、密碼登錄之后,服務器會給當前用戶創建一個session會話,同時也會生成一個cookie,最后返回給前端。
當用戶訪問其他后端的服務時,我們只需要檢查一下當前用戶的session是否有效,如果無效,就再次跳轉到登錄頁面;如果有效,就進入業務處理流程。
但是,如果訪問不同的域名系統時,這個cookie是無效的,因此不能跨系統訪問,同時也不支持集群環境的共享。
對于單點登錄的場景,我們需要重新設計一套新的方案。
2.2、單點登錄系統登錄
先來一張圖!
這個流程圖,就是單點登錄系統與應用系統之間的交互圖。
當用戶登錄某應用系統時,應用系統會把將客戶端傳入的token,調用單點登錄系統驗證token合法性接口,如果不合法就會跳轉到單點登錄系統的登錄頁面;如果合法,就直接進入首頁。
進入登錄頁面之后,會讓用戶輸入用戶名、密碼進行登錄驗證,如果驗證成功之后,會返回一個有效的token,然后客戶端會根據服務端返回的參數鏈接,跳轉回之前要訪問的應用系統。
接著,應用系統會再次驗證token的合法性,如果合法,就進入首頁,流程結束。
引入單點登錄系統后,接入的應用系統不需要關系用戶登錄這塊,只需要對客戶端的token做一下合法性鑒權操作就可以了。
而單點登錄系統,只需要做好用戶的登錄流程和鑒權并返回安全的token給客戶端。
有的項目,會將生成的token,存放在客戶端的cookie中,這樣做的目的,就是避免每次調用接口的時候都在url里面帶上token。
但是,瀏覽器只允許同域名下的cookies可以共享,對于不同的域名系統, cookie 是無法共享的。
對于這種情況,我們可以先將 token 放入到url鏈接中,類似上面流程圖中跳轉思路,對于同一個應用系統,我們可以將token放入到 cookie 中,不同的應用系統,我們可以通過 url 鏈接進行傳遞,實現token的傳輸。
三、項目實踐
在實踐上,token的存儲,有兩種方案:
- 存放在服務器,如果是分布式環境,一般都會存儲在 redis 中
- 存儲在客戶端,服務器做驗證,天然支持分布式
3.1、存放在redis
存放在redis中,是一種比較常見的處理辦法,最開始的時候也是這種處理辦法。
當用戶登錄成功之后,會將用戶的信息作為value,用uuid作為key,存儲到redis中,各個服務集群共享用戶信息。
代碼實踐也非常簡單。
用戶登錄之后,將用戶信息存在到redis,同時返回一個有效的token給客戶端。
- @RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
- public TokenVO login(@RequestBody LoginDTO loginDTO){
- //...參數合法性驗證
- //從數據庫獲取用戶信息
- User dbUser = userService.selectByUserNo(loginDTO.getUserNo);
- //....用戶、密碼驗證
- //創建token
- String token = UUID.randomUUID();
- //將token和用戶信息存儲到redis,并設置有效期2個小時
- redisUtil.save(token, dbUser, 2*60*60);
- //定義返回結果
- TokenVO result = new TokenVO();
- //封裝token
- result.setToken(token);
- //封裝應用系統訪問地址
- result.setRedirectURL(loginDTO.getRedirectURL());
- return result;
- }
客戶端收到登錄成功之后,根據參數組合進行跳轉到對應的應用系統。
跳轉示例如下:http://xxx.com/page.html?token=xxxxxx
各個應用系統,只需要編寫一個過濾器TokenFilter對token參數進行驗證攔截,即可實現對接,代碼如下:
- @Override
- public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws ServletException, IOException, SecurityException {
- HttpServletRequest request = (HttpServletRequest) servletRequest;
- HttpServletResponse response = (HttpServletResponse) servletResponse;
- String requestUri = request.getRequestURI();
- String contextPath = request.getContextPath();
- String serviceName = request.getServerName();
- //添加到白名單的URL放行
- String[] excludeUrls = {
- "(?:/images/|/css/|/js/|/template/|/static/|/web/|/constant/).+$",
- "/user/login",
- "/user/createImage"
- };
- for (String url : excludeUrls) {
- if (requestUri.matches(contextPath + url) || (serviceName.matches(url))) {
- filterChain.doFilter(request, response);
- return;
- }
- }
- //運行跨域探測
- if(RequestMethod.OPTIONS.name().equals(request.getMethod())){
- filterChain.doFilter(request, response);
- return;
- }
- //檢查token是否有效
- final String token = request.getHeader("token");
- if(StringUtils.isEmpty(token) || !redisUtil.exist(token)){
- ResultMsg<Object> resultMsg = new ResultMsg<>(4000, "token已失效");
- //封裝跳轉地址
- resultMsg.setRedirectURL("http://sso.xxx.com?redirectURL=" + request.getRequestURL());
- WebUtil.buildPrintWriter(response, resultMsg);
- return;
- }
- //將用戶信息,存入request中,方便后續獲取
- User user = redisUtil.get(token);
- request.setAttribute("user", user);
- filterChain.doFilter(request, response);
- return;
- }
上面返回的是json數據給前端,當然你還可以直接在服務器采用重定向進行跳轉,具體根據自己的情況進行選擇。
由于每個應用系統都可能需要進行對接,因此我們可以將上面的方法封裝成一個公共jar包,應用系統只需要依賴包即可完成對接!
3.2、token存放客戶端
還有一種方案,是將token存放客戶端,這種方案就是服務端根據規則對數據進行加密生成一個簽名串,這個簽名串就是我們所說的token,最后返回給前端。
因為加密的操作都是在服務端完成的,因此密鑰的管理非常重要,不能泄露出去,不然很容易被黑客解密出來。
最典型的應用就是JWT!
JWT 是由三段信息構成的,將這三段信息文本用.鏈接一起就構成了JWT字符串。就像這樣:
- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
如何實現呢?首先我們需要添加一個jwt依賴包。
- <!-- jwt支持 -->
- <dependency>
- <groupId>com.auth0</groupId>
- <artifactId>java-jwt</artifactId>
- <version>3.4.0</version>
- </dependency>
然后,創建一個用戶信息類,將會通過加密存放在token中。
- @Data
- @EqualsAndHashCode(callSuper = false)
- @Accessors(chain = true)
- public class UserToken implements Serializable {
- private static final long serialVersionUID = 1L;
- /**
- * 用戶ID
- */
- private String userId;
- /**
- * 用戶登錄賬戶
- */
- private String userNo;
- /**
- * 用戶中文名
- */
- private String userName;
- }
接著,創建一個JwtTokenUtil工具類,用于創建token、驗證token。
- public class JwtTokenUtil {
- //定義token返回頭部
- public static final String AUTH_HEADER_KEY = "Authorization";
- //token前綴
- public static final String TOKEN_PREFIX = "Bearer ";
- //簽名密鑰
- public static final String KEY = "q3t6w9z$C&F)J@NcQfTjWnZr4u7x";
- //有效期默認為 2hour
- public static final Long EXPIRATION_TIME = 1000L*60*60*2;
- /**
- * 創建TOKEN
- * @param content
- * @return
- */
- public static String createToken(String content){
- return TOKEN_PREFIX + JWT.create()
- .withSubject(content)
- .withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
- .sign(Algorithm.HMAC512(KEY));
- }
- /**
- * 驗證token
- * @param token
- */
- public static String verifyToken(String token) throws Exception {
- try {
- return JWT.require(Algorithm.HMAC512(KEY))
- .build()
- .verify(token.replace(TOKEN_PREFIX, ""))
- .getSubject();
- } catch (TokenExpiredException e){
- throw new Exception("token已失效,請重新登錄",e);
- } catch (JWTVerificationException e) {
- throw new Exception("token驗證失敗!",e);
- }
- }
- }
同時編寫配置類,允許跨域,并且創建一個權限攔截器。
- @Slf4j
- @Configuration
- public class GlobalWebMvcConfig implements WebMvcConfigurer {
- /**
- * 重寫父類提供的跨域請求處理的接口
- * @param registry
- */
- @Override
- public void addCorsMappings(CorsRegistry registry) {
- // 添加映射路徑
- registry.addMapping("/**")
- // 放行哪些原始域
- .allowedOrigins("*")
- // 是否發送Cookie信息
- .allowCredentials(true)
- // 放行哪些原始域(請求方式)
- .allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")
- // 放行哪些原始域(頭部信息)
- .allowedHeaders("*")
- // 暴露哪些頭部信息(因為跨域訪問默認不能獲取全部頭部信息)
- .exposedHeaders("Server","Content-Length", "Authorization", "Access-Token", "Access-Control-Allow-Origin","Access-Control-Allow-Credentials");
- }
- /**
- * 添加攔截器
- * @param registry
- */
- @Override
- public void addInterceptors(InterceptorRegistry registry) {
- //添加權限攔截器
- registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");
- }
- }
使用AuthenticationInterceptor攔截器對接口參數進行驗證。
- @Slf4j
- public class AuthenticationInterceptor implements HandlerInterceptor {
- @Override
- public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
- // 從http請求頭中取出token
- final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);
- //如果不是映射到方法,直接通過
- if(!(handler instanceof HandlerMethod)){
- return true;
- }
- //如果是方法探測,直接通過
- if (HttpMethod.OPTIONS.equals(request.getMethod())) {
- response.setStatus(HttpServletResponse.SC_OK);
- return true;
- }
- //如果方法有JwtIgnore注解,直接通過
- HandlerMethod handlerMethod = (HandlerMethod) handler;
- Method method=handlerMethod.getMethod();
- if (method.isAnnotationPresent(JwtIgnore.class)) {
- JwtIgnore jwtIgnore = method.getAnnotation(JwtIgnore.class);
- if(jwtIgnore.value()){
- return true;
- }
- }
- LocalAssert.isStringEmpty(token, "token為空,鑒權失敗!");
- //驗證,并獲取token內部信息
- String userToken = JwtTokenUtil.verifyToken(token);
- //將token放入本地緩存
- WebContextUtil.setUserToken(userToken);
- return true;
- }
- @Override
- public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
- //方法結束后,移除緩存的token
- WebContextUtil.removeUserToken();
- }
- }
最后,在controller層用戶登錄之后,創建一個token,存放在頭部即可。
- /**
- * 登錄
- * @param userDto
- * @return
- */
- @JwtIgnore
- @RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
- public UserVo login(@RequestBody UserDto userDto, HttpServletResponse response){
- //...參數合法性驗證
- //從數據庫獲取用戶信息
- User dbUser = userService.selectByUserNo(userDto.getUserNo);
- //....用戶、密碼驗證
- //創建token,并將token放在響應頭
- UserToken userToken = new UserToken();
- BeanUtils.copyProperties(dbUser,userToken);
- String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));
- response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token);
- //定義返回結果
- UserVo result = new UserVo();
- BeanUtils.copyProperties(dbUser,result);
- return result;
- }
到這里基本就完成了!
其中AuthenticationInterceptor中用到的JwtIgnore是一個注解,用于不需要驗證token的方法上,例如驗證碼的獲取等等。
- @Target({ElementType.METHOD, ElementType.TYPE})
- @Retention(RetentionPolicy.RUNTIME)
- public @interface JwtIgnore {
- boolean value() default true;
- }
而WebContextUtil是一個線程緩存工具類,其他接口通過這個方法即可從token中獲取用戶信息。
- public class WebContextUtil {
- //本地線程緩存token
- private static ThreadLocal<String> local = new ThreadLocal<>();
- /**
- * 設置token信息
- * @param content
- */
- public static void setUserToken(String content){
- removeUserToken();
- local.set(content);
- }
- /**
- * 獲取token信息
- * @return
- */
- public static UserToken getUserToken(){
- if(local.get() != null){
- UserToken userToken = JSONObject.parseObject(local.get() , UserToken.class);
- return userToken;
- }
- return null;
- }
- /**
- * 移除token信息
- * @return
- */
- public static void removeUserToken(){
- if(local.get() != null){
- local.remove();
- }
- }
- }
對應用系統而言,重點在于token的驗證,可以將攔截器方法封裝成一個公共的jar包,然后各個應用系統引用即可!
和上面介紹的token存儲到redis方案類似,不同點在于:一個將用戶數據存儲到redis,另一個是采用加密算法存儲到客戶端進行傳輸。
四、小結
在實際的使用過程中,我個人更加傾向于采用jwt方案,直接在服務端使用簽名加密算法生成一個token,然后在客戶端進行流轉,天然支持分布式,但是要注意加密時用的密鑰要安全管理。
而采用redis方案存儲的時候,你需要搭建高可用的集群環境,同時保證緩存數據不會失效等等,維護成本高!
在實際的實現上,每個公司玩法不一樣,有的安全性要求高,后端還會加上密鑰環節進行安全驗證,基本思路大同小異。