SpringSecurity

一、快速入门

  • Spring Security 是一个提供身份验证(authentication)、授权(authorization)和抵御常见攻击(protection against common attacks)的框架
    • authentication:认证,谁可以访问系统
    • authorization:授权,谁可以访问系统中的哪些资源
    • 抵御攻击:如CSRF
  • 官方文档:https://docs.spring.io/spring-security/reference/6.5/index.html

1、初体验

  • 创建Maven项目security1

  • 引入依赖

    <?xml version="1.0" encoding="UTF-8"?>
    <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.0</modelVersion>
    
        <groupId>cn.freyfang</groupId>
        <artifactId>security1</artifactId>
        <version>1.0-SNAPSHOT</version>
    
        <properties>
            <maven.compiler.source>24</maven.compiler.source>
            <maven.compiler.target>24</maven.compiler.target>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </properties>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>3.5.9</version>
        </parent>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
    
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
            </dependency>
    
            <!-- spring-boot-starter-security 3.5.9使用的spring-security版本是6.5.7 -->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-security</artifactId>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    </project>
    
  • 创建测试接口IndexController

    package cn.freyfang.controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @Controller
    public class IndexController {
        @GetMapping("/hello")
        @ResponseBody
        public String hello(){
            return "hello world";
        }
    }
    
  • 创建启动类

    package cn.freyfang;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    
    @SpringBootApplication
    public class Main {
        public static void main(String[] args) {
            SpringApplication.run(Main.class, args);
        }
    }
    
  • 启动应用,会生成默认用户名user和默认随机密码(打印在了控制台)

  • 测试

    • Postman访问http://localhost:8080/hello,返回状态码401 Unauthorized和响应头WWW-Authenticate: Basic realm="xxx" ,即需要进行 Basic 认证
      • Postman使用Http Basic认证方式,在请求头中携带用户名和密码信息,成功响应hello world
      • Http Basic:Postman将用户名和密码组合成 username:password 的格式,然后使用 Base64 编码后放入请求头的 Authorization 字段中
    • 浏览器访问http://localhost:8080/hello,会重定向到默认生成的登录页面http://localhost:8080/login
      • 输入用户名密码登录,成功响应hello world
  • 创建配置文件application.yml,指定用户名和密码

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

2、默认行为

  • 访问任何端点都需要经过身份验证
  • 在启动时生成一个默认用户及随机密码
  • 采用BCrypt等算法对密码存储进行保护
  • 生成默认的登录和注销页面
  • 提供基于表单的登录和注销流程
  • 对基于表单的登录以及HTTP Basic进行验证
  • 提供内容协商功能:对于web请求,会重定向到登录页面;对于服务请求,则返回401未授权
  • 处理跨站请求伪造(CSRF)攻击
  • 处理会话劫持攻击
  • 写入Strict-Transport-Security以确保HTTPS
  • 写入X-Content-Type-Options以处理嗅探攻击
  • 写入Cache Control头来保护经过身份验证的资源
  • 写入X-Frame-Options以处理点击劫持攻击
  • 发布认证成功和失败事件

3、自动配置

  • spring-boot-autoconfigure-3.5.9.jar!\META-INF\spring\org.springframework.boot.autoconfigure.AutoConfiguration.imports文件中配置了SecurityAutoConfiguration自动配置类

    @AutoConfiguration(
        before = {UserDetailsServiceAutoConfiguration.class} //
    )
    @ConditionalOnClass({DefaultAuthenticationEventPublisher.class})
    @EnableConfigurationProperties({SecurityProperties.class}) // 
    @Import({SpringBootWebSecurityConfiguration.class, SecurityDataConfiguration.class}) //
    public class SecurityAutoConfiguration {
        @Bean
        @ConditionalOnMissingBean({AuthenticationEventPublisher.class})
        public DefaultAuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher publisher) {
            return new DefaultAuthenticationEventPublisher(publisher);
        }
    }
    
  • SpringBootWebSecurityConfiguration注入了默认的SecurityFilterChain

    @Bean
    @Order(2147483642)
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests((requests) -> ((AuthorizeHttpRequestsConfigurer.AuthorizedUrl)requests.anyRequest()).authenticated());
        http.formLogin(Customizer.withDefaults()); // 开启表单认证
        /* HTTP Basic 认证
                是一种简单的认证方式,客户端通过在请求头中携带 Authorization: Basic base64(username:password) 来完成认证。
                适用于前后端分离或 API 接口的简单认证场景
        */
        http.httpBasic(Customizer.withDefaults()); // 开启`HTTP Basic`认证
        return (SecurityFilterChain)http.build();
    }
    
  • SecurityProperties中指定了默认用户名和默认密码

    @ConfigurationProperties("spring.security")
    public class SecurityProperties {
        public static final int BASIC_AUTH_ORDER = 2147483642;
        public static final int DEFAULT_FILTER_ORDER = -100;
        private final Filter filter = new Filter();
        private final User user = new User();
        // ...
        public static class Filter {
            // ...
        }
    
        public static class User {
            private String name = "user";
            private String password = UUID.randomUUID().toString();
            private List<String> roles = new ArrayList();
            private boolean passwordGenerated = true;
            // ...
        }
    }
    
  • UserDetailsServiceAutoConfiguration注入默认的UserDetailsService,即基于内存的InMemoryUserDetailsManager

    @AutoConfiguration
    @ConditionalOnClass(AuthenticationManager.class)
    @Conditional(MissingAlternativeOrUserPropertiesConfigured.class)
    @ConditionalOnBean(ObjectPostProcessor.class)
    @ConditionalOnMissingBean(value = { AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class,
    		AuthenticationManagerResolver.class }, type = "org.springframework.security.oauth2.jwt.JwtDecoder") // 当容器中不存在以上这些类的实例时,才会注入默认的基于内存的 inMemoryUserDetailsManager
    @ConditionalOnWebApplication(type = Type.SERVLET)
    public class UserDetailsServiceAutoConfiguration {
    
    	private static final String NOOP_PASSWORD_PREFIX = "{noop}";
    
    	private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\\{.+}.*$");
    
    	private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);
    
    	@Bean
    	public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,
    			ObjectProvider<PasswordEncoder> passwordEncoder) {
    		SecurityProperties.User user = properties.getUser();
    		List<String> roles = user.getRoles();
    		return new InMemoryUserDetailsManager(User.withUsername(user.getName())
    			.password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))
    			.roles(StringUtils.toStringArray(roles))
    			.build());
    	}
    
    	private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {
    		String password = user.getPassword();
    		if (user.isPasswordGenerated()) {
    			logger.warn(String.format(
    					"%n%nUsing generated security password: %s%n%nThis generated password is for development use only. "
    							+ "Your security configuration must be updated before running your application in "
    							+ "production.%n",
    					user.getPassword()));
    		}
    		if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {
    			return password;
    		}
    		return NOOP_PASSWORD_PREFIX + password; // 密码前使用`{noop}`,即使用 NoOpPasswordEncoder 比对明文密码
    	}
    	// ...
    }
    

4、核心组件

  • DelegatingFilterProxy

    • 可以将 Servlet 容器中的 Filter 实例放在 Spring 容器中进行管理,方便按需加载
  • FilterChainProxy

    • 负责管理 SecurityFilterChain, 通过 SecurityFilterChain 将请求转发给多个过滤器实例
  • SecurityFilterChain

    • 根据当前请求来确定应调用哪些过滤器实例
  • DefaultSecurityFilterChain

    • 是 SecurityFilterChain 的实现类,程序启动后,查看默认加载了以下16个过滤器

      DisableEncodeUrlFilter
      WebAsyncManagerIntegrationFilter
      SecurityContextHolderFilter
      HeaderWriterFilter
      CsrfFilter 处理Csrf
      LogoutFilter 注销
      UsernamePasswordAuthenticationFilter 认证
      DefaultResourcesFilter 生成默认css样式
      DefaultLoginPageGeneratingFilter 生成默认登录页
      DefaultLogoutPageGeneratingFilter 生成默认登出页
      BasicAuthenticationFilter 处理Http Basic请求认证
      RequestCacheAwareFilter
      SecurityContextHolderAwareRequestFilter
      AnonymousAuthenticationFilter
      ExceptionTranslationFilter 异常处理
      AuthorizationFilter 授权
      
  • UserDetailsService

    • 认证过程中会通过UserDetails loadUserByUsername(String username)方法从内存或DB中获取用户信息
    • 实现类InMemoryUserDetailsManager用来管理基于内存的用户信息
    • 实际开发中需要自定义实现类,实现从DB用户表中获取用户信息
  • UserDetails

    • 从内存或DB中获取的用户信息会封装成UserDetails对象
    • 实现类org.springframework.security.core.userdetails.User
  • PasswordEncoder

    • 实现类BCryptPasswordEncoder是官方推荐的密码器
  • SecurityContextHolder

    • 安全上下文管理器,用于存储已认证用户信息的地方
    • 默认使用 ThreadLocal 来存储用户信息,所以同一线程中的方法始终可以访问到 SecurityContext
  • SecurityContext

    • 安全上下文接口,包含当前已认证用户的信息
  • Authentication

    • 认证用户信息接口,常用实现类UsernamePasswordAuthenticationToken
    • 两个作用
      • 认证前,用于接收用户提交的用户名密码,并传给身份认证管理器 AuthenticationManager 进行认证
      • 认证后,用于封装已认证用户的信息,并保存到 SecurityContext
    • 包含的信息
      • principal:用户主体,通常是 UserDetails 实例
      • credentials:通常指密码,在用户认证成功后一般会被清除,防止泄露
      • authorities:是 GrantedAuthority 的实例,表示用户被授予的权限
  • AuthenticationManager

    • 认证管理器接口,定义了执行身份认证的API
    • 实现类ProviderManager
  • AuthenticationProvider

    • 认证提供者接口,由 ProviderManager 负责调用,执行特定类型的认证操作
    • 实现类DaoAuthenticationProvider支持基于用户密码的认证
  • AuthorizationManager

    • 授权管理器接口

二、身份认证

1、基于内存的认证

  • 创建配置类SecurityConfig,注入InMemoryUserDetailsManager

    package cn.freyfang.config;
    
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.provisioning.InMemoryUserDetailsManager;
    
    @Configuration
    public class SecurityConfig {
        /**
         * 向容器中注入 UserDetailsService 实例
         */
        @Bean
        public UserDetailsService userDetailsService() {
            // 会对密码进行加密
            User.UserBuilder users = User.withDefaultPasswordEncoder();
            UserDetails user = users
                    .username("user")
                    .password("111111")
                    .roles("USER")
                    .build();
            UserDetails admin = users
                    .username("admin")
                    .password("222222")
                    .roles("USER", "ADMIN")
                    .build();
            // 向内存中存入两个用户
            return new InMemoryUserDetailsManager(user, admin);
        }
    }
    
  • 使用useradmin登录

2、认证流程

  • 用户输入账号密码,点击登录

  • 如果请求地址是/login且请求方式是Post,则被UsernamePasswordAuthenticationFilterdoFilter()方法处理

    • 将用户输入的账号密码封装成 UsernamePasswordAuthenticationToken对象
    • 调用认证管理器的实现类ProviderManagerAuthentication authenticate(Authentication authentication)方法认证
    • 如果认证成功,将Authentication用户信息存入session和SecurityContext,并调用AuthenticationSuccessHandler
    • 如果认证失败,清空SecurityContext中,并调用AuthenticationFailureHandler
  • ProviderManager 实际调用DaoAuthenticationProvider 进行认证

    • 调用UserDetailsService某个实现类的UserDetails loadUserByUsername(String username)方法从内存或DB中加载用户信息
    • 调用additionalAuthenticationChecks(userDetails, (UsernamePasswordAuthenticationToken) authentication)方法校验密码,通过PasswordEncoder比较加载出来的用户密码和用户提交的密码是否一致
    • 如果认证成功,将用户名密码和加载出来的权限信息封装为UsernamePasswordAuthenticationToken对象

3、基于DB的认证

  • 创建数据库security及用户表user

  • 引入依赖

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
        <version>3.5.11</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.23</version>
    </dependency>
    
  • 配置数据源

    spring:
      datasource:
        driver-class-name: com.mysql.cj.jdbc.Driver
        url: jdbc:mysql://localhost:3306/security?useUnicode=true&characterEncoding=utf-8
        username: root
        password: 123456
    
    mybatis-plus:
      configuration:
        log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
        
    logging:
      level:
        org.springframework.security: DEBUG
    
  • 创建实体类SysUser、UserMapper、UserService

    @Data
    @TableName("user")
    public class SysUser {
        private Long id;
        private String username;
        private String password;
    }
    
    @Mapper
    public interface UserMapper extends BaseMapper<SysUser> {
    }
    
    /**
     * 模拟数据库操作
     */
    @Service
    public class UserService {
        public Set<String> selectPermissions(SysUser user) {
            // 如果是角色,需要添加`ROLE_`前缀,这是约定,用于区分角色和权限
            return Set.of("index:hello", "ROLE_admin");
        }
    }
    
  • 修改配置类,向容器中注册密码器

    // 指定密码编码器
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 参数strength: 工作因子,默认值是10,最小值是4,最大值是31,值越大运算速度越慢,从而提高破解的门槛
        return new BCryptPasswordEncoder();
    }
    
  • 自定义UserDetailsService的实现类UserDetailsServiceImpl,并将实例注入到容器中

    package cn.freyfang.service;
    
    import cn.freyfang.mapper.UserMapper;
    import cn.freyfang.model.SysUser;
    import com.baomidou.mybatisplus.core.toolkit.Wrappers;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.userdetails.User;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.stereotype.Service;
    
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.Set;
    
    @Slf4j
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        @Autowired
        private UserMapper userMapper;
        @Autowired
        private UserService userService;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 从数据库中加载用户信息
            SysUser user = userMapper.selectOne(Wrappers.lambdaQuery(SysUser.class).eq(SysUser::getUsername, username));
            if (user == null) {
                throw new UsernameNotFoundException("user '" + username + "' not found");
            }
    
            // 从数据库中加载权限信息
            Set<String> permissions = userService.selectPermissions(user);
            Collection<GrantedAuthority> authorities = new ArrayList<>();
            for (String permission : permissions) {
                authorities.add(() -> permission);
            }
    
            // 封装成 UserDetails
            return new User(user.getUsername(), user.getPassword(),
                    true, // 是否启用
                    true, // 用户账号是否过期
                    true, // 用户凭证是否过期
                    true, // 用户是否未被锁定
                    authorities);// 权限列表
        }
    }
    
  • 创建测试类TestUser,新增测试用户

    package cn.freyfang;
    
    import cn.freyfang.mapper.UserMapper;
    import cn.freyfang.model.SysUser;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    @SpringBootTest
    public class TestUser {
    
        @Autowired
        private UserMapper userMapper;
    
        @Autowired
        private PasswordEncoder passwordEncoder;
    
        @Test
        public void addUser() {
            SysUser user = new SysUser();
            user.setUsername("admin");
            user.setPassword(passwordEncoder.encode("123456"));
            System.out.println("user = " + user);
            int insert = userMapper.insert(user);
            System.out.println("insert = " + insert);
        }
    }
    
  • 启动应用,访问http://localhost:8080/hello,输入数据库中的用户名和密码进行认证

4、常用配置

  • 自定义登录页,开启后会自动禁用以下过滤器:DefaultResourcesFilter、DefaultLoginPageGeneratingFilter、DefaultLogoutPageGeneratingFilter、BasicAuthenticationFilter
  • 自定义登录请求参数名
  • 自定义登录成功/失败处理器、自定义退出成功处理器
  • 获取用户信息
  • 会话并发管理,开启后会自动启用以下过滤器:ConcurrentSessionFilter、SessionManagementFilter

测试

  • 引入依赖

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    
  • 创建登录页面src/main/resources/templates/login.html

    <!DOCTYPE html>
    <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
    <head>
        <title>Please Log In</title>
    </head>
    <body>
    <h1>Please Log In</h1>
    <div th:if="${param.error}">
        Invalid username and password.
    </div>
    <div th:if="${param.logout}">
        You have been logged out.
    </div>
    <form th:action="@{/login}" method="post">
        <div>
            <input type="text" name="name" placeholder="Username"/>
        </div>
        <div>
            <input type="password" name="pwd" placeholder="Password"/>
        </div>
        <input type="submit" value="Log in"/>
    </form>
    </body>
    </html>
    
  • 创建首页src/main/resources/templates/index.html

    <!DOCTYPE html>
    <html lang="en" xmlns:th="https://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <h1>首页</h1>
    <div th:text="'欢迎:'+${username}"></div>
    <form th:action="@{/logout}" method="post">
        <input type="submit" value="退出"></input>
    </form>
    <a th:href="@{/hello}">hello</a>
    </body>
    </html>
    
  • 修改IndexController,新增方法

    package cn.freyfang.controller;
    
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    @Controller
    public class IndexController {
        @GetMapping("/hello")
        @ResponseBody
        public String hello() {
            return "hello world";
        }
    
        // 跳转到登录页面
        @GetMapping("/to-login")
        public String toLogin() {
            return "login";
        }
    
        // 用于 用户登录成功/失败 后转发,因为登录请求是post,所以转发的请求方式还是post
        @RequestMapping("/")
        public String index(Model model) {
            // 获取已认证用户信息
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            String username = authentication.getName();
            model.addAttribute("username", username);
            return "index";
        }
    
        @PostMapping("/login-fail")
        @ResponseBody
        public String loginFail() {
            return "loginFail";
        }
    }
    
  • 修改配置类,自定义SecurityFilterChain实例

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(requests ->
                                   requests
                                   .requestMatchers("/to-login", "/login-fail").permitAll() // 允许匿名访问
                                   .anyRequest().authenticated() // 对所有请求开启认证保护
                                  );
        // 认证
        http.formLogin(form ->
                       form
                       .loginPage("/to-login") // 自定义登录页面
                       .usernameParameter("name") // 自定义登录用户名参数,默认是username
                       .passwordParameter("pwd") // 自定义登录密码参数,默认是password
                       .loginProcessingUrl("/login") // 登录处理url,默认是/login
                       // 指定登录成功处理器
                       // 方式1:通过 ForwardAuthenticationSuccessHandler 实现登录成功后转发到指定地址
                       .successForwardUrl("/")
                       // 方式2:自定义登录成功处理器,实现 AuthenticationSuccessHandler 接口
                       //                                .successHandler((request, response, authentication) -> {
                       //                                    System.out.println("=====================successHandler() 登录成功");
                       //                                })
                       // 指定登录失败处理器
                       // 方式1:通过 ForwardAuthenticationFailureHandler 实现登录失败后转发到指定地址
                       .failureForwardUrl("/login-fail")
                       // 方式2:自定义登录失败处理器,实现 AuthenticationFailureHandler 接口
                       //                                .failureHandler((request, response, exception) -> {
                       //                                    String localizedMessage = exception.getLocalizedMessage();
                       //                                    System.out.println("=====================failureHandler() 登录失败 " + localizedMessage);
                       //                                })
                      );
    
        // 注销
        http.logout(logout ->
                    logout.logoutUrl("/logout") // 注销url
                    // 指定登出处理器
                    // 方式1:使用 SimpleUrlLogoutSuccessHandler 实现注销成功后重定向到指定页面
                    .logoutSuccessUrl("/to-login")
                    // 方式2:自定义注销成功处理器,实现 LogoutSuccessHandler 接口
                    //                                .logoutSuccessHandler((request, response, authentication) -> {
                    //                                    System.out.println("=====================logoutSuccessHandler() 注销成功");
                    //                                })
                   );
    
        // 会话管理
        http.sessionManagement(session -> {
            session.maximumSessions(1) // 限制最大会话数,设置每个用户最多只能有一个活跃会话,后登录的账号会使先登录的账号失效
                // 处理会话过期:当会话因并发登录被踢出时,自定义响应
                .expiredSessionStrategy(event -> {
                    System.out.println("=====================会话并发");
                    String result = new ObjectMapper().writeValueAsString(Map.of("code", 500, "msg", "该账号已从其他设备登录"));
                    HttpServletResponse response = event.getResponse();
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(result);
                });
        });
    
        return http.build();
    }
    
  • 访问http://localhost:8080/,会重定向到/to-login,响应自定义登录页面

  • 输入错误的用户名或密码,会转发到/login-fail,响应loginFail

  • 输入正确的用户名或密码,会转发到/,响应自定义首页

  • 使用另一个浏览器登录相同的账号,然后当前浏览器访问/hello,响应{"msg":"该账号已从其他设备登录","code":500}

5、记住我

  • 两种实现方式

    • 使用TokenBasedRememberMeServices,即基于cookie保存token。如果Cookie被窃取,攻击者可以在有效期内伪造身份。后台无法主动使令牌失效(除非修改密码或等待过期)
    • 使用PersistentTokenBasedRememberMeServices(推荐),即使用数据库持久化token。服务端维护令牌的状态,支持主动撤销令牌或删除记录。每次成功验证后,旧令牌会被替换为新令牌,防止令牌被重复使用

1)步骤

  • 创建表,用于持久化token

    create table persistent_logins (username varchar(64) not null,
                                    series varchar(64) primary key,
                                    token varchar(64) not null,
                                    last_used timestamp not null)
    
  • 登录页面增加复选框

    <div>
        记住我:<input type="checkbox" name="remember"/>
    </div>
    
  • 修改配置类,注入多个组件

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http , RememberMeServices rememberMeServices) throws Exception {
        // ......
    
        // 记住我
        http.rememberMe(rememberMe ->
                        rememberMe
                        .rememberMeServices(rememberMeServices)
                        //注意:结合rememberMeServices使用时,以下参数不会生效,因为rememberMeServices会覆盖该参数
                        //                        .tokenValiditySeconds(60 * 60 * 24 * 20)
                        //                        .rememberMeParameter("abc")
                       );
        return http.build();
    }
    
    @Autowired
    private UserDetailsService userDetailsService; 
    @Autowired
    private DataSource dataSource;
    
    @Bean
    public RememberMeServices rememberMeServices(PersistentTokenRepository tokenRepository) {
        PersistentTokenBasedRememberMeServices services = new PersistentTokenBasedRememberMeServices("123456", userDetailsService, tokenRepository);
        services.setTokenValiditySeconds(60 * 60 * 24 * 7); // 默认14天
        services.setParameter("remember"); // 记住我参数,默认是`remember-me`
        return services;
    }
    
    @Bean
    public PersistentTokenRepository persistentTokenRepository() { // 用于持久化操作
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        jdbcTokenRepository.setDataSource(dataSource);
        return jdbcTokenRepository;
    }
    
  • 访问登录页面,勾选记住我复选框,登录成功后,会自动向cookies和persistent_logins表中写入token

  • 关闭浏览器后重新打开访问,会自动进行认证,无需再输入用户名密码

2)流程

  • 设置rememberMe配置后会启用RememberMeAuthenticationFilter过滤器拦截请求
  • 如果 SecurityContextHolder 中没有已认证用户信息,则执行自动登录
    • 从请求头携带的cookie中解析remember-me,并解码出series和token
    • 然后根据series从数据库中查找并返回PersistentRememberMeToken。如果找不到则异常
    • 比较cookie中的token和数据库中的token是否一致,如果不一致则异常,可能cookie已被窃取
    • 判断token是否过期:数据库中的last_used + token有效时间,如果小于当前时间则异常
    • 生成新的token,更新到cookie和数据库
    • 使用UserDetailsService 查询用户信息和权限,封装成RememberMeAuthenticationToken
  • 通过 ProviderManager 认证管理器走认证流程

三、授权

  • 基于角色或权限进行访问控制
  • 常用方法
    • hasAuthority(String authority) 表示访问资源需要authority权限
    • hasAnyAuthority(String... authorities) 需要authorities中的任一权限
    • hasRole(String role) 需要role角色
    • hasAnyRole(String... roles) 需要roles中的任一角色

1、步骤

1)创建资源

  • 修改IndexController,新增3个方法

    // 测试授权的三个方法
    @GetMapping("/hey")
    @ResponseBody
    public String hey() {
        return "hey";
    }
    
    @GetMapping("/hi")
    @ResponseBody
    public String hi() {
        return "hi";
    }
    
    @GetMapping("/ok")
    @ResponseBody
    public String ok() {
        return "ok";
    }
    

2)为资源设置所需权限

  • 方式1:基于请求地址。修改配置类

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(requests ->
                                   requests
                                   .requestMatchers("/to-login", "/login-fail").permitAll() // 允许匿名访问
                                   
                                   // 为资源配置被访问时所需的权限:方式1
                                   //具有 index:hey 权限的用户可以访问 /hello
                                   .requestMatchers("/hey").hasAuthority("index:hey")
                                   //具有 index:hi 权限的用户可以访问 /hi
                                   .requestMatchers("/hi").hasAuthority("index:hi")
                                   //具有 ROLE_admin 角色的用户可以访问 /ok
                                   .requestMatchers("/ok").hasRole("admin") //  hasRole() 会自动添加ROLE_ 前缀
    
                                   .anyRequest().authenticated() // 对所有请求开启认证保护
                                  );
        //......
        return http.build();
    }
    
  • 方式2:基于Controller方法

    • 在配置类上使用@EnableMethodSecurity开启方法授权

    • 修改IndexController,添加鉴权注解。同时删除方式1的权限配置

      // 测试授权的三个方法
      @PreAuthorize("hasAuthority('index:hey')")
      @GetMapping("/hey")
      @ResponseBody
      public String hey() {
          return "hey";
      }
      
      @PreAuthorize("hasAuthority('index:hi')")
      @GetMapping("/hi")
      @ResponseBody
      public String hi() {
          return "hi";
      }
      
      @PreAuthorize("hasRole('admin')")
      @GetMapping("/ok")
      @ResponseBody
      public String ok() {
          return "ok";
      }
      
    • 常用注解

      • @PreAuthorize 适合进入方法前的权限验证
      • @PostAuthorize 在方法执行后再进行权限验证,适合验证带有返回值的权限
      • @PreFilter 进入方法之前对数据进行过滤
      • @PostFilter 权限验证之后对数据进行过滤

3)为用户授权

  • 用户登录时,从数据库中查询权限信息保存到Authentication对象中。修改UserService,假如用户具有"index:hey"和"ROLE_admin"权限

    @Service
    public class UserService {
        public Set<String> selectPermissions(SysUser user) {
            // 如果是角色,需要添加`ROLE_`前缀,这是约定,用于区分角色和权限
            return Set.of("index:hey", "ROLE_admin");
        }
    }
    

4)请求未授权的接口

  • 访问/hey/ok正常响应
  • 访问/hi,响应默认403 Forbidden页面

2、自定义异常处理

  • 修改配置类的SecurityFilterChain实例

    // 统一处理认证和授权失败
    http.exceptionHandling(exception ->
                           exception
                           // 处理未认证情况:自定义 AuthenticationEntryPoint 覆盖默认处理
                           //                        .authenticationEntryPoint((request, response, authenticationException) -> {
                           //                            System.out.println("=====================未认证");
                           //                            // 可以返回json或重定向到页面
                           //                        })
                           // 处理未授权情况:当用户访问无访问权限的资源时
                           // 方式1:通过 AccessDeniedHandlerImpl 实现重定向到指定页面
                           .accessDeniedPage("/unauthorized")
                           // 方式2:自定义 AccessDeniedHandler 处理器
                           //                        .accessDeniedHandler((request, response, accessDeniedException) -> {
                           //                            System.out.println("=====================未授权");
                           //                            accessDeniedException.printStackTrace();
                           //                        })
                          );
    
  • 修改Controller,新增方法

    // 响应未授权
    @GetMapping("/unauthorized")
    @ResponseBody
    public String unauthorized() {
        return "未授权";
    }
    
  • 访问/hi,响应未授权

3、授权流程

  • 授权过滤器 AuthorizationFilter 负责拦截用户请求,先调用授权管理器 RequestMatcherDelegatingAuthorizationManager 处理,它将不同请求委托给特定的授权管理器
    • 如果访问无需认证的资源(如/to-login),则由 SingleResultAuthorizationManager 处理,总是允许
    • 如果访问只需认证就能访问的资源(如//hello),则由 AuthenticatedAuthorizationManager 处理,判断当前用户是否已经过认证
    • 如果访问需要授权才能访问的资源(如/hey ),则由 AuthorityAuthorizationManager 处理,检查当前已认证身份信息中是否包含所需的权限
  • 如果使用基于方法的授权,即开启了@EnableMethodSecurity,则会import一个方法拦截器 AuthorizationManagerBeforeMethodInterceptor
    • 先由 AuthorizationFilter 调用 AuthenticatedAuthorizationManager,判断当前用户是否已经过认证
    • 再由 AuthorizationManagerBeforeMethodInterceptor 调用 PreAuthorizeAuthorizationManager,检查当前已认证身份信息中是否包含目标方法上@PreAuthorize注解中要求的权限

四、前后端分离

1、JWT

  • JWT(JSON Web Token)是一种开放标准,用于在各方之间安全地传输信息。它主要用于身份验证和信息交换
  • 默认情况下JWT是未加密的,任何人都可以解读其内容,因此不要构建隐私信息字段
  • JWT由三部分组成,用点(.)分隔
    • Header(头部):包含令牌类型和签名算法,使用Base64Url编码
    • Payload(载荷):包含声明信息(用户信息、权限等),使用Base64Url编码
    • Signature(签名):用于验证令牌完整性,使用加密签名算法
      • HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload),secret)
  • 工作原理:用户登录 → 服务器生成JWT → 客户端存储 → 后续请求携带JWT → 服务器验证
  • JWT特别适合RESTful API和微服务架构的身份认证场景

2、搭建基础环境

  • 创建Maven项目security2

  • 引入依赖

    <?xml version="1.0" encoding="UTF-8"?>
    <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.0</modelVersion>
    
        <groupId>cn.freyfang</groupId>
        <artifactId>security2</artifactId>
        <version>1.0-SNAPSHOT</version>
    
        <properties>
            <maven.compiler.source>24</maven.compiler.source>
            <maven.compiler.target>24</maven.compiler.target>
            <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        </properties>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>3.5.9</version>
        </parent>
    
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-web</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-security</artifactId>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
            </dependency>
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </dependency>
    
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>0.9.1</version>
            </dependency>
            <!-- 解决jwt报错java.lang.ClassNotFoundException: javax.xml.bind.DatatypeConverter -->
            <dependency>
                <groupId>javax.xml.bind</groupId>
                <artifactId>jaxb-api</artifactId>
                <version>2.3.1</version>
            </dependency>
            <dependency>
                <groupId>com.alibaba.fastjson2</groupId>
                <artifactId>fastjson2</artifactId>
                <version>2.0.60</version>
            </dependency>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-data-redis</artifactId>
            </dependency>
        </dependencies>
    
        <build>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                </plugin>
            </plugins>
        </build>
    </project>
    
  • 定义常量类Constants

    package cn.freyfang.contstant;
    
    import io.jsonwebtoken.Claims;
    
    public class Constants {
        public static final String UTF8 = "UTF-8";
        public static final String TOKEN = "token";
        public static final String TOKEN_PREFIX = "Bearer ";
        public static final String LOGIN_USER_KEY = "login_user_key";
        public static final String LOGIN_TOKEN_KEY = "login_tokens:";
        public static final String JWT_USERNAME = Claims.SUBJECT;
        // 自动识别json对象白名单配置(仅允许解析的包名,范围越小越安全)
        public static final String[] JSON_WHITELIST_STR = {"cn.freyfang"};
    }
    
  • 创建配置类RedisConfig

    package cn.freyfang.config;
    
    import cn.freyfang.contstant.Constants;
    import com.alibaba.fastjson2.JSON;
    import com.alibaba.fastjson2.JSONReader;
    import com.alibaba.fastjson2.JSONWriter;
    import com.alibaba.fastjson2.filter.Filter;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.data.redis.serializer.RedisSerializer;
    import org.springframework.data.redis.serializer.SerializationException;
    import org.springframework.data.redis.serializer.StringRedisSerializer;
    
    import java.nio.charset.Charset;
    
    @Configuration
    public class RedisConfig {
        @Bean
        public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
            RedisTemplate<Object, Object> template = new RedisTemplate<>();
            template.setConnectionFactory(connectionFactory);
    
            FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class);
    
            // 使用StringRedisSerializer来序列化和反序列化redis的key值
            template.setKeySerializer(new StringRedisSerializer());
            template.setValueSerializer(serializer);
    
            // Hash的key也采用StringRedisSerializer的序列化方式
            template.setHashKeySerializer(new StringRedisSerializer());
            template.setHashValueSerializer(serializer);
    
            template.afterPropertiesSet();
            return template;
        }
    
        /**
         * Redis使用FastJson序列化
         */
        private class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> {
            public static final Charset DEFAULT_CHARSET = Charset.forName(Constants.UTF8);
    
            static final Filter AUTO_TYPE_FILTER = JSONReader.autoTypeFilter(Constants.JSON_WHITELIST_STR);
    
            private Class<T> clazz;
    
            public FastJson2JsonRedisSerializer(Class<T> clazz) {
                super();
                this.clazz = clazz;
            }
    
            @Override
            public byte[] serialize(T t) throws SerializationException {
                if (t == null) {
                    return new byte[0];
                }
                return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET);
            }
    
            @Override
            public T deserialize(byte[] bytes) throws SerializationException {
                if (bytes == null || bytes.length <= 0) {
                    return null;
                }
                String str = new String(bytes, DEFAULT_CHARSET);
    
                return JSON.parseObject(str, clazz, AUTO_TYPE_FILTER);
            }
        }
    }
    
  • 创建工具类ResponseUtil

    package cn.freyfang.util;
    
    import cn.freyfang.contstant.Constants;
    import jakarta.servlet.http.HttpServletResponse;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.MediaType;
    
    import java.io.IOException;
    
    public class ResponseUtil {
        public static void out(HttpServletResponse response, String string) {
            response.setStatus(HttpStatus.OK.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding(Constants.UTF8);
            try {
                response.getWriter().print(string);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
    }
    
  • 定义统一响应类R

    package cn.freyfang.model;
    
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class R {
        private Integer code;
        private String msg;
        private Object data;
    
        public static R ok() {
            return ok("操作成功");
        }
    
        public static R ok(String msg) {
            return ok(msg, null);
        }
    
        public static R ok(Object data) {
            return ok("操作成功", data);
        }
    
        public static R ok(String msg, Object data) {
            return new R(200, msg, data);
        }
    
        public static R error() {
            return error("操作失败");
        }
    
        public static R error(String msg) {
            return error(msg, null);
        }
    
        public static R error(String msg, Object data) {
            return new R(500, msg, data);
        }
    
        public static R error(Integer code, String msg) {
            return new R(code, msg, null);
        }
    }
    
  • 创建全局异常处理器GlobalExceptionHandler

    package cn.freyfang.exception;
    
    import cn.freyfang.model.R;
    import jakarta.servlet.http.HttpServletRequest;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;
    
    @Slf4j
    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(RuntimeException.class)
        public R handleRuntimeException(RuntimeException e, HttpServletRequest request) {
            String requestURI = request.getRequestURI();
            log.error("请求地址'{}',发生未知异常.", requestURI, e);
            return R.error(e.getMessage());
        }
    
        @ExceptionHandler(Exception.class)
        public R handleException(Exception e, HttpServletRequest request) {
            String requestURI = request.getRequestURI();
            log.error("请求地址'{}',发生系统异常.", requestURI, e);
            return R.error(e.getMessage());
        }
    }
    
  • 创建实体类SysUser映射数据库用户表

    package cn.freyfang.model;
    
    import com.fasterxml.jackson.annotation.JsonProperty;
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import java.io.Serializable;
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class SysUser implements Serializable {
        private static final long serialVersionUID = 1L;
    
        private Long userId;
        private String username;
        @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
        private String password;
    }
    
  • 创建UserService模拟数据库操作

    package cn.freyfang.service;
    
    import cn.freyfang.model.SysUser;
    import org.springframework.stereotype.Service;
    import java.util.Set;
    
    /**
     * 模拟操作数据库
     */
    @Service
    public class UserService {
        /**
         * 根据用户名查询用户信息
         */
        public SysUser getUserByName(String username) {
            // 先将密码改为密文:passwordEncoder.encode("123456")
            return new SysUser(1L, "admin", "$2a$10$79iMOUW67OL6s1SaAsYgp.67.ivR34LT6h80uJNUvxEnnrgKhqVJq");
        }
    
        /**
         * 获取用户权限
         */
        public Set<String> getPermissionsByUser(SysUser user) {
            return Set.of("index:hello", "ROLE_admin");
        }
    
        /**
         * 获取用户角色
         */
        public Set<String> getRolesByUser(SysUser user) {
            return Set.of("ROLE_admin");
        }
    }
    

3、创建核心配置

  • 创建UserDetails实现类LoginUser

    package cn.freyfang.model;
    
    import com.alibaba.fastjson2.annotation.JSONField;
    import lombok.Data;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.util.StringUtils;
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.Set;
    
    @Data
    public class LoginUser implements UserDetails {
        private static final long serialVersionUID = 1L;
    
        private Long userId; // 用户ID
        private Set<String> permissions; // 权限列表
        private SysUser user; // 用户信息
        private String token;
        private Long loginTime; // 登录时间
        private Long expireTime; // 过期时间
    
        public LoginUser(Long userId, SysUser user, Set<String> permissions) {
            this.userId = userId;
            this.user = user;
            this.permissions = permissions;
        }
    
        @JSONField(serialize = false)
        @Override
        public String getPassword() {
            return user.getPassword();
        }
    
        @Override
        public String getUsername() {
            return user.getUsername();
        }
    
        /**
         * 账户是否未过期,过期无法验证
         */
        @JSONField(serialize = false)
        @Override
        public boolean isAccountNonExpired() {
            return true;
        }
    
        /**
         * 指定用户是否解锁,锁定的用户无法进行身份验证
         */
        @JSONField(serialize = false)
        @Override
        public boolean isAccountNonLocked() {
            return true;
        }
    
        /**
         * 指示是否已过期的用户的凭据(密码),过期的凭据防止认证
         */
        @JSONField(serialize = false)
        @Override
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        /**
         * 是否可用 ,禁用的用户不能身份验证
         */
        @JSONField(serialize = false)
        @Override
        public boolean isEnabled() {
            return true;
        }
    
        @JSONField(serialize = false)
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            Collection<GrantedAuthority> authorities = new ArrayList<>();
            for (String permission : permissions) {
                if (StringUtils.hasLength(permission)) {
                    SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
                    authorities.add(authority);
                }
            }
            return authorities;
        }
    }
    
  • 创建UserDetailsService实现类UserDetailsServiceImpl

    package cn.freyfang.service;
    
    import cn.freyfang.model.LoginUser;
    import cn.freyfang.model.SysUser;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UsernameNotFoundException;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.stereotype.Service;
    import java.util.Set;
    
    @Slf4j
    @Service
    public class UserDetailsServiceImpl implements UserDetailsService {
    
        @Autowired
        private PasswordEncoder passwordEncoder;
    
        @Autowired
        private UserService userService;
    
        @Override
        public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
            // 查询用户信息
            SysUser user = userService.getUserByName(username);
            if (user == null) {
                log.info("登录用户:{} 不存在.", username);
                throw new RuntimeException("用户名或密码错误");
            }
    
            // 查询权限信息
            Set<String> permissions = userService.getPermissionsByUser(user);
            return new LoginUser(user.getUserId(), user, permissions);
        }
    }
    
  • 创建TokenUtil

    package cn.freyfang.util;
    
    import cn.freyfang.contstant.Constants;
    import cn.freyfang.model.LoginUser;
    import io.jsonwebtoken.Claims;
    import io.jsonwebtoken.Jwts;
    import io.jsonwebtoken.SignatureAlgorithm;
    import io.micrometer.common.util.StringUtils;
    import jakarta.servlet.http.HttpServletRequest;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Component;
    import java.util.HashMap;
    import java.util.Map;
    import java.util.UUID;
    import java.util.concurrent.TimeUnit;
    
    @Slf4j
    @Component
    public class TokenUtil {
        // 令牌自定义标识
        @Value("${token.header:Authorization}")
        private String header;
    
        // 令牌秘钥
        @Value("${token.secret:123456}")
        private String secret;
    
        // 令牌有效期(默认30分钟)
        @Value("${token.expireTime:30}")
        private int expireTime;
    
        private static final Long MILLIS_MINUTE_TWENTY = 20 * 60 * 1000L;
    
        @Autowired
        public RedisTemplate redisTemplate;
    
        /**
         * 获取用户身份信息
         */
        public LoginUser getLoginUser(HttpServletRequest request) {
            // 1、从请求头中获取token
            String token = request.getHeader(header);
            if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX)) {
                token = token.replace(Constants.TOKEN_PREFIX, "");
            }
    
            // 2、从token中解析key,并根据key从redis中获取用户信息
            if (StringUtils.isNotEmpty(token)) {
                try {
                    Claims claims = Jwts.parser()
                            .setSigningKey(secret)
                            .parseClaimsJws(token)
                            .getBody();
    
                    String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
    //                String username = claims.getSubject();
                    String userKey = getTokenKey(uuid);
                    LoginUser user = (LoginUser) redisTemplate.opsForValue().get(userKey);
                    return user;
                } catch (Exception e) {
                    log.error("获取用户信息异常'{}'", e.getMessage());
                }
            }
            return null;
        }
    
        /**
         * 删除用户身份信息
         */
        public void delLoginUser(String token) {
            if (StringUtils.isNotEmpty(token)) {
                String userKey = getTokenKey(token);
                redisTemplate.delete(userKey);
            }
        }
    
        /**
         * 创建令牌
         */
        public String createToken(LoginUser loginUser) {
            String token = UUID.randomUUID().toString();
            loginUser.setToken(token);
            refreshToken(loginUser);
    
            Map<String, Object> claims = new HashMap<>();
            claims.put(Constants.LOGIN_USER_KEY, token);
            claims.put(Constants.JWT_USERNAME, loginUser.getUsername());
            return Jwts.builder()
                    .setClaims(claims)
                    .signWith(SignatureAlgorithm.HS512, secret).compact();
        }
    
        /**
         * 验证令牌有效期,相差不足20分钟,自动刷新缓存
         */
        public void verifyToken(LoginUser loginUser) {
            long expireTime = loginUser.getExpireTime();
            long currentTime = System.currentTimeMillis();
            if (expireTime - currentTime <= MILLIS_MINUTE_TWENTY) {
                refreshToken(loginUser);
            }
        }
    
        /**
         * 刷新令牌有效期
         */
        public void refreshToken(LoginUser loginUser) {
            loginUser.setLoginTime(System.currentTimeMillis());
            loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * 60 * 1000L);
            // 根据uuid将loginUser缓存到redis
            String userKey = getTokenKey(loginUser.getToken());
            redisTemplate.opsForValue().set(userKey, loginUser, expireTime, TimeUnit.MINUTES);
        }
    
        private String getTokenKey(String uuid) {
            return Constants.LOGIN_TOKEN_KEY + uuid;
        }
    }
    
  • 创建JwtAuthenticationTokenFilter

    package cn.freyfang.filter;
    
    import cn.freyfang.model.LoginUser;
    import cn.freyfang.util.TokenUtil;
    import jakarta.servlet.FilterChain;
    import jakarta.servlet.ServletException;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletResponse;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
    import org.springframework.stereotype.Component;
    import org.springframework.web.filter.OncePerRequestFilter;
    import java.io.IOException;
    
    @Component
    public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
        @Autowired
        private TokenUtil tokenUtil;
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
                throws ServletException, IOException
        {
            // 1、从请求头中获取token,并从redis中获取用户信息
            LoginUser loginUser = tokenUtil.getLoginUser(request);
            if (loginUser != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                // 2、续期token
                tokenUtil.verifyToken(loginUser);
                // 3、将用户信息放入上下文中
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
            chain.doFilter(request, response);
        }
    }
    
  • 创建IndexController

    package cn.freyfang.controller;
    
    import org.springframework.security.access.prepost.PreAuthorize;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class IndexController {
        @GetMapping("/")
        public String index() {
            return "欢迎";
        }
    
        @PreAuthorize("hasAuthority('index:hello')")
        @GetMapping("/hello")
        public String hello() {
            return "hello";
        }
    
        @PreAuthorize("hasAuthority('index:hi')")
        @GetMapping("/hi")
        public String hi() {
            return "hi";
        }
    
        @PreAuthorize("hasRole('admin')")
        @GetMapping("/ok")
        public String ok() {
            return "ok";
        }
    }
    
  • 创建UserController

    package cn.freyfang.controller;
    
    import cn.freyfang.contstant.Constants;
    import cn.freyfang.model.LoginUser;
    import cn.freyfang.model.R;
    import cn.freyfang.model.SysUser;
    import cn.freyfang.service.UserService;
    import cn.freyfang.util.TokenUtil;
    import jakarta.annotation.Resource;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.security.authentication.AuthenticationManager;
    import org.springframework.security.authentication.BadCredentialsException;
    import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
    import org.springframework.security.core.Authentication;
    import org.springframework.security.core.context.SecurityContextHolder;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RestController;
    import java.util.Map;
    import java.util.Set;
    
    @RestController
    public class UserController {
    
        @Resource
        private AuthenticationManager authenticationManager;
    
        @Autowired
        private TokenUtil tokenUtil;
    
        @Autowired
        private UserService userService;
    
        /**
         * 登录方法
         */
        @PostMapping("/login")
        public R login(@RequestBody SysUser loginBody) {
            String username = loginBody.getUsername();
            String password = loginBody.getPassword();
            // 用户验证
            Authentication authentication = null;
            try {
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
                // 该方法会去调用 UserDetailsServiceImpl.loadUserByUsername
                authentication = authenticationManager.authenticate(authenticationToken);
            } catch (Exception e) {
                if (e instanceof BadCredentialsException) {
                    throw new RuntimeException("用户名或密码错误");
                } else {
                    throw new RuntimeException(e.getMessage());
                }
            }
    
            LoginUser loginUser = (LoginUser) authentication.getPrincipal();
            // 生成token 并写入redis
            String token = tokenUtil.createToken(loginUser);
    
            return R.ok(Map.of(Constants.TOKEN, token));
        }
    
        /**
         * 获取用户信息
         */
        @GetMapping("getInfo")
        public R getInfo() {
            LoginUser loginUser = (LoginUser) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            SysUser user = loginUser.getUser();
    
            // 从数据库中获取用户的最新角色和权限数据,并更新redis
            Set<String> roles = userService.getRolesByUser(user);
            Set<String> permissions = userService.getPermissionsByUser(user);
            if (!loginUser.getPermissions().equals(permissions)) {
                loginUser.setPermissions(permissions);
                tokenUtil.refreshToken(loginUser);
            }
    
            return R.ok(Map.of("user", user, "roles", roles, "permissions", permissions));
        }
    }
    
  • 创建配置类SecurityConfig

    package cn.freyfang.config;
    
    import cn.freyfang.filter.JwtAuthenticationTokenFilter;
    import cn.freyfang.model.LoginUser;
    import cn.freyfang.model.R;
    import cn.freyfang.util.ResponseUtil;
    import cn.freyfang.util.TokenUtil;
    import com.alibaba.fastjson2.JSON;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.http.HttpMethod;
    import org.springframework.security.authentication.AuthenticationManager;
    import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
    import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.config.http.SessionCreationPolicy;
    import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
    import org.springframework.security.crypto.password.PasswordEncoder;
    import org.springframework.security.web.SecurityFilterChain;
    import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
    
    @Configuration
    @EnableMethodSecurity
    public class SecurityConfig {
    
        @Autowired
        private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
    
        @Autowired
        private TokenUtil tokenUtil;
    
        // 指定密码加密器
        @Bean
        public PasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    
        // 注入认证管理器,在UserController登录请求中使用
        @Bean
        public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
            return authenticationConfiguration.getAuthenticationManager();
        }
    
        @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
            // 关闭csrf,因为不使用 session
            http.csrf(csrf -> csrf.disable());
    
            // 认证
            http.authorizeHttpRequests(request ->
                    request
                            .requestMatchers("/login").permitAll() // 登录接口无需认证
                            .requestMatchers(HttpMethod.GET, "/", "/*.html", "/**.html", "/**.css", "/**.js").permitAll()  // 静态资源无需认证
                            .anyRequest().authenticated() // 除上面外的所有请求全部需要鉴权认证
            );
    
            // 异常
            http.exceptionHandling(exception ->
                    exception
                            // 未认证处理类
                            .authenticationEntryPoint(
                                    (request, response, authException) -> {
                                        String msg = String.format("请求访问:%s,认证失败,无法访问系统资源", request.getRequestURI());
                                        ResponseUtil.out(response, JSON.toJSONString(R.error(401, msg)));
                                    }
                            )
                            // 未授权处理类
                            .accessDeniedHandler((request, response, accessDeniedException) -> {
                                String msg = String.format("请求访问:%s,授权失败,无法访问系统资源", request.getRequestURI());
                                ResponseUtil.out(response, JSON.toJSONString(R.error(403, msg)));
                            })
            );
    
            // 注销
            http.logout(logout ->
                    logout.logoutUrl("/logout")
                            // 退出成功处理类
                            .logoutSuccessHandler((request, response, authentication) -> {
                                LoginUser loginUser = tokenUtil.getLoginUser(request);
                                if (loginUser != null) {
                                    // 删除redis缓存记录
                                    tokenUtil.delLoginUser(loginUser.getToken());
                                }
                                ResponseUtil.out(response, JSON.toJSONString(R.ok("退出成功")));
                            })
            );
    
            // 添加JWT filter
            http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    
            return http.build();
        }
    }
    

4、测试

  • 启动应用,查看 DefaultSecurityFilterChain 发现共启用了11个过滤器

    DisableEncodeUrlFilter
    WebAsyncManagerIntegrationFilter
    SecurityContextHolderFilter
    HeaderWriterFilter
    LogoutFilter
    JwtAuthenticationTokenFilter
    RequestCacheAwareFilter
    SecurityContextHolderAwareRequestFilter
    AnonymousAuthenticationFilter
    ExceptionTranslationFilter
    AuthorizationFilter
    
    • 禁用了csrf相关过滤器:CsrfFilter
    • 没有启用formLogin相关过滤器:UsernamePasswordAuthenticationFilter、DefaultResourcesFilter、DefaultLoginPageGeneratingFilter、DefaultLogoutPageGeneratingFilter
    • 没有启用httpBasic相关过滤器:BasicAuthenticationFilter
    • 注册了自定义过滤器:JwtAuthenticationTokenFilter
  • 使用Postman等工具测试

  • 请求/login(该接口无需认证即可访问),携带请求体参数{"username":"admin","password":"123456"},正确返回JWT

    • 先使用 AuthenticationManager 进行认证
    • 认证成功生成JWT,并将用户信息写入redis。注意:JWT中保存了redis中的key
  • 以下请求都要携带请求头Authorization=Bearer ${token}

  • 请求/getInfo(该接口只需认证成功),正确响应用户信息

    • JwtAuthenticationTokenFilter 先进行处理:从header中解析JWT,然后从readis中取出用户信息,并放入 SecurityContextHolder
    • AuthorizationFilter 再进行处理:如果 SecurityContextHolder 中有已认证的用户信息,则通过
    • AuthorizationManagerBeforeMethodInterceptor 再判断已认证的用户信息中是否具有访问目标方法的权限
  • 请求/hello(该接口需要授权,且用户有权限):正确响应

  • 请求/hi(该接口需要授权,且用户无权限):抛出 AuthorizationDeniedException

五、OAuth2

  • OAuth 2.0是一种开放授权协议,核心目标是让第三方应用“安全地获取”用户在某个服务商(如微信、GitHub、Google)上的有限权限,而无需用户将账号密码直接告诉第三方应用。主要用于社交登录

  • 相关角色

    • 资源所有者(Resource Owner):指用户
    • 客户应用(Client):这里指我们自己创建的应用,即第三方应用
    • 资源服务器(Resource Server):某个服务商,如GitHub
    • 授权服务器(Authorization Server):某个服务商,如GitHub
  • 四种模式

    • 授权码(authorization-code):安全,且支持刷新令牌
    • 隐藏式(implicit):适合纯前端应用,授权服务器直接将令牌通过URL片段返回给浏览器。极不安全,且不支持刷新令牌,过期后需要重新授权
    • 密码式(password):适合高度信任的应用间授权。用户将自己的账号密码直接交给客户应用,客户应用再去授权服务器换令牌
    • 客户端凭证(client credentials):适合服务间授权。该模式与用户无关,是客户应用需要访问自己创建的资源

1、授权码模式

  • 最常用、最安全、功能最完整的模式,也是 OAuth 2.0 官方推荐的流程

  • 通过一个临时的、一次性的“授权码”来交换最终的访问令牌,从而避免了令牌在浏览器中暴露的风险

  • 流程步骤

    • 客户应用如需开通某个社交登录功能,需要先在服务商网站上创建应用并获得 client_id 和 client_secret
    • 用户访问客户应用的登录页面,选择社交登录方式(GitHub/QQ等)
    • 客户应用重定向到授权服务器的授权端点,并携带自己的 client_id、请求的权限范围 scope、一个随机生成的 state(用于防止 CSRF 攻击)和一个回调地址 redirect_uri
    • 用户登录与同意:用户在授权服务器的页面上登录,并确认是否同意授予客户应用所请求的权限
    • 发放授权码:如果用户同意,授权服务器会将用户重定向回在之前提供的 redirect_uri,并在 URL 参数中附上一个授权码
    • 交换访问令牌:客户应用的后端服务拿着这个授权码,加上自己的 client_id、client_secret(只有自己和授权服务器知道),向授权服务器的令牌端点发起请求,换取访问令牌和可选的刷新令牌
    • 使用访问令牌:客户应用的后端服务拿到访问令牌后,就可以用它来调用资源服务器的 API,获取用户数据了

2、GitHub社交登录

  • Spring Security 提供了对 OAuth2 客户端的原生支持,可以非常便捷地集成 GitHub 登录功能。其核心原理是:Spring Security 作为 OAuth2 客户端,而 GitHub 作为 OAuth2 授权服务器和资源服务器

  • 登录GitHub,在Settings-> Developer Settings-> OAuth Apps页面创建应用,设置回调地址http://localhost:8080/login/oauth2/code/github,获取Client ID、Client secrets

  • 创建Maven项目security3

  • 引入依赖

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- 客户应用 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
    
  • CommonOAuth2Provider中预定义了一些服务商的属性

    package org.springframework.security.config.oauth2.client;
    
    import org.springframework.security.oauth2.client.registration.ClientRegistration;
    import org.springframework.security.oauth2.core.AuthorizationGrantType;
    import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
    
    public enum CommonOAuth2Provider {
        // GOOGLE 
        // FACEBOOK
        // OKTA
        GITHUB {
            public ClientRegistration.Builder getBuilder(String registrationId) {
                ClientRegistration.Builder builder = this.getBuilder(registrationId, ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "{baseUrl}/{action}/oauth2/code/{registrationId}");// 回调地址
                builder.scope(new String[]{"read:user"});
                builder.authorizationUri("https://github.com/login/oauth/authorize");// 授权地址
                builder.tokenUri("https://github.com/login/oauth/access_token"); // 获取 access_token 地址
                builder.userInfoUri("https://api.github.com/user"); // 获取用户信息地址
                builder.userNameAttributeName("id"); // 将id作为用户名
                builder.clientName("GitHub");
                return builder;
            }
        }
    }
    
  • 创建配置文件

    spring:
      security:
        oauth2:
          client:
            registration:
              github: 
                client-id: xxx
                client-secret: xxx
    
  • 创建IndexController接收返回信息

    @RestController
    public class IndexController {
    
        @GetMapping("/")
        public Map<String,Object> index(
                @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient,
                @AuthenticationPrincipal OAuth2User oauth2User) {
    
            String principalName = authorizedClient.getPrincipalName();
            System.out.println("principalName = " + principalName);
            ClientRegistration clientRegistration = authorizedClient.getClientRegistration();
            System.out.println("clientRegistration.getClientName() = " + clientRegistration.getClientName());
            System.out.println("clientRegistration.getRedirectUri() = " + clientRegistration.getRedirectUri());
    
            System.out.println("oauth2User.getName() = " + oauth2User.getName());
            System.out.println("oauth2User.getAttributes() = " + oauth2User.getAttributes());
            System.out.println("oauth2User.getAuthorities() = " + oauth2User.getAuthorities());
    
            return Map.of("OAuth2AuthorizedClient", authorizedClient,
                    "OAuth2User", oauth2User);
        }
    }
    
  • 启动应用,访问自动生成的登录页面http://localhost:8080/login,点击页面上的GitHub链接http://localhost:8080/oauth2/authorization/github

  • 该接口会拼接参数让浏览器重定向到GitHub授权页面https://github.com/login/oauth/authorize?response_type=code&client_id=xxx&scope=read:user&state=3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%3D&redirect_uri=http://localhost:8080/login/oauth2/code/github

  • GitHub检测当前用户未登录,重定向到GitHub登录页面https://github.com/login?client_id=&return_to=%2Flogin%2Foauth%2Fauthorize%3Fclient_id%3Dxxx%26redirect_uri%3Dhttp%253A%252F%252Flocalhost%253A8080%252Flogin%252Foauth2%252Fcode%252Fgithub%26response_type%3Dcode%26scope%3Dread%253Auser%26state%3D3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%253D,输入GitHub账号密码并登录

  • 浏览器重定向到授权页面:https://github.com/login/oauth/authorize?client_id=xxx&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Flogin%2Foauth2%2Fcode%2Fgithub&response_type=code&scope=read%3Auser&state=3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%3D,点击授权

  • GitHub携带授权码回调后端服务http://localhost:8080/login/oauth2/code/github?code=xxx&state=3BNHSDAD2i4zoqOjBQ8Bydps69wnXq6CU3gnqw5xum4%3D

  • 后端服务访问https://github.com/login/oauth/access_token获取access_token,携带参数{grant_type=[authorization_code], code=[xxx], redirect_uri=[http://localhost:8080/login/oauth2/code/github]}

  • 后端服务访问https://api.github.com/user获取用户信息,携带access_token

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值