Spring Security开发手册(从零搭建,超详细教程)

Spring Security开发手册

此项目基于Spring Boot 3.2.xSpring Security 6

一 入门程序

1. 新建Spring Boot工程

打开Idea -> 新建项目new project -> 选择Spring Boot工程spring initializr -> 选择相应配置 -> finish

2. 导入相关依赖

       <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

3. 编写测试Controller

用于浏览器访问路径

@RestController
@RequestMapping("/admin")
public class AdminController {

    @GetMapping("/info")
    public String admin(){
        return "I am admin";
    }
}

4.在yaml文件中配置默认用户

springsecurity会自动生成一个用户并在控制台显示,也可以自己配置

spring:
  security:
    user:
      name: user
      password: 123456

5. 启动项目

测试登录功能

在浏览器地址栏填写Controller中编写的测试路径:

image-20240403215632121

回车自动跳转到默认生成的登录页:

image-20240403215759139

输入我们自己配置的默认用户:

image-20240403215855548

回车确认:

image-20240403215948892

测试登出功能

此时在浏览器地址栏输入:l

image-20240403220306628

这也是springsecurity自带的登出功能

image-20240403220402515

回车确认后返回到登录页

image-20240403220535756

此时登录已经失效,无法访问其它功能

6. 结论

引入spring-boot-starter-security依赖后,项目中除登录退出外所有资源都会被保护起来,认证(登录)用户可以访问所有资源,不经过认证用户任何资源也访问不了

7. 问题

所有资源均以保护,但是用户只有一个,密码是随机生成(也可自己修改)的,只适用于开发环境

二 基于内存的多用户管理

1.创建SecurityConfiguration配置类

重写UserDetailsService方法并注册为Bean

@Configuration
public class SecurityConfiguration {

    @Bean
    public UserDetailsService userDetailsService(){

        //创建基于内存的用户
        UserDetails user1 = User.builder()
                .username("admin").
                password("123456").
                roles("admin").build();
        UserDetails user2 = User.builder()
                .username("student")
                .password("123456")
                .roles("user").build();
        UserDetails user3 = User.builder()
                .username("teacher")
                .password("123456")
                .roles("user").build();

        //内存中用户管理器
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();

        //添加用户
        manager.createUser(user1);
        manager.createUser(user2);
        manager.createUser(user3);

        //InMemoryUserDetailsManager的上层UserDetailsManager继承了UserDetailsService
        return manager;
    }
}

2. 启动项目

此时会发现控制台如下报错信息:

image-20240403224235526

3. 报错解读

这个错误这个错误消息表明在Spring Security中没有找到对应的PasswordEncoder实现。PasswordEncoder是用于对用户密码进行加密和验证的组件。因此需要确保在Spring Security的配置中定义了一个PasswordEncoder

@Bean
    public PasswordEncoder passwordEncoder(){
    	//这个NoPasswordEncoder实际上并不会对明文密码加密,只是为了敷衍spring检查
        return NoOpPasswordEncoder.getInstance();
    }

4. 运行测试

此时重新启动项目,在浏览器输入:http://localhost:8080/admin/info

重定向到登录页

image-20240403225233943

输入前面定义的用户:

image-20240403225326892

可以看到我们之前定义的用户已经生效

image-20240403225346363

此时我们再测试配置文件中定义的user

可以发现配置文件中定义的默认用户已经失效

image-20240403225621557

5. 问题

使用未加密的明文密码登录,存在安全隐患;不同类型的用户的访问权限相同,无法分配响应数据;可使用的用户仍然有限,且账号密码只能由后台进行控制,无法进行持久化。

6. 自定义UserDetailsService

关于自定义**UserDetailsService**,目前主要有两个方式:

实现UserDetailsService接口

在自己定义的service类中实现UserDetailsService接口,并重写loadUserByUsername( )方法,该方法要求返回一个UserDetails类型的对象,通过User.build( )创建,注意这个User是Security自带的类,而不是自己写的用户实体类。

image-20240406213424895

在该方法中,我们通过username参数去数据库中查询对应的account,若查找成功,则通过User.build( )构造一个UserDetails对象返回,反之则抛出相应异常。

注册UserDetailsService的Bean方法

**注意!**使用此方式的实体类需要实现UserDetails接口

image-20240406215142713

在开启了@Configuration注解的配置类中,注册一个返回值为UserDetailsService对象的Bean方法

image-20240406214446716

在该方法中,获取传入的username参数,通过自定义的mapper去数据库中查询该用户是否存在,若存在则直接返回该对象,若不存在则抛出异常。

有的人可能会有疑问:为什么明明没有传入username这个参数,但在return中却可以使用?

我们点开UserDetailsService源代码,可以发现该接口只有一个方法,且该方法只有一个username参数,所以这是一个典型的函数式接口,可以通过lambda表达式直接调用。

其实我们还可以注意到,UserDetailsService中的该方法的返回值是UserDetails类型,这也就解释了为什么实体类只实现了UserDetails接口却可以当作UserDetailsService类型返回,因为这两种方式的最终目的都是为了得到一个UserDetails类型对象。

image-20240406220959355

7. 总结

在上文的例子中,也是采用了此种方式,只不过前者是创建基于内存的用户,且交给InMemoryUserDetailsManager管理,该类实现了UserDetailsManager。

image-20240406220119670

为什么实现了UserDetailsManager接口就可以作为UserDetailsService返回呢?点开UserDetailsManager,发现了我们的老朋友UserdetailsService,由于UserDetailsManager是它的子类,自然作为父类型返回了。

image-20240406220510029

三 密码加密学习

1. 新建一个测试类EncodeTest

@SpringBootTest
public class EncodeTest {

    @Test
    public void encode() {
        String password1 = "123456";
        String password2 = "123456";
        String password3 = "123456";
        
        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        String encode1 = encoder.encode(password1);
        String encode2 = encoder.encode(password2);
        String encode3 = encoder.encode(password3);
        
        System.out.println(encode1);
        System.out.println(encode2);
        System.out.println(encode3);
    }
}

2. 运行测试

可以看到,同样的明文加密之后竟然生成了不一样的密文

image-20240404180031535

我们可以将这几个密文去相互对比一下

@Test
    public void encode() {
        String password1 = "123456";
        String password2 = "123456";
        String password3 = "123456";

        BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
        String encode1 = encoder.encode(password1);
        String encode2 = encoder.encode(password2);
        String encode3 = encoder.encode(password3);

        System.out.println(encoder.matches("123456", encode1));
        System.out.println(encoder.matches("123456", encode2));
        System.out.println(encoder.matches("123456", encode3));
        

    }

查看控制台发现:

虽然这三个密文字符串看起来不一样,但是都可以与同一个明文匹配

image-20240404180846955

3. 结论

bcrypt 是专为密码存储设计的哈希函数,通过盐值、工作因子和 Blowfish 算法的结合使用,能有效对抗彩虹表攻击和暴力破解。随着时间的推移,可以通过调整工作因子来适应新的威胁模型

4. 改进

我们可以在Security框架中引入BCryptEncoder取代之前的No0pPasswordEncoder以提升安全性

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

此时重启项目,进入浏览器输入相应账号密码:

image-20240405160605627

这时会发现为什么换了加密器之后输入正确的账号密码确用不了了呢?

这是因为用户在浏览器输入的密码传入到服务器时被BcryptPasswordEncoder进行了加密,在比对密码时和我们原来设置的明文密码进行比较,自然就比对不成功了

解决办法:在后端设置的用户明文密码也进行一次加密

//创建基于内存的用户
        UserDetails user1 = User.builder()
                .username("admin").
                password(passwordEncoder().encode("123456")).
                roles("admin").build();
        UserDetails user2 = User.builder()
                .username("student")
                .password(passwordEncoder().encode("123456"))
                .roles("user").build();
        UserDetails user3 = User.builder()
                .username("teacher")
                .password(passwordEncoder().encode("123456"))
                .roles("user").build();

此时再重启服务器运行测试,输入账号密码回车,登录成功

image-20240405161257376

四 认证入门

涉及的类

**Authentication**接口:它的实现类,表示当前访问系统的用户,封装了用户相关信息。

**AuthenticationManager**接口:定义了认证Authentication的方法

**UserDetailsService**接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。

**UserDetails**接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

1. 获取当前用户登录信息

新建一个UserController,编写如下代码:

@RestController
@RequestMapping("/user")
public class UserController {

    @GetMapping("/current")
    public Authentication user(Authentication authentication) {
        //通过认证信息获取
        return authentication;
    }

    @GetMapping("/current2")
    public Principal user2(Principal principal) {
        //通过当前登录状态获取
        return principal;
    }

    @GetMapping("/current3")
    public Authentication user3() {
        //通过安全上下文持有器获取安全上下文,再获取认证信息
        return SecurityContextHolder.getContext().getAuthentication();
    }
}

重启服务器运行,在浏览器登录一个有效用户

image-20240405163455177

通过认证信息获取

在浏览器地址栏输入:localhost:8080/user/current

@GetMapping("/current")
public Authentication user(Authentication authentication) {
    //通过认证信息获取
    return authentication;
}

image-20240405163531399

可以看到后端返回了当前用户的所有认证信息的Json字符串

image-20240405163611846

通过当前登录状态获取
@GetMapping("/current2")
public Principal user2(Principal principal) {
    //通过当前登录状态获取
    return principal;
}

在浏览器地址栏输入:localhost:8080/user/current2

image-20240405163942180

可以看到依然返回了与上面方式相同的结果

image-20240405164025831

通过安全上下文获取
@GetMapping("/current3")
    public Authentication user3() {
        //通过安全上下文持有器获取安全上下文,再获取认证信息
        return SecurityContextHolder.getContext().getAuthentication();
    }

在浏览器地址栏输入:localhost:8080/user/current3

image-20240405164223239

仍然是同一个东西

image-20240405164251285

2. 分析结果

先将该Json字符串格式化后得到如下内容:

{
    "authorities": [
        {
            "authority": "ROLE_admin"
        }
    ],
    "details": {
        "remoteAddress": "0:0:0:0:0:0:0:1",
        "sessionId": "CC056F172F3FCB0077B9D1CFFD6F6201"
    },
    "authenticated": true,
    "principal": {
        "password": null,
        "username": "admin",
        "authorities": [
            {
                "authority": "ROLE_admin"
            }
        ],
        "accountNonExpired": true,
        "accountNonLocked": true,
        "credentialsNonExpired": true,
        "enabled": true
    },
    "credentials": null,
    "name": "admin"
}

相关字段对应属性如下:

  • authorities:用户拥有的权限列表,每个元素是一个包含"authority"字段的对象,表示角色(如"ROLE_admin")。

  • details:认证细节信息,包括:

  • remoteAddress:远程客户端IP地址。

  • sessionId:当前会话ID。

  • authenticated:是否已通过身份验证。

  • principal:主体信息,通常包含用户名、密码、权限等用户核心属性,具体如下:

  • password:(通常为空,因安全原因不返回实际密码)。

  • username:用户名。

  • authorities:与顶层的"authorities"相同,用户的角色列表。

  • accountNonExpired:账号是否未过期。

  • accountNonLocked:账号是否未被锁定。

  • credentialsNonExpired:凭证(如密码)是否未过期。

  • enabled:用户是否启用(即是否允许登录)。

  • credentials:(通常为空,因安全原因不返回实际凭证)。

  • name:用户名称(通常是用户名)。

3. 用户认证流程

用户登录成功之后会将用户信息(UserDetails)封装到主体类Principle中,再将主体类Principle封装到认证信息类(Authentication)中,而Authentication最终会放到安全上下文(SecurityContext)中。

这也就解释了为什么我们上面三个不同的获取方式得到的结果却是一样的

在这里插入图片描述

4. 配置用户权限

代码解读

在我们配置用户信息的时候,往往需要配置一个角色:

image-20240405170917935

那么这个方法到底是在干嘛呢,直接上源码:

image-20240405171033115

从参数 String … roles 不难看出,这是一个可变长度参数,意味着可以同时接收多个角色,而且对于输入的角色不能以 ROLE_ 开头,因为系统会自动加上这个前缀, 角色的前面加上 ROLE_ 就变成了权限, 比如 ROLE_admin。

角色与权限

首先一定要明确一个概念:角色是权限的集合

我们将之前的两个用户稍作修改

        UserDetails user2 = User.builder()
                .username("student")
                .password(passwordEncoder().encode("123456"))
                .authorities("student:edit","student:add", "student:delete")
                .roles("user")
                .build();

        UserDetails user3 = User.builder()
                .username("teacher")
                .password(passwordEncoder().encode("123456"))
                .roles("user")
                .authorities("teacher:edit","teacher:add", "teacher:delete")
                .build();

重启服务器,再次通过浏览器分别获取两个用户的认证信息

这是student用户的:

image-20240405210925558

上面的权限集合中只有ROLE_user,并没有显示我们单独设置的权限

image-20240405211354531

此时我们再退出登录,切换至teacher再次登录,查看权限信息

这是teacher用户的

image-20240405211014237

与student用户不同的是,teacher用户的认证信息只显示了单独设置的权限,并没有显示ROLE_user

image-20240405211628528

5.结论

通过对比发现,这两个用户都设置了角色与权限,唯一的区别就是设置角色与设置权限的先后顺序,结果都是只显示后位设置的,也就是说,角色与权限如果同时设置的话,后者会覆盖掉前者。

通过对**roles( )方法与authorities( )**源码探究,竟然发现这两者最后都是在调用同一个方法:

image-20240405212702718

这个方法体只执行一个操作,就是直接将传参的权限集合赋值给当前对象的authorities属性,也就是完全覆盖,这也就解释了为什么后调用的方法会覆盖掉前者设置的属性而不是合并了。

6. 问题

在当前项目中,仍然存在不足之处。我们的不同的用户登录之后,都可以通过url访问其他用户的controller,存在巨大的安全隐患,这对于实际业务来说是万万不可以的,所以在后面的开发中我们仍需要设法改进。

五 授权控制

为了解决不同角色的资源访问控制问题,我们需要在配置类中对不同路径的URL做相应的请求控制

1. 基于角色访问控制

创建一个WebSecurityConfiguration配置类

@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration {

    /**
     * 配置安全过滤链,定义不同URL路径的访问权限。
     *
     * @param http 用于配置HttpSecurity的接口
     * @return 返回配置好的SecurityFilterChain对象
     * @throws Exception 抛出异常的情况
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 配置请求的授权规则
        return http.authorizeHttpRequests(conf->{
            // /admin/**路径需要"ADMIN"角色
            conf.requestMatchers("/admin/**").hasRole("ADMIN");
            // /teacher/**路径需要"TEACHER"角色
            conf.requestMatchers("/teacher/**").hasRole("TEACHER");
            // /student/**路径需要"STUDENT"角色
            conf.requestMatchers("/student/**").hasRole("STUDENT");
            // 对于其余所有请求都需要认证
            conf.anyRequest().authenticated();;
        }).formLogin(conf->{
            //允许所有登录请求
            conf.permitAll();
        }).logout(conf->{
            //允许所有登出请求
            conf.permitAll();
        }).build();
    }

}

在该配置类中我们定义了对于不同的路径请求只能由相应的角色才能访问。注意,要对前端web路径请求控制必须先要开启@EnableWebSecurity注解

启动项目,打开浏览器以student用户登录

image-20240405220034787

登录进去后访问StudentController,可以正常访问

image-20240405220126898

那此时我们去访问其他的Controller呢?

TeacherContrller

image-20240405220442028

AdminController

image-20240405220510388

页面均显示403报错,说明访问被拒绝。

在配置类中设置之后,每个角色只能访问相应的Controller,实现了对角色的访问控制

2. 基于权限访问控制

修改配置类中的代码,对不同的路径请求只能由持有相应权限的用户才能访问

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 配置请求的授权规则
        return http.authorizeHttpRequests(conf->{
//            // /admin/**路径需要"ADMIN"角色
//            conf.requestMatchers("/admin/**").hasRole("ADMIN");
//            // /teacher/**路径需要"TEACHER"角色
//            conf.requestMatchers("/teacher/**").hasRole("TEACHER");
//            // /student/**路径需要"STUDENT"角色
//            conf.requestMatchers("/student/**").hasRole("STUDENT");

            // /admin/**路径需要 "teacher:edit" 权限
            conf.requestMatchers("/admin/**").hasAuthority("teacher:edit");
            // /teacher/**路径需要 "student:edit" 权限
            conf.requestMatchers("/teacher/**").hasAuthority("teacher:edit");
            // /student/**路径需要 "teacher:edit" 权限
            conf.requestMatchers("/student/**").hasAuthority("student:edit");
            // 对于其余所有请求都需要认证
            conf.anyRequest().authenticated();;
        }).formLogin(conf->{
            //允许所有登录请求
            conf.permitAll();
        }).logout(conf->{
            //允许所有登出请求
            conf.permitAll();
        }).build();
    }

启动项目,打开浏览器以teacher用户登录

image-20240405222323077

先访问自己的TeacherController

image-20240405222621358

由于teacher用户持有相应的权限,所以访问成功

image-20240405222813022

此时再去访问StudentController

image-20240405222652153

由与我们设置了StudentController的访问请求只能由持有 ”student:edit“ 权限的用户访问,所以teacher用户自然无法访问该路径

image-20240405223100036

如果我们去访问AdminController呢?会怎么样?

image-20240405223210556

teacher用户竟然访问成功了,这是为什么呢?

原因是我们在对admin请求路径控制时,设置了持有 “teacher:edit” 权限的用户可以访问,所以teacher用户可以访问该路径

image-20240405223444203

3. 补充说明

hasRole(String role):仅该参数的角色可以访问

hasAnyRole(String... role):满足该参数中的任一角色可以访问

hasAuthority(String authority):仅持有该参数的权限可以访问

hasAnyAuthority(String... authority):持有该参数中的任一权限可以访问

六 返回JSON数据

在实际开发环境中,前后端分离是当前主流的开发模式,后端工程师无需关心前端代码编写,只需要对其返回相应的数据即可。

在我们项目的Controller中,直接打上@RestController注解返回String类型的数据给前端,这是最简单粗暴的方式

image-20240406223252337

但是这样存在一个问题:倘若返回的数据量大且复杂,以这种方式返回给前端的数据会过于杂乱无章,前端人员很难对其解析处理,造成数据错乱的现象。

那么如何解决这种问题呢?当下最好的解决方案就是将数据格式统一转换成JSON的数据格式,简单易读且方便解析。

1. 定义返回值对象

创建一个工具类RestBean用于封装返回给前端的数据

@Data
public class RestBean<T> {

    private int status;
    private boolean success;
    private T data;

    /**
     * RestBean的私有构造方法。
     * 用于创建一个包含操作状态、成功标志和数据的实例。
     *
     * @param status 操作的状态码。
     * @param success 操作是否成功的标志。
     * @param data 操作返回的数据。
     */
    private RestBean(int status, boolean success, T data) {
        this.status = status;
        this.success = success;
        this.data = data;
    }


    public static <T> RestBean<T> success(T data) {
        return new RestBean<>(200, true, data);
    }

    public static <T> RestBean<T> success() {
        return new RestBean<>(200, true, null);
    }

    public static <T> RestBean<T> failure(int status) {
        return new RestBean<>(status, false, null);
    }

    public static <T> RestBean<T> failure(int status, T data) {
        return new RestBean<>(status, false, data);
    }
}

2. 认证相关处理器

在进行认证操作后,通常会执行一些响应方法来返回结果,我们可以自定义这些方法。

自定义SuccessHandler

在开启了@EnableWebSecurity的配置类中添加如下方法:

/**
     * 处理认证成功的逻辑。
     * 当用户成功登录或退出时,根据请求的URL,返回相应的JSON响应。
     *
     * @param request  HttpServletRequest对象,代表客户端的HTTP请求
     * @param response HttpServletResponse对象,用于向客户端发送响应
     * @param authentication 认证对象,包含当前登录用户的认证信息
     */
    public void onAuthenticationSuccessHandler(HttpServletRequest request,
                                               HttpServletResponse response,
                                               Authentication authentication) throws IOException {
        // 设置响应的Content-Type为JSON格式
        response.setContentType("application/json;charset=utf-8");
        // 判断请求的URL,分别处理登录和退出成功的逻辑
        if (request.getRequestURI().endsWith("/login")){
            // 登录成功时,返回JSON格式的响应
            response.getWriter().write(objectMapper.writeValueAsString(RestBean.success("登录成功")));
        } else if(request.getRequestURI().endsWith("/logout")){
            // 退出登录成功时,返回JSON格式的响应
            response.getWriter().write(objectMapper.writeValueAsString(RestBean.success("退出登录成功")));
        }
    }

该方法定义了处理认证成功时的执行逻辑。接下来在配置类中引用该处理器

image-20240407193636615

当我们登录时,若认证成功则会返回我们的自定义信息

image-20240407193842295

同样的,登出时也会有数据返回

image-20240407193945880

自定义FailureHandler

同样的,有认证成功处理器,那就会有认证失败处理器

public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException{
        // 设置响应的Content-Type为JSON格式,以便客户端能够正确解析响应内容
        response.setContentType("application/json;charset=utf-8");
        // 将认证失败的信息封装为JSON格式,并写入响应体中
        response.getWriter().write(objectMapper.writeValueAsString(RestBean.failure(401,exception.getMessage())));
    }

将该方法绑定到登录失败处理器(登出功能不需要,因为不会发生异常)

image-20240407200158036

当我们用错误的账号密码登录时,就会发生认证失败异常,返回我们自定义的数据

image-20240407200306987

自定义DeniedHandler

这是一个访问拒绝处理器,用于处理权限不足或访问被拒绝的情况

private void onAuthenticationDenied(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AccessDeniedException e) throws IOException {
        // 设置响应的内容类型为JSON,确保客户端可以正确解析响应内容
        response.setContentType("application/json;charset=utf-8");
        // 将表示认证失败的RestBean对象转换为JSON字符串,并写入响应体中
        response.getWriter().write(objectMapper.writeValueAsString(RestBean.failure(401,"权限不足!")));
    }

将该方法绑定到异常处理器,当我们越级访问资源时,将会返回我们自定义的数据

image-20240407201934748

启动项目,尝试登录student用户去访问admin的Controller

image-20240407202213871

3.小结

通过定义各种逻辑处理器去应对不同场景下的数据交互,有效地减少显示了403、302等报错页面,对用户以及开发者都很友好。

七 自定义用户信息

在我们前面的项目中,用来登录认证的用户都是写死在代码里,也就是由内存管理,项目启动时创建,项目终止时销毁,修改用户信息时需要在代码里一个一个改,很不方便且不利于管理,因此我们需要自定义一个用户认证实体类来统一管理。

1. 创建实体类Account

用户自定义实体类需要实现UserDetails接口才能用于认证

@Data
public class Account implements UserDetails {


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        //权限
        return null;
    }

    @Override
    public String getPassword() {
        //密码一定要加密后才能返回,否则会不匹配
        return new BCryptPasswordEncoder().encode("123456");
    }

    @Override
    public String getUsername() {
        //用户名
        return "admin";
    }

    @Override
    public boolean isAccountNonExpired() {
        //账户是否未过期
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        //账户是否未锁定
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        //凭据是否未过期
        return true;
    }

    @Override
    public boolean isEnabled() {
        //账户是否启用
        return true;
    }
}

上述代码中我们赋予了初始账号密码,便于测试

2. 实现UserDetailsService接口

先创建AccountService接口,该接口需要继承UserDetailsService接口

public interface AccountService extends UserDetailsService {

}

再创建该接口的实现类AccountServiceImpl,再重写接口方法loadUserByUsername( )

注意:也可以直接用这个类实现UserDetailsService接口,效果是一样的

@Service
public class AccountServiceImpl implements AccountService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        if (!StringUtils.hasText(username)) {   //先判断用户名是否为空
            throw new UsernameNotFoundException("用户名不能为空");
        } else if (!username.equals("admin")) { //再判断用户名是否正确
            throw new UsernameNotFoundException("用户名不正确");
        }
        //用户名匹配则返回带有初始值的对象
        return new Account();
    }
}

到这里,我们就可以将SecurityConfiguration中的内存用户删除了

image-20240407210644596

3.运行测试

启动项目,在浏览器输入账号密码

image-20240407210827413

回车后可以看到仍然是登录成功了的

image-20240407210920832

4. 补充

在上文我们自定义了一个UserDetails实体类,是可以通过认证的,但是我们并没有对其赋予任何权限,我们可以在在浏览器中去查看当前用户的认证信息

image-20240407211507791

当前用户的权限信息为空,也就是说这个用户无法访问任何被权限控制的Controller

image-20240407211608709

八 基于数据库的认证

1.创建数据库表

SpringSecurity用户认证的经典五表:

**用户表(users)**包含了用户的基本信息,如用户名、密码和账号是否启用等。

**角色表(roles)**记录了系统中所有的角色信息。

**用户角色关系表(user_roles)**用于记录用户和角色之间的关系。

**权限表(permissions)**用于存储系统中所有的权限信息。

**角色权限关系表(role_permissions)**记录了角色和权限之间的关系。

相应的SQL语句如下:

#创建用户表:
CREATE TABLE `users` (
   `id` INT AUTO_INCREMENT PRIMARY KEY,
   `username` VARCHAR(255) NOT NULL UNIQUE,
   `password` VARCHAR(255) NOT NULL,
   `enabled` TINYINT(1) NOT NULL
);

#创建角色表:
CREATE TABLE `roles` (
   `id` INT AUTO_INCREMENT PRIMARY KEY,
   `name` VARCHAR(255) NOT NULL
);

#创建用户角色关系表:
CREATE TABLE `user_roles` (
   `user_id` INT NOT NULL,
   `role_id` INT NOT NULL,
   PRIMARY KEY (`user_id`, `role_id`),
   FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
   FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE
);

#创建权限表:
CREATE TABLE `permissions` (
   `id` INT AUTO_INCREMENT PRIMARY KEY,
   `name` VARCHAR(255) NOT NULL
);

#创建角色权限关系表:
CREATE TABLE `role_permissions` (
   `role_id` INT NOT NULL,
   `permission_id` INT NOT NULL,
   PRIMARY KEY (`role_id`, `permission_id`),
   FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE,
   FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE CASCADE
);

在表中赋予相应的初始数据:

users

image-20240407215637375

user_roles

image-20240407215829542

roles

image-20240407215703205

role_permissions

image-20240407215944708

permissions

image-20240407215751781

2. 引入相关依赖

		<!-- Spring Boot 数据库连接支持 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jdbc</artifactId>
        </dependency>
        <!-- MyBatis Plus 支持,用于简化 CRUD 操作和动态 SQL -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.5</version>
        </dependency>
        <!-- MySQL 连接器,用于与 MySQL 数据库通信 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.33</version>
        </dependency>

3. 开发环境配置

实体类Users
@TableName(value ="users")
@Data
public class Users implements Serializable {

    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;

    @TableField(value = "username")
    private String username;

    @TableField(value = "password")
    private String password;

    @TableField(value = "enabled")
    private Integer enabled;

    @Serial
    @TableField(exist = false)
    private static final long serialVersionUID = 1L;

数据访问接口UsersMapper
@Mapper
public interface UsersMapper extends BaseMapper<Users> {
	@Select("select * from users where username = #{username}")
    Users loadUserByUsername(String username);
}
业务层接口UsersService
public interface UsersService extends IService<Users>, UserDetailsService {

}
业务层实现类UsersServiceImpl
@Service
public class UsersServiceImpl extends ServiceImpl<UsersMapper, Users> implements UsersService{

    @Resource
    UsersMapper usersMapper;
    @Resource
    BCryptPasswordEncoder encoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (username==null || username.equals("")){
            throw new UsernameNotFoundException("用户名不能为空");
        }
        Users user = usersMapper.loadUserByUsername(username);
        if (user==null){
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .disabled(user.getEnabled() == 0)
                .build();
    }
}

注意在该方法中,我们通过User.builder( )手动构建一个UserDetails,并传入实体类信息后返回。这一步其实还可以用别的方法,就是将自己的实体类去实现UserDetails接口,并重写其中的方法,这样我们的实体类对象就可以当作UserDetails类型直接返回了,而不需要再通过**User.builder( )**手动构建。

数据库连接配置
# 服务器配置
server:
  port: 8080

# Spring配置
spring:
  # 输出配置
  output:
    ansi:
      enabled: always # 总是启用ANSI颜色输出
  # 数据源配置
  datasource:
    # 数据库连接URL
    url: jdbc:mysql://localhost:3306/user?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root # 数据库用户名
    password: 123456 # 数据库密码
    driver-class-name: com.mysql.cj.jdbc.Driver # 数据库驱动类名
    
# 日志配置(此处省略其他配置项,只展示level)
logging:
    level:
      com.lmw.mapper: debug # 设置com.lmw.mapper包的日志级别为debug

4. 测试运行

启动项目,在浏览器输入数据库中的账号密码

image-20240407233407163

浏览器返回数据显示登录成功

image-20240407233551551

查看控制台发现后台自动帮我们在数据库中查询对应的数据,返回Total:1表示查询到一条信息,说明该用户存在

image-20240407233720565

九 基于数据库的授权

在基于数据库的认证中,我们通过在数据库中查询用户比对信息来认证用户是否有效,但仅仅完成登录认证还不够,在前面的例子中,登录成功之后只能够访问无权限控制的资源,因为这个用户没有任何权限。如果需要对其授权,我们要在查询用户的时候顺便查询该用户的角色与权限。

1. 测试SQL可行性

在数据库中我们定义了五张表,为了方便起见,我们这里不对角色权限做细分,即角色代表权限,这样只需要连接三张表而不是五张表。

通过内连接查询用户以及对应的角色

SELECT * from users 
INNER JOIN user_roles ON users.id = user_roles.user_id
INNER JOIN roles ON user_roles.role_id = roles.id;

执行SQL语句,查询成功且数据准确无误,说明这条SQL语句可以使用

image-20240408171426538

2. 领域模型

在我们的实体类Users中,只定义了如下属性

image-20240408172507929

显然这是不够用的,因为角色属性Role单独作为了一个表,所以我们需要将其与实体类Users结合起来。

在这之前,我们要先了解一个概念:领域模型

  • DTO(Data Transfer Object):数据传输对象,也就是前端给后端传递的数据。
  • VO(view object):可视层对象,用于给前端显示的对象。(只传递有需要的参数以保障数据安全)。
  • DO(Domain Object):此对象与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。
  • BO(Business Object):业务对象,由 Service 层输出的封装业务逻辑的对象。
  • PO(Persistent Object):持久化对象,是一种 o/r 映射关系,可以看成是数据库表到java对象的映射。

大致的示例代码:

Controller层

public List<UserVO> getUsers(UserQuery userQuery);

此层常见的转换为:DTO转VO

Service层、Manager层

// 普通的service层接口
 List<UserDTO> getUsers(UserQuery userQuery);

然后在Service内部使用UserBO封装中间所需的逻辑对象

// 来自前端的请求

 List<UserDTO> getUsers(UserAO userAo);

此层常见的转换为:DO转BO、BO转DTO

DAO层

List<UserDO> getUsers(UserQuery userQuery);

我们的目标就是将两个PO对象(即UsersRoles)结合成一个Bo

3. 创建角色实体类Roles

@TableName(value ="roles")
@Data
public class Roles implements Serializable {

    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;
    
    @TableField(value = "name")
    private String name;

    @TableField(exist = false)
    private static final long serialVersionUID = 1L;

}

4. 创建Bo对象UserBo

在UserBo中实现UserDetails接口作为用户认证信息类,因为此类包含了Users信息以及Roles信息,方便我们授权。

为整合两个数据对象,应在UserBo中将这两个类设为final属性确保数据的不可变性。(这其实就是Record类型)

public record UserBo(Users user, Roles role) implements UserDetails {


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        return List.of(new SimpleGrantedAuthority(role.getName()));
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return user.getEnabled()==1;
    }
}

**record**类是JDK16的新特性

record申明的类,具备这些特点:

  • 它是一个final
  • 自动实现equalshashCodetoString函数
  • 成员变量均为public属性

record关键词的引入,主要是为了提供一种更为简洁、紧凑的final类的定义方式。

5. 创建RolesMapper

先定义RolesMapper接口

@Mapper
public interface RolesMapper extends BaseMapper<Roles> {

    Roles loadRolesByUsername(String username);
}

对于复杂查询的SQL语句,我们一般推荐在RolesMapper.xml文件中编写

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
        
<mapper namespace="com.lmw.mapper.RolesMapper">

    <resultMap id="BaseResultMap" type="com.lmw.domain.Roles">
            <id property="id" column="id" jdbcType="INTEGER"/>
            <result property="name" column="name" jdbcType="VARCHAR"/>
    </resultMap>
    <select id="loadRolesByUsername" resultType="com.lmw.domain.Roles">
        select r.* from roles r,user_roles ur,users u
        where r.id = ur.role_id and ur.user_id = u.id and u.username = #{username}
    </select>
    
</mapper>

6. 修改用户认证逻辑

loadUserByUsername认证方法中的Users对象替换为通过UsersRoles构建的UserBo记录对象

由于UsersBo实现了UserDetails接口,所以可以直接在该方法中返回

@Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 检查用户名是否为空
        if (username == null || username.isEmpty()) {
            throw new UsernameNotFoundException("用户名不能为空");
        }
        // 从数据库加载用户信息
        Users user = usersMapper.loadUserByUsername(username);
        // 用户名不存在时抛出异常
        if (user == null) {
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        // 构造并返回UserBo对象,包含用户角色信息
        return new UserBo(user, rolesMapper.loadRolesByUsername(username));
    }

7. 控制访问路径

WebSecurityConfiguration配置类中,修改securityFilterChain方法中URL的访问控制

return http.authorizeHttpRequests(conf->{
            // /admin/**路径需要"ADMIN"角色
            conf.requestMatchers("/admin/**").hasAnyRole("ADMIN");
            // /teacher/**路径需要"TEACHER"或"ADMIN"角色
            conf.requestMatchers("/teacher/**").hasAnyRole("TEACHER","ADMIN");
            // /student/**路径需要"STUDENT"或"TEACHER"或"ADMIN"角色
            conf.requestMatchers("/student/**").hasAnyRole("TEACHER","ADMIN","STUDENT");
            // 对于其余所有请求都需要认证
            conf.anyRequest().authenticated();;
        })

8. 系统测试

启动项目,在浏览器地址栏输入 localhost:8080重定向到登录页

image-20240408190208738

以admin身份登录

image-20240408190420293

登录成功后,先查看admin用户的认证信息

image-20240408190522195

再查看控制台信息可以发现后台已经帮我们查询了相关数据并绑定到了UserBo

image-20240408190627106

接下来去测试每个用户的权限是否符合我们设计的逻辑

测试admin用户权限

根据我们的设计逻辑,admin用户可以访问所有资源

访问AdminController,可以正常访问,符合预期

image-20240408190914687

访问TeacherController,可以正常访问,符合预期

image-20240408191012384

访问StudentController,可以正常访问,符合预期

image-20240408191046872

测试teacher用户权限

根据我们的设计逻辑,teacher用户可以访问teacher和student资源,但不能访问admin资源

登录teacher用户

image-20240408191255425

查看认证信息,可以看到teacher用户拥有TEACHER角色

image-20240408191331450

访问AdminController,访问被拒绝,符合预期

image-20240408191518113

访问TeacherController,可以正常访问,符合预期

image-20240408191706663

访问StudentController,可以正常访问,符合预期

image-20240408191745870

测试student用户权限

根据我们的设计逻辑,student用户只能访问student资源

登录student用户

image-20240408191941487

登录成功,查看用户认证信息

image-20240408192038912

访问AdminController,访问被拒绝,符合预期

image-20240408192111030

访问TeacherController,访问被拒绝,符合预期

image-20240408192131847

访问StudentController,可以正常访问,符合预期

image-20240408192156486

9. 小结

在上面的例子中,我们通过查询数据库获得相关用户信息,再将不同的实体类封装成一个业务层数据对象用与登录认证及授权。

UserBo中,重写了一个返回权限集合的方法,但是由于我们使用的是角色Role代表权限,并没有将权限细分,所以在写返回值时需要做一些转化,将角色名称传入SimpleGrantedAuthority构造函数创建一个权限对象,并使用**List.of( )**方法将其放入列表中返回。

    /**
     * 获取用户角色的权限集合。
     * 该方法不需要参数,它会返回一个包含用户角色权限的集合。
     * 
     * @return Collection<? extends GrantedAuthority> 返回一个权限集合,其中每个权限都是SimpleGrantedAuthority类型的实例。
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        // 创建并返回一个包含用户角色的权限列表
        return List.of(new SimpleGrantedAuthority(role.getName()));
    }

关于SimpleGrantedAuthority,这个类已经实现了GrantedAuthority,所以能够放到返回的Collection集合中

image-20240408193608284

最后由于我们的UserBo实现了UserDetails接口,所以可以自动将传入的角色role值加上**“ROLE_”**的前缀转化为权限集合。

这个步骤实际上等价于User.builder().roles(role.getName()).build();

image-20240408194039812

十 集成thymeleaf

在前面的项目中,我们一直做的是后端内容,并没用涉及到前端的页面及请求,为了更贴合实际场景,我们需要引入前端的页面,以及从前端页面发送请求到后端处理

thymeleaf组合security6特性:

sec:authorize

authorize是用来判断普通权限的,通过判断用户是否具有对应的权限而控制其所包含内容的显示,其可以指定如下属性。

1️⃣sec:authorize="isAnonymous()" //用户为游客则显示
2️⃣sec:authorize="isAuthenticated()" //用户通过验证则显示
3️⃣sec:authorize="hasRole('common')" //用户为common角色则显示
4️⃣sec:authorize="hasAuthority('ROLE_vip')"//用户为ROLE_vip权限则显示

sec:authentication

authentication标签用来代表当前Authentication对象,主要用于获取当前Authentication的相关信息。

如通常我们的Authentication对象中存放的principle是一个UserDetails对象,所以我们可以通过如下的方式来获取当前用户的用户名。

1️⃣sec:authentication="name"  //当前用户的用户名
2️⃣sec:authentication="principal.username" //同上
3️⃣sec:authentication="principal.authorities" //当前用户的身份
4️⃣sec:authentication="principal.password" //当前用户的密码(显示不出来,但确实有)

1. 引入相关依赖

		<!-- 整合Thymeleaf模板引擎 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.thymeleaf.extras</groupId>
            <artifactId>thymeleaf-extras-springsecurity6</artifactId>
        </dependency>
        <!-- JSON数据转换工具 -->
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2-extension-spring6</artifactId>
            <version>2.0.34</version>
        </dependency>

2. 新建thymeleaf模板

image-20240408203506458

3. 配置相关属性

在YML文件的spring下添加

  thymeleaf:
    cache: false  #开发环境不使用缓存
    check-template: true  #是否检查模板

  mvc:
    view:
      suffix: .html	 #定义mvc视图的后缀名为.html
  web:
    resources:
      static-locations: classpath:/templates,classpath:/static	#指定静态资源的查找路径

4. 创建认证相关Controller

新建一个AuthController用来处理用户认证相关的路径请求

统一规定认证相关请求接口名为:**/api/auth/****

@Controller
@Slf4j
@RequestMapping("/api/auth")
public class AuthController {

    @GetMapping("/toLogin")
    public String toLogin() {
        // 跳转到登录页面
        return "login";
    }

}

5. 配置WebSecurityConfiguration

之前的登录界面是Security自动帮我们生成的,过于简洁不适用于实际场景,我们需要在WebSecurity中自定义登录页

@Configuration
@EnableWebSecurity
@Slf4j
public class WebSecurityConfiguration {

    /**
     * 配置安全过滤链,定义不同URL路径的访问权限。
     *
     * @param http 用于配置HttpSecurity的接口
     * @return 返回配置好的SecurityFilterChain对象
     * @throws Exception 抛出异常的情况
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 配置请求的授权规则
        return http.authorizeHttpRequests(conf->{
            //静态资源放行
            conf.requestMatchers("/css/**").permitAll();
            conf.requestMatchers("/font/**").permitAll();
            conf.requestMatchers("/image/**").permitAll();
            conf.requestMatchers("/js/**").permitAll();
            conf.requestMatchers("/picture/**").permitAll();
            // /api/auth/**路径不需要认证
            conf.requestMatchers("/api/auth/**").permitAll();
            // /admin/**路径需要"ADMIN"角色
            conf.requestMatchers("/admin/**").hasAnyRole("ADMIN");
            // /teacher/**路径需要"TEACHER"或"ADMIN"角色
            conf.requestMatchers("/teacher/**").hasAnyRole("TEACHER","ADMIN");
            // /student/**路径需要"STUDENT"或"TEACHER"或"ADMIN"角色
            conf.requestMatchers("/student/**").hasAnyRole("TEACHER","ADMIN","STUDENT");
            // 对于其余所有请求都需要认证
            conf.anyRequest().authenticated();
        }).formLogin(conf->{
            //自定义登录页面
            conf.loginPage("/api/auth/toLogin");
            //自定义登录处理
            conf.loginProcessingUrl("/api/auth/doLogin");
            //当登录成功后调用该处理器
            conf.successHandler(this::onAuthenticationSuccess);
            //认证失败时调用该处理器
            conf.failureHandler(this::onAuthenticationFailure);
            //允许所有登录请求
            conf.permitAll();
        }).logout(conf->{
            //配置退出请求
            conf.logoutUrl("/api/auth/doLogout");
            //当登出成功后调用该处理器
            conf.logoutSuccessHandler(this::onAuthenticationSuccess);
            //允许所有登出请求
            conf.permitAll();
        }).exceptionHandling(conf->{
            //异常时调用
            conf.accessDeniedHandler(this::onAuthenticationDenied);
        }).cors(conf -> {
            CorsConfiguration cors = new CorsConfiguration();
            //添加前端站点地址,这样就可以告诉浏览器信任了
            cors.addAllowedOrigin("*");
            //虽然也可以像这样允许所有 cors.addAllowedOriginPattern("*");
            //但是这样并不安全,我们应该只许可给我们信任的站点
            cors.setAllowCredentials(true);  //允许跨域请求中携带Cookie
            cors.addAllowedHeader("*");   //其他的也可以配置,为了方便这里就 * 了
            cors.addAllowedMethod("*");
            cors.addExposedHeader("*");
            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            source.registerCorsConfiguration("/**", cors);  //直接针对于所有地址生效
            conf.configurationSource(source);
        }).csrf(AbstractHttpConfigurer::disable)    //关闭csrf攻击防御
          .build();
    }


    /**
     * 处理认证成功的逻辑。
     * 当用户成功登录或退出时,根据请求的URL,返回相应的JSON响应。
     *
     * @param request  HttpServletRequest对象,代表客户端的HTTP请求
     * @param response HttpServletResponse对象,用于向客户端发送响应
     * @param authentication 认证对象,包含当前登录用户的认证信息
     */
    public void onAuthenticationSuccess(HttpServletRequest request,
                                               HttpServletResponse response,
                                               Authentication authentication) throws IOException {
        // 设置响应的Content-Type为JSON格式
        response.setContentType("application/json;charset=utf-8");
        response.setStatus(200);
        // 判断请求的URL,分别处理登录和退出成功的逻辑
        if (request.getRequestURI().endsWith("/doLogin")){
            log.info("用户:"+authentication.getName()+"登录成功");
            // 登录成功时,返回JSON格式的响应
            response.getWriter().write(JSON.toJSONString(RestBean.success("登录成功")));
            //一定要及时刷新刷新缓冲区,否则前端收不到响应
            response.getWriter().flush();
        } else if(request.getRequestURI().endsWith("/doLogout")){
            log.info("用户:"+authentication.getName()+"退出登录");
            // 退出登录成功时,返回JSON格式的响应
            response.getWriter().write(JSON.toJSONString(RestBean.success("退出登录成功")));
            //一定要及时刷新刷新缓冲区,否则前端收不到响应
            response.getWriter().flush();
        }
    }

    /**
     * 处理认证失败的回调方法。
     * 当用户认证失败时,此方法将被调用,用于向客户端返回认证失败的信息。
     *
     * @param request  HttpServletRequest对象,代表客户端的请求。
     * @param response HttpServletResponse对象,用于向客户端发送响应。
     * @param exception 认证异常对象,包含认证失败的详细信息。
     * @throws IOException 如果在写响应时发生IO错误。
     * @throws ServletException 如果在处理请求时发生Servlet相关异常。
     */
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException{
        response.setStatus(401);
        log.info("认证失败:"+exception.getMessage());
        // 设置响应的Content-Type为JSON格式,以便客户端能够正确解析响应内容
        response.setContentType("application/json;charset=utf-8");
        // 将认证失败的信息封装为JSON格式,并写入响应体中
        response.getWriter().write(JSON.toJSONString(RestBean.failure(401,exception.getMessage())));
        //一定要及时刷新刷新缓冲区,否则前端收不到响应
        response.getWriter().flush();
    }


    /**
     * 当认证被拒绝时的处理逻辑。
     *
     * @param request HttpServletRequest对象,代表客户端的请求。
     * @param response HttpServletResponse对象,用于向客户端发送响应。
     * @param e AccessDeniedException异常,表示访问被拒绝。
     * @throws IOException 如果在写响应时发生IO错误。
     */
    private void onAuthenticationDenied(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AccessDeniedException e) throws IOException {
        response.setStatus(401);
        // 设置响应的内容类型为JSON,确保客户端可以正确解析响应内容
        response.setContentType("application/json;charset=utf-8");
        // 将表示认证失败的RestBean对象转换为JSON字符串,并写入响应体中
        response.getWriter().write(JSON.toJSONString(RestBean.failure(401,"权限不足!")));
        //一定要及时刷新刷新缓冲区,否则前端收不到响应
        response.getWriter().flush();
    }

}

6. 引入前端资源

在SpringMVC中,默认的前端资源放在resources目录下的static(静态资源)目录和templates(前端页面)目录,但实际上由于我们前面配置了静态资源的路径如下所示:

image-20240410152131041

所以实际上templates下的文件与static下的文件是在同一层级下的,写相对路径不需要写templates或static开头,只需要将这两个目录下的文件当作同一目录使用即可,例如templates下的login.html引入css文件只需要以**“/css”**开头就可以访问到

image-20240410152453181

而静态资源在URL中的位置默认从根路径开始,无需额外配置,例如访问picture下的logo.png只需要在地址栏输入:localhost:8080/picture/logo.png

image-20240410152836323

7. 配置前端发送请求

以往的前后端开发中,通常采用表单登录的方式发送登录请求即在form标签中通过配置action属性和method属性指定请求路径和请求方式。但这种方式存在一些局限性:

  • 耦合性高,不符合前后端分离开发思想

  • 发送数据只能通过input控件携带,增加了代码量

  • 如果使用表单提交数据,则会导致页面会发生跳转,页面之前的状态和数据也会丢失

解决方式就是通过Ajax发送异步请求将数据提交到服务器,这里使用的是jquery

<script>

    /**
     * 执行登录操作
     * 无参数
     * 无返回值
     */
        /**
     * 执行登录操作。
     * 从页面获取用户输入的用户名和密码,使用AJAX请求向服务器发送登录请求。
     * 如果登录成功,将跳转到首页;如果登录失败,显示错误信息。
     */
    function doLogin() {
        // 获取输入框中的用户名和密码值
        const username = document.getElementById('username').value;
        const password = document.getElementById('password').value;
        // 发起登录请求
        $.ajax({
            url: "/api/auth/doLogin",
            type: "POST",
            data: {
                username: username,
                password: password
            },
            success: function (response) {
                // 登录成功,跳转到首页
                window.location.href="/";
            },
            error: function (xhr,status, error) {
                // 登录失败,显示错误信息,一定要用responseJSON才能识别后端发送的json数据
                alert(xhr.responseJSON.message);
            }
        });

    }
</script>

注意!!!如果用ajax发送登录请求,且要提交的数据在form表单中,一定不要用form表单里面的button按钮触发onclick事件,会发生冲突导致请求发不出去,最好是改成span标签触发onclick事件来发送请求

image-20240410232354776

8. 测试登录功能

启动项目,在浏览器地址栏输入localhost:8080自动跳转到登录页,此时已经变成了我们自定义的登录页面

image-20240410233212591

先输入错误的账号密码测试效果,可以看到有错误提示框

image-20240410233323415

控制台也打印了相关信息,可以说对开发人员是非常友好了

image-20240410233443487

此时再测试正确的账号密码

image-20240410233547237

点击登录后,后台验证成功自动跳转到首页

image-20240410233906944

控制台也打印了相应信息

image-20240410234002480

至此,我们已经完成了自定义登录认证功能,但这仅仅只是个开始,还有许多功能模块需要完善,比如说登录退出功能、记住我功能、验证码功能等等,后续要逐步实现。

9. 记住我功能

当前我们登录的信息都是储存在浏览器的Cookie中,其中的数据也就是我们的sessionID,当会话关闭或服务器停止后就会销毁,再次进入就要重新登录,显然是很不方便的,这里就要用到 remember-me记住我功能

remeber-me记住我功能,是我们在登录web系统时的常见勾选项。当我们登录一个web系统时除了输入常规的用户名、密码后还可以勾选记住我选项(假设该系统提供了该选项),此时假设用户名、密码输入正确那么系统将会在客户浏览器cookie中记录用户登录相关认证相关信息。实现的效果就是当我们下次再次访问该网站某些页面时无需再次登录。

实现原理

在这里插入图片描述

验证流程

在这里插入图片描述

代码实现

在配置类中注册一个PersistentTokenRepository类型的Bean

/**
     * 创建并配置PersistentTokenRepository实例,用于存储和检索记住我(Remember Me)功能的令牌。
     *
     * @param dataSource 数据源,用于支持JDBC访问。
     * @return PersistentTokenRepository 实例,具体实现为JdbcTokenRepositoryImpl,它通过数据库来管理记住我令牌。
     */
    @Bean
    public PersistentTokenRepository tokenRepository(DataSource dataSource){
        JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
        // 设置在启动时是否自动创建数据库表,仅在首次启动时需要,避免重复创建表
        repository.setCreateTableOnStartup(false);
        repository.setDataSource(dataSource); // 设置数据源,以便于JdbcTokenRepositoryImpl使用数据库
        return repository;
    }

WebSecurityConfiguration配置类中添加如下代码:

	@Resource
    private PersistentTokenRepository tokenRepository;
    //其它代码保持不变
    ...
    .rememberMe(conf->{
            conf.rememberMeParameter("remember");
            conf.tokenValiditySeconds(60*60*24*7);
            conf.tokenRepository(tokenRepository);
        })
    ...

配置前端请求

image-20240411211537277

功能测试

启动项目,在浏览器输入账号密码并勾选 记住我 单选框

image-20240411211731500

回车登录成功跳转到首页,查看数据库

image-20240411211929729

可以看到系统自动帮我们创建了一张表,上面记录了用户名、登录token信息以及登录时间,这个数据用来下次访问浏览器验证身份

关闭服务器再重新启动后重新访问网站,可以发现无需重新登录直接可以访问其他页面。

image-20240411212226061

10. 退出登录功能

退出登录功能很简单,只需要发送一个退出登录请求就可以了,如果设置了记住我功能,在退出登录时还会清除数据库中的持久化信息。

WebSecurityConfiguration中添加如下代码(我们前面已经写好了):

image-20240411213133600

在需要设置退出登录功能的页面下编写一个发送ajax请求的方法

		function doLogout() {
            $.ajax({
                url: "/api/auth/doLogout",
                type: "POST",
                success: function (data) {
                    if (data.success){
                        window.location.href = "/login.html";
                    }
                }
            });
        }

启动项目,运行测试

在登录状态下点击退出登录按钮

image-20240411213444281

退出登录成功后登录状态失效自动跳转到登录页

image-20240411213605294

查看数据库发现之前储存的token信息已经被删除

image-20240411213641984

十一 基于Token的无状态分离

基于Token的前后端分离主打无状态,无状态服务是指在处理每个请求时,服务本身不会维持任何与请求相关的状态信息。每个请求被视为独立的、自包含的操作,服务只关注处理请求本身,而不关心前后请求之间的状态变化。也就是说,用户在发起请求时,服务器不会记录其信息,而是通过用户携带的Token信息来判断是哪一个用户:

  • 有状态:用户请求接口 -> 从Session中读取用户信息 -> 根据当前的用户来处理业务 -> 返回
  • 无状态:用户携带Token请求接口 -> 从请求中获取用户信息 -> 根据当前的用户来处理业务 -> 返回

无状态服务的优点包括:

  1. 服务端无需存储会话信息:传统的会话管理方式需要服务端存储用户的会话信息,包括用户的身份认证信息和会话状态。而使用Token,服务端无需存储任何会话信息,所有的认证信息都包含在Token中,使得服务端变得无状态,减轻了服务器的负担,同时也方便了服务的水平扩展。
  2. 减少网络延迟:传统的会话管理方式需要在每次请求中都携带会话标识,即使是无状态的RESTful API也需要携带身份认证信息。而使用Token,身份认证信息已经包含在Token中,只需要在请求的Authorization头部携带Token即可,减少了每次请求的数据量,减少了网络延迟。
  3. 客户端无需存储会话信息:传统的会话管理方式中,客户端需要存储会话标识,以便在每次请求中携带。而使用Token,客户端只需要保存Token即可,方便了客户端的存储和管理。
  4. 跨域支持:Token可以在各个不同的域名之间进行传递和使用,因为Token是通过签名来验证和保护数据完整性的,可以防止未经授权的修改。

这一部分,我们将深入学习目前比较主流的基于Token的前后端分离方案。

认识JWT令牌

在认识Token前后端分离之前,我们需要先学习最常见的JWT令牌,官网:https://jwt.io

JSON Web Token令牌(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑和自成一体的方式,用于在各方之间作为JSON对象安全地传输信息。这些信息可以被验证和信任,因为它是数字签名的。JWT可以使用密钥(使用HMAC算法)或使用RSAECDSA进行公钥/私钥对进行签名。

JWT令牌的格式如下:

image-20230307000004710

一个JWT令牌由3部分组成:标头(Header)、有效载荷(Payload)和签名(Signature)。在传输的时候,会将JWT的钱2部分分别进行Base64编码后用.进行连接形成最终需要传输的字符串。

  • 标头:包含一些元数据信息,比如JWT签名所使用的加密算法,还有类型,这里统一都是JWT。
  • 有效载荷:包括用户名称、令牌发布时间、过期时间、JWT ID等,当然我们也可以自定义添加字段,我们的用户信息一般都在这里存放。
  • 签名:首先需要指定一个密钥,该密钥仅仅保存在服务器中,保证不能让其他用户知道。然后使用Header中指定的算法对Header和Payload进行base64加密之后的结果通过密钥计算哈希值,然后就得出一个签名哈希。这个会用于之后验证内容是否被篡改。

这里还是补充一下一些概念,因为很多东西都是我们之前没有接触过的:

  • **Base64:**就是包括小写字母a-z、大写字母A-Z、数字0-9、符号"+“、”/"一共64个字符的字符集(末尾还有1个或多个=用来凑够字节数),任何的符号都可以转换成这个字符集中的字符,这个转换过程就叫做Base64编码,编码之后会生成只包含上述64个字符的字符串。相反,如果需要原本的内容,我们也可以进行Base64解码,回到原有的样子。

    public void test(){
        String str = "你们可能不知道只用20万赢到578万是什么概念";
      	//Base64不只是可以对字符串进行编码,任何byte[]数据都可以,编码结果可以是byte[],也可以是字符串
        String encodeStr = Base64.getEncoder().encodeToString(str.getBytes());
        System.out.println("Base64编码后的字符串:"+encodeStr);
    
        System.out.println("解码后的字符串:"+new String(Base64.getDecoder().decode(encodeStr)));
    }
    

    注意Base64不是加密算法,只是一种信息的编码方式而已。

  • **加密算法:加密算法分为对称加密和非对称加密,其中对称加密(Symmetric Cryptography)**比较好理解,就像一把锁配了两把钥匙一样,这两把钥匙你和别人都有一把,然后你们直接传递数据,都会把数据用锁给锁上,就算传递的途中有人把数据窃取了,也没办法解密,因为钥匙只有你和对方有,没有钥匙无法进行解密,但是这样有个问题,既然解密的关键在于钥匙本身,那么如果有人不仅窃取了数据,而且对方那边的治安也不好,于是顺手就偷走了钥匙,那你们之间发的数据不就凉凉了吗。

    因此,**非对称加密(Asymmetric Cryptography)**算法出现了,它并不是直接生成一把钥匙,而是生成一个公钥和一个私钥,私钥只能由你保管,而公钥交给对方或是你要发送的任何人都行,现在你需要把数据传给对方,那么就需要使用私钥进行加密,但是,这个数据只能使用对应的公钥进行解密,相反,如果对方需要给你发送数据,那么就需要用公钥进行加密,而数据只能使用私钥进行解密,这样的话就算对方的公钥被窃取,那么别人发给你的数据也没办法解密出来,因为需要私钥才能解密,而只有你才有私钥。

    因此,非对称加密的安全性会更高一些,包括HTTPS的隐私信息正是使用非对称加密来保障传输数据的安全(当然HTTPS并不是单纯地使用非对称加密完成的,感兴趣的可以去了解一下)

    对称加密和非对称加密都有很多的算法,比如对称加密,就有:DES、IDEA、RC2,非对称加密有:RSA、DAS、ECC

  • 不可逆加密算法:常见的不可逆加密算法有MD5, HMAC, SHA-1, SHA-224, SHA-256, SHA-384, 和SHA-512, 其中SHA-224、SHA-256、SHA-384,和SHA-512我们可以统称为SHA2加密算法,SHA加密算法的安全性要比MD5更高,而SHA2加密算法比SHA1的要高,其中SHA后面的数字表示的是加密后的字符串长度,SHA1默认会产生一个160位的信息摘要。经过不可逆加密算法得到的加密结果,是无法解密回去的,也就是说加密出来是什么就是什么了。本质上,其就是一种哈希函数,用于对一段信息产生摘要,以防止被篡改

    实际上这种算法就常常被用作信息摘要计算,同样的数据通过同样的算法计算得到的结果肯定也一样,而如果数据被修改,那么计算的结果肯定就不一样了。

因此,JWT令牌实际上是一种经过加密的JSON数据,其中包含了用户名字、用户ID等信息,我们可以直接解密JWT令牌得到用户的信息,我们可以写一个小测试来看看,导入JWT支持库依赖:

<dependency>
     <groupId>com.auth0</groupId>
     <artifactId>java-jwt</artifactId>
     <version>4.3.0</version>
</dependency>

要生成一个JWT令牌非常简单:

public class Main {
    public static void main(String[] args) {
        String jwtKey = "abcdefghijklmn";                 //使用一个JWT秘钥进行加密
        Algorithm algorithm = Algorithm.HMAC256(jwtKey);  //创建HMAC256加密算法对象
        String jwtToken = JWT.create()
                .withClaim("id", 1)   //向令牌中塞入自定义的数据
                .withClaim("name", "lbw")
                .withClaim("role", "nb")
                .withExpiresAt(new Date(2024, Calendar.FEBRUARY, 1))  //JWT令牌的失效时间
                .withIssuedAt(new Date())   //JWT令牌的签发时间
                .sign(algorithm);    //使用上面的加密算法进行加密,完成签名
        System.out.println(jwtToken);   //得到最终的JWT令牌
    }
}

可以看到最后得到的JWT令牌就长这样:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoibmIiLCJuYW1lIjoibGJ3IiwiaWQiOjEsImV4cCI6NjE2NjQ4NjA4MDAsImlhdCI6MTY5MDEzMTQ3OH0.KUuGKM0OynL_DEUnRIETDBlmGjoqbt_5dP2r21ZDE1s

我们可以使用Base64将其还原为原本的样子:

public static void main(String[] args) {
        String jwtToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlIjoibmIiLCJuYW1lIjoibGJ3IiwiaWQiOjEsImV4cCI6NjE2NjQ4NjA4MDAsImlhdCI6MTY5MDEzMTQ3OH0.KUuGKM0OynL_DEUnRIETDBlmGjoqbt_5dP2r21ZDE1s";
        String[] split = jwtToken.split("\\.");
        for (int i = 0; i < split.length - 1; i++) {
            String s = split[i];
            byte[] decode = Base64.getDecoder().decode(s);
            System.out.println(new String(decode));
        }
}

解析前面两个部分得到:

{"typ":"JWT","alg":"HS256"}
{"role":"nb","name":"lbw","id":1,"exp":61664860800,"iat":1690131478}

可以看到确实是经过Base64加密的JSON格式数据,包括我们的自定义数据也在其中,而我们可以直接使用JWT令牌来作为我们权限校验的核心,我们可以像这样设计我们的系统:

image-20230724010807761

首先用户还是按照正常流程进行登录,只不过用户在登录成功之后,服务端会返回一个JWT令牌用于后续请求使用,由于JWT令牌具有时效性,所以说当过期之后又需要重新登录。就像我们进入游乐园需要一张门票一样,只有持有游乐园门票才能进入游乐园游玩,如果没有门票就会被拒之门外,而游乐园门票也有时间限制,如果已经过期,我们也是没有办法进入游乐园的。

所以,我们只需要在后续请求中携带这个Token即可(可以放在Cookie中,也可以放在请求头中)这样服务器就可以直接从Token中解密读取到我们用户的相关信息以及判断用户是否登录过期了。

不过这个时候会有小伙伴疑问,既然现在用户信息都在JWT中,那要是用户随便修改里面的内容,岂不是可以以任意身份访问服务器了?这会不会存在安全隐患?对于这个问题,前面我们已经说的很清楚了,JWT实际上最后会有一个加密的签名,这个是根据秘钥+JWT本体内容计算得到的,用户在没有持有秘钥的情况下,是不可能计算得到正确的签名的,所以说服务器会在收到JWT时对签名进行重新计算,比较是否一致,来验证JWT是否被用户恶意修改,如果被修改肯定也是不能通过的。

image-20230724011814993

SpringSecurity实现JWT校验

前面我们介绍了JWT的基本原理以及后端的基本校验流程,那么我们现在就来看看如何实现这样的流程。

SpringSecurity中并没有为我们提供预设的JWT校验模块(只有OAuth2模块才有,但是知识太超前了,我们暂时不讲解)这里我们只能手动进行整合,JWT可以存放在Cookie或是请求头中,不过不管哪种方式,我们都可以通过Request获取到对应的JWT令牌,这里我们使用比较常见的请求头携带JWT的方案,客户端发起的请求中会携带这样的的特殊请求头:

Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJzZWxmIiwic3ViIjoidXNlciIsImV4cCI6MTY5MDIxODE2NCwiaWF0IjoxNjkwMTgyMTY0LCJzY29wZSI6ImFwcCJ9.Z5-WMeulZyx60WeNxrQg2z2GiVquEHrsBl9V4dixbRkAD6rFp-6gCrcAXWkebs0i-we4xTQ7TZW0ltuhGYZ1GmEaj4F6BP9VN8fLq2aT7GhCJDgjikaTs-w5BbbOD2PN_vTAK_KeVGvYhWU4_l81cvilJWVXAhzMtwgPsz1Dkd04cWTCpI7ZZi-RQaBGYlullXtUrehYcjprla8N-bSpmeb3CBVM3kpAdehzfRpAGWXotN27PIKyAbtiJ0rqdvRmvlSztNY0_1IoO4TprMTUr-wjilGbJ5QTQaYUKRHcK3OJrProz9m8ztClSq0GRvFIB7HuMlYWNYwf7lkKpGvKDg

这里的Authorization请求头就是携带JWT的专用属性,值的格式为"Bearer Token",前面的Bearer代表身份验证方式,默认情况下有两种:

Basic 和 Bearer 是两种不同的身份验证方式。

Basic 是一种基本的身份验证方式,它将用户名和密码进行base64编码后,放在 Authorization 请求头中,用于向服务器验证用户身份。这种方式不够安全,因为它将密码以明文的形式传输,容易受到中间人攻击。

Bearer 是一种更安全的身份验证方式,它基于令牌(Token)来验证用户身份。Bearer 令牌是由身份验证服务器颁发给客户端的,客户端在每个请求中将令牌放在 Authorization 请求头的 Bearer 字段中。服务器会验证令牌的有效性和权限,以确定用户的身份。Bearer 令牌通常使用 JSON Web Token (JWT) 的形式进行传递和验证。

一会我们会自行编写JWT校验拦截器来处理这些信息。

首先我们先把用于处理JWT令牌的工具类完成一下:

public class JwtUtils {
    //Jwt秘钥
    private static final String key = "abcdefghijklmn";

    //根据用户信息创建Jwt令牌
    public static String createJwt(UserBo user){
        // 创建一个指定算法的哈希对象,此处使用HMAC256算法,传入的key用于签名过程
        Algorithm algorithm = Algorithm.HMAC256(key);
        // 获取当前时间的Calendar实例
        Calendar calendar = Calendar.getInstance();
        // 获取当前时间
        Date now = calendar.getTime();
        // 将日历时间增加7天(3600秒/小时 * 24小时/天 * 7天),用于设定签名校验的有效期
        calendar.add(Calendar.SECOND, 3600 * 24 * 7);

        return JWT.create()
                .withClaim("name", user.getUsername())  //配置JWT自定义信息
                .withClaim("authorities", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
                .withExpiresAt(calendar.getTime())  //设置过期时间
                .withIssuedAt(now)    //设置创建创建时间
                .sign(algorithm);   //最终签名
    }

    //根据Jwt验证并解析用户信息
    public static UserBo resolveJwt(String token){
        // 创建JWT验证器
        // algorithm: 使用HMAC256算法基于key来创建算法实例
        Algorithm algorithm = Algorithm.HMAC256(key);

        // 基于上述算法实例构建JWT验证器
        JWTVerifier jwtVerifier = JWT.require(algorithm).build();

        try {
            DecodedJWT verify = jwtVerifier.verify(token);  //对JWT令牌进行验证,看看是否被修改
            Map<String, Claim> claims = verify.getClaims();  //获取令牌中内容
            if(new Date().after(claims.get("exp").asDate())) //如果是过期令牌则返回null
                return null;
            else
                return new UserBo(new Users(null,claims.get("name").asString(),"", 1),
                                new Roles(null,claims.get("authorities").asArray(String.class)[0]));
        } catch (JWTVerificationException e) {
            return null;
        }
    }
}

接着我们需要自行实现一个JwtAuthenticationFilter加入到SpringSecurity默认提供的过滤器链(有关SpringSecurity的实现原理介绍,我们在SSM中已经详细讲解过,各位小伙伴可以回顾一下)中,用于处理请求头中携带的JWT令牌,并配置登录状态:

public class JwtAuthenticationFilter extends OncePerRequestFilter {  
//继承OncePerRequestFilter表示每次请求过滤一次,用于快速编写JWT校验规则

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
      	//首先从Header中取出JWT
        String authorization = request.getHeader("Authorization");
      	//判断是否包含JWT且格式正确
        if (authorization != null && authorization.startsWith("Bearer ")) {
            String token = authorization.substring(7);	
          	//开始解析成UserDetails对象,如果得到的是null说明解析失败,JWT有问题
            UserDetails user = JwtUtils.resolveJwt(token);
            if(user != null) {
              	//验证没有问题,那么就可以开始创建Authentication了,这里我们跟默认情况保持一致
              	//使用UsernamePasswordAuthenticationToken作为实体,填写相关用户信息进去
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
              	//然后直接把配置好的Authentication塞给SecurityContext表示已经完成验证
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
      	//最后放行,继续下一个过滤器
      	//可能各位小伙伴会好奇,要是没验证成功不是应该拦截吗?这个其实没有关系的
      	//因为如果没有验证失败上面是不会给SecurityContext设置Authentication的,后面直接就被拦截掉了
      	//而且有可能用户发起的是用户名密码登录请求,这种情况也要放行的,不然怎么登录,所以说直接放行就好
        filterChain.doFilter(request, response);
    }
}

最后我们来配置一下SecurityConfiguration配置类,其实配置方法跟之前还是差不多,用户依然可以使用表单进行登录,并且登录方式也是一样的,就是有两个新增的部分需要我们注意一下:

@Configuration
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
          			//其他跟之前一样,就省略掉了
                ...  
                //将Session管理创建策略改成无状态,这样SpringSecurity就不会创建会话了,也不会采用之前那套机制记录用户,因为现在我们可以直接从JWT中获取信息
                .sessionManagement(conf -> {
                    conf.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
                })
          			//添加我们用于处理JWT的过滤器到Security过滤器链中,注意要放在UsernamePasswordAuthenticationFilter之前
                .addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
                .build();
    }

  	public void onAuthenticationSuccess(HttpServletRequest request,
                                               HttpServletResponse response,
                                               Authentication authentication) throws IOException, ServletException {
        // 设置响应的Content-Type为JSON格式
        response.setContentType("application/json;charset=utf-8");
        response.setStatus(200);
        // 判断请求的URL,分别处理登录和退出成功的逻辑
        if (request.getRequestURI().endsWith("/doLogin")){
            log.info("用户:"+authentication.getName()+"登录成功");
            // 登录成功时,返回JSON格式的Jwt令牌响应
            response.getWriter().write(JSON.toJSONString(RestBean.success(JwtUtils.createJwt((UserBo) authentication.getPrincipal()))));
            //一定要及时刷新刷新缓冲区,否则前端收不到响应
            response.getWriter().flush();
        } else if(request.getRequestURI().endsWith("/doLogout")){
            log.info("用户:"+authentication.getName()+"退出登录");
            // 退出登录成功时,返回JSON格式的响应
            response.getWriter().write(JSON.toJSONString(RestBean.success("退出登录成功")));
            //一定要及时刷新刷新缓冲区,否则前端收不到响应
            response.getWriter().flush();
        }
    }
}

编写上述代码后,就可以启动项目运行接口测试了

image-20240413154355075

登录成功之后,可以看到现在返回给我们了一个JWT令牌,接着我们就可以使用这个令牌了。比如现在我们要访问某个接口获取数据,那么就可以携带这个令牌进行访问:

image-20240413161448518

若请求头不带这个令牌则会拦截到登录页:

image-20240413161614741

修改前端操作

在前面的配置中,我们设置了登录成功后返回一个jwt令牌,现在我们需要在前端在登录成功后将这个令牌保存到sessionStorage

function doLogin() {
        const username = document.getElementById('username').value;
        const password = document.getElementById('password').value;
        const remember = document.getElementById('remember').value;
        $.ajax({
            url: "/api/auth/doLogin",
            type: "POST",
            data: {
                username: username,
                password: password,
                remember: remember
            },
            success: function (response) {
                alert(response.message);
                //将得到的JWT令牌存到sessionStorage用于本次会话
                sessionStorage.setItem("access_token",response.message)
                window.location.href = "/api/page/toIndex";
            },
            error: function (xhr,status, error) {
                alert(xhr.responseJSON.message);
                location.reload();
            }
        });

    }

这样以后前端每次请求都只需要在请求头中携带这个令牌就可以通过认证

...
headers: {
       "Authorization": "Bearer " + sessionStorage.getItem("access_token")
}
...    

这样我们就实现了基于SpringSecurity的JWT校验,整个流程还是非常清晰的。

退出登录JWT处理

虽然我们使用JWT已经很方便了,但是有一个很严重的问题就是,我们没办法像Session那样去踢用户下线,什么意思呢?我们之前可以使用退出登录接口直接退出,用户Session中的验证信息也会被销毁,但是现在是无状态的,用户来管理Token令牌,服务端只认Token是否合法,那这个时候该怎么让用户正确退出登录呢?

首先我们从最简单的方案开始,我们可以直接让客户端删除自己的JWT令牌,这样不就相当于退出登录了吗,这样甚至不需要请求服务器,直接就退了:

这里我们以黑名单机制为例,让用户退出登录之后,无法再次使用之前的JWT进行操作,首先我们需要给JWT额外添加一个用于判断的唯一标识符,这里就用UUID好了:

public class JwtUtils {
    private static final String key = "abcdefghijklmn";

    public static String createJwt(UserBo user){
        // 创建一个指定算法的哈希对象,此处使用HMAC256算法,传入的key用于签名过程
        Algorithm algorithm = Algorithm.HMAC256(key);
        // 获取当前时间的Calendar实例
        Calendar calendar = Calendar.getInstance();
        // 获取当前时间
        Date now = calendar.getTime();
        // 将日历时间增加7天(3600秒/小时 * 24小时/天 * 7天),用于设定签名校验的有效期
        calendar.add(Calendar.SECOND, 3600 * 24 * 7);

        return JWT.create()
                //额外添加一个UUID用于记录黑名单,将其作为JWT的ID属性jti
                .withJWTId(UUID.randomUUID().toString())
                .withClaim("name", user.getUsername())  //配置JWT自定义信息
                .withClaim("authorities", user.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList())
                .withExpiresAt(calendar.getTime())  //设置过期时间
                .withIssuedAt(now)    //设置创建创建时间
                .sign(algorithm);   //最终签名
    }
  
		...
}

这样我们发出去的所有令牌都会携带一个UUID作为唯一凭据,接着我们可以创建一个专属的表用于存储黑名单:

public class JwtUtils {	
  
  private static final HashSet<String> blackList = new HashSet<>();
  //加入黑名单方法
  public static boolean invalidate(String token){
        Algorithm algorithm = Algorithm.HMAC256(key);
        JWTVerifier jwtVerifier = JWT.require(algorithm).build();
        try {
            DecodedJWT verify = jwtVerifier.verify(token);
            Map<String, Claim> claims = verify.getClaims();
          	//取出UUID丢进黑名单中
            return blackList.add(verify.getId());
        } catch (JWTVerificationException e) {
            return false;
        }
  }
  
  ...
  
	public static UserDetails resolveJwt(String token){
        Algorithm algorithm = Algorithm.HMAC256(key);
        JWTVerifier jwtVerifier = JWT.require(algorithm).build();
        try {
            DecodedJWT verify = jwtVerifier.verify(token);
            //判断是否存在于黑名单中,如果存在,则返回null表示失效
            if(blackList.contains(verify.getId()))
                return null;
            Map<String, Claim> claims = verify.getClaims();
            if(new Date().after(claims.get("exp").asDate()))
                return null;
            return User
                    .withUsername(claims.get("name").asString())
                    .password("")
                    .authorities(claims.get("authorities").asArray(String.class))
                    .build();
        } catch (JWTVerificationException e) {
            return null;
        }
    }
}

接着我们来SecurityConfiguration中配置一下退出登录操作:

public void onAuthenticationSuccess(HttpServletRequest request,
                                               HttpServletResponse response,
                                               Authentication authentication) throws IOException, ServletException {
    	...
        else if(request.getRequestURI().endsWith("/doLogout")){
            log.info("用户:"+authentication.getName()+"退出登录");
            // 退出登录成功时,返回JSON格式的响应
            String authorization = request.getHeader("Authorization");
            if(authorization != null && authorization.startsWith("Bearer ")) {
                String token = authorization.substring(7);
                //将Token加入黑名单
                if(JwtUtils.invalidate(token)) {
                    //只有成功加入黑名单才会退出成功
                    response.getWriter().write(JSON.toJSONString(RestBean.success("退出登录成功")));
                    //一定要及时刷新刷新缓冲区,否则前端收不到响应
                    response.getWriter().flush();
                    return;
                }
            }
            response.getWriter().write(JSON.toJSONString(RestBean.failure(400, "退出登录失败")));
            //一定要及时刷新刷新缓冲区,否则前端收不到响应
            response.getWriter().flush();
        }
    }

这样,我们就成功安排上了黑名单机制,即使用户提前保存,这个Token依然是失效的。
r.verify(token);
Map<String, Claim> claims = verify.getClaims();
//取出UUID丢进黑名单中
return blackList.add(verify.getId());
} catch (JWTVerificationException e) {
return false;
}
}

public static UserDetails resolveJwt(String token){
    Algorithm algorithm = Algorithm.HMAC256(key);
    JWTVerifier jwtVerifier = JWT.require(algorithm).build();
    try {
        DecodedJWT verify = jwtVerifier.verify(token);
        //判断是否存在于黑名单中,如果存在,则返回null表示失效
        if(blackList.contains(verify.getId()))
            return null;
        Map<String, Claim> claims = verify.getClaims();
        if(new Date().after(claims.get("exp").asDate()))
            return null;
        return User
                .withUsername(claims.get("name").asString())
                .password("")
                .authorities(claims.get("authorities").asArray(String.class))
                .build();
    } catch (JWTVerificationException e) {
        return null;
    }
}

}


接着我们来SecurityConfiguration中配置一下退出登录操作:

```java
public void onAuthenticationSuccess(HttpServletRequest request,
                                               HttpServletResponse response,
                                               Authentication authentication) throws IOException, ServletException {
    	...
        else if(request.getRequestURI().endsWith("/doLogout")){
            log.info("用户:"+authentication.getName()+"退出登录");
            // 退出登录成功时,返回JSON格式的响应
            String authorization = request.getHeader("Authorization");
            if(authorization != null && authorization.startsWith("Bearer ")) {
                String token = authorization.substring(7);
                //将Token加入黑名单
                if(JwtUtils.invalidate(token)) {
                    //只有成功加入黑名单才会退出成功
                    response.getWriter().write(JSON.toJSONString(RestBean.success("退出登录成功")));
                    //一定要及时刷新刷新缓冲区,否则前端收不到响应
                    response.getWriter().flush();
                    return;
                }
            }
            response.getWriter().write(JSON.toJSONString(RestBean.failure(400, "退出登录失败")));
            //一定要及时刷新刷新缓冲区,否则前端收不到响应
            response.getWriter().flush();
        }
    }

这样,我们就成功安排上了黑名单机制,即使用户提前保存,这个Token依然是失效的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值