springboot3 spring security+jwt实现接口权限验证实现

背景

前一个项目基于springboot2做的后台服务,使用到了spring security做权限验证,token是用java生成的uuid,把token信息存储到了redis服务中。

新的项目计划使用springboot3,且希望使用JWT实现token,以下重新记录下实现思路。

项目依赖

XML 复制代码
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.3.9</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>com.jy.bike</groupId>
	<artifactId>bike</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>bike</name>
	<description>Demo project for Spring Boot</description>
	<url/>


	<properties>
		<mysql-connector>8.0.18</mysql-connector>
		<java.version>17</java.version>
	</properties>
	<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>
			<scope>test</scope>
		</dependency>

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

		<dependency>
			<groupId>com.baomidou</groupId>
			<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
			<version>3.5.10</version>
		</dependency>

		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
			<version>${mysql-connector}</version>
		</dependency>

		<dependency>
			<groupId>org.springdoc</groupId>
			<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
			<version>2.2.0</version>
		</dependency>

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

		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-api</artifactId>
			<version>0.11.5</version>
		</dependency>

		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-impl</artifactId>
			<version>0.11.5</version>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt-jackson</artifactId>
			<version>0.11.5</version>
		</dependency>


	</dependencies>


	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

Spring Security的鉴权原理

通过jwt生成token后,后续接口请求时,在header中传入jwt token,通过自定义JwtAuthenticationFilter获取登录用户信息,并放在spring security context里。由后续UsernamePasswordAuthenticationFilter验证和拦截鉴权

实现步骤

1.配置SecurityConfiguration

其中包括:白名单放行(swagger,login等资源),自定义JwtAuthenticationFilter并放在UsernamePasswordAuthenticationFilter之前,自定义UserService并通过其userDetailsService方法获取用户信息,使用BCryptPasswordEncoder密文验证账号密码,

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

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // 关闭跨站请求
        http.csrf(AbstractHttpConfigurer::disable).authorizeHttpRequests(request ->
                // 配置放行白名单
                request.requestMatchers("login", "logout").permitAll()
                        .requestMatchers("swagger-ui/*", "v3/api-docs", "v3/api-docs/*", "/druid/**").permitAll().anyRequest().authenticated())
                // 禁用session
                .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS))
                .authenticationProvider(authenticationProvider()).addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userService.userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

2.定义一个实体类,继承UserDetails类

用于放在spring security context里,里面包括登录账号的名称,密码,权限,状态等

java 复制代码
public class LoginUserDetails implements UserDetails {
    private static final long serialVersionUID = 1L;

    private User user;

    public LoginUserDetails(User user) {
        this.user = user;
    }

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

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

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

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

3.实现一个UserService,通过其loadUserByUsername获取用户

实际应该用username查询数据库获取,这里写死一个用户,并传入经过BCryptPasswordEncoder加密后的密文(原文是123456)

java 复制代码
@Service
public class UserService {

    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                User curUser = new User();
                curUser.setName("madixin");                curUser.setPassword("$2a$10$Yt3wAk1P1aZZsJKnjGbnQehJD8F80tLS.tsenpPTC1kMrMdbjvN7.");
                curUser.setEnabled(true);
                LoginUserDetails loginUser = new LoginUserDetails(curUser);
                return loginUser;
            }
        };
    }
}

4.实现一个jwtservice,用于把用户信息加密和解密成token

java 复制代码
@Service
public class JwtService implements IJwtService {
    @Value("${token.signing.key}")
    private String jwtSigningKey;
    @Override
    public String extractUserName(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    @Override
    public String generateToken(UserDetails userDetails) {
        return generateToken(new HashMap<>(), userDetails);
    }

    @Override
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String userName = extractUserName(token);
        return (userName.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolvers) {
        final Claims claims = extractAllClaims(token);
        return claimsResolvers.apply(claims);
    }

    private String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder().setClaims(extraClaims).setSubject(userDetails.getUsername())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 24))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256).compact();
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token)
                .getBody();
    }

    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSigningKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

5.实现自己的login接口,验证登录账号和密码是否正确,是否禁用,如果通过,则使用jwtservice生成token返回。

调用authenticationManager.authenticate时,会自动调用UserService的loadUserByUsername获取用户和校验密码

java 复制代码
@RestController
public class LoginController {
    private static final Logger LOGGER = LoggerFactory.getLogger(LoginController.class);

    @Autowired
    private LoginService loginService;

    /**
     * 登录方法
     *
     * @param loginDto 登录信息
     * @return 结果
     */
    @PostMapping("/login")
    public ResponseResult<String> login(@RequestBody LoginDto loginDto) {
        try {
            // 返回JWT令牌
            return ResponseResult.success(loginService.login(loginDto.getPhone(), loginDto.getPassword()));
        } catch (BikeBaseException e) {
            LOGGER.error(e.getMessage());
            return ResponseResult.fail(e.getErrorCode());
        }
    }
}


@Service
public class LoginService {
    private static final Logger LOGGER = LoggerFactory.getLogger(LoginService.class);

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtService jwtService;

    public String login(String username, String password) throws BikeBaseException {
        // 该方法会去调用UserDetailsService.loadUserByUsername
        Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
        LoginUserDetails loginUser = (LoginUserDetails) authentication.getPrincipal();
        if (loginUser == null){
            throw new BikeBaseException(ErrorCode.ILLEGAL_AUTHENTICATE);// 认证失败
        }
        return jwtService.generateToken(loginUser);
    }

    public void logout() throws BikeBaseException {

    }
}

6.自实现JwtAuthenticationFilter(第一步已配置在UsernamePasswordAuthenticationFilter前),从header中获取token,如果验证通过,则把用户信息放在spring security context里。

java 复制代码
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtService jwtService;
    @Autowired
    private UserService userService;
    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response, @NonNull FilterChain filterChain)
            throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userEmail;
        if (StringUtils.isEmpty(authHeader) || !StringUtils.startsWith(authHeader, "Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        jwt = authHeader.substring(7);
        userEmail = jwtService.extractUserName(jwt);
        if (StringUtils.isNotEmpty(userEmail)
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userService.userDetailsService()
                    .loadUserByUsername(userEmail);
            if (jwtService.isTokenValid(jwt, userDetails)) {
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                context.setAuthentication(authToken);
                SecurityContextHolder.setContext(context);
            }
        }
        filterChain.doFilter(request, response);
    }
}

7.自定义权限异常返回

实现AuthenticationEntryPoint和AccessDeniedHandler,通过response写会统一的异常返回。

java 复制代码
@Component
public class CustomerAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setContentType("application/json;charset=utf-8");
        ServletOutputStream outputStream = response.getOutputStream();
        ObjectMapper objectMapper = new ObjectMapper();
        outputStream.write(objectMapper.writeValueAsString(ResponseResult.fail(ErrorCode.ILLEGAL_AUTHENTICATE)).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    }
}


@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        response.setContentType("application/json;charset=utf-8");
        ServletOutputStream outputStream = response.getOutputStream();
        ObjectMapper objectMapper = new ObjectMapper();
        outputStream.write(objectMapper.writeValueAsString(ResponseResult.fail(ErrorCode.ILLEGAL_AUTHENTICATE)).getBytes(StandardCharsets.UTF_8));
        outputStream.flush();
        outputStream.close();
    }
}

在SecurityConfiguration中配置异常处理

java 复制代码
http.exceptionHandling(configurer -> {
      configurer.authenticationEntryPoint(restAuthenticationEntryPoint);
      configurer.accessDeniedHandler(customerAccessDeniedHandler);
});

8.基于角色,更细粒度的控制每个接口的权限

通过在每个接口,配置@PreAuthorize装饰器实现,如

@PreAuthorize("@ss.hasPermi('sysadmin,company_admin,project_admin,worker')")

具体代码就不附上了。

参考

源码:https://github.com/buingoctruong/springboot3-springsecurity6-jwt

视频:2024最新SpringSecurity6安全框架教程-Spring Security+JWT实现项目级前端分离认证_哔哩哔哩_bilibili

相关推荐
Savvy..1 分钟前
RabbitMQ
java·rabbitmq·java-rabbitmq
TT哇2 分钟前
Spring Boot 项目中关于文件上传与访问的配置方案
java·spring boot·后端
程序员阿周3 分钟前
boost、websocketpp、curl 编译(Windows)
后端
踏浪无痕3 分钟前
信不信?一天让你从Java工程师变成Go开发者
后端·go
浪里行舟4 分钟前
使用亚马逊云科技 Elemental MediaConvert 实现 HLS 标准加密
后端
峥嵘life5 分钟前
Android16 EDLA 认证测试BTS过程介绍
android·java·linux
韩立学长5 分钟前
Springboot考研自习室预约管理系统1wdeuxh6(程序、源码、数据库、调试部署方案及开发环境)系统界面展示及获取方式置于文档末尾,可供参考。
数据库·spring boot·后端
残花月伴6 分钟前
天机学堂-day5(互动问答)
java·spring boot·后端
BingoGo13 分钟前
再推荐 10 个低调但非常实用的 PHP 包
后端·php
北友舰长1 小时前
基于Springboot+thymeleaf图书管理系统的设计与实现【Java毕业设计·安装调试·代码讲解】
java·spring boot·mysql·课程设计·图书管理·b/s·图书