Spring Security 学习笔记 4:用户/密码认证

Spring Security 学习笔记 4:用户/密码认证

本文使用 Spring Security + JWT 实现一个使用用户名/密码进行身份验证,并之后通过 JWT 访问令牌进行请求和验证的前后端分离系统的服务端示例。

准备工作

数据库

这里使用数据库保存用户名和密码,具体使用的是 MySQL。创建用户表:

sql 复制代码
create table user
(
    id int auto_increment
    primary key,
    username varchar(50)          not null,
    password varchar(500)         not null,
    enabled  tinyint(1) default 1 not null,
    constraint username
    unique (username)
);

导入数据:

sql 复制代码
INSERT INTO learn_spring_security.user (id, username, password, enabled) VALUES (1, 'admin', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', 1);
INSERT INTO learn_spring_security.user (id, username, password, enabled) VALUES (2, 'user', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', 1);

SpringSecurity 支持多种哈希算法对密码进行加密,这里密码的前部{...}即加密时使用的算法,将加密算法作为密码的一部分保存的好处是可以很容易进行加密算法升级,比如对新生成的密码运用新的加密算法,旧的密码依然使用旧的加密算法进行验证。

官方文档有说明,这样做并不会降低安全性,攻击者知道加密算法本身并不是问题。而且一些加密算法加密后的结果本身也具备一些特征,很容易被看出来。

配置数据库连接:

yaml 复制代码
spring:
  application:
    name: jwt
  datasource:
    url: jdbc:mysql://localhost:3306/learn_spring_security?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&useSSL=false&allowPublicKeyRetrieval=true
    username: root
    password: mysql
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource

Redis

后文使用管理和刷新令牌会使用 Redis,按照一般性的 Spring Boot 集成 Redis 即可,这里不作赘述。

Spring Security

Spring Security 用于认证的用户/权限信息可以保存在任何地方,如果是保存在数据库,需要添加一个数据源:

xml 复制代码
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.27</version>
</dependency>
yaml 复制代码
spring:
  datasource:
    # Druid连接池专有配置
    initialSize: 5
    minIdle: 5
    maxActive: 20
    maxWait: 60000
    filters: stat,wall,log4j2 # 开启监控统计和防火墙功能
java 复制代码
@Configuration
public class DruidConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DataSource dataSource(){
        return new DruidDataSource();
    }
}

添加 Spring Security 的必要配置:

java 复制代码
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;

    /**
     * 配置路径认证规则
     * @param http HttpSecurity
     * @param customAccessDeniedHandler 自定义403处理
     * @param jwtAuthenticationEntryPoint 自定义401处理
     * @return SecurityFilterChain
     * @throws Exception 异常
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           CustomAccessDeniedHandler customAccessDeniedHandler,
                                           JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint) throws Exception {
        http
                .csrf((csrf) -> csrf.disable())// 通常JWT无状态应用可禁用CSRF
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 无状态会话
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/api/auth/**","/error").permitAll() // 登录注册公开
                        .requestMatchers("/admin/**").hasRole("ADMIN") // 管理员可访问
                        .anyRequest().authenticated() // 其他请求需认证
                )
                .exceptionHandling((exception) ->
                        exception
                                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                                .accessDeniedHandler(customAccessDeniedHandler)
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 添加JWT过滤器
        return http.build();
    }

    /**
     * 认证管理器
     * @param userDetailsService 用户详情服务
     * @param passwordEncoder 密码编码器
     * @return 认证管理器
     */
    @Bean
    public AuthenticationManager authenticationManager(
            UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder);
        return new ProviderManager(authenticationProvider);
    }

    /**
     * 密码编码器
     * @return 密码编码器
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

包含三个 Bean:

  • SecurityFilterChain:路径的认证规则,包括是否使用 CSRF 保护、会话是有状态还是无状态、哪些路径需要认证、异常处理器等。
  • AuthenticationManager:认证管理器,认证管理器包含的UserDetailsService决定如何获取用户信息进行认证,因此需要重写这个 Bean 注入,以覆盖默认的 UserDetailsService Bean。
  • PasswordEncoder:密码编码器,通过不可逆 HASH 算法对密码进行编码,以保护原始密码。Spring Security 支持多种 HASH 算法实现的密码编码器,可以挑选合适的,或者通过PasswordEncoderFactories.createDelegatingPasswordEncoder()获取一个推荐的密码编码器,这样做的好处是可以更方便的升级密码编码算法。

创建表示用户信息的实体类:

java 复制代码
@Data
@TableName("user")
public class User {
    private Integer id;
    private String username;
    private String password;
    private Boolean enabled;
}

重写实现接口UserDetailsService的 Bean,从数据库获取用户信息:

java 复制代码
@Service
public class UserDetailServiceImpl implements UserDetailsService {
    @Autowired
    private UserMapper userMapper;

    /**
     * 从数据库获取用户信息
     * @param username 用户名
     * @return 用户信息
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username", username);
        User user = userMapper.selectOne(queryWrapper);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在" + username);
        }
        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                // 假设用户角色为USER
                .authorities("ROLE_USER")
                .disabled(!user.getEnabled())
                .build();
    }
}

这里使用 MyBatisPlus 实现持久层查询,具体实现不再赘述。

JWT

JWT 是利用特定算法,将明文的 JSON 内容编码为签名,由服务端进行分发和验证。JWT 有多种第三方库实现,这里使用 JJWT。

添加依赖:

xml 复制代码
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>${jjwt.version}</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>${jjwt.version}</version>
    <scope>runtime</scope>
</dependency>

实现一个 JWT 工具类,用于生成和验证 token:

java 复制代码
@Component
public class JwtTokenProvider {
    @Autowired
    private JwtProperties jwtProperties;
    // 随机生成一个长度足够的密钥
    private final SecretKey key = Jwts.SIG.HS512.key().build();

    /**
     * 生成 JWT 令牌(access token)
     * @param userDetails Spring Security 用户信息
     * @return JWT 令牌
     */
    public String generateToken(UserDetails userDetails) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtProperties.getExpirationMs());

        return Jwts.builder()
                .subject(userDetails.getUsername()) // 通常存放用户名
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(key) // 指定算法和密钥
                .compact();
    }

    /**
     * 生成 refresh token
     * @param userDetails Spring Security 用户信息
     * @return refresh token
     */
    public String generateRefreshToken(UserDetails userDetails) {
        RefreshToken refreshToken = createRefreshTokenInstance(userDetails);
        return generateRefreshToken(refreshToken);
    }

    /**
     * 创建 refresh token 实例
     * @param userDetails Spring Security 用户信息
     * @return refresh token 实例
     */
    private RefreshToken createRefreshTokenInstance(UserDetails userDetails) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtProperties.getRefreshTokenExpirationHours() * 60 * 60 * 1000);
        return RefreshToken.builder()
                .id(UUID.randomUUID().toString())
                .userName(userDetails.getUsername())
                .issuedAt( now)
                .expiryDate(expiryDate)
                .build();
    }

    /**
     * 生成 refresh token
     * @param refreshToken refresh token 实例
     * @return refresh token
     */
    private String generateRefreshToken(RefreshToken refreshToken){
        return Jwts.builder()
                .id(refreshToken.getId())
                .subject(refreshToken.getUserName()) // 通常存放用户名
                .issuedAt(refreshToken.getIssuedAt())
                .expiration(refreshToken.getExpiryDate())
                .signWith(key) // 签名
                .compact();
    }

    /**
     * 从 Token 中获取用户名,如果验证失败则抛出异常
     * @param token JWT令牌
     * @return 用户名
     * @throws JwtException 验证失败异常
     */
    public String getUsernameFromJWT(String token) throws JwtException{
        Jws<Claims> claimsJws = Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token);
        return claimsJws.getPayload().getSubject();
    }
}

JJWT 支持以 JSON 或二进制字节码的方式在 token 中添加负载信息(payload),这里使用 JSON 的方式。信息格式可以自由定制,也可以使用 JJWT 已经定义好的内容,比如:

  • id:token 的唯一 id
  • subject:用户唯一标识

其他需要指定的属性:

  • issuedAt:token 签发时间
  • expiration:token 到期时间
  • signWith:用于签名的 key

用于签名的 key 的类型是 SecretKey,它同时包含了特定的签名算法和盐(Salt)。

应当对盐严格保密,泄漏后会让攻击者可以伪造签名,因此建议使用 NACOS 等第三方配置库存储和下发。此外,部分算法对盐的长度同样有要求,过短会报错。

如果可以接受服务器重启后所有 JWT token 失效,可以在服务器启动时使用随机的盐生成密钥:

java 复制代码
private final SecretKey key = Jwts.SIG.HS512.key().build();

实现令牌生成和验证的服务类:

java 复制代码
@Service
public class JwtTokenServiceImpl implements JwtTokenService {
    @Autowired
    private JwtTokenProvider tokenProvider;
    @Autowired
    private RefreshTokenRedis refreshTokenRedis;
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 生成 access token 和 refresh token
     * @param userDetails 用户信息
     * @return 登录响应
     */
    @Override
    public AuthController.LoginResponse generateToken(@NonNull UserDetails userDetails) {
        // 生成 access token
        String accessToken = tokenProvider.generateToken(userDetails);
        // 生成 refresh token
        String refreshToken = tokenProvider.generateRefreshToken(userDetails);
        // 将 refresh token 保存到 Redis
        refreshTokenRedis.save(userDetails.getUsername(), refreshToken);
        return new AuthController.LoginResponse(accessToken, refreshToken);
    }

    /**
     * 验证 refresh token
     * 验证失败,抛出异常
     * 验证成功,返回新的 access token 和 refresh token
     * @param refreshToken refresh token
     * @return 新的 access token 和 refresh token
     */
    @Override
    public AuthController.LoginResponse validateToken(@NonNull String refreshToken) {
        // 验证 JWT 加密是否正确
        String username;
        try {
            username = tokenProvider.getUsernameFromJWT(refreshToken);
        } catch (JwtException e) {
            // 不正确的 JWT 令牌,可能是伪造的,返回错误信息
            throw BusinessException.builder()
                    .httpStatusCode(HttpStatus.UNAUTHORIZED.value())
                    .code("refresh.token.invalid")
                    .message("refresh token 无效,请重新登录")
                    .sourceException(e)
                    .build();
        }
        if (!StringUtils.hasText(username)) {
            // 格式正确,但缺少用户名,可能是系统 bug
            throw BusinessException.builder()
                    .httpStatusCode(HttpStatus.INTERNAL_SERVER_ERROR.value())
                    .code("refresh.token.invalid")
                    .message("refresh token 无效,请重新登录")
                    .build();
        }
        // 检查 redis 中是否有效
        boolean validate = refreshTokenRedis.validate(username, refreshToken);
        if (!validate) {
            // 不是在 redis 中记录的有效 refresh token,可能是黑客窃取并进行了重放攻击
            // 清除 redis 中记录的 refresh token 以防被利用
            refreshTokenRedis.delete(username);
            throw BusinessException.builder()
                    .httpStatusCode(HttpStatus.UNAUTHORIZED.value())
                    .code("refresh.token.invalid")
                    .message("refresh token 无效,请重新登录")
                    .build();
        }
        // 刷新令牌验证通过,生成新的刷新令牌和访问令牌,并返回
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null// 密码在此处不需要
                );
        SecurityContextHolder.getContext().setAuthentication(authentication);
        return generateToken(userDetails);
    }
}

令牌分为两种,访问令牌和刷新令牌,访问令牌有效期较短,刷新令牌有效期较长,有接口访问时,如果访问令牌验证失败,客户端可以通过刷新令牌和特定接口获取新的访问令牌和刷新令牌。

实现注册到 SecurityFilterChain 的用于验证请求 Token 的 Filter:

java 复制代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtTokenProvider tokenProvider;

    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * @param request 请求
     * @param response 响应
     * @param filterChain 过滤器链
     * @throws ServletException
     * @throws IOException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);

            if (StringUtils.hasText(jwt)) {
                String username;
                try {
                    username = tokenProvider.getUsernameFromJWT(jwt);
                } catch (ExpiredJwtException e) {
                    // Token过期
                    sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "AUTH_EXPIRED", "登录信息已过期,请使用刷新令牌更新访问令牌");
                    return; // 注意:立即返回,不再执行后续过滤器
                } catch (SignatureException e) {
                    // 🔴 捕获签名异常
                    sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "INVALID_SIGNATURE", "令牌无效,拒绝访问");
                    return;
                } catch (MalformedJwtException e) {
                    // Token格式错误
                    sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "MALFORMED_TOKEN", "令牌格式错误");
                    return;
                } catch (Exception e) {
                    // 其他异常
                    sendErrorResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, "SYSTEM_ERROR", "系统异常,请稍后重试");
                    return;
                }

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
            throw ex;
        }

        filterChain.doFilter(request, response);
    }

    /**
     * 从请求中获取JWT
     */
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    /**
     * 统一发送错误响应的方法
     */
    private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String errorCode, String errorMessage) throws IOException {
        response.setStatus(status.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        // 构建一个结构化的错误响应体
        Result<Void> errorDetails = Result.error(errorCode, errorMessage);

        ObjectMapper objectMapper = new ObjectMapper();
        response.getWriter().write(objectMapper.writeValueAsString(errorDetails));
    }
}

实现用于登录和刷新令牌的接口:

java 复制代码
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    public record LoginRequest(String username, String password) {
    }
    public record LoginResponse(String accessToken, String refreshToken) {
    }
    public record RefreshRequest(String refreshToken) {
    }

    @Autowired
    private AuthenticationManager authenticationManager;
    @Autowired
    private JwtTokenService jwtTokenService;

    /**
     * 登录
     *
     * @param loginRequest 登录请求
     * @return 登录结果
     */
    @PostMapping("/login")
    public Result<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
        // 进行认证
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.username(),
                        loginRequest.password()
                )
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 登录成功后返回 access token 和 refresh token
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        if (userDetails == null) {
            throw new UsernameNotFoundException("用户[" + loginRequest.username() + "]不存在");
        }
        return Result.success(jwtTokenService.generateToken(userDetails));
    }

    /**
     * 刷新令牌
     *
     * @param refreshRequest 刷新令牌请求
     * @return 刷新令牌结果
     */
    @PostMapping("/refresh")
    public Result<LoginResponse> refresh(@RequestBody RefreshRequest refreshRequest) {
        // 验证 refreshToken 并生成新的访问令牌和刷新令牌
        LoginResponse loginResponse = jwtTokenService.validateToken(refreshRequest.refreshToken());
        return Result.success(loginResponse);
    }
}

自定义 UserDetails

Spring Security 中默认的 User 实体类是 org.springframework.security.core.userdetails.User,因此在UserDetailServiceImpl中可以组装对应的对象并返回:

java 复制代码
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // ...
    return org.springframework.security.core.userdetails.User.builder()
        .username(user.getUsername())
        .password(user.getPassword())
        // 假设用户角色为USER
        .authorities("ROLE_USER")
        .disabled(!user.getEnabled())
        .build();
}

但通常我们都会添加自定义用户实体类,此时只要让自定义的用户类实现 UserDetails 接口即可:

java 复制代码
@Data
@TableName("user")
public class CustomUser implements UserDetails {
    private Integer id;
    private String username;
    private String password;
    private Boolean enabled;

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

    @Override
    public boolean isEnabled() {
        return BooleanUtil.isTrue(enabled);
    }
}

获取当前登录用户

通过SecurityContextHolder可以很容易获取到当前用户信息,可以封装到工具类:

java 复制代码
public class UserUtil {
    public static UserDetails getCurrentUser() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            throw BusinessException.builder()
                    .code("NOT_LOGIN")
                    .message("用户未登录")
                    .httpStatusCode(401)
                    .build();
        }
        return (UserDetails) authentication.getPrincipal();
    }
}

Authentication中的Principal可能是任何类型的对象,这取决于UserDetailsServiceloadUserByUsername返回的对象类型。但通常都会是一个UserDetails

当然,在这里我们使用了自定义的CustomUser,因此这里可以修改为:

java 复制代码
public static CustomUser getCurrentUser() {
    // ...
    return (CustomUser) authentication.getPrincipal();
}

现在可以在需要获取当前用户信息的地方:

java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping("/info")
    public UserDTO getUserInfo() {
        CustomUser currentUser = UserUtil.getCurrentUser();
        return UserDTO.builder()
                .id(currentUser.getId())
                .username(currentUser.getUsername())
                .enabled(currentUser.isEnabled())
                .build();
    }
}

Spring Security 可以和 Spring MVC 很好的集成,此时也可以通过依赖注入的方式获取当前登录的用户信息:

java 复制代码
@GetMapping("/info")
public UserDTO getUserInfo2(@AuthenticationPrincipal CustomUser currentUser) {
    return UserDTO.builder()
            .id(currentUser.getId())
            .username(currentUser.getUsername())
            .enabled(currentUser.isEnabled())
            .build();
}

需要在配置类上使用@EnableWebSecurity注解以开启集成。

权限控制

添加用户权限关系表:

sql 复制代码
create table user_authority
(
    username  varchar(50) not null,
    authority varchar(50) not null,
    constraint ix_auth_username
        unique (username, authority),
    constraint fk_authorities_users
        foreign key (username) references user (username)
);

对应的实体类:

java 复制代码
@TableName("user_authority")
@Data
public class UserAuthority implements GrantedAuthority {
    @TableId(type = IdType.INPUT)
    private String username;
    private String authority;
}

在查找用户信息的时候填充权限信息:

java 复制代码
@Service
public class UserDetailServiceImpl implements UserDetailsService {
    // ...
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // ...
        // 填充用户权限
        List<UserAuthority> userAuthorities = userAuthorityMapper.selectList(new QueryWrapper<UserAuthority>()
                .eq("username", username));
        user.setAuthorities(userAuthorities);
        return user;
    }
}

Spring Security 添加权限控制有两种,一种是在配置中为特定路径添加控制权限,比如:

java 复制代码
.requestMatchers("/admin/**").hasRole("ADMIN") // 管理员可访问

此时拥有 ADMIN 角色的用户才有访问相应接口的权限。

除了这种方式,还有一种更细力度的控制方式------在方法上添加访问控制权限:

java 复制代码
@GetMapping("/index")
@PreAuthorize("hasRole('ADMIN')")
public Result<String> index() {
    return Result.success("index");
}

这种权限控制是通过 Spring AOP 实现的,并且并不仅限于 Controller 的方法,可以在任意的方法上添加。

@PreAuthorize中可以编写复杂的 SPEL 表达式,比如:

java 复制代码
@PreAuthorize("hasRole('ADMIN') || hasAuthority('/api/index:read')")

这表示拥有 ADMIN 角色或者拥有/api/index:read权限就可以访问接口。

角色权限

Spring Security 中,无论是角色权限还是普通权限,都混杂在一起,通过UserDetails接口获取:

java 复制代码
public interface UserDetails extends Serializable {
	Collection<? extends GrantedAuthority> getAuthorities();
}

GrantedAuthority接口很简单:

java 复制代码
public interface GrantedAuthority extends Serializable {
	@Nullable String getAuthority();
}

getAuthority方法返回一个表示具体权限的字符串。

区别在于,表示角色的权限会有一个前缀,默认是 ROLE_,这也是为什么保存在数据库中的用户权限表中的数据会是:

虽然这样的权限设置已经可以满足使用,我们可以很灵活地给一个用户添加若干的角色权限和普通权限,但弊端是繁琐的配置以及过长的权限控制表达式,比如:

java 复制代码
@GetMapping("/userIndex")
@PreAuthorize("hasRole('USER') || hasAuthority('/api/userIndex:read')")
public Result<String> userIndex(){
    return Result.success("userIndex");
}

这个接口需要具有 USER 角色或者/api/userIndex:read权限才能访问。如果还有其他角色,这个表达式就会很复杂。

Spring Security 官方建议在这种情况下优先考虑关联角色与具体权限,比如我们可以建立角色-权限关系表:

sql 复制代码
create table role_authority
(
    role      varchar(50)  not null,
    authority varchar(255) not null,
    primary key (role, authority)
);

将具体权限与角色相关联:

当然这样就需要修改用户实体中的权限:

java 复制代码
// ...
@Data
@TableName("user")
public class CustomUser implements UserDetails, CredentialsContainer {
    // ...
    @TableField(exist = false)
    private SequencedSet<GrantedAuthority> grantedAuthorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if (grantedAuthorities == null){
            return List.of();
        }
        return grantedAuthorities;
    }

    private void addGrantedAuthority(GrantedAuthority grantedAuthority){
        if (grantedAuthorities == null){
            grantedAuthorities = new LinkedHashSet<>();
        }
        grantedAuthorities.add(grantedAuthority);
    }

    /**
     * 为用户添加被授予的权限
     * @param grantedAuthority 权限
     */
    private void addGrantedAuthority(String grantedAuthority){
        addGrantedAuthority(new SimpleGrantedAuthority(grantedAuthority));
    }

    /**
     * 为用户添加角色权限
     * @param roleName 角色名称
     */
    public void addRoleAuthority(String roleName){
        addGrantedAuthority(ROLE_PREFIX + roleName);
    }


    /**
     * 为用户添加普通权限
     * @param authority 权限
     */
    public void addNormalAuthority(String authority){
        addGrantedAuthority(authority);
    }
	// ...
}

在根据用户名获取用户时检索用户关联的角色以及角色关联的权限,并将这些权限添加到用户:

java 复制代码
@Override
public CustomUser getByUsername(@NonNull String username) {
    CustomUser customUser = getOneByUserName(username);
    if (customUser == null) {
        throw new UsernameNotFoundException("用户不存在" + username);
    }
    // 获取用户相关的权限
    List<UserAuthority> userAuthorities = userAuthorityService.listByUsername(username);
    if (!CollectionUtil.isEmpty(userAuthorities)) {
        for (UserAuthority userAuthority : userAuthorities) {
            String authority = userAuthority.getAuthority();
            if (StrUtil.isEmpty(authority)) {
                continue;
            }
            // 如果是普通权限,直接添加
            if (!AuthorityUtil.isRoleAuthority(authority)) {
                log.debug("添加普通权限:{}", authority);
                customUser.addNormalAuthority(authority);
            } else {
                // 角色权限,添加角色权限
                String roleName = AuthorityUtil.getRoleName(authority);
                customUser.addRoleAuthority(roleName);
                log.debug("添加角色权限:{}", roleName);
                // 获取角色相关的权限
                List<RoleAuthority> roleAuthorities = roleAuthorityService.listByRole(roleName);
                if (!CollectionUtil.isEmpty(roleAuthorities)) {
                    for (RoleAuthority roleAuthority : roleAuthorities) {
                        String authorityText = roleAuthority.getAuthority();
                        if (StrUtil.isEmpty(authorityText)) {
                            continue;
                        }
                        customUser.addNormalAuthority(authorityText);
                        log.debug("添加角色[{}]相关的普通权限:{}", roleName, authorityText);
                    }
                }
            }
        }
    }
    return customUser;
}

这里省略了工具类和持久层代码,完整代码可以查看文末的链接。

现在权限控制表达式可以修改为:

java 复制代码
@PreAuthorize("hasAuthority('/api/userIndex:read')")

只要用户拥有关联了/api/userIndex:read权限的角色,就可以访问该接口。

本文的完整示例可以从这里获取。

参考资料

相关推荐
burning_maple2 小时前
redis笔记
数据库·redis·笔记
我爱娃哈哈2 小时前
SpringBoot + Spring Security + RBAC:企业级权限模型设计与动态菜单渲染实战
spring boot·后端·spring
googleccsdn2 小时前
ENSP Pro Lab笔记:配置BGP VXLAN双栈(3)
网络·笔记
雪碧聊技术2 小时前
4.Spring整合LangChain4j
spring·langchain4j·调用大模型
爱宁~2 小时前
UnityShader学习笔记[二百九十九]UGUI中的Mask遮罩半透明Shader
笔记·学习
想用offer打牌4 小时前
Spring AI vs Spring AI Alibaba
java·人工智能·后端·spring·系统架构
啦哈拉哈4 小时前
【Python】知识点零碎学习4
python·学习·算法
HyperAI超神经4 小时前
【vLLM 学习】Rlhf Utils
人工智能·深度学习·学习·机器学习·ai编程·vllm
P.H. Infinity4 小时前
【QLIB】三、学习层(一)
学习