本文使用 Spring Security + JWT 实现一个使用用户名/密码进行身份验证,并之后通过 JWT 访问令牌进行请求和验证的前后端分离系统的服务端示例。
准备工作
数据库
这里使用数据库保存用户名和密码,具体使用的是 MySQL。创建用户表:
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)
);
导入数据:
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 支持多种哈希算法对密码进行加密,这里密码的前部{...}即加密时使用的算法,将加密算法作为密码的一部分保存的好处是可以很容易进行加密算法升级,比如对新生成的密码运用新的加密算法,旧的密码依然使用旧的加密算法进行验证。
官方文档有说明,这样做并不会降低安全性,攻击者知道加密算法本身并不是问题。而且一些加密算法加密后的结果本身也具备一些特征,很容易被看出来。
配置数据库连接:
spring
application
namejwt
datasource
urljdbcmysql//localhost3306/learn_spring_security?useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&transformedBitIsBoolean=true&allowMultiQueries=true&useSSL=false&allowPublicKeyRetrieval=true
usernameroot
passwordmysql
driver-class-namecom.mysql.cj.jdbc.Driver
typecom.alibaba.druid.pool.DruidDataSource
Redis
后文使用管理和刷新令牌会使用 Redis,按照一般性的 Spring Boot 集成 Redis 即可,这里不作赘述。
Spring Security
Spring Security 用于认证的用户/权限信息可以保存在任何地方,如果是保存在数据库,需要添加一个数据源:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.27</version>
</dependency>
spring
datasource
# Druid连接池专有配置
initialSize5
minIdle5
maxActive20
maxWait60000
filtersstat,wall,log4j2 # 开启监控统计和防火墙功能
public class DruidConfig {
(prefix = "spring.datasource")
public DataSource dataSource(){
return new DruidDataSource();
}
}
添加 Spring Security 的必要配置:
public class SecurityConfig {
private JwtAuthenticationFilter jwtAuthenticationFilter;
/**
* 配置路径认证规则
* @param http HttpSecurity
* @param customAccessDeniedHandler 自定义403处理
* @param jwtAuthenticationEntryPoint 自定义401处理
* @return SecurityFilterChain
* @throws Exception 异常
*/
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 认证管理器
*/
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
/**
* 密码编码器
* @return 密码编码器
*/
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
包含三个 Bean:
-
SecurityFilterChain:路径的认证规则,包括是否使用 CSRF 保护、会话是有状态还是无状态、哪些路径需要认证、异常处理器等。 -
AuthenticationManager:认证管理器,认证管理器包含的UserDetailsService决定如何获取用户信息进行认证,因此需要重写这个 Bean 注入,以覆盖默认的UserDetailsServiceBean。 -
PasswordEncoder:密码编码器,通过不可逆 HASH 算法对密码进行编码,以保护原始密码。Spring Security 支持多种 HASH 算法实现的密码编码器,可以挑选合适的,或者通过PasswordEncoderFactories.createDelegatingPasswordEncoder()获取一个推荐的密码编码器,这样做的好处是可以更方便的升级密码编码算法。
创建表示用户信息的实体类:
("user")
public class User {
private Integer id;
private String username;
private String password;
private Boolean enabled;
}
重写实现接口UserDetailsService的 Bean,从数据库获取用户信息:
public class UserDetailServiceImpl implements UserDetailsService {
private UserMapper userMapper;
/**
* 从数据库获取用户信息
* @param username 用户名
* @return 用户信息
* @throws UsernameNotFoundException
*/
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。
添加依赖:
<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:
public class JwtTokenProvider {
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 失效,可以在服务器启动时使用随机的盐生成密钥:
private final SecretKey key = Jwts.SIG.HS512.key().build();
实现令牌生成和验证的服务类:
public class JwtTokenServiceImpl implements JwtTokenService {
private JwtTokenProvider tokenProvider;
private RefreshTokenRedis refreshTokenRedis;
private UserDetailsService userDetailsService;
/**
* 生成 access token 和 refresh token
* @param userDetails 用户信息
* @return 登录响应
*/
public AuthController.LoginResponse generateToken( 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
*/
public AuthController.LoginResponse validateToken( 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:
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private JwtTokenProvider tokenProvider;
private UserDetailsService userDetailsService;
/**
* @param request 请求
* @param response 响应
* @param filterChain 过滤器链
* @throws ServletException
* @throws IOException
*/
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));
}
}
实现用于登录和刷新令牌的接口:
("/api/auth")
public class AuthController {
public record LoginRequest(String username, String password) {
}
public record LoginResponse(String accessToken, String refreshToken) {
}
public record RefreshRequest(String refreshToken) {
}
private AuthenticationManager authenticationManager;
private JwtTokenService jwtTokenService;
/**
* 登录
*
* @param loginRequest 登录请求
* @return 登录结果
*/
("/login")
public Result<LoginResponse> login( 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 刷新令牌结果
*/
("/refresh")
public Result<LoginResponse> refresh( RefreshRequest refreshRequest) {
// 验证 refreshToken 并生成新的访问令牌和刷新令牌
LoginResponse loginResponse = jwtTokenService.validateToken(refreshRequest.refreshToken());
return Result.success(loginResponse);
}
}
自定义 UserDetails
Spring Security 中默认的 User 实体类是 org.springframework.security.core.userdetails.User,因此在UserDetailServiceImpl中可以组装对应的对象并返回:
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 接口即可:
("user")
public class CustomUser implements UserDetails {
private Integer id;
private String username;
private String password;
private Boolean enabled;
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
public boolean isEnabled() {
return BooleanUtil.isTrue(enabled);
}
}
获取当前登录用户
通过SecurityContextHolder可以很容易获取到当前用户信息,可以封装到工具类:
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可能是任何类型的对象,这取决于UserDetailsService的loadUserByUsername返回的对象类型。但通常都会是一个UserDetails。
当然,在这里我们使用了自定义的CustomUser,因此这里可以修改为:
public static CustomUser getCurrentUser() {
// ...
return (CustomUser) authentication.getPrincipal();
}
现在可以在需要获取当前用户信息的地方:
("/user")
public class UserController {
("/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 很好的集成,此时也可以通过依赖注入的方式获取当前登录的用户信息:
("/info")
public UserDTO getUserInfo2( CustomUser currentUser) {
return UserDTO.builder()
.id(currentUser.getId())
.username(currentUser.getUsername())
.enabled(currentUser.isEnabled())
.build();
}
需要在配置类上使用
@EnableWebSecurity注解以开启集成。
本文的完整示例可以从获取。

文章评论