如何设计一套单点登录系统
一、介绍
昨天介绍了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
上面返回的是json数据给前端,当然你还可以直接在服务器采用重定向进行跳转,具体根据自己的情况进行选择。
由于每个应用系统都可能需要进行对接,因此我们可以将上面的方法封装成一个公共jar包,应用系统只需要依赖包即可完成对接!
3.2、token存放客户端
还有一种方案,是将token存放客户端,这种方案就是服务端根据规则对数据进行加密生成一个签名串,这个签名串就是我们所说的token,最后返回给前端。
因为加密的操作都是在服务端完成的,因此密钥的管理非常重要,不能泄露出去,不然很容易被黑客解密出来。
最典型的应用就是JWT!
JWT 是由三段信息构成的,将这三段信息文本用.链接一起就构成了JWT字符串。就像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
如何实现呢?首先我们需要添加一个jwt依赖包。
????com.auth0
????java-jwt
????3.4.0
然后,创建一个用户信息类,将会通过加密存放在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?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方案存储的时候,你需要搭建高可用的集群环境,同时保证缓存数据不会失效等等,维护成本高!
在实际的实现上,每个公司玩法不一样,有的安全性要求高,后端还会加上密钥环节进行安全验证,基本思路大同小异。