SpringSecurity6(认证-前后端分离)

文章目录

1.环境

SpringBoot3.2.8、JDK17、SpringSecurity6.1.11、Redis6.0.8

认证涉及:

  1. 验证码过滤器及用户名密码认证
  2. 密码加密及密码自动升级更新
  3. 记住我
  4. 并发会话
  5. 会话存储
  6. csrf防护
  7. cros跨域(未配置,有兴趣自行实现)
  8. 统一异常处理

2.pom

xml 复制代码
<parent>
  <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.8</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.springframework.boot</groupId>-->
<!--            <artifactId>spring-boot-starter-thymeleaf</artifactId>-->
<!--        </dependency>-->

        <!--springboot3.0接入mybatisPlus的依赖-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.5</version>
        </dependency>
       
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.15</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.51</version>
        </dependency>
        
        <!--redis会话存储相关-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
            <version>3.2.4</version>
        </dependency>

    </dependencies>

3.application.yml

yml 复制代码
server:
  port: 8080
  servlet:
    session:
      timeout: 30

spring:
  security:
    user:
      name: root
      password: 123456
  thymeleaf:
    cache: false
    prefix: classpath:/templates/
    suffix: .html
  datasource:
    url: jdbc:mysql://192.168.159.100:3306/ssm?useUnicode=true&characterEncoding=utf-8&useSSL=true&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      # 最大连接池数量
      maximum-pool-size: 20
      # 最小空闲线程数量
      minimum-idle: 10
      # 配置获取连接等待超时的时间
      connectionTimeout: 30000
      # 校验超时时间
      validationTimeout: 5000
      # 空闲连接存活最大时间,默认10分钟
      idleTimeout: 600000
      # 此属性控制池中连接的最长生命周期,值0表示无限生命周期,默认30分钟
      maxLifetime: 1800000
      # 连接测试query(配置检测连接是否有效)
      connectionTestQuery: SELECT 1
    type: com.zaxxer.hikari.HikariDataSource
  data:
    redis:
      port: 6379
      host: 192.168.159.100
logging:
  level:
    org:
      springframework:
        security: TRACE

mybatis-plus:
  mapper-locations: classpath:com.linging.mapper/*Mapper.xml

4.SpringSecurity6主配置类

java 复制代码
@Configuration
@EnableWebSecurity // 高版本使用注解
@EnableRedisIndexedHttpSession  // 注入FindByIndexNameSessionRepository,实现redis持久化会话
public class SecurityConfig {

    @Resource
    private MyUserDetailService myUserDetailService;

    @Resource
    private DataSource dataSource;

    @Resource
    private FindByIndexNameSessionRepository<? extends Session> sessionRepository;


    public PasswordEncoder passwordEncoder(){
        // 该类的升级算法由springSecurity指定,无法直接修改,不过可以复制出来,自行修改
        //PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();

        String encodingId = "MD5"; //指定升级的加密算法
        Map<String, PasswordEncoder> encoders = new HashMap();
        encoders.put("bcrypt", new BCryptPasswordEncoder());
        encoders.put("ldap", new LdapShaPasswordEncoder());
        encoders.put("MD4", new Md4PasswordEncoder());
        encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
        encoders.put("noop", NoOpPasswordEncoder.getInstance());
        encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
        encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
        encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
        encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
        encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
        encoders.put("sha256", new StandardPasswordEncoder());
        encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
        encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
        // SpringSecurity默认的加密代理就是DelegatingPasswordEncoder
        return new DelegatingPasswordEncoder(encodingId, encoders);
    }

    /**
     * 全局配置AuthenticationManager
     * 定义AuthenticationManager,加入两种AuthenticationProvider
     */
    @Bean
    public AuthenticationManager authenticationManager() {
        // 保留原来账号密码登录的AuthenticationProvider
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(myUserDetailService); // 指定用户认证的服务
        daoAuthenticationProvider.setUserDetailsPasswordService(myUserDetailService); // 指定加密密码自动更新的服务

        //daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        //daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder);

        ProviderManager providerManager = new ProviderManager(daoAuthenticationProvider);
        providerManager.setAuthenticationEventPublisher(new DefaultAuthenticationEventPublisher());
        return providerManager;
    }

    // 这种加密方式,只能指定唯一的加密算法,无法自动升级
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder(){
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder;
    }

    // token持久化
    @Bean
    public PersistentTokenRepository myPersistentTokenRepository(){
        JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
        repository.setDataSource(dataSource);
        //repository.setCreateTableOnStartup(true); //启动创建表,mysql创建语句有问题,可以重写或者手动创建
        /**
         create table persistent_logins (
         username varchar(64) not null,
         series varchar(64) primary key,
         token varchar(64) not null,
         last_used timestamp NOT null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
         )
         */
        return repository;
    }

    // 记住我持久化实现逻辑
    @Bean
    public RememberMeServices rememberMeServices(){
        // myUserDetailService 记住我自动登录逻辑
        // new InMemoryTokenRepositoryImpl() 记住我持久化实现,内存
        return new PersistentTokenBasedRememberMeServices(
                UUID.randomUUID().toString(),
                myUserDetailService,   // 指定记住我时的认证的调用服务
                myPersistentTokenRepository()); // 记住我token持久化的实现
    }

    // 验证码+用户密码认证 过滤器
    @Bean
    public AuthCodeFilter authCodeFilter(){
        AuthCodeFilter authCodeFilter = new AuthCodeFilter();
        authCodeFilter.setFilterProcessesUrl("/doLogin");
        authCodeFilter.setAuthenticationManager(authenticationManager());
        authCodeFilter.setAuthenticationSuccessHandler(new MyAuthenticationSuccessHandler());
        authCodeFilter.setAuthenticationFailureHandler(new MyAuthenticationFailureHandler());

        // 记住我,需配置两个地方
        authCodeFilter.setRememberMeServices(rememberMeServices());

        // springSecurity6需要手动设置,不然默认使用RequestAttributeSecurityContextRepository
        authCodeFilter.setSecurityContextRepository(securityContextRepository());

         // 会话策略
         authCodeFilter.setSessionAuthenticationStrategy(compositeSessionAuthenticationStrategy());
        return authCodeFilter;
    }

    // 代理认证上下文,DelegatingSecurityContextRepository,认证后的用户信息存放在哪里
    @Bean
    public SecurityContextRepository securityContextRepository(){
        return new DelegatingSecurityContextRepository(
                new RequestAttributeSecurityContextRepository(),
                new HttpSessionSecurityContextRepository()
        );
    }

    // 返回会话策略代理
    @Bean
    public CompositeSessionAuthenticationStrategy compositeSessionAuthenticationStrategy(){
        List<SessionAuthenticationStrategy> strategies = new ArrayList<>();
        // 顺序:先 并发控制会话 再 注册会话,顺序不可变
        strategies.add(controlAuthenticationStrategy());
        strategies.add(changeSessionIdAuthenticationStrategy());
        strategies.add(registerSessionAuthenticationStrategy());

        // 多个会话策略构成,按顺序执行
        return new CompositeSessionAuthenticationStrategy(strategies);
    }

    // 并发会话策略
    @Bean
    public ConcurrentSessionControlAuthenticationStrategy controlAuthenticationStrategy(){
        ConcurrentSessionControlAuthenticationStrategy authenticationStrategy =
                new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
        authenticationStrategy.setMaximumSessions(1);
        // ExceptionIfMaximumExceeded 默认false,当超过最大session数时
        // true: 不允许新session, 保持旧session
        // false: 销毁旧session, 新session生效
        authenticationStrategy.setExceptionIfMaximumExceeded(false);
        return authenticationStrategy;
    }

    // 会话注册策略
    @Bean
    public RegisterSessionAuthenticationStrategy registerSessionAuthenticationStrategy(){
        return new RegisterSessionAuthenticationStrategy(sessionRegistry());
    }

    // 默认会话策略
    @Bean
    public ChangeSessionIdAuthenticationStrategy changeSessionIdAuthenticationStrategy(){
        return new ChangeSessionIdAuthenticationStrategy();
    }

    // 在线会话存储
    @Bean
    public SessionRegistry sessionRegistry(){
        // new SessionRegistryImpl(); 实现为内存,使用ConcurrentHashMap维护

        // 使用redis来维护session
        return new SpringSessionBackedSessionRegistry(sessionRepository);
    }


    // 会话事件发布,监听会话的创建、销毁、并更,发布对应事件
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }

    // 并发会话过滤器
    @Bean
    public ConcurrentSessionFilter concurrentSessionFilter(){
        return new ConcurrentSessionFilter(sessionRegistry(), strategy -> {
            HttpServletResponse response = strategy.getResponse();
            response.setContentType("application/json;charset=utf-8");
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
            response.getWriter().write("已在另一个地方登录");
        });
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(authz -> authz
                        //.requestMatchers("/index/**", "/login.html", "/error.html").permitAll()  // 放行的路径,不认证就可以访问
                        .anyRequest().authenticated() // 剩余的其他路径都要认证
                )
//                .formLogin(configurer -> configurer
//                        .loginProcessingUrl("/doLogin")  //登录接口
//                        .successHandler(new MyAuthenticationSuccessHandler()) // 登录成功,返回json
//                        .failureHandler(new MyAuthenticationFailureHandler()) //登录失败,返回json
//                )
                .addFilterAt(authCodeFilter(), UsernamePasswordAuthenticationFilter.class) // 替换过滤器
                .addFilterAt(concurrentSessionFilter(), ConcurrentSessionFilter.class)
                .exceptionHandling(ex -> {
                    ex.authenticationEntryPoint((request, response, authException) -> {
                        // 当未认证的用户尝试访问受保护资源时的处理逻辑
                        response.setContentType("application/json;charset=utf-8");
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                        response.getWriter().write("未认证");
                    }).accessDeniedHandler((request, response, authException) -> {
                        //当已认证的用户尝试访问其无权限访问的资源时,会触发访问拒绝处理器
                        response.setContentType("application/json;charset=utf-8");
                        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                        response.getWriter().write("权限不足");
                    });
                })
                //.authenticationManager(authenticationManager)  //本地配置
                //.userDetailsService(myUserDetailService)  //可以不注入,只要容器中有实例,security会自动注入
                .rememberMe(cus -> { //记住我,需配置两个地方
                    cus.rememberMeServices(rememberMeServices());
                })
                .securityContext(cus -> {
                    cus.securityContextRepository(securityContextRepository()).requireExplicitSave(true);
                })
                .httpBasic(withDefaults())
//                .csrf(cus -> {  // 开启csrf跨站脚本攻击防护
//                    cus.csrfTokenRepository(new CookieCsrfTokenRepository()) // csrftoken保存在cookie中
//                       .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()); // csrfToken设置请求处理器,默认为XorCsrfTokenRequestAttributeHandler,此为加密token,需要额外处理传递的csrftoken
//                });
                .csrf(AbstractHttpConfigurer::disable); //测试暂时关闭
        return http.build();
    }
}

5.其他配置类

5.1.MyUserDetailService

java 复制代码
/**
 * 自定义用户认证介质UserDetailsService
 * 自定义用户密码自动更新介质UserDetailsPasswordService
 */
@Component
public class MyUserDetailService implements UserDetailsService, UserDetailsPasswordService {

    @Resource
    private UserService userService;

    @Resource
    private RoleService roleService;

    /**
     * 加载用户名称
     * @param username
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userService.getOne(new QueryWrapper<User>().eq("username", username), false);
        if(user == null){
            throw new UsernameNotFoundException("用户名不正确");
        }
        MyUser myUser = new MyUser();
        BeanUtils.copyProperties(user, myUser);

        List<Role> roleList = roleService.getByUserId(user.getId());
        myUser.setRoles(roleList);
        return myUser;
    }


    /**
     * 当加密策略变更时,会更新数据库的密码为新密码
     * @param user
     * @param newPassword
     * @return
     */
    @Override
    public UserDetails updatePassword(UserDetails user, String newPassword) {
        String username = user.getUsername();
        boolean update = userService.update(new UpdateWrapper<User>()
                .set("password", newPassword)
                .eq("username", username));
        if(update){
            MyUser myUser = (MyUser) user;
            myUser.setPassword(newPassword);
        }
        return user;
    }
}

5.2.MyAuthenticationSuccessHandler

java 复制代码
/**
 * 自定义认证成功json返回
 */
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        Map<Object, Object> map = new HashMap<>();
        map.put("msg", "登录成功");
        map.put("code", 200);
        map.put("authentication", authentication);
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().write(new ObjectMapper().writeValueAsString(map));
    }
}

5.3.MyAuthenticationFailureHandler

java 复制代码
/**
 * 自定义认证失败json返回
 */
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        Map<Object, Object> map = new HashMap<>();
        map.put("msg", "出现错误");
        map.put("code", 500);
        map.put("exception", exception.getMessage());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(new ObjectMapper().writeValueAsString(map));
    }
}

5.4.VerifyCodeAuthenticationException

java 复制代码
/**
 * 验证码异常,集成AuthenticationException,否则无法被框架捕获
 */
public class VerifyCodeAuthenticationException extends AuthenticationException {
    public VerifyCodeAuthenticationException(String msg, Throwable cause) {
        super(msg, cause);
    }

    public VerifyCodeAuthenticationException(String msg) {
        super(msg);
    }
}

5.5.AuthCodeFilter

java 复制代码
/**
 * 验证码+用户名和密码过滤器
 */
@Component
public class AuthCodeFilter extends UsernamePasswordAuthenticationFilter {

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        String authCode = request.getParameter("authCode");
        if(authCode == null){
            throw new VerifyCodeAuthenticationException("验证码不能为空");
        }
        if(!authCode.equalsIgnoreCase(getAuthCode(obtainUsername(request)))){
            throw new VerifyCodeAuthenticationException("验证码错误");
        }
        return super.attemptAuthentication(request,response);
    }

    private String getAuthCode(String username) {
        return "123456";
    }
}

5.6.User

java 复制代码
@Data
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * id
     */
    @TableId(value = "id", type = IdType.AUTO)
    protected Integer id;

    /**
     * 账号
     */
    protected String username;

    /**
     * 密码
     */
    protected String password;

    /**
     * 是否启用
     */
    protected boolean enabled;

    /**
     * 账号是否过期
     */
    protected boolean accountNonExpired;

    /**
     * 账号是否锁定
     */
    protected boolean accountNonLocked;

    /**
     * 密码是否过期
     */
    protected boolean credentialsNonExpired;
}

5.7.MyUser

java 复制代码
@Data
public class MyUser extends User implements UserDetails {

    private List<Role> roles = new ArrayList<>();

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream().map(role ->
                new SimpleGrantedAuthority(role.getName())).collect(Collectors.toSet());
    }

    @Override
    public boolean isAccountNonExpired() {
        return super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return super.isEnabled();
    }

    @Override
    public boolean equals(Object o) {
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(super.username, user.getUsername());
    }

    @Override
    public int hashCode() {
        return Objects.hashCode(super.username);
    }
}

6.测试

登录接口,此处登录参数使用表单的方式,如要使用json传递参数,可对上面AuthCodeFilter extends UsernamePasswordAuthenticationFilter中将UsernamePasswordAuthenticationFilter中获取参数的方式进行重写,也是比较简单的,可自行实现。

持久化token表结构:

user表结构:

role表结构:

user_role表结构: