springboot3 spring security+jwt实现接口权限验证实现
背景
前一个项目基于springboot2做的后台服务,使用到了spring security做权限验证,token是用java生成的uuid,把token信息存储到了redis服务中。
新的项目计划使用springboot3,且希望使用JWT实现token,以下重新记录下实现思路。
项目依赖
<?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密文验证账号密码,
@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里,里面包括登录账号的名称,密码,权限,状态等
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)
@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
@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获取用户和校验密码
@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里。
@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写会统一的异常返回。
@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中配置异常处理
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