Spring Security 6 系列之十二 - 最后终章

之所以想写这一系列,是因为之前工作过程中使用Spring Security,但当时基于spring-boot 2.3.x,其默认的Spring Security是5.3.x。之后新项目升级到了spring-boot 3.3.0,结果一看Spring Security也升级为6.3.0,关键是其风格和内部一些关键Filter大改,导致在配置同样功能时,多费了些手脚,因此花费了些时间研究新版本的底层原理,这里将一些学习经验分享给大家。

注意由于框架不同版本改造会有些使用的不同,因此本次系列中使用基本框架是 spring-boo-3.3.0(默认引入的Spring Security是6.3.0),JDK版本使用的是19,所有代码都在spring-security-study项目上:https://github.com/forever1986/spring-security-study.git

前面我们对Spring Security 6 的内容从底层原理到基本配置都讲了一遍,可能还有许多功能没有讲述,但是大部分应用常用功能都讲到了。作为这个系列的终章,想以一个生产环境的方式来做一次总结。过程中会使用到前面章节的内容,如果有些忘记的朋友可以去回顾一下。

1 前提条件

本次演示的基本架构图如下:
在这里插入图片描述
1)通过Nacos统一管理服务的配置
2)通过Redis存储token,存储手机验证码
3)通过MySQL存储用户数据和权限数据

  • 启动一个Nacos,创建demo-biz三个命名空间,并创建demo-biz用户,分配demo-biz命名空间权限给demo-biz用户
  • 启动一个Redis,可以预存入一个key= phone:13788888888 value=8633 的string值,用于手机验证码
  • 启动一个MySQL,创建spring_security_study数据库,创建用户表和权限相关表。这里采用RBAC的权限控制方式。创建脚本如下:
-- spring_security_study.t_user 用户表
CREATE TABLE `t_user` (
  `id` bigint NOT NULL AUTO_INCREMENT,
 `username` varchar(100) NOT NULL,
 `password` varchar(100) NOT NULL,
 `email` varchar(100) DEFAULT NULL,
 `phone` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
 PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- spring_security_study.t_permission 权限码表
CREATE TABLE `t_permission` (
 `id` bigint NOT NULL AUTO_INCREMENT,
  `permission_code` varchar(200) NOT null,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- spring_security_study.t_role 角色表
CREATE TABLE `t_role` (
 `id` bigint NOT NULL AUTO_INCREMENT,
 `role_code` varchar(100) NOT null,
  `role_name` varchar(100) NOT null,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- spring_security_study.t_role_permission 角色与权限关联表
CREATE TABLE `t_role_permission` (
 `id` bigint NOT NULL AUTO_INCREMENT,
  `role_id` bigint NOT NULL,
  `permission_id` bigint NOT null,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- spring_security_study.t_user_role 用户与角色关联表
CREATE TABLE `t_user_role` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL,
  `role_id` bigint NOT null,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
INSERT INTO spring_security_study.t_user (id, username, password, email, phone) VALUES(4, 'test', '{noop}1234', 'test@demo.com', '13788888888');
INSERT INTO spring_security_study.t_user (id, username, password, email, phone) VALUES(5, 'admin', '{noop}1234', 'admin@demo.com', '13766666666');
INSERT INTO spring_security_study.t_permission (id, permission_code) VALUES(1, 'PRODUCT_LIST');
INSERT INTO spring_security_study.t_permission (id, permission_code) VALUES(2, 'PRODUCT_EDIT');
INSERT INTO spring_security_study.t_role (id, role_code, role_name) VALUES(1, 'ADMIN', '管理员');
INSERT INTO spring_security_study.t_role (id, role_code, role_name) VALUES(2, 'USER', '普通用户');
INSERT INTO spring_security_study.t_role_permission (id, role_id, permission_id) VALUES(1, 1, 1);
INSERT INTO spring_security_study.t_role_permission (id, role_id, permission_id) VALUES(2, 1, 2);
INSERT INTO spring_security_study.t_role_permission (id, role_id, permission_id) VALUES(3, 2, 1);
INSERT INTO spring_security_study.t_user_role (id, user_id, role_id) VALUES(1, 4, 2);
INSERT INTO spring_security_study.t_user_role (id, user_id, role_id) VALUES(2, 5, 1);

本次演示的内容有:

  • 通过数据库存储用户信息,也就是基于数据库的用户认证
  • 基于RBAC权限模型,存在数据库权限进行验证
  • 前后端分离,采用JWT认证(JWT采用非对称加密RSA),并且登录信息存储在Redis
  • 配置信息存储在Nacos上面
  • 包括:异常处理、手机验证码登录、白名单等其它功能

代码参考lesson12子模块

2 基础搭建

1)创建lesson12子模块,其pom引入如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <!--Spring Boot 提供的 Security 启动器 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
<dependencies>

2)创建controller包,其下设置DemoController

@RestController
public class DemoController {

    @GetMapping("/demo")
    public String demo() {
        return"demo";
    }

}

3)配置logback日志,在resources目录下新建logback-spring.xml

<?xml version="1.0" encoding="utf-8" ?>

<configuration>

    <!-- logback-spring加载早于application.yml,如果直接通过${参数key}的形式是无法获取到对应的参数值-->
    <!-- source指定的是application.yml配置文件中key,其它地方直接用${log.path}引用这个值 -->
    <!-- 解决在相对路径下生成log.path_IS_UNDEFINED的问题,增加defaultValue -->
    <springProperty scope="context" name="base.path" source="logging.file.path" defaultValue="${user.home}/logs"/>

    <!-- app.name根据你的应用名称修改 -->
    <springProperty scope="context" name="app.name" source="spring.application.name" defaultValue="applog"/>

    <property name="log.path" value="${base.path}/${app.name}"/>

    <!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度,%msg:日志消息,%n是换行符-->
    <property name="log.pattern"
              value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%property{app.name}] %X [%thread] %-5level %logger{36} -%msg%n"/>

    <!-- 控制台日志输出配置 -->
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <pattern>${log.pattern}</pattern>
        </encoder>
    </appender>

    <!-- 文件输出日志配置,按照每天生成日志文件 -->
    <appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- 日志文件输出的文件名称 -->
            <FileNamePattern>${log.path}-%d{yyyy-MM-dd}.%i.log</FileNamePattern>
            <!-- 日志保留天数 -->
            <MaxHistory>30</MaxHistory>
            <MaxFileSize>3MB</MaxFileSize>
            <TotalSizeCap>500MB</TotalSizeCap>
            <CleanHistoryOnStart>true</CleanHistoryOnStart>
        </rollingPolicy>

        <encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
            <!--格式化输出:%d表示日期,%thread表示线程名,%-5level:级别从左显示5个字符宽度%msg:日志消息,%n是换行符-->
            <pattern>${log.pattern}</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="stdout"/>
        <appender-ref ref="file"/>
    </root>

    <!-- mybatis日志配置 -->
    <logger name="java.sql.Connection" level="DEBUG"/>
    <logger name="java.sql.Statement" level="DEBUG"/>
    <logger name="java.sql.PreparedStatement" level="DEBUG"/>
    <logger name="org.mongodb.driver.connection" level="WARN"/>
    <logger name="org.springframework.data.mongodb.core.MongoTemplate" level="DEBUG"/>

</configuration>

4)在yaml文件配置日志

# 日志配置
logging:
  level:
    com.demo: debug

5)创建启动类

@SpringBootApplication
public class SecurityLesson12Application {

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

}

6)测试访问:http://127.0.0.1:8080/demo 需要登录后可访问

3 基于数据库用户认证

配置数据库,并配置自定义UserDetailsService,实现基于数据库的用户认证

1)在pom中引入mysql、mybatis、druid依赖

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
</dependency>

2)在entity包下,新建TUser类

@Data
public class TUser {

    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    private String username;

    private String password;

    private String email;

    private String phone;

}

3)在mapper包下,新建TUserMapper。

@Mapper
public interface TUserMapper {

    // 根据用户名,查询用户信息
    @Select("select * from t_user where username = #{username}")
    TUser selectByUsername(String username);

}

4)在service包下面,定义JdbcUserDetailsServiceImpl类,获取数据库用户

@Service
public class JdbcUserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private TUserMapper tUserMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询自己数据库的用户信息
        TUser user = tUserMapper.selectByUsername(username);
        if(user == null){
            throw new UsernameNotFoundException(username);
        }
        return new LoginUserDetails(user);
    }

}

5)配置yaml文件,增加数据库和mybatis-plus的配置

server:
  port: 8080
spring:
  # 配置数据源
  datasource:
  	type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/spring_security_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: root
    druid:
      initial-size: 5
      min-idle: 5
      maxActive: 20
      maxWait: 3000
      timeBetweenEvictionRunsMillis: 60000
      minEvictableIdleTimeMillis: 300000
      validationQuery: select 'x'
      testWhileIdle: true
      testOnBorrow: false
      testOnReturn: false
      poolPreparedStatements: false
      filters: stat,wall,slf4j
      connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200

mybatis-plus:
  global-config:
    banner: false
  mapper-locations: classpath:mappers/*.xml
  type-aliases-package: com.demo.lesson05.entity
  configuration:
    cache-enabled: false
    local-cache-scope: statement
 
 # 日志配置
logging:
  level:
    com.demo: debug

4 前后端分离+JWT

屏蔽原有登录页面和登录流程,采用自定义的前后端分离接口以及JWT认证,其中生成token采用非对称加密,因此先生成密钥对放到resources目录下
生成jks,在命令行模式下,执行下面语句
keytool -genkeypair -alias demo -keyalg RSA -keypass linmoo -storepass linmoo -keysize 2048 -keystore demo.jks
这时候会在目录下生成一个demo.jks文件,该文件包括私钥和公钥 。该文件拷贝到项目resources文件下面,供签名使用
在这里插入图片描述

1)引入Redis、jjwt、fastjson等依赖

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>javax.xml.bind</groupId>
    <artifactId>jaxb-api</artifactId>
</dependency>

2)在config包下,配置Redis的配置RedisConfiguration

@Configuration
public class RedisConfiguration {

    /**
     * 主要做redis配置。redis有2种不同的template(2种的key不能共享)
     * 1.StringRedisTemplate:以String作为存储方式:默认使用StringRedisTemplate,其value都是以String方式存储
     * 2.RedisTemplate:
     *    1)使用默认RedisTemplate时,其value都是根据jdk序列化的方式存储
     *    2)自定义Jackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是LinkedHashMap
     *    3)自定义GenericJackson2JsonRedisSerializer序列化,以json格式存储,其key与StringRedisTemplate共享,返回值是原先对象(因为保存了classname)
     */
    @Bean
    @ConditionalOnMissingBean({RedisTemplate.class})
    public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate();
        template.setConnectionFactory(factory);
        //本实例采用GenericJackson2JsonRedisSerializer
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        GenericJackson2JsonRedisSerializer jackson2JsonRedisSerializer = new GenericJackson2JsonRedisSerializer();
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringRedisSerializer);
        template.setHashKeySerializer(stringRedisSerializer);
        template.setValueSerializer(jackson2JsonRedisSerializer);
        template.setHashValueSerializer(jackson2JsonRedisSerializer);
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    @ConditionalOnMissingBean({StringRedisTemplate.class})
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        StringRedisTemplate template = new StringRedisTemplate();
        template.setConnectionFactory(factory);
        return template;
    }
}

3)在entity包下面,配置前后端交互参数LoginDTO,以及自定义UserDetails的LoginUserDetails

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginDTO {

    private String username;
    private String password;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginUserDetails implements UserDetails {

    private TUser tUser;

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of();
    }

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

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

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

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

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

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

4)在utils包下面建立JwtUtil,用于生成和解析JWT的token(注意:这里使用非对称加密RSA256,确保已经生成密钥对文件)

/**
 * JWT工具类
 */
@Component
@ConfigurationProperties("rsa")
public class JwtUtil {

    //有效期为
    private final Long JWT_TTL = 60 * 60 * 1000L; // 60 * 60 *1000  一个小时
    //设置秘钥密码
    @Setter
    private String key;
    //设置密钥名称
    @Setter
    private String jks;
    //密钥对
    private KeyPair keyPair;

    public String getUUID(){
        return UUID.randomUUID().toString().replaceAll("-", "");
    }

    /**
     * 创建token
     */
    public String createToken(String subject) {
        return getJwtBuilder(subject, null, getUUID());// 设置过期时间
    }

    /**
     * 创建token
     */
    public String createToken(String subject, Long ttlMillis) {
        return getJwtBuilder(subject, ttlMillis, getUUID());// 设置过期时间
    }

    /**
     * 创建token
     */
    public String createToken(String id, String subject, Long ttlMillis) {
        return getJwtBuilder(subject, ttlMillis, id);// 设置过期时间
    }

    /**
     * 解析token
     */
    public String parseJWT(String token) throws Exception {
        KeyPair tempKeyPair =  keyPair();
        return Jwts.parser()
                .setSigningKey(tempKeyPair.getPublic())
                .parseClaimsJws(token)
                .getBody().getSubject();
    }

    /**
     * 生成token
     */
    private String getJwtBuilder(String subject, Long ttlMillis, String uuid) {
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.RS256;
        KeyPair tempKeyPair =  keyPair();
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);
        if(ttlMillis==null){
            ttlMillis=JWT_TTL;
        }
        long expMillis = nowMillis + ttlMillis;
        Date expDate = new Date(expMillis);
        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setId(uuid)            //唯一的ID
                .setSubject(subject)    // 主题  可以是JSON数据
                .setIssuer("spring-security-study")       // 签发者
                .setIssuedAt(now)       // 签发时间
                .signWith(signatureAlgorithm, tempKeyPair.getPrivate())    //使用RS256非对称加密算法签名, 第二个参数为秘钥
                .setExpiration(expDate)
                .compact();
    }

    /**
     * 密钥库中获取密钥对(公钥+私钥)
     */
    @Bean
    public KeyPair keyPair() {
        if(keyPair!=null)
            return keyPair;
        KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource(jks), key.toCharArray());
        keyPair = factory.getKeyPair("demo", key.toCharArray());
        return keyPair;
    }
}

5)在service包下面定义登录和登出接口,LoginService接口以及其实现类LoginServiceImpl

public interface LoginService {

    Result<String> login(LoginDTO loginDTO);

    Result<String> logout();
}
@Service
public class LoginServiceImpl implements LoginService {

    // 注入AuthenticationManagerBuilder,用于获得authenticationManager
    @Autowired
    private AuthenticationManagerBuilder authenticationManagerBuilder;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private JwtUtil jwtUtil;

    private static String PRE_KEY = "user:";

    @Override
    public Result<String> login(LoginDTO loginDTO) {
        String username = loginDTO.getUsername();
        String password = loginDTO.getPassword();
        UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
        try {
            Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
            if(authentication!=null && authentication.isAuthenticated()){
                SecurityContextHolder.getContext().setAuthentication(authentication);
                LoginUserDetails user = (LoginUserDetails)authentication.getPrincipal();
                String subject = PRE_KEY + user.getTUser().getId();
                String token = jwtUtil.createToken(subject, 1000*60*5L);
                redisTemplate.opsForValue().set(subject, user, 1000*60*5L, TimeUnit.MILLISECONDS);
                return Result.success(token);
            }
        }catch (AuthenticationException e){
            return Result.failed(e.getLocalizedMessage());
        }
        catch (Exception e){
            e.printStackTrace();
        }
        return Result.failed("认证失败");
    }

    @Override
    public Result<String> logout() {
        if(SecurityContextHolder.getContext().getAuthentication()!=null){
            LoginUserDetails user = (LoginUserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            if(user!=null){
                String key = PRE_KEY + user.getTUser().getId();
                redisTemplate.delete(key);
            }else {
                return Result.failed("登出失败,用户不存在");
            }
        }
        return Result.success("登出成功");
    }
}

6)在controller包下,定义LoginController

@RestController
public class LoginController {

    @Autowired
    private LoginService loginService;

    @PostMapping("/login")
    public Result<String> login(@RequestBody LoginDTO loginDTO) {
        return loginService.login(loginDTO);
    }

    @PostMapping("/logout")
    public Result<String> logout() {
        return loginService.logout();
    }
}

7)在yaml文件中增加密钥的密码和密钥对名称

# 密钥加密密码
rsa:
  key: linmoo
  jks: demo.jks

8)在filter包下,定义JwtAuthenticationTokenFilter,用于Security过滤器链判断JWT

/**
 * 用于集成JWT认证
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 过滤login接口
        if("/login".equals(request.getRequestURI())){
            filterChain.doFilter(request, response);
            return;
        }

        // 从请求头获取token
        String token = request.getHeader("access_token");
        // 检查获取到的token是否为空或空白字符串。(判断给定的字符串是否包含文本)
        if (!StringUtils.hasText(token)) {
            // 如果token为空,则直接放行请求到下一个过滤器,不做进一步处理并结束当前方法,不继续执行下面代码。
            filterChain.doFilter(request, response);
            return;
        }
        // 解析token
        String userAccount;
        try {
            userAccount = jwtUtil.parseJWT(token);
        } catch (Exception e) {
            e.printStackTrace();
            throw new BadCredentialsException("token非法");
        }
        // 临时缓存中 获取 键 对应 数据
        Object object = redisTemplate.opsForValue().get(userAccount);
        LoginUserDetails loginUser = (LoginUserDetails)object;
        if (Objects.isNull(loginUser)) {
            throw new BadCredentialsException("用户未登录");
        }
        // 将用户信息存入 SecurityConText
        // UsernamePasswordAuthenticationToken 存储用户名 密码 权限的集合
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        // SecurityContextHolder是Spring Security用来存储当前线程安全的认证信息的容器。
        // 将用户名 密码 权限的集合存入SecurityContextHolder
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        // 放行
        filterChain.doFilter(request, response);
    }
}

9)在config包下,配置SecurityConfig

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth->auth
                        //允许/login访问
                        .requestMatchers("/login").permitAll().anyRequest().authenticated())
                // 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
                .csrf(AbstractHttpConfigurer::disable)
                // 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
                .addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
                // 由于采用token方式认证,因此可以关闭session管理
                .sessionManagement(SessionManagementConfigurer::disable)
                // 禁用原来登录页面
                .formLogin(AbstractHttpConfigurer::disable)
                // 禁用系统原有的登出
                .logout(LogoutConfigurer::disable);
        return http.build();
    }
}

10)使用postman测试
使用post方法访问:http://127.0.0.1:8080/login
在这里插入图片描述
使用get方法访问:http://127.0.0.1:8080/demo
在这里插入图片描述

5 异常处理

1)在handler包下,定义认证异常DemoAuthenticationEntryPoint

public class DemoAuthenticationEntryPoint implements AuthenticationEntryPoint {

    /**
     *
     * @param request 请求request
     * @param response 请求response
     * @param authException 认证的异常
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        // authException.getLocalizedMessage()返回本地化语言的错误信息
        Result<String> result = Result.failed(authException.getLocalizedMessage());
        String json = JSON.toJSONString(result);
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().println(json);
    }
}

2)在handler包下,定义授权异常DemoAccessDeniedHandler

public class DemoAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // accessDeniedException.getLocalizedMessage()返回本地化语言的错误信息
        Result<String> result = Result.failed(accessDeniedException.getLocalizedMessage());
        String json = JSON.toJSONString(result);
        response.setContentType("application/json;charset=utf-8");
        response.getWriter().println(json);
    }
}

3)在config包下的SecurityConfig类,加入2个异常处理类

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth->auth
                        //允许/login访问
                        .requestMatchers("/login").permitAll().anyRequest().authenticated())
                // 异常处理
                .exceptionHandling(handling-> handling
                        .accessDeniedHandler(new DemoAccessDeniedHandler())
                        .authenticationEntryPoint(new DemoAuthenticationEntryPoint()))
                // 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
                .csrf(AbstractHttpConfigurer::disable)
                // 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
                .addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
                // 由于采用token方式认证,因此可以关闭session管理
                .sessionManagement(SessionManagementConfigurer::disable)
                // 禁用原来登录页面
                .formLogin(AbstractHttpConfigurer::disable)
                // 禁用系统原有的登出
                .logout(LogoutConfigurer::disable);
        return http.build();
    }
}

6 手机验证码登录

  • 这里与前面系列10中的手机验证码有一点不一样,这里是前后端分离,因此我们只需要自定义基于手机验证的AuthenticationProvider和AbstractAuthenticationToken即可,不需要定义Filter
  • 验证码我们采用phone:+手机号码,在Redis中预先存入一个key=phone:13788888888,value=8633 的String

1)在authentication包下,定义手机验证的AbstractAuthenticationTokenPho继承类neCodeAuthenticationToken

/**
 * 手机验证码token
 */
public class PhoneCodeAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;

    private Object credentials;

    public PhoneCodeAuthenticationToken(Object principal, Object credentials) {
        super(null);
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(false);
    }

    public PhoneCodeAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        super.setAuthenticated(true); // must use super, as we override
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }
}

2)在authentication包下,定义手机验证的AuthenticationProvider

public class PhoneCodeAuthenticationProvider implements AuthenticationProvider {

    private TUserMapper tUserMapper;

    private RedisTemplate redisTemplate;

    private static String PRE_PHONE_KEY = "phone:";

    public PhoneCodeAuthenticationProvider(TUserMapper tUserMapper, RedisTemplate redisTemplate) {
        this.tUserMapper = tUserMapper;
        this.redisTemplate = redisTemplate;
    }

    /**
     * 支持PhoneCodeAuthenticationToken认证
     */
    @Override
    public boolean supports(Class<?> aClass) {
        return PhoneCodeAuthenticationToken.class.isAssignableFrom(aClass);
    }

    /**
     * 认证
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        if (!supports(authentication.getClass())) {
            return null;
        }
        PhoneCodeAuthenticationToken token = (PhoneCodeAuthenticationToken) authentication;
        // 判断电话是否为空
        String phone = (String) token.getPrincipal();
        if (phone == null) {
            throw new BadCredentialsException("无法获取电话信息");
        }
        String code = (String) token.getCredentials();
        if (code == null) {
            throw new BadCredentialsException("验证码为空");
        }
        // 这里应该是从另外发送短信服务中存入验证码
        // redisTemplate.opsForValue().set(PRE_PHONE_KEY + phone, "8633", 1000*60*5L, TimeUnit.MILLISECONDS);
        try {
            // 从Redis中获取预先存入的验证码
            Object object = redisTemplate.opsForValue().get(PRE_PHONE_KEY + phone);
            if (!object.equals(code)) {
                throw new BadCredentialsException("验证码错误");
            }
        } catch (Exception e) {
            e.printStackTrace();
            throw new BadCredentialsException("验证码无效");
        }

        TUser tUser = tUserMapper.selectByPhone(phone);
        if( tUser == null){
            throw new BadCredentialsException("找不到用户");
        }
        // 这里PhoneCodeAuthenticationToken第三个入参应该是权限,这里没有使用数据库,就默认一个空值。完整情况应该是前面通过phone获取到用户,再找到权限
        PhoneCodeAuthenticationToken result =
                new PhoneCodeAuthenticationToken(tUser, code, new ArrayList<>());
        result.setDetails(token.getDetails());
        return result;
    }

}

3)在mapper包下的TUserMapper,增加基于手机号搜索用户的接口

@Mapper
public interface TUserMapper {

    // 根据用户名,查询用户信息
    @Select("select * from t_user where username = #{username}")
    TUser selectByUsername(String username);

    // 根据电话号码,查询用户信息
    @Select("select * from t_user where phone = #{phone}")
    TUser selectByPhone(String phone);

}

4)在service包下,LoginService和其实现类LoginServiceImpl,增加loginPhoneCode接口,同时改造一下,将原先自动注入的AuthenticationManagerBuilder改为AuthenticationManager

public interface LoginService {

    Result<String> login(LoginDTO loginDTO);

    Result<String> loginPhoneCode(LoginDTO loginDTO);

    Result<String> logout();
}
@Service
public class LoginServiceImpl implements LoginService {

    // 注入AuthenticationManager
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private JwtUtil jwtUtil;

    private static String PRE_KEY = "user:";

    @Override
    public Result<String> login(LoginDTO loginDTO) {
        String username = loginDTO.getUsername();
        String password = loginDTO.getPassword();
        UsernamePasswordAuthenticationToken authenticationToken = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
        try {
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
            if(authentication!=null && authentication.isAuthenticated()){
                SecurityContextHolder.getContext().setAuthentication(authentication);
                LoginUserDetails user = (LoginUserDetails)authentication.getPrincipal();
                String subject = PRE_KEY + user.getTUser().getId();
                String token = jwtUtil.createToken(subject, 1000*60*5L);
                redisTemplate.opsForValue().set(subject, user, 1000*60*5L, TimeUnit.MILLISECONDS);
                return Result.success(token);
            }
        }catch (AuthenticationException e){
            return Result.failed(e.getLocalizedMessage());
        }
        catch (Exception e){
            e.printStackTrace();
        }
        return Result.failed("认证失败");
    }

    @Override
    public Result<String> loginPhoneCode(LoginDTO loginDTO) {
        String username = loginDTO.getPhone();
        String password = loginDTO.getCode();
        PhoneCodeAuthenticationToken authenticationToken = new PhoneCodeAuthenticationToken(username, password);
        try {
            Authentication authentication = authenticationManager.authenticate(authenticationToken);
            if(authentication!=null && authentication.isAuthenticated()){
                SecurityContextHolder.getContext().setAuthentication(authentication);
                TUser tUser = (TUser)authentication.getPrincipal();
                String subject = PRE_KEY + tUser.getId();
                String token = jwtUtil.createToken(subject, 1000*60*5L);
                LoginUserDetails loginUserDetails = new LoginUserDetails(tUser);
                redisTemplate.opsForValue().set(subject, loginUserDetails, 1000*60*5L, TimeUnit.MILLISECONDS);
                return Result.success(token);
            }
        }catch (AuthenticationException e){
            return Result.failed(e.getLocalizedMessage());
        }
        catch (Exception e){
            e.printStackTrace();
        }
        return Result.failed("认证失败");
    }

    @Override
    public Result<String> logout() {
        if(SecurityContextHolder.getContext().getAuthentication()!=null){
            LoginUserDetails user = (LoginUserDetails)SecurityContextHolder.getContext().getAuthentication().getPrincipal();
            if(user!=null){
                String key = PRE_KEY + user.getTUser().getId();
                redisTemplate.delete(key);
            }else {
                return Result.failed("登出失败,用户不存在");
            }
        }
        return Result.success("登出成功");
    }
}

5)在controller包下的LoginController,增加基于手机验证码登录接口

@RestController
public class LoginController {

    @Autowired
    private LoginService loginService;

    @PostMapping("/login")
    public Result<String> login(@RequestBody LoginDTO loginDTO) {
        return loginService.login(loginDTO);
    }

    @PostMapping("/loginphone")
    public Result<String> loginPhone(@RequestBody LoginDTO loginDTO) {
        return loginService.loginPhoneCode(loginDTO);
    }

    @PostMapping("/logout")
    public Result<String> logout() {
        return loginService.logout();
    }

}

6)JwtAuthenticationTokenFilter类过滤掉"/loginphone"
在这里插入图片描述

7)修改SecurityConfig 配置,自定义PasswordEncoder ,自定义AuthenticationManager(加入原先的密码验证和手机验证的Provider)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private TUserMapper tUserMapper;

    @Autowired
    private RedisTemplate redisTemplate;

    @Bean
    public PhoneCodeAuthenticationProvider phoneCodeAuthenticationProvider() {
        return new PhoneCodeAuthenticationProvider(tUserMapper, redisTemplate);
    }

    /**
     * 定义密码加密方式,用于DaoAuthenticationProvider
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    /**
     * 定义AuthenticationManager,加入两种AuthenticationProvider
     */
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        // 保留原来账号密码登录的AuthenticationProvider
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        // 将daoAuthenticationProvider和 phoneCodeAuthenticationProvider 加入到authenticationManager
        return new ProviderManager(daoAuthenticationProvider, phoneCodeAuthenticationProvider());
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth->auth
                        //允许/login访问
                        .requestMatchers("/login", "/loginphone").permitAll().anyRequest().authenticated())
                // 异常处理
                .exceptionHandling(handling-> handling
                        .accessDeniedHandler(new DemoAccessDeniedHandler())
                        .authenticationEntryPoint(new DemoAuthenticationEntryPoint()))
                // 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
                .csrf(AbstractHttpConfigurer::disable)
                // 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
                .addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
                // 由于采用token方式认证,因此可以关闭session管理
                .sessionManagement(SessionManagementConfigurer::disable)
                // 禁用原来登录页面
                .formLogin(AbstractHttpConfigurer::disable)
                // 禁用系统原有的登出
                .logout(LogoutConfigurer::disable)
                // 设置全局authenticationManager
                .authenticationManager(authenticationManager());
        return http.build();
    }
}

8)测试
使用Postman,分别访问:
在这里插入图片描述

在这里插入图片描述
都可以登录成功。

7 白名单

这里我们将原先login和loginphone两个接口放入白名单中

1)在entity包下,新建WhiteDTO

@Data
@NoArgsConstructor
@AllArgsConstructor
public class WhiteDTO {

    private String httpMethod;
    private String url;
}

2)在properties包下,新建IgnoreUrlsProperties读取yaml配置

@Data
@ConfigurationProperties("security")
public class IgnoreUrlsProperties {

    private List<WhiteDTO> ignoreUrls = new ArrayList<>();

    public RequestMatcher[] getRequestMatcher(){
        List<RequestMatcher> requestMatchers = new ArrayList<>();
        ignoreUrls.stream().map(whiteDTO->new AntPathRequestMatcher(whiteDTO.getUrl(), whiteDTO.getHttpMethod())).forEach(requestMatchers::add);
        return requestMatchers.toArray(new RequestMatcher[0]);
    }
}

3)SecurityConfig 配置下,增加白名单

@Configuration
@EnableWebSecurity
@EnableConfigurationProperties(IgnoreUrlsProperties.class)
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Autowired
    private UserDetailsService userDetailsService;

    @Autowired
    private TUserMapper tUserMapper;

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private IgnoreUrlsProperties ignoreUrlsProperties;

    @Bean
    public PhoneCodeAuthenticationProvider phoneCodeAuthenticationProvider() {
        return new PhoneCodeAuthenticationProvider(tUserMapper, redisTemplate);
    }

    /**
     * 定义密码加密方式,用于DaoAuthenticationProvider
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    /**
     * 定义AuthenticationManager,加入两种AuthenticationProvider
     */
    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        // 保留原来账号密码登录的AuthenticationProvider
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        // 将daoAuthenticationProvider和 phoneCodeAuthenticationProvider 加入到authenticationManager
        return new ProviderManager(daoAuthenticationProvider, phoneCodeAuthenticationProvider());
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth->auth
                        //允许/login访问
                        .requestMatchers("/login", "/loginphone").permitAll()
                        // 加入免鉴权白名单
                        .requestMatchers(ignoreUrlsProperties.getRequestMatcher()).permitAll()
                        .anyRequest().authenticated())
                // 异常处理
                .exceptionHandling(handling-> handling
                        .accessDeniedHandler(new DemoAccessDeniedHandler())
                        .authenticationEntryPoint(new DemoAuthenticationEntryPoint()))
                // 禁用csrf,因为登录和登出是post请求,csrf会屏蔽掉post请求
                .csrf(AbstractHttpConfigurer::disable)
                // 添加到过滤器链路中,确保在AuthorizationFilter过滤器之前
                .addFilterBefore(jwtAuthenticationTokenFilter, AuthorizationFilter.class)
                // 由于采用token方式认证,因此可以关闭session管理
                .sessionManagement(SessionManagementConfigurer::disable)
                // 禁用原来登录页面
                .formLogin(AbstractHttpConfigurer::disable)
                // 禁用系统原有的登出
                .logout(LogoutConfigurer::disable)
                // 设置全局authenticationManager
                .authenticationManager(authenticationManager());
        return http.build();
    }
}

4)yaml文件增加白名单配置

# 白名单
security:
  ignoreUrls:
    - httpMethod: 'POST'
      url: '/login'
    - httpMethod: 'POST'
      url: '/loginphone'

5)测试login和loginphone接口,看看是否可以访问

8 基于数据库权限认证

首先,我们是基于RBAC权限模型,因此数据库中新增几个表(这个在前提条件中已经加入,这里只是提一下这里使用的)
里面有用户user和admin,其中user是普通用户角色,拥有PRODUCT_LIST权限;admin是管理员角色,拥有PRODUCT_LIST和PRODUCT_EDIT权限

1)在mapper包下,新增TPermissionMapper查询权限码

@Mapper
public interface TPermissionMapper {

    // 根据用户名,查询用户信息
    @Select("select distinct p.permission_code from t_user u\n" +
            "left join t_user_role ur on ur.user_id = u.id\n" +
            "left join t_role_permission rp on ur.role_id = rp.role_id \n" +
            "left join t_permission p on p.id = rp.permission_id \n" +
            "where u.id = #{id}")
    List<String> selectPermissionByUserId(Long id);

}

2)LoginUserDetails新增permissions,并改造getAuthorities()方法

@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class LoginUserDetails implements UserDetails {

    private TUser tUser;

    private List<String> permissions;

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> list = new ArrayList<>();
        if(permissions!=null){
            permissions.forEach(permission-> list.add(new SimpleGrantedAuthority(permission)));
        }
        return list;
    }

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

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

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

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

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

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

3)修改JdbcUserDetailsServiceImpl和LoginServiceImpl,将使用TPermissionMapper 查询到权限,并设置到

4)修改JwtAuthenticationTokenFilter,存储权限集合到Context中
在这里插入图片描述

5)SecurityConfig增加@EnableMethodSecurity注解,使用注解方式设置权限

6)在controller包下的DemoController 新增2个方法list和edit,并设置权限

@RestController
public class DemoController {

    @GetMapping("/demo")
    public String demo() {
        return"demo";
    }

    @GetMapping("/list")
    @PreAuthorize("hasAuthority('PRODUCT_LIST')")
    public String list() {
        return"list";
    }

    @GetMapping("/edit")
    @PreAuthorize("hasAuthority('PRODUCT_EDIT')")
    public String edit() {
        return"edit";
    }

}

7)测试
我们从数据库表中知道user用户只用PRODUCT_LIST权限,而admin用户有PRODUCT_LIST和PRODUCT_EDIT权限。分别登录user和admin用户,测试 http://127.0.0.1:8080/list 和 http://127.0.0.1:8080/edit
最终效果是:user可以访问 http://127.0.0.1:8080/list ,无法访问 http://127.0.0.1:8080/edit ;admin两个接口都可以访问

9 基于nacos的配置

现实项目中,配置基本上放在配置中心,因此我们这里使用一个nacos。在demo-biz命名空间中配置3个名为demo-biz-service
y的aml文件,分别对于开发、测试和正式环境
在这里插入图片描述

1) nacos上面配置3个demo-biz-service的内容如下:(如果自己端口等配置有所改动,可以自行修改)

spring:
  # 配置数据源
  datasource:
  	type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/spring_security_study?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: root
    druid:
      initial-size: 5
      min-idle: 5
      maxActive: 20
      maxWait: 3000
      timeBetweenEvictionRunsMillis: 60000
      minEvictableIdleTimeMillis: 300000
      validationQuery: select 'x'
      testWhileIdle: true
      testOnBorrow: false
      testOnReturn: false
      poolPreparedStatements: false
      filters: stat,wall,slf4j
      connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000;socketTimeout=10000;connectTimeout=1200

  #redis配置
  data:
    redis:
      host: 127.0.0.1

mybatis-plus:
  global-config:
    banner: false
  mapper-locations: classpath:mappers/*.xml
  type-aliases-package: com.demo.lesson05.entity
  configuration:
    cache-enabled: false
    local-cache-scope: statement

# 日志配置
logging:
  level:
    com.demo: debug

# 密钥加密密码
rsa:
  key: linmoo
  jks: demo.jks

# 白名单
security:
  ignoreUrls:
    - httpMethod: 'POST'
      url: '/login'
    - httpMethod: 'POST'
      url: '/loginphone'

2)将原先resources下的application.yml删除

3)在resources下新增bootstrap.yml,内容如下:

server:
  port: 8080
spring:

  # 应用名称
  application:
    name: demo-biz-service

  # 激活环境
  profiles:
    active: dev

  # nacos配置
  cloud:
    nacos:
      config:
        server-addr: 127.0.0.1:8848
        namespace: demo-biz
        group: ${spring.profiles.active}
        file-extension: yaml
        username: demo-biz
        password: demo-biz

logging:
  level:
    com.demo.client: debug

4)父项目spring-security-study,其pom引入springcloud的依赖

<!-- 引入spring cloud alibaba依赖-->

<properties>
    <spring-cloud.version>2023.0.3</spring-cloud.version>
    <spring-cloud-alibaba.version>2023.0.1.0</spring-cloud-alibaba.version>
</properties>
    
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-alibaba-dependencies</artifactId>
    <version>${spring-cloud-alibaba.version}</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>
<!-- 引入spring cloud依赖-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-dependencies</artifactId>
    <version>${spring-cloud.version}</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>

5)lesson12子模块的pom引入以下依赖:

 <!-- 引入nacos config的依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- 新版本bootstrap已经从spring-cloud-starter-config移除,因此需要手动依赖 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

6)启动项目验证登录和访问

结语:我们通过12章的系列,从底层原理讲到实际应用。基本上揽括了Spring Security 6的功能,当然还有许多功能没有谈到,比如CRSF 、Remember me、密码升级等等,我相信学习完这一系列后,你如果有兴趣了解Spring Security的其它功能,对你来说应该易如反掌。另外现在内部微服务做统一登录认证,都会使用网关+OAuth2方式,这部分我们将会单独开一个系列来讲解。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

linmoo1986

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值