写点什么

如何设计一套单点登录系统 ?

  • 2024-08-10
    福建
  • 本文字数:6874 字

    阅读完需:约 23 分钟

一、介绍


在企业发展初期,使用的后台管理系统还比较少,一个或者两个。


以电商系统为例,在起步阶段,可能只有一个商城下单系统和一个后端管理产品和库存的系统。


随着业务量越来越大,此时的业务系统会越来越复杂,项目会划分成多个组,每个组负责各自的领域,例如: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 参数进行验证拦截,即可实现对接,代码如下:

@Overridepublic 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@Configurationpublic 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拦截器对接口参数进行验证

@Slf4jpublic 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方案存储的时候,你需要搭建高可用的集群环境,同时保证缓存数据不会失效等等,维护成本高!


在实际的实现上,每个公司玩法不一样,有的安全性要求高,后端还会加上密钥环节进行安全验证,基本思路大同小异。


文章转载自:潘志的研发笔记

原文链接:https://www.cnblogs.com/dxflqm/p/18297690

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
如何设计一套单点登录系统 ?_单点登录_快乐非自愿限量之名_InfoQ写作社区