07--单点登录系统(SSO)的设计与实现

1. 系统简介

1.1 Http协议

web应用采用browser/server架构,http作为通讯协议。http是无状态协议,浏览器的每一次请求服务器都会单独处理,不与之前或者之后的请求产生关联。这个过程可用下图说明,三次请求/响应之间没有任何联系。
07--单点登录系统(SSO)的设计与实现_第1张图片
但这也同时意味着任何用户都能通过浏览器访问服务器资源,如果想保护服务器的某些资源,必须限制浏览器的请求。要限制浏览器的请求,就必须要鉴别浏览器的请求,响应合法的请求。鉴别浏览器的请求,就必须要清楚浏览器的请求状态。既然http协议无状态,那就可以让浏览器与服务器共同维持一个状态,这就是会话机制。

1.2 有状态会话

浏览器第一次请求服务器,服务器创建一个会话,并将会话的ID作为响应的一部分发送给浏览器,浏览器存储会话的ID,并且在后续第二次和第三次请求中带上会话ID,服务器取得请求中的会话ID,就知道是不是同一个用户了。下图说明了这个过程,后续请求与第一次请求产生了关联。
07--单点登录系统(SSO)的设计与实现_第2张图片
服务器在内存中保存会话对象,浏览器就会自己来维护这个会话ID,每次发送http请求时,浏览器自动发送会话ID,Cookie机制正好用来做这件事。Cookie是浏览器用来存储少量数据的一种机制,数据以key-value的形式存储,浏览器每次发送http请求时,会自动附带Cookie信息。

1.3 tomcat会话机制

tomcat会话机制当然也实现了Cookie,访问tomcat服务器时,浏览器中可以看到一个名为"JSESSIONID"的Cookie,这就是tomcat会话机制维护的会话ID,使用了Cookie的请求响应过程如下图:
07--单点登录系统(SSO)的设计与实现_第3张图片

1.4 记录登录状态

假设浏览器第一次请求服务器需要输入用户名与密码验证身份,服务器拿到用户名密码去数据库比对,如果正确,说明当前这个持有这个会话的用户是合法用户,应该将这个会话状态进行保存。
07--单点登录系统(SSO)的设计与实现_第4张图片

2. 单点登录系统设计

2.1 概述

web系统早已从久远的单系统发展成为如今由多系统组成的应用群。面对如此多的系统,用户要一个一个登录,如下图所示:
07--单点登录系统(SSO)的设计与实现_第5张图片
Web系统由单系统发展成为多系统组成的应用群,复杂性应该由系统内部承担,而不是用户。无论web系统内部多么复杂,对于用户而言,都是一个统一整体,也就是说,用户访问web系统的整个应用群与访问单个系统一样,登录/注销一次即可!
07--单点登录系统(SSO)的设计与实现_第6张图片
虽然单系统的登录解决方案很完美,但是对于多系统应用群已经不适用了。因为单系统登录解决方案的核心是cookie,cookie携带会话ID在浏览器与服务器之间维护会话状态。但是cookie是有限制的,这个限制就是cookie的域(通常对应网站的域名),浏览器发送http请求时,会自动携带与该域匹配的cookie,而不是所有的cookie。
07--单点登录系统(SSO)的设计与实现_第7张图片
早期很多系统登录采用将web应用群中的所有子系统的域名统一为一个cookie域,这种方式被称为同域名共享cookie。
这种做法理论上是可行的,但是共享cookie的方式存在众多的局限性。首先,应用群的域名要统一,而且应用群各系统之间使用的技术要相同(至少是web服务器),不然cookie的key值(tomcat为JSESSIONID)不同,就无法维持会话。而且共享cookie的方式也是无法实现跨语言技术平台登录的。cookie它本身就不安全的。
因为,我们需要一种全新的登录方式来实现多系统应用群的登录,单点登录就可以很好的满足这个需求。单点登录全称Single Sign On(简称:SSO),是指在多系统应用群中登录一个系统,便可以在其他所有系统中得到授权,而无需再次登录,包括单点登录与单点注销两部分。

2.2 登录业务设计

相比于单系统登录,SSO需要一个独立的认证中心。只有认证中心能接受用户的用户名和密码等安全信息,其他系统不提供登录入口,只接受认证中心的间接授权,而间接授权通过令牌实现。sso认证中心会验证用户的用户信息,若都正确,会创建授权令牌。在后面的跳转过程中,授权令牌会作为参数发送给各个子系统,子系统拿到令牌后,就得到了授权,可以借此创建局部会话,局部会话登录方式与单系统登录方式相同。这个过程也就是单点登录的原理,如下图:
07--单点登录系统(SSO)的设计与实现_第8张图片
下面是对上图的简要描述:

  1. 用户访问系统1的受保护资源,系统1发现用户未登录,跳转至SSO认证中心,并将自己的地址作为参数。
  2. SSO认证中心发现用户未登录,会将用户引导至登录页面。
  3. 用户输入用户名和密码提交登录申请。
  4. SSO认证中心校验用户信息,创建用户与SSO认证中心之间的会话,称为全局会话,同时创建授权令牌。
  5. SSO认证中心带着令牌跳转回最初的请求地址(系统1)。
  6. 系统1拿到令牌,去SSO认证中心校验令牌是否有效。
  7. SSO认证中心校验令牌,返回有效,注册系统1。
  8. 系统1使用该令牌创建与用户的会话,称为局部会话,返回受保护资源。
  9. 用户访问系统2的受保护资源。
  10. 系统2发现用户未登录,跳转至SSO认证中心,并将自己的地址作为参数。
  11. SSO认证中心发现用户已登录,跳转回系统2的地址,并附上令牌。
  12. 系统2拿到令牌,去SSO认证中心校验令牌是否有效。
  13. SSO认证中心校验令牌,返回有效,注册系统2。
  14. 系统2使用该令牌创建与用户的局部会话,返回受保护资源。

2.3 单点系统架构

用户登录成功之后,会与SSO认证中心及各个子系统建立会话,用户与SSO认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护的资源将不再通过SSO认证中心。
07--单点登录系统(SSO)的设计与实现_第9张图片

3 创建项目-聚合工程

创建聚合工程的目的是对项目中的资源进行统一管理,多个项目module之间共享资源,这次项目的maven工程结构如下:
07--单点登录系统(SSO)的设计与实现_第10张图片

3.1 创建父工程

第一步:创建父工程,命名为:04-jt-sso-system.
第二步:删除父工程中的src目录(父工程一般不需要写java代码).
第三步:在pom.xml文件中添加parent标签元素,并指定SpringBoot的依赖.


<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0modelVersion>

    <packaging>pompackaging>
    <modules>
        <module>sso-resourcemodule>
        <module>sso-authenticationmodule>
        <module>sso-commonmodule>
        <module>sso-uimodule>
    modules>
    <parent>
        <artifactId>spring-boot-starter-parentartifactId>
        <groupId>org.springframework.bootgroupId>
        <version>2.3.2.RELEASEversion>
    parent>

    <groupId>com.cy.jtgroupId>
    <artifactId>04-jt-sso-systemartifactId>
    <version>1.0-SNAPSHOTversion>

    <properties>
        <maven.compiler.source>8maven.compiler.source>
        <maven.compiler.target>8maven.compiler.target>
    properties>

project>

3.2 创建认证工程 — sso-authentication

第一步:在父工程下创建sso-authentication项目。
07--单点登录系统(SSO)的设计与实现_第11张图片
第二步:打开pom.xml文件添加项目依赖。

<dependencies>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-securityartifactId>
        dependency>
        <dependency>
            <groupId>io.jsonwebtokengroupId>
            <artifactId>jjwtartifactId>
            <version>0.9.1version>
        dependency>
    dependencies>

第三步:创建application.yml配置文件,定义服务端口。

server:
  port: 8284
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jt_security?serverTimezone=Asia/Shanghai&characterEncoding=utf8
    username: root
    password: root
    driver-class-name: com.mysql.jdbc.Driver # 可以省略,默认会自动识别

第四步:编写项目启动类。

/**
 * 认证服务器的启动类
 */

@SpringBootApplication
public class AuthApplication {
    public static void main(String[] args) {
        SpringApplication.run(AuthApplication.class, args);
    }
}

数据库访问操作

背景分析

我们登录时的账号信息都应该来自于数据库中,还包括用户对应的权限信息。

业务及表的设计

实际项目中用户权限控制,是通过用户,角色,菜单以及他们的关系表进行数据存储的,其业务描述如下:
07--单点登录系统(SSO)的设计与实现_第12张图片

sql脚本

DROP DATABASE IF EXISTS `jt_security`;
CREATE DATABASE  `jt_security` DEFAULT CHARACTER SET utf8mb4;

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

use `jt_security`;

CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `name` varchar(50) NOT NULL COMMENT '权限名称',
  `permission` varchar(200) DEFAULT NULL COMMENT '权限标识',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='权限表';
CREATE TABLE `sys_role` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `role_name` varchar(50) NOT NULL COMMENT '角色名称',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='角色表';
CREATE TABLE `sys_role_menu` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `role_id` bigint(11) DEFAULT NULL COMMENT '角色ID',
  `menu_id` bigint(11) DEFAULT NULL COMMENT '权限ID',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8  COMMENT='角色与权限关系表';

CREATE TABLE `sys_user` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
  `username` varchar(50) NOT NULL COMMENT '用户名',
  `password` varchar(100) DEFAULT NULL COMMENT '密码',
  `status` varchar(10) DEFAULT NULL COMMENT '状态 PROHIBIT:禁用   NORMAL:正常',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `username` (`username`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8  COMMENT='系统用户表';

CREATE TABLE `sys_user_role` (
  `id` bigint(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `user_id` bigint(11) DEFAULT NULL COMMENT '用户ID',
  `role_id` bigint(11) DEFAULT NULL COMMENT '角色ID',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8  COMMENT='用户与角色关系表';

INSERT INTO `sys_menu` VALUES (1, 'select users', 'sys:res:create');
INSERT INTO `sys_menu` VALUES (2, 'select menus', 'sys:res:retrieve');
INSERT INTO `sys_menu` VALUES (3, 'select roles', 'sys:res:delete');
INSERT INTO `sys_role` VALUES (1, 'ADMIN');
INSERT INTO `sys_role` VALUES (2, 'USER');
INSERT INTO `sys_role_menu` VALUES (1, 1, 1);
INSERT INTO `sys_role_menu` VALUES (2, 1, 2);
INSERT INTO `sys_role_menu` VALUES (3, 1, 3);
INSERT INTO `sys_role_menu` VALUES (4, 2, 1);
INSERT INTO `sys_user` VALUES (1,'admin','$2a$10$hIAewJVvpTdDSidROQmoXuBBucjLC7sxf7PDMWggZG49cKYhTXt16','NORMAL');
INSERT INTO `sys_user` VALUES (2,'user','$2a$10$hIAewJVvpTdDSidROQmoXuBBucjLC7sxf7PDMWggZG49cKYhTXt16','NORMAL');
INSERT INTO `sys_user_role` VALUES (1, 1, 1);
INSERT INTO `sys_user_role` VALUES (2, 2, 2);

项目初始化

第一步:在sso-authentication工程中添加依赖。

 <!--springboot整合mybatis的依赖-->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.0</version>
        </dependency>
        <!--数据库驱动依赖-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.21</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>

第二步:修改sso-authentication工程中的配置文件——application.yml,添加数据库部分。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jt_security?serverTimezone=Asia/Shanghai&characterEncoding=utf8
    username: root
    password: root
    driver-class-name: com.mysql.jdbc.Driver # 可以省略,默认会自动识别

3.3 认证服务器的实现

3.3.1 JwtUtils工具类

第一步:定义JWT工具类,用于创建,解析,验证令牌(token),代码如下:

public class JwtUtils {
    //密钥
     private static  String secret = "AABBCCDD";

    /*1.基于负载和算法创建token令牌*/
    public static String generatorToken(Map<String,Object> map){
        String token = Jwts.builder()
                .setClaims(map) // 负载信息
                .setExpiration(new Date(System.currentTimeMillis()+3600*2400)) //设置过期时间
                .setIssuedAt(new Date()) //设置签发时间
                .signWith(SignatureAlgorithm.HS256,secret) //签名加密算法以及密钥盐
                .compact();//签约,创建令牌
        return token;
    }

    /*2.解析token获取数据*/
    public static Claims getClaimsFromToken(String token){
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }

    /*3.判定token是否过期 */
    public static boolean isTokenExpired(String token){
        Date expiration = getClaimsFromToken(token).getExpiration();
        return expiration.before(new Date());
    }

    /*4.为指定用户生成token令牌*/
    public static String generateToken(Map<String,Object> claims){
        Date createdTime = new Date();
        Date expirationTime = new Date(System.currentTimeMillis()+3600*1000);
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(createdTime)
                .setExpiration(expirationTime)
                .signWith(SignatureAlgorithm.HS256,secret)
                .compact();
    }
}

3.3.2 WebUtils工具类

第二步:定义Web工具类,用于向客户端响应Json数据。

public class WebUtils {
    //将数据以json格式写到客户端
    public static void writeJsonToClient(HttpServletResponse response,
                                           Map<String,Object> dataMap) throws IOException {
        //1.设置响应数据的编码
        response.setCharacterEncoding("utf-8");
        //2.告诉浏览器响应数据的内容类型以及编码
        response.setContentType("application/json;charset=utf-8");
        //3.将数据转换为json格式数据字符串
        String jsonStr = new ObjectMapper().writeValueAsString(dataMap);
        //4.获取输出流对象将json数据写到客户端
        PrintWriter out = response.getWriter();
        out.println(jsonStr);
        out.flush();
    }
}

3.3.3 安全配置类–SecurityConfig

第三步:定义认证规则以及异常处理的方式。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    };

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //1.关闭跨域攻击
        http.csrf().disable();
        //2.配置form认证
        http.formLogin()
                .successHandler(successHandler()) //登录成功
                .failureHandler(failureHandler());//登录失败
        http.exceptionHandling()
                .authenticationEntryPoint(entryPoint());  //提示需要认证
        //3.所有资源都要认证
        http.authorizeRequests().anyRequest().authenticated();
    }

    /*登录成功的处理器*/
    private AuthenticationSuccessHandler successHandler(){
        //匿名内部类的简单写法
        return (request, response, authentication) -> {
            Map<String,Object> map = new HashMap<>();
            map.put("state","200");
            map.put("message","欢迎你,登录成功!");
            Map<String,Object> userInfo = new HashMap<>();
            //获取用户对象,此对象为登录成功以后封装的登录信息的对象
            User user = (User) authentication.getPrincipal();
            //获取用户名
            userInfo.put("username",user.getUsername());
            //获取用户权限并封装到userInfo中
            List<String> authorities = new ArrayList<>();
            user.getAuthorities().forEach((authority)->{
                authorities.add(authority.getAuthority());//sys:res:create
            });
            userInfo.put("authorities", authorities);
            String token = JwtUtils.generateToken(userInfo);
            map.put("token",token);
            WebUtils.writeJsonToClient(response, map);
        };
    }

    /*登录失败的处理器*/
    private AuthenticationFailureHandler failureHandler(){
        return (request,response,exception)->{
            Map<String,Object> map = new HashMap<>();
            map.put("state", "500");
            map.put("message","登录失败(密码或者账号输入错误)!");
            WebUtils.writeJsonToClient(response, map);
        };
    }

    /*未登录访问资源时给出提示*/
    private AuthenticationEntryPoint entryPoint(){
        return (request,response,exception)->{
            Map<String,Object> map = new HashMap<>();
            map.put("state", "401");
            map.put("message","请先登录!");
            WebUtils.writeJsonToClient(response, map);
        };
    }
}

3.3.4 认证逻辑对象

第一步:先创建UserMapper接口,定义数据访问方法,与数据库交互。

@Mapper
public interface UserMapper {
    /**
     * 基于用户名查询用户信息
     * @param username
     * @return 查询到用户信息,表中的字段名会作为map中的key,字段名对应的值会作为mao中的value进行存储。
     */
    @Select("select * from sys_user where username = #{username}")
    Map<String,Object> selectUserByUsername(@Param("username") String username);
     /*基于用户id查询用户权限信息*/
    @Select(" select distinct m.permission "+
            " from sys_user u left join sys_user_role ur on u.id=ur.user_id "+
            " left join sys_role_menu rm on ur.role_id=rm.role_id "+
            " left join sys_menu m on rm.menu_id=m.id "+
            " where u.id=#{id} ")
    List<String> selectUserPermissions(@Param("id") Long id);
}

第二步:在UserDetailServiceImpl类中添加数据库的访问操作。

@Service
public class UserDetailServiceImpl implements UserDetailsService {
    /**
     * @Autowired注解描述属性时的规则:
     * spring框架会依据@Autowired注解描述的属性类型,从spring容器查找对应的Bean,假如只找到一个则直接注入,假如找到多个
     * 还会对比属性名是否与容器中的Bean的名字是否相同,有则直接注入,没有则抛出异常。
     */
    @Autowired
    private UserMapper userMapper;
    /**
     * 当我们执行登录操作时,底层会通过过滤器等对象,调用这个方法
     * @param username 此参数为页面输出的用户名
     * @return 一般是从数据库基于用户名查询到的用户信息
     * @throws UsernameNotFoundException
     */

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //1.基于用户名从数据库查询用户信息
        Map<String,Object> userMap =  userMapper.selectUserByUsername(username);
        if(userMap==null)throw new UsernameNotFoundException("用户不存在");
        //2.将用户信息封装到UserDetails对象中并返回
        List<String> userPermissioins = userMapper.selectUserPermissions((Long) userMap.get("id"));
        //这个user是SpringSecurity提供的UserDetails接口的实现,用于封装用户信息
        User user = new User(username,
                (String) userMap.get("password"),
                AuthorityUtils.createAuthorityList(userPermissioins.toArray(new String[]{})));
        return user;
    }
}

3.4 创建资源工程 — sso-resource

第一步:在父工程下创建sso-resource项目module。
第二步:打开pom.xml文件,添加项目依赖。

<dependencies>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-securityartifactId>
        dependency>
        <dependency>
            <groupId>io.jsonwebtokengroupId>
            <artifactId>jjwtartifactId>
            <version>0.9.1version>
        dependency>
    dependencies>

第三步:创建application.yml配置文件,定义服务端口。

server:
  port: 8283

第四步:编写项目启动类。

@SpringBootApplication
@EnableGlobalMethodSecurity(prePostEnabled = true) //开启权限访问(有权限才能访问,无则不能访问)
public class ResApplication {
    public static void main(String[] args) {
        SpringApplication.run(ResApplication.class, args);
    }
}

3.5 资源服务器的实现

3.5.1 JwtUtils工具类

第一步:定义JWT工具类,用于创建,解析,验证令牌(token)。内容与实现认证服务器的相同。

3.5.2 WebUtils工具类

第二步:定义Web工具类,用于向客户端响应Json数据。内容与实现认证服务器的相同。

3.5.3 安全配置类

第三步:在资源服务器中定义权限配置类SecurityConfig,默认将所有认证请求都放行。

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //1.关闭跨域攻击
        http.csrf().disable();
        //2.设置拒绝处理器(不允许访问资源时,应该给出的反馈)
        http.exceptionHandling().accessDeniedHandler(accessDeniedHandler());
        //3.资源访问(所有资源在本项目中的访问不进行认证)
        http.authorizeRequests().anyRequest().permitAll();
    }
    public AccessDeniedHandler accessDeniedHandler(){
        return (request,response,e)->{
            //1.构建响应信息
            Map<String,Object> map = new HashMap<>();
            map.put("state", 403);
            map.put("message","您的权限不足,不能访问!");
            //2.将响应信息写到客户端
            WebUtils.writeJsonToClient(response, map);
        };
    }
}

3.5.4资源服务器对象

第四步:定义资源服务对象ResourceController,处理客户端的资源访问服务。

/**
 * 可以将这里的Controller看成系统内部的一个资源对象,我们要求访问此对象中的方法时需要进行权限检查。
 */
@RestController
public class ResourceController {
    //@PreAuthorize注解用于描述在访问方法时首先要对用户是否有访问该方法的权限进行检验

    //1.添加操作
    //@PreAuthorize("hasRole('admin')")//登录用户具备admin这个角色才可以访问
    @PreAuthorize("hasAnyAuthority('sys:res:create')") //登录用户具备sys:res:create权限才能访问资源
    @RequestMapping("/doCreate")
    public String doCreate(){
        return "insert resource  data Ok!";
    }
    //2.查询操作
    @PreAuthorize("hasAnyAuthority('sys:res:retrieve')")
    @RequestMapping("/doRetrieve")
    public String doRetrieve(){
        return "select resource  data Ok!";
    }

    //3.更新操作
    @PreAuthorize("hasAnyAuthority('sys:res:update')")
    @RequestMapping("/doUpdate")
    public String doUpdate(){
        return "update resource data Ok!";
    }

    //4.删除操作
    @PreAuthorize("hasAnyAuthority('sys:res:delete')")
    @RequestMapping("/doDelete")
    public String doDelete(){
        return  "delete resource data Ok!";
    }
    /**
     * 获取登录用户信息
     * @return
     */
    @GetMapping("/doGetUser")
    public String doGetUser(){
        //从Session中获取用户认证信息
        //1)Authentication 认证对象(封装了登录用户信息的对象)
        //2)SecurityContextHolder 持有登录状态的信息的对象(底层可通过session获取用户信息)
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //基于认证对象获取用户身份信息
        User principal =  (User) authentication.getPrincipal();//获取资源
        System.out.println("principal.class:"+principal.getClass());
        return principal.getUsername()+":"+principal.getAuthorities();
    }
}

3.5.5 SpringMVC拦截器

资源服务器中的资源不是所有人都可以访问,需要具备一定的权限才可以访问。首先我们要判定用户是否登录,然后判定登录用户是否具有访问权限,有才可以访问,这个操作可以放到SpringMVC拦截器中进行实现。
第一步:定义SpringMVC拦截器–TokenInterceptor。

public class TokenInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {
        //1.从请求中获取token对象(如何获取取决于你传递的方式,header,params)

        String token =  request.getHeader("token");

        //2.验证token是否存在

        if(token==null||"".equals(token))throw new RuntimeException("请先登录!");

        //3.验证token是否过期

        if(JwtUtils.isTokenExpired(token))throw new RuntimeException("登录超时,请重新登录");

        //4.解析token中的认证和权限信息(一般存储在jwt格式中的负载部分)

        Claims claims = JwtUtils.getClaimsFromToken(token);
        List<String> list = (List<String>) claims.get("authorities");

        //5.封装和存储认证和权限信息
        //5.1构建UserDetails对象
        UserDetails userDetails = User.builder()
                .username((String) claims.get("username"))
                .password("")
                .authorities(list.toArray(new String[]{}))
                .build();

        //5.2构建Security权限交互对象(固定写法)
        PreAuthenticatedAuthenticationToken authToken =
                new PreAuthenticatedAuthenticationToken(
                        userDetails,
                        userDetails.getPassword(),
                        userDetails.getAuthorities());

        //5.3将权限交互对象与当前请求进行绑定
        authToken.setDetails(new WebAuthenticationDetails(request));

        //5.4将认证后的token存储到Security(会话对象)
        SecurityContextHolder.getContext().setAuthentication(authToken);

        return true;
    }
}

第二步:创建Spring Web配置类,用于注册和配置Spring MVC拦截器。

/**
 * 定义Spring Web MVC配置类
 */
@Configuration
public class SpringWebConfig implements WebMvcConfigurer {
    /**
     *  将拦截器添加到spring mvc的执行链中
     * @param registry 此对象提供了一个list集合,可以将拦截器添加到集合中
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new TokenInterceptor())
                .addPathPatterns("/**");//设置拦截的目标(URL)
    }
}

3.6 访问测试

第一步:启动认证服务器,通过Postman进行登录认证。
07--单点登录系统(SSO)的设计与实现_第13张图片
第二步:启动资源服务器,并基于认证服务器返回的令牌进行资源访问。
07--单点登录系统(SSO)的设计与实现_第14张图片

3.7 创建通用工程 — sso-common

3.7.1 背景分析

当多个项目都有一部分公共资源重复编写时,我们可以创建一个公共工程,在这个工程中创建共性对象和依赖。其他工程可以直接引用。

3.7.2 创建工程

07--单点登录系统(SSO)的设计与实现_第15张图片

3.7.3 初始化工程

第一步:添加依赖。

<dependencies>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>
        <dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-securityartifactId>
        dependency>
        <dependency>
            <groupId>io.jsonwebtokengroupId>
            <artifactId>jjwtartifactId>
            <version>0.9.1version>
        dependency>
    dependencies>

第二步:拷贝工具类。
将认证服务器和资源服务器中的WebUtils,JwtUtils工具类都移动到sso-common工程的sso.util包中。

3.7.4 创建跨域配置类

在前后端分析工程中,当通过前端工程访问认证服务器和资源服务器时,需要进行跨域配置。

@Configuration
public class CorsFilterConfig {
    /*服务端过滤器层面的过滤器设计 */
    @Bean
    public FilterRegistrationBean<CorsFilter> filterFilterRegistrationBean(){
        //1.对此过滤器进行配置(跨域设置-url,method)
        UrlBasedCorsConfigurationSource configSource=new UrlBasedCorsConfigurationSource();
        CorsConfiguration config=new CorsConfiguration();
        config.addAllowedHeader("*");//所有请求头信息
        config.addAllowedMethod("*");//所有请求方式 post,delete,get,put,....
        config.addAllowedOrigin("*");//所有请求参数
        config.setAllowCredentials(true);//所有认证信息,例如:cookie
        //2.注册过滤器并设置其优先级
        configSource.registerCorsConfiguration("/**", config);
        FilterRegistrationBean<CorsFilter> fBean=
                new FilterRegistrationBean(new CorsFilter(configSource));
        fBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
        return fBean;
    }

}

3.7.5 引用通用工程

第一步:删除认证服务器和资源服务器工程下的utils包。
第二步:删除认证服务器和资源服务器工程下的公共依赖。
第三步:在认证服务器和资源服务器工程中添加通用工程的依赖。

<dependencies>
        <dependency>
            <groupId>com.cy.jtgroupId>
            <artifactId>sso-commonartifactId>
            <version>1.0-SNAPSHOTversion>
        dependency>
    dependencies>

3.8 创建前端工程

3.8.1 背景分析

我们做后端,一般在测试时直接基于postman进行访问即可,但是为了更好的理解前后端的通讯过程,我们暂且基于SpringBoot工程构建一个前端工程。
07--单点登录系统(SSO)的设计与实现_第16张图片

3.8.2 初始化工程

第一步:添加web依赖。

<dependency>
            <groupId>org.springframework.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>

第二步:创建application.yml配置文件。

server:
  port: 8285

第三步:创建启动类。

@SpringBootApplication
public class UIApplication {
    public static void main(String[] args) {
        SpringApplication.run(UIApplication.class, args);
    }
}

3.8.3 创建静态页面

07--单点登录系统(SSO)的设计与实现_第17张图片

登录界面:login.html

doctype html>
<html lang="en">
<head>
    
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <title>logintitle>
head>
<body>
<div class="container"id="app">
    <h3>Please Loginh3>
    <form>
        <div class="mb-3">
            <label for="usernameId" class="form-label">Usernamelabel>
            <input type="text" v-model="username" class="form-control" id="usernameId" aria-describedby="emailHelp">
        div>
        <div class="mb-3">
            <label for="passwordId" class="form-label">Passwordlabel>
            <input type="password" v-model="password" class="form-control" id="passwordId">
        div>
        <button type="button" @click="doLogin()" class="btn btn-primary">Submitbutton>
    form>
div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous">script>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js">script>
<script src="https://unpkg.com/axios/dist/axios.min.js">script>
<script>
    var vm=new Vue({
        el:"#app",//定义监控点,vue底层会基于此监控点在内存中构建dom树
        data:{ //此对象中定义页面上要操作的数据
            username:"",
            password:""
        },
        methods: {//此位置定义所有业务事件处理函数
            doLogin() {
                //1.定义url
                let url = "http://localhost:8284/login"
                //2.定义参数

                var params = new URLSearchParams()
                params.append('username',this.username);
                params.append('password',this.password);
                //3.发送异步请求
                axios.post(url, params).then((response) => {
                   var data=response.data;
                   console.log(data);
                    if (data.state == 200) {
                        alert("login ok");
                        window.localStorage.setItem("token",data.token);
                        location.href="/index.html"
                    } else {
                        alert(response.message);
                    }
                })
            }
        }
    });
script>
body>
html>

登录成功后的页面:index.html.

DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Titletitle>
head>
<body>
<div id="appIndex">
<h1>Index Page <a href="#"  @click="doLogout()">Logouta>h1>
<h2>CRUD(Create,Retrieve,Update,Delete) Operationh2>
<ul>
    <li><a href="#" @click="doCreate()">Create(添加-insert)a>li>
    <li><a href="#">Retrieve(查询-select)a>li>
    <li><a href="#" @click="doUpdate()">Update(更新-update)a>li>
    <li><a href="#">Delete(删除-delete)a>li>
ul>
div>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js">script>
<script src="https://unpkg.com/axios/dist/axios.min.js">script>
<script>
    let vm=new Vue({
        el:"#appIndex",//定义监控点,vue底层会基于此监控点在内存中构建dom树
        methods: {//此位置定义所有业务事件处理函数
            doCreate() {
                //1.定义url
                let url = "http://localhost:8283/doCreate"
                //3.发送异步请求
                axios.get(url,{headers:{"token":localStorage.getItem("token")}}).then((response) => {
                   alert(response.data)
                })
            },
            doUpdate() {
                //1.定义url
                let url = "http://localhost:8283/doUpdate"
                //3.发送异步请求
                let token = localStorage.getItem("token");
                axios.get(url,{headers:{"token":token==null?"":token}}).then((response) => {
                    alert(response.data.message);
                })
            },
            doLogout() {
                localStorage.removeItem("token");
                location.href="/login.html";
            },
        }
    });
script>
body>
html>

3.9 工程访问测试

第一步:启动sso-authentication、sso-resource、sso-ui工程,然后访问http://localhost:8285/login.html进行登录。
07--单点登录系统(SSO)的设计与实现_第18张图片
第二步:输入正确的账号和密码执行登录,登录成功以后会自动跳转index.html页面。
07--单点登录系统(SSO)的设计与实现_第19张图片
第三步:对Create和Update选项进行访问,检验输出结果。

4.资源访问过程的分析

07--单点登录系统(SSO)的设计与实现_第20张图片

总结

重点分析

  • 单体架构中的登录设计
  • 分布式架构中的单点登录设计
  • SpringSecutiry在认证服务器和资源服务器中的配置
  • JWT在认证授权系统中的应用

常见FAQ

  • 传统单体架构方式的会话是是如何实现的?(Cookie+Session)
  • 传统单体架构方式的登录在分布式架构中有什么缺陷?(cookie的跨域,session的共享)
  • 分布式架构中的认证方式如何实现?(方式1:Session数据持久化,方式2:认证服务器创建令牌,客户端
  • 存储令牌,资源服务端解析令牌)
  • 认证服务器用来做什么?(创建并响应令牌,设置认证机制-登录成功,失败,没有认证)
  • 认证服务器的令牌基于什么规范进行创建?(JWT-JSON Web Token)
  • 资源服务器你要做什么?(解析令牌,存储用户认证和权限信息,提供有条件的资源访问)
  • SpringBoot工程中编写单元测试要注意什么?(包-启动类所在包或子包,注解-@SpringBootTest,@Test-org.junit.jupiter.api.Test)
  • SecurityConfig的作用是什么?(配置认证规则,授权方式)
  • UserDetailsService接口的作用是什么?(访问数据库用户信息以及用户对应的权限信息,并进行封装,底层会交给AuthenticationManager管理器去进行认证.)
  • @EnableGlobalMethodSecurity 注解的作用是什么?(描述启动类或配置,用于告诉底层系统,假如方法上有 @PreAuthorize注解,则在方法层面启动权限检测,有权限则授权访问,没有权限则抛异常.)
  • 客户端拿到JWT令牌以后,如何进行的存储?(localStorage)
  • 客户端在访问资源服务时,如何将令牌传递到资源服务器?(将令牌放在ajax请求的请求头中)

Bug分析

  • 401 (访问资源时还没有认证)
  • 403 (访问资源时没有权限)
  • NullPointerException (对象访问属性或方法时因为对象为空而出现的异常)
  • ClassNotFoundException(类没有找到,假如不是自己类,检查对应的依赖.)

你可能感兴趣的:(SpringBoot,服务器,tomcat,java)