您好,登錄后才能下訂單哦!
這篇文章主要為大家展示了“web開(kāi)發(fā)中怎么設(shè)計(jì)一套單點(diǎn)登錄系統(tǒng)”,內(nèi)容簡(jiǎn)而易懂,條理清晰,希望能夠幫助大家解決疑惑,下面讓小編帶領(lǐng)大家一起研究并學(xué)習(xí)一下“web開(kāi)發(fā)中怎么設(shè)計(jì)一套單點(diǎn)登錄系統(tǒng)”這篇文章吧。
昨天介紹了API接口設(shè)計(jì)token鑒權(quán)方案,其實(shí)token鑒權(quán)最佳的實(shí)踐場(chǎng)景就是在單點(diǎn)登錄系統(tǒng)上。
在企業(yè)發(fā)展初期,使用的后臺(tái)管理系統(tǒng)還比較少,一個(gè)或者兩個(gè)。
以電商系統(tǒng)為例,在起步階段,可能只有一個(gè)商城下單系統(tǒng)和一個(gè)后端管理產(chǎn)品和庫(kù)存的系統(tǒng)。
隨著業(yè)務(wù)量越來(lái)越大,此時(shí)的業(yè)務(wù)系統(tǒng)會(huì)越來(lái)越復(fù)雜,項(xiàng)目會(huì)劃分成多個(gè)組,每個(gè)組負(fù)責(zé)各自的領(lǐng)域,例如:A組負(fù)責(zé)商城系統(tǒng)的開(kāi)發(fā),B組負(fù)責(zé)支付系統(tǒng)的開(kāi)發(fā),C組負(fù)責(zé)庫(kù)存系統(tǒng)的開(kāi)發(fā),D組負(fù)責(zé)物流跟蹤系統(tǒng)的開(kāi)發(fā),E組負(fù)責(zé)每日業(yè)績(jī)報(bào)表統(tǒng)計(jì)的開(kāi)發(fā)...等等。
規(guī)模變大的同時(shí),人員也會(huì)逐漸的增多,以研發(fā)部來(lái)說(shuō),大致的人員就有這么幾大類:研發(fā)人員、測(cè)試人員、運(yùn)維人員、產(chǎn)品經(jīng)理、技術(shù)支持等等。
他們會(huì)頻繁的登錄各自的后端業(yè)務(wù)系統(tǒng),然后進(jìn)行辦公。
此時(shí),我們可以設(shè)想一下,如果每個(gè)組都自己開(kāi)發(fā)一套后端管理系統(tǒng)的登錄,假如有10個(gè)這樣的系統(tǒng),同時(shí)一個(gè)新入職的同事需要每個(gè)系統(tǒng)都給他開(kāi)放一個(gè)權(quán)限,那么我們可能需要給他開(kāi)通10個(gè)賬號(hào)。
隨著業(yè)務(wù)規(guī)模的擴(kuò)大,大點(diǎn)的公司,可能高達(dá)一百多個(gè)業(yè)務(wù)系統(tǒng),那豈不是要配置一百多個(gè)賬號(hào),讓人去做這種操作,豈不傷天害理。
面對(duì)這種繁瑣而且又無(wú)效的工作,IT大佬們想到一個(gè)辦法,那就是開(kāi)發(fā)一套登錄系統(tǒng),所有的業(yè)務(wù)系統(tǒng)都認(rèn)可這套登錄系統(tǒng),那么就可以實(shí)現(xiàn)只需要登錄一次,就可以訪問(wèn)其他相互信任的應(yīng)用系統(tǒng)。
這個(gè)登錄系統(tǒng),我們把它稱為:?jiǎn)吸c(diǎn)登錄系統(tǒng)。
好了,言歸正傳,下面我們從兩個(gè)方面來(lái)介紹單點(diǎn)登錄系統(tǒng)的實(shí)現(xiàn)。
方案設(shè)計(jì)
項(xiàng)目實(shí)踐
在傳統(tǒng)的單體后端系統(tǒng)中,簡(jiǎn)單點(diǎn)的操作,我們一般都會(huì)這么玩,用戶使用賬號(hào)、密碼登錄之后,服務(wù)器會(huì)給當(dāng)前用戶創(chuàng)建一個(gè)session會(huì)話,同時(shí)也會(huì)生成一個(gè)cookie,最后返回給前端。
當(dāng)用戶訪問(wèn)其他后端的服務(wù)時(shí),我們只需要檢查一下當(dāng)前用戶的session是否有效,如果無(wú)效,就再次跳轉(zhuǎn)到登錄頁(yè)面;如果有效,就進(jìn)入業(yè)務(wù)處理流程。
但是,如果訪問(wèn)不同的域名系統(tǒng)時(shí),這個(gè)cookie是無(wú)效的,因此不能跨系統(tǒng)訪問(wèn),同時(shí)也不支持集群環(huán)境的共享。
對(duì)于單點(diǎn)登錄的場(chǎng)景,我們需要重新設(shè)計(jì)一套新的方案。
先來(lái)一張圖!
這個(gè)流程圖,就是單點(diǎn)登錄系統(tǒng)與應(yīng)用系統(tǒng)之間的交互圖。
當(dāng)用戶登錄某應(yīng)用系統(tǒng)時(shí),應(yīng)用系統(tǒng)會(huì)把將客戶端傳入的token,調(diào)用單點(diǎn)登錄系統(tǒng)驗(yàn)證token合法性接口,如果不合法就會(huì)跳轉(zhuǎn)到單點(diǎn)登錄系統(tǒng)的登錄頁(yè)面;如果合法,就直接進(jìn)入首頁(yè)。
進(jìn)入登錄頁(yè)面之后,會(huì)讓用戶輸入用戶名、密碼進(jìn)行登錄驗(yàn)證,如果驗(yàn)證成功之后,會(huì)返回一個(gè)有效的token,然后客戶端會(huì)根據(jù)服務(wù)端返回的參數(shù)鏈接,跳轉(zhuǎn)回之前要訪問(wèn)的應(yīng)用系統(tǒng)。
接著,應(yīng)用系統(tǒng)會(huì)再次驗(yàn)證token的合法性,如果合法,就進(jìn)入首頁(yè),流程結(jié)束。
引入單點(diǎn)登錄系統(tǒng)后,接入的應(yīng)用系統(tǒng)不需要關(guān)系用戶登錄這塊,只需要對(duì)客戶端的token做一下合法性鑒權(quán)操作就可以了。
而單點(diǎn)登錄系統(tǒng),只需要做好用戶的登錄流程和鑒權(quán)并返回安全的token給客戶端。
有的項(xiàng)目,會(huì)將生成的token,存放在客戶端的cookie中,這樣做的目的,就是避免每次調(diào)用接口的時(shí)候都在url里面帶上token。
但是,瀏覽器只允許同域名下的cookies可以共享,對(duì)于不同的域名系統(tǒng), cookie 是無(wú)法共享的。
對(duì)于這種情況,我們可以先將 token 放入到url鏈接中,類似上面流程圖中跳轉(zhuǎn)思路,對(duì)于同一個(gè)應(yīng)用系統(tǒng),我們可以將token放入到 cookie 中,不同的應(yīng)用系統(tǒng),我們可以通過(guò) url 鏈接進(jìn)行傳遞,實(shí)現(xiàn)token的傳輸。
在實(shí)踐上,token的存儲(chǔ),有兩種方案:
存放在服務(wù)器,如果是分布式環(huán)境,一般都會(huì)存儲(chǔ)在 redis 中
存儲(chǔ)在客戶端,服務(wù)器做驗(yàn)證,天然支持分布式
存放在redis中,是一種比較常見(jiàn)的處理辦法,最開(kāi)始的時(shí)候也是這種處理辦法。
當(dāng)用戶登錄成功之后,會(huì)將用戶的信息作為value,用uuid作為key,存儲(chǔ)到redis中,各個(gè)服務(wù)集群共享用戶信息。
代碼實(shí)踐也非常簡(jiǎn)單。
用戶登錄之后,將用戶信息存在到redis,同時(shí)返回一個(gè)有效的token給客戶端。
@RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"}) public TokenVO login(@RequestBody LoginDTO loginDTO){ //...參數(shù)合法性驗(yàn)證 //從數(shù)據(jù)庫(kù)獲取用戶信息 User dbUser = userService.selectByUserNo(loginDTO.getUserNo); //....用戶、密碼驗(yàn)證 //創(chuàng)建token String token = UUID.randomUUID(); //將token和用戶信息存儲(chǔ)到redis,并設(shè)置有效期2個(gè)小時(shí) redisUtil.save(token, dbUser, 2*60*60); //定義返回結(jié)果 TokenVO result = new TokenVO(); //封裝token result.setToken(token); //封裝應(yīng)用系統(tǒng)訪問(wèn)地址 result.setRedirectURL(loginDTO.getRedirectURL()); return result; }
客戶端收到登錄成功之后,根據(jù)參數(shù)組合進(jìn)行跳轉(zhuǎn)到對(duì)應(yīng)的應(yīng)用系統(tǒng)。
跳轉(zhuǎn)示例如下:http://xxx.com/page.html?token=xxxxxx
各個(gè)應(yīng)用系統(tǒng),只需要編寫一個(gè)過(guò)濾器TokenFilter對(duì)token參數(shù)進(jìn)行驗(yàn)證攔截,即可實(shí)現(xiàn)對(duì)接,代碼如下:
@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; } } //運(yùn)行跨域探測(cè) 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已失效"); //封裝跳轉(zhuǎn)地址 resultMsg.setRedirectURL("http://sso.xxx.com?redirectURL=" + request.getRequestURL()); WebUtil.buildPrintWriter(response, resultMsg); return; } //將用戶信息,存入request中,方便后續(xù)獲取 User user = redisUtil.get(token); request.setAttribute("user", user); filterChain.doFilter(request, response); return; }
上面返回的是json數(shù)據(jù)給前端,當(dāng)然你還可以直接在服務(wù)器采用重定向進(jìn)行跳轉(zhuǎn),具體根據(jù)自己的情況進(jìn)行選擇。
由于每個(gè)應(yīng)用系統(tǒng)都可能需要進(jìn)行對(duì)接,因此我們可以將上面的方法封裝成一個(gè)公共jar包,應(yīng)用系統(tǒng)只需要依賴包即可完成對(duì)接!
還有一種方案,是將token存放客戶端,這種方案就是服務(wù)端根據(jù)規(guī)則對(duì)數(shù)據(jù)進(jìn)行加密生成一個(gè)簽名串,這個(gè)簽名串就是我們所說(shuō)的token,最后返回給前端。
因?yàn)榧用艿牟僮鞫际窃诜?wù)端完成的,因此密鑰的管理非常重要,不能泄露出去,不然很容易被黑客解密出來(lái)。
最典型的應(yīng)用就是JWT!
JWT 是由三段信息構(gòu)成的,將這三段信息文本用.鏈接一起就構(gòu)成了JWT字符串。就像這樣:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
如何實(shí)現(xiàn)呢?首先我們需要添加一個(gè)jwt依賴包。
<!-- jwt支持 --> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.4.0</version> </dependency>
然后,創(chuàng)建一個(gè)用戶信息類,將會(huì)通過(guò)加密存放在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; }
接著,創(chuàng)建一個(gè)JwtTokenUtil工具類,用于創(chuàng)建token、驗(yàn)證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"; //有效期默認(rèn)為 2hour public static final Long EXPIRATION_TIME = 1000L*60*60*2; /** * 創(chuàng)建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)); } /** * 驗(yàn)證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已失效,請(qǐng)重新登錄",e); } catch (JWTVerificationException e) { throw new Exception("token驗(yàn)證失?。?quot;,e); } } }
同時(shí)編寫配置類,允許跨域,并且創(chuàng)建一個(gè)權(quán)限攔截器。
@Slf4j @Configuration public class GlobalWebMvcConfig implements WebMvcConfigurer { /** * 重寫父類提供的跨域請(qǐng)求處理的接口 * @param registry */ @Override public void addCorsMappings(CorsRegistry registry) { // 添加映射路徑 registry.addMapping("/**") // 放行哪些原始域 .allowedOrigins("*") // 是否發(fā)送Cookie信息 .allowCredentials(true) // 放行哪些原始域(請(qǐng)求方式) .allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD") // 放行哪些原始域(頭部信息) .allowedHeaders("*") // 暴露哪些頭部信息(因?yàn)榭缬蛟L問(wèn)默認(rèn)不能獲取全部頭部信息) .exposedHeaders("Server","Content-Length", "Authorization", "Access-Token", "Access-Control-Allow-Origin","Access-Control-Allow-Credentials"); } /** * 添加攔截器 * @param registry */ @Override public void addInterceptors(InterceptorRegistry registry) { //添加權(quán)限攔截器 registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**"); } }
使用AuthenticationInterceptor攔截器對(duì)接口參數(shù)進(jìn)行驗(yàn)證。
@Slf4j public class AuthenticationInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 從http請(qǐng)求頭中取出token final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY); //如果不是映射到方法,直接通過(guò) if(!(handler instanceof HandlerMethod)){ return true; } //如果是方法探測(cè),直接通過(guò) if (HttpMethod.OPTIONS.equals(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return true; } //如果方法有JwtIgnore注解,直接通過(guò) 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為空,鑒權(quán)失?。?quot;); //驗(yàn)證,并獲取token內(nèi)部信息 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 { //方法結(jié)束后,移除緩存的token WebContextUtil.removeUserToken(); } }
最后,在controller層用戶登錄之后,創(chuàng)建一個(gè)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){ //...參數(shù)合法性驗(yàn)證 //從數(shù)據(jù)庫(kù)獲取用戶信息 User dbUser = userService.selectByUserNo(userDto.getUserNo); //....用戶、密碼驗(yàn)證 //創(chuàng)建token,并將token放在響應(yīng)頭 UserToken userToken = new UserToken(); BeanUtils.copyProperties(dbUser,userToken); String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken)); response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token); //定義返回結(jié)果 UserVo result = new UserVo(); BeanUtils.copyProperties(dbUser,result); return result; }
到這里基本就完成了!
其中AuthenticationInterceptor中用到的JwtIgnore是一個(gè)注解,用于不需要驗(yàn)證token的方法上,例如驗(yàn)證碼的獲取等等。
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface JwtIgnore { boolean value() default true; }
而WebContextUtil是一個(gè)線程緩存工具類,其他接口通過(guò)這個(gè)方法即可從token中獲取用戶信息。
public class WebContextUtil { //本地線程緩存token private static ThreadLocal<String> local = new ThreadLocal<>(); /** * 設(shè)置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(); } } }
對(duì)應(yīng)用系統(tǒng)而言,重點(diǎn)在于token的驗(yàn)證,可以將攔截器方法封裝成一個(gè)公共的jar包,然后各個(gè)應(yīng)用系統(tǒng)引用即可!
和上面介紹的token存儲(chǔ)到redis方案類似,不同點(diǎn)在于:一個(gè)將用戶數(shù)據(jù)存儲(chǔ)到redis,另一個(gè)是采用加密算法存儲(chǔ)到客戶端進(jìn)行傳輸。
以上是“web開(kāi)發(fā)中怎么設(shè)計(jì)一套單點(diǎn)登錄系統(tǒng)”這篇文章的所有內(nèi)容,感謝各位的閱讀!相信大家都有了一定的了解,希望分享的內(nèi)容對(duì)大家有所幫助,如果還想學(xué)習(xí)更多知識(shí),歡迎關(guān)注億速云行業(yè)資訊頻道!
免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。