单点登录系统设计

news2024/11/29 10:46:36

一、介绍

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1576565.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

腾讯云流量怎么计算的?轻量服务器流量价格表

腾讯云轻量应用服务器套餐带流量包&#xff0c;就是有月流量限制的意思&#xff0c;超出轻量套餐的流量需要另外支付流量费&#xff0c;轻量服务器地域不同超额流量费用也不同&#xff0c;北京上海广州等中国内地地域流量价格是0.8元每GB&#xff0c;中国香港地域流量价格是1元…

单片机IGBT驱动电路一例

概述&#xff1a; 驱动的作用有三个&#xff1a; 1.是作为放大器获得所需要的驱动电压。 2.是提高输出电流能力。 3.是进行功率回路和控制回路的隔离 信号从MCU到IGBT驱动芯片 首先驱动电流需要放大 MCU的输出电流是mA级别&#xff0c;而IGBT需要的驱动电流可能达到几安培…

解锁ETLCloud中Kettle的用法

随着大数据时代的到来&#xff0c;数据的处理和管理成为各行各业不可或缺的一环。ETL&#xff08;Extract-Transform-Load&#xff09;工具作为数据处理的重要环节&#xff0c;扮演着将数据从源端抽取出来、经过转换处理&#xff0c;最终加载至目标端的关键角色。在众多ETL工具…

香港科技大学(广州)智能制造学域可持续能源与环境学域博士招生宣讲会——重庆大学专场(暨全额奖学金政策)

两个学域代表教授亲临现场&#xff0c;面对面答疑解惑助攻申请&#xff01;可带简历现场咨询和面试&#xff01; &#x1f4b0;一经录取&#xff0c;享全额奖学金1.5万/月&#xff01; 报名链接&#xff1a;https://www.wjx.top/vm/wmuN2ea.aspx# 地点&#xff1a;重庆大学A区…

docker一键部署GPU版ChatGLM3

一键运行 docker run --gpus all -itd --name chatglm3 -p 81:80 -p 6006:6006 -p 8888:8888 -p 7860:7860 -p 8501:8501 -p 8000:8000 --shm-size32gb registry.cn-hangzhou.aliyuncs.com/cwp-docker/chatglm3-gpu:1.0 进入容器 docker exec -it chatglm3 /bin/bash cd /…

MacOS Docker 部署 Redis 数据库

一、简介 Redis是一个开源的、使用C语言编写的、基于内存亦可持久化的Key-Value数据库&#xff0c;它提供了多种语言的API&#xff0c;并支持网络交互。Redis的数据存储在内存中&#xff0c;因此其读写速度非常快&#xff0c;每秒可以处理超过10万次读写操作&#xff0c;是已知…

Vue项目打包成exe文件(electron)

1.将写好的vue项目打包 1.1运行vue ui命令 输出目标文件 如果打开index.html是空白的&#xff0c;而且控制台报错获取xxx资源失败的问题&#xff0c;你需要在vue.config.js 上加一个命令&#xff0c;如果没有你需要创建一个。 2.下载electron官方示例 git clone https://gith…

Go 项目依赖注入wire工具最佳实践介绍与使用

文章目录 一、引入二、控制反转与依赖注入三、为什么需要依赖注入工具3.1 示例3.2 依赖注入写法与非依赖注入写法 四、wire 工具介绍与安装4.1 wire 基本介绍4.2 安装 五、Wire 的基本使用5.1 前置代码准备5.2 使用 Wire 工具生成代码 六、Wire 核心技术5.1 抽象语法树分析5.2 …

idea开发 java web 疫情信息查询系统bootstrap框架web结构java编程计算机网页接口查询

一、源码特点 java 疫情信息查询系统是一套完善的完整信息系统&#xff0c;结合java web开发和bootstrap UI框架完成本系统 &#xff0c;对理解JSP java编程开发语言有帮助&#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采用B/S模式开发。 前段主要技术 css j…

复习知识点整理

零碎语法 1.导入某个文件夹的index文件&#xff0c;index可以省略&#xff08;这里导入的是router和store文件下的index.js文件&#xff09; 2.路由懒加载 this 1.在vue文件中使用router\store对象时 this&#xff1a;普通函数的this指向vue实例对象(在没有明确指向的时候…

ubuntu系统设置java环境变量 jdk离线安装

目录 目的一、下载jdk二、安装三、配置jdk环境变量1、编辑文件~/.bashrc2、刷新系统环境变量3、验证 目的 在ubuntu环境下配置java环境变量&#xff0c;除jdk压缩包下载外&#xff0c;其余安装及配置过程无需联网&#xff0c;可离线完成。 一、下载jdk 一些比较靠谱的免登录…

Davinci报警40359

问题现象 前几天做实验的时候遇到个问题被卡着&#xff0c;报警40359。 问题描述 报警信息里面描述&#xff0c;我的一个IDT里面的基础类型有问题。 问题分析 根据提示排查 首先检查了出问题的IDT里面的基础类型&#xff0c;引用的是基础类型里面的布尔类型&#xff0c;非…

【Java】maven的下载和安装配置

Maven下载&#xff1a; 下载地址&#xff1a;http://maven.apache.org/download.cgi Maven 3.3 require JDK 1.7 or above &#xff1a; 因为Maven是一个纯粹的java实现的工具 下载完成后&#xff0c;得到一个压缩包&#xff0c;解压&#xff0c;可以看到maven的组成目录 Mav…

深入浅出 -- 系统架构之垂直架构

当业务复杂度增加、访问量逐渐增大出现高并发时&#xff0c;单体架构无法满足需求&#xff0c;可以根据业务功能对系统进行拆分&#xff0c;以提高访问效率。 垂直架构介绍 1.垂直架构一般是因为单体架构太过于庞大而进行的拆分&#xff0c;拆分后各个系统应满足独立运行互相不…

海外媒体宣发套餐推广8个要点解析为标题-华媒舍

在当前全球化的时代背景下&#xff0c;海外市场的开拓对于企业的发展至关重要。而海外媒体宣传是一种有效的推广方式&#xff0c;可以帮助企业在全球范围内打开市场。本文将对8个海外媒体宣发套餐的推广要点进行解析&#xff0c;帮助企业了解如何在海外市场进行宣传推广。 1. 媒…

uniapp极光推送、java服务端集成

一、准备工作 1、进入【服务中心】-【开发者平台】 2、【创建应用】&#xff0c;填写应用名称和图标&#xff08;填写项目名称&#xff0c;项目logo就行&#xff0c;也可填写其他的&#xff09; 3、选择【消息推送】服务&#xff0c;点击下一步 ​ ​ Demo测试 参照文档&…

4.网络编程-websocket(golang)

目录 什么是websocket golang中使用websocket Server端 Client端 什么是websocket WebSocket是一种在互联网上提供全双工通信的协议&#xff0c;即允许服务器和客户端之间进行双向实时通信的网络技术。它是作为HTML5的一部分标准化的&#xff0c;旨在解决传统HTTP协议在实…

C++ 之 【类与对象】从入门到精通一条龙服务 入门篇

不要觉的自己很没用&#xff0c;其实你还可以给家人带来温暖&#xff0c;比如爸妈看到你就来火 目录&#xff1a; 一、面向过程和面向对象初步认识 二、类的引入 三、类的定义 四、类的访问限定符及封装 1.访问限定符 2.封装 五、类的作用域 六、类的实例化 七、类的…

违法解除劳动合同——典型案例

目录 什么是违法解除劳动合同&#xff1f; 用人单位负举证责任 典型案例 案例&#xff1a;指导案例180号——违法解除劳动合同 案例&#xff1a;浙江省杭州市中级⼈⺠法院——违纪案例 案例&#xff1a;浙江省杭州市中级⼈⺠法院——违纪案例(不服从工作安排) 案例&#…

算法-数论-蓝桥杯

算法-数论 1、最大公约数 def gcd(a,b):if b 0:return areturn gcd(b, a%b) # a和b的最大公约数等于b与a mod b 的最大公约数def gcd(a,b):while b ! 0:cur aa bb cur%bpassreturn a欧几里得算法 a可以表示成a kb r&#xff08;a&#xff0c;b&#xff0c;k&#xff0c…