如何設(shè)計(jì)一套單點(diǎn)登錄系統(tǒng)
一、介紹
昨天介紹了API接口設(shè)計(jì)token鑒權(quán)方案,其實(shí)token鑒權(quán)最佳的實(shí)踐場景就是在單點(diǎn)登錄系統(tǒng)上。
在企業(yè)發(fā)展初期,使用的后臺管理系統(tǒng)還比較少,一個或者兩個。
以電商系統(tǒng)為例,在起步階段,可能只有一個商城下單系統(tǒng)和一個后端管理產(chǎn)品和庫存的系統(tǒng)。
隨著業(yè)務(wù)量越來越大,此時的業(yè)務(wù)系統(tǒng)會越來越復(fù)雜,項(xiàng)目會劃分成多個組,每個組負(fù)責(zé)各自的領(lǐng)域,例如:A組負(fù)責(zé)商城系統(tǒng)的開發(fā),B組負(fù)責(zé)支付系統(tǒng)的開發(fā),C組負(fù)責(zé)庫存系統(tǒng)的開發(fā),D組負(fù)責(zé)物流跟蹤系統(tǒng)的開發(fā),E組負(fù)責(zé)每日業(yè)績報(bào)表統(tǒng)計(jì)的開發(fā)...等等。
規(guī)模變大的同時,人員也會逐漸的增多,以研發(fā)部來說,大致的人員就有這么幾大類:研發(fā)人員、測試人員、運(yùn)維人員、產(chǎn)品經(jīng)理、技術(shù)支持等等。
他們會頻繁的登錄各自的后端業(yè)務(wù)系統(tǒng),然后進(jìn)行辦公。
此時,我們可以設(shè)想一下,如果每個組都自己開發(fā)一套后端管理系統(tǒng)的登錄,假如有10個這樣的系統(tǒng),同時一個新入職的同事需要每個系統(tǒng)都給他開放一個權(quán)限,那么我們可能需要給他開通10個賬號。
隨著業(yè)務(wù)規(guī)模的擴(kuò)大,大點(diǎn)的公司,可能高達(dá)一百多個業(yè)務(wù)系統(tǒng),那豈不是要配置一百多個賬號,讓人去做這種操作,豈不傷天害理。
面對這種繁瑣而且又無效的工作,IT大佬們想到一個辦法,那就是開發(fā)一套登錄系統(tǒng),所有的業(yè)務(wù)系統(tǒng)都認(rèn)可這套登錄系統(tǒng),那么就可以實(shí)現(xiàn)只需要登錄一次,就可以訪問其他相互信任的應(yīng)用系統(tǒng)。
這個登錄系統(tǒng),我們把它稱為:單點(diǎn)登錄系統(tǒng)。
好了,言歸正傳,下面我們從兩個方面來介紹單點(diǎn)登錄系統(tǒng)的實(shí)現(xiàn)。
方案設(shè)計(jì) 項(xiàng)目實(shí)踐
二、方案設(shè)計(jì)
2.1、單體后端系統(tǒng)登錄
在傳統(tǒng)的單體后端系統(tǒng)中,簡單點(diǎn)的操作,我們一般都會這么玩,用戶使用賬號、密碼登錄之后,服務(wù)器會給當(dāng)前用戶創(chuàng)建一個session會話,同時也會生成一個cookie,最后返回給前端。

當(dāng)用戶訪問其他后端的服務(wù)時,我們只需要檢查一下當(dāng)前用戶的session是否有效,如果無效,就再次跳轉(zhuǎn)到登錄頁面;如果有效,就進(jìn)入業(yè)務(wù)處理流程。
但是,如果訪問不同的域名系統(tǒng)時,這個cookie是無效的,因此不能跨系統(tǒng)訪問,同時也不支持集群環(huán)境的共享。
對于單點(diǎn)登錄的場景,我們需要重新設(shè)計(jì)一套新的方案。
2.2、單點(diǎn)登錄系統(tǒng)登錄
先來一張圖!

這個流程圖,就是單點(diǎn)登錄系統(tǒng)與應(yīng)用系統(tǒng)之間的交互圖。
當(dāng)用戶登錄某應(yīng)用系統(tǒng)時,應(yīng)用系統(tǒng)會把將客戶端傳入的token,調(diào)用單點(diǎn)登錄系統(tǒng)驗(yàn)證token合法性接口,如果不合法就會跳轉(zhuǎn)到單點(diǎn)登錄系統(tǒng)的登錄頁面;如果合法,就直接進(jìn)入首頁。
進(jìn)入登錄頁面之后,會讓用戶輸入用戶名、密碼進(jìn)行登錄驗(yàn)證,如果驗(yàn)證成功之后,會返回一個有效的token,然后客戶端會根據(jù)服務(wù)端返回的參數(shù)鏈接,跳轉(zhuǎn)回之前要訪問的應(yīng)用系統(tǒng)。
接著,應(yīng)用系統(tǒng)會再次驗(yàn)證token的合法性,如果合法,就進(jìn)入首頁,流程結(jié)束。
引入單點(diǎn)登錄系統(tǒng)后,接入的應(yīng)用系統(tǒng)不需要關(guān)系用戶登錄這塊,只需要對客戶端的token做一下合法性鑒權(quán)操作就可以了。
而單點(diǎn)登錄系統(tǒng),只需要做好用戶的登錄流程和鑒權(quán)并返回安全的token給客戶端。
有的項(xiàng)目,會將生成的token,存放在客戶端的cookie中,這樣做的目的,就是避免每次調(diào)用接口的時候都在url里面帶上token。
但是,瀏覽器只允許同域名下的cookies可以共享,對于不同的域名系統(tǒng), cookie 是無法共享的。
對于這種情況,我們可以先將 token 放入到url鏈接中,類似上面流程圖中跳轉(zhuǎn)思路,對于同一個應(yīng)用系統(tǒng),我們可以將token放入到 cookie 中,不同的應(yīng)用系統(tǒng),我們可以通過 url 鏈接進(jìn)行傳遞,實(shí)現(xiàn)token的傳輸。
三、項(xiàng)目實(shí)踐
在實(shí)踐上,token的存儲,有兩種方案:
存放在服務(wù)器,如果是分布式環(huán)境,一般都會存儲在 redis 中 存儲在客戶端,服務(wù)器做驗(yàn)證,天然支持分布式
3.1、存放在redis
存放在redis中,是一種比較常見的處理辦法,最開始的時候也是這種處理辦法。
當(dāng)用戶登錄成功之后,會將用戶的信息作為value,用uuid作為key,存儲到redis中,各個服務(wù)集群共享用戶信息。
代碼實(shí)踐也非常簡單。
用戶登錄之后,將用戶信息存在到redis,同時返回一個有效的token給客戶端。
@RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
public TokenVO login(@RequestBody LoginDTO loginDTO){
//...參數(shù)合法性驗(yàn)證
//從數(shù)據(jù)庫獲取用戶信息
User dbUser = userService.selectByUserNo(loginDTO.getUserNo);
//....用戶、密碼驗(yàn)證
//創(chuàng)建token
String token = UUID.randomUUID();
//將token和用戶信息存儲到redis,并設(shè)置有效期2個小時
redisUtil.save(token, dbUser, 2*60*60);
//定義返回結(jié)果
TokenVO result = new TokenVO();
//封裝token
result.setToken(token);
//封裝應(yīng)用系統(tǒng)訪問地址
result.setRedirectURL(loginDTO.getRedirectURL());
return result;
}
客戶端收到登錄成功之后,根據(jù)參數(shù)組合進(jìn)行跳轉(zhuǎn)到對應(yīng)的應(yīng)用系統(tǒng)。
跳轉(zhuǎn)示例如下:http://xxx.com/page.html?token=xxxxxx
各個應(yīng)用系統(tǒng),只需要編寫一個過濾器TokenFilter對token參數(shù)進(jìn)行驗(yàn)證攔截,即可實(shí)現(xiàn)對接,代碼如下:
@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)行跨域探測
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)行選擇。
由于每個應(yīng)用系統(tǒng)都可能需要進(jìn)行對接,因此我們可以將上面的方法封裝成一個公共jar包,應(yīng)用系統(tǒng)只需要依賴包即可完成對接!
3.2、token存放客戶端
還有一種方案,是將token存放客戶端,這種方案就是服務(wù)端根據(jù)規(guī)則對數(shù)據(jù)進(jìn)行加密生成一個簽名串,這個簽名串就是我們所說的token,最后返回給前端。
因?yàn)榧用艿牟僮鞫际窃诜?wù)端完成的,因此密鑰的管理非常重要,不能泄露出去,不然很容易被黑客解密出來。
最典型的應(yīng)用就是JWT!
JWT 是由三段信息構(gòu)成的,將這三段信息文本用.鏈接一起就構(gòu)成了JWT字符串。就像這樣:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
如何實(shí)現(xiàn)呢?首先我們需要添加一個jwt依賴包。
<!-- jwt支持 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
然后,創(chuàng)建一個用戶信息類,將會通過加密存放在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)建一個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已失效,請重新登錄",e);
} catch (JWTVerificationException e) {
throw new Exception("token驗(yàn)證失??!",e);
}
}
}
同時編寫配置類,允許跨域,并且創(chuàng)建一個權(quán)限攔截器
@Slf4j
@Configuration
public class GlobalWebMvcConfig implements WebMvcConfigurer {
/**
* 重寫父類提供的跨域請求處理的接口
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
// 添加映射路徑
registry.addMapping("/**")
// 放行哪些原始域
.allowedOrigins("*")
// 是否發(fā)送Cookie信息
.allowCredentials(true)
// 放行哪些原始域(請求方式)
.allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")
// 放行哪些原始域(頭部信息)
.allowedHeaders("*")
// 暴露哪些頭部信息(因?yàn)榭缬蛟L問默認(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攔截器對接口參數(shù)進(jìn)行驗(yàn)證
@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為空,鑒權(quán)失敗!");
//驗(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)建一個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ù)庫獲取用戶信息
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是一個注解,用于不需要驗(yàn)證token的方法上,例如驗(yàn)證碼的獲取等等。
@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<>();
/**
* 設(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();
}
}
}
對應(yīng)用系統(tǒng)而言,重點(diǎn)在于token的驗(yàn)證,可以將攔截器方法封裝成一個公共的jar包,然后各個應(yīng)用系統(tǒng)引用即可!
和上面介紹的token存儲到redis方案類似,不同點(diǎn)在于:一個將用戶數(shù)據(jù)存儲到redis,另一個是采用加密算法存儲到客戶端進(jìn)行傳輸。
四、小結(jié)
在實(shí)際的使用過程中,我個人更加傾向于采用jwt方案,直接在服務(wù)端使用簽名加密算法生成一個token,然后在客戶端進(jìn)行流轉(zhuǎn),天然支持分布式,但是要注意加密時用的密鑰要安全管理。
而采用redis方案存儲的時候,你需要搭建高可用的集群環(huán)境,同時保證緩存數(shù)據(jù)不會失效等等,維護(hù)成本高!
在實(shí)際的實(shí)現(xiàn)上,每個公司玩法不一樣,有的安全性要求高,后端還會加上密鑰環(huán)節(jié)進(jìn)行安全驗(yàn)證,基本思路大同小異。
支持就在看
一鍵四連,你的offer也四連

