Spring Security 认证
本项目使用 Spring Security 实现认证授权功能,结合 JWT 和 Redis 实现无状态认证机制。
1.引入相关依赖
在项目中引入 Spring Security 和 JWT 相关依赖来实现认证授权功能。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
</dependency>2.登录流程
2.1 JWT 认证流程
- 前端携带用户名和密码访问服务器的登录接口。
- 服务器接收到请求后,会去数据库中查询对应的用户名和密码进行校验。
- 如果用户名和密码正确,服务器会使用用户名或用户 ID 生成一个 JWT Token。
- 服务器将生成的 JWT Token 响应给前端。
- 用户登录成功后,前端在访问其他需要认证的接口时,需要在请求头中携带之前获取到的 Token。
- 服务器接收到带有 Token 的请求后,会解析 Token 获取用户 ID,根据用户 ID 获取用户的相关信息,判断是否有权访问目标资源。
- 如果有权限,则允许访问目标资源,并将响应信息返回给前端。
2.2 Spring Security 登录流程
提示
Spring Security 登录流程涉及多个组件和步骤,对应的时序图较为复杂且篇幅较大,建议将其下载后放大查看以获得更好的阅读体验。此外,需要设置断点的关键代码位置均已添加高亮标记,请仔细查看相关代码块。
Spring Security 的登录认证流程虽然涉及多个环节,但整体逻辑清晰,通过仔细阅读文档和时序图,并结合实际调试,可以很好地理解整个认证过程。文档下方已详细列出各个关键步骤和代码位置,便于开发者进行学习和调试。
最后,作者本人也是初学者,整个流程是参考网络文档并结合代码调试梳理而成,如有错误欢迎在git上评论或修改。
由于登录接口在
SecurityConfig中已配置为anonymous()访问权限,因此不会强制要求认证。同时登录请求不会携带 Token,所以在JwtAuthenticationTokenFilter中无法获取到用户信息,当前过滤器会直接放行进入下一个过滤器。@Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { LoginUser loginUser = tokenService.getLoginUser(request); if (loginUser != null && SecurityContextHolder.getContext().getAuthentication() == null) { tokenService.refreshToken(loginUser); UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authenticationToken); } filterChain.doFilter(request, response); }请求进入
IndexController调用SysUserService,创建UsernamePasswordAuthenticationToken对象,封装前端传递的用户名和密码。AuthenticationManager接收UsernamePasswordAuthenticationToken,其具体实现类ProviderManager调用authenticate()方法进行认证处理。@Override public Result login(LoginDTO loginDTO) { UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(loginDTO.getUsername(), loginDTO.getPassword()); Authentication authenticate; try { authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken); } catch (AuthenticationException e) { throw new RuntimeException(e.getMessage()); } LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); String token = tokenService.createToken(loginUser); return Result.success(token); }ProviderManager管理多个AuthenticationProvider实现,遍历所有AuthenticationProvider找到能处理当前authentication的provider。for (AuthenticationProvider provider : getProviders()) { if (provider.supports(authentication.getClass())) { Authentication result = provider.authenticate(authentication); if (result != null) { return result; } } }ProviderManager找到AbstractUserDetailsAuthenticationProvider,该 provider 调用authentication方法,内部再调用抽象方法retrieveUser(),由其子类DaoAuthenticationProvider负责从数据库检索用户信息。String username = determineUsername(authentication); boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException ex) { this.logger.debug(LogMessage.format("Failed to find user '%s'", username)); if (!this.hideUserNotFoundExceptions) { throw ex; } throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); }DaoAuthenticationProvider调用retrieveUser(),获取配置的UserDetailsService,然后调用自定义实现的loadUserByUsername()方法查询数据库用户信息。try { UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); if (loadedUser == null) { throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation"); } return loadedUser; }UserDetailsService调用loadUserByUsername()方法,从数据库查询用户信息并封装成LoginUser对象。@Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser = Db.lambdaQuery(SysUser.class).eq(SysUser::getUsername, username).one(); if (Objects.isNull(sysUser)) { throw new UsernameNotFoundException("用户不存在"); } if (sysUser.getStatus() == -1) { throw new LockedException("账号已停用"); } return new LoginUser(sysUser, Collections.emptySet()); }将用户信息封装到
LoginUser后返回到AbstractUserDetailsAuthenticationProvider的authentication方法,继续执行后续验证流程。try { this.preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException ex) { if (!cacheWasUsed) { throw ex; } // There was a problem, so try again after checking // we're using latest data (i.e. not from the cache) cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); this.preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); }this.preAuthenticationChecks.check(user)内部会校验 登录实体类 中实现的UserDetails接口方法返回值,如检查账户是否被锁定、是否过期等状态。抽象方法
additionalAuthenticationChecks()会调用DaoAuthenticationProvider的具体实现。首先判断 登录实体类 中重写的getPassword()方法是否为空,然后调用matches()方法,按照 Spring Security 配置 中配置的密码加密方式进行密码匹配。protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { if (authentication.getCredentials() == null) { this.logger.debug("Failed to authenticate since no credentials provided"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } String presentedPassword = authentication.getCredentials().toString(); if (!this.passwordEncoder.get().matches(presentedPassword, userDetails.getPassword())) { this.logger.debug("Failed to authenticate since password does not match stored value"); throw new BadCredentialsException(this.messages .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } }本项目配置的是
BCryptPasswordEncoder,进入matches()方法后,先进行格式校验,验证密码是否符合 BCrypt 格式,之后进入checkpw()方法进行密码比对。@Override public boolean matches(CharSequence rawPassword, String encodedPassword) { if (rawPassword == null) { throw new IllegalArgumentException("rawPassword cannot be null"); } if (encodedPassword == null || encodedPassword.length() == 0) { this.logger.warn("Empty encoded password"); return false; } if (!this.BCRYPT_PATTERN.matcher(encodedPassword).matches()) { this.logger.warn("Encoded password does not look like BCrypt"); return false; } return BCrypt.checkpw(rawPassword.toString(), encodedPassword); }checkpw()方法接收两个参数:用户输入的未加密密码和数据库中存储的加密密码。将未加密密码转换为字节数组后传入hashpwforcheck()方法进行加密处理,最后通过MessageDigest.isEqual()方法安全地比较两个密码。public static boolean checkpw(String plaintext, String hashed) { byte[] passwordb = plaintext.getBytes(StandardCharsets.UTF_8); return equalsNoEarlyReturn(hashed, hashpwforcheck(passwordb, hashed)); } static boolean equalsNoEarlyReturn(String a, String b) { return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); }密码比对成功后,认证流程逐层返回,最终回到
SysUserServiceImpl.login()方法,从authenticate对象中获取LoginUser信息,然后调用TokenService生成 Token 并返回给前端。登录成功后,后续请求中前端会将 Token 存入请求头中。在
JwtAuthenticationTokenFilter中获取用户信息,并将用户存入SecurityContextHolder,方便在业务代码中获取当前用户信息。
3.Spring Security 配置
3.1 安全配置类
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenService tokenService;
private final AuthenticationEntryPoint authenticationEntryPoint;
/**
* 密码加密
*
* @return 密码加密
*/
@Bean
public PasswordEncoder passwordEncoder() {
// 数值越大计算越慢,安全性越高
return new BCryptPasswordEncoder(12);
}
/**
* 获取AuthenticationManager(认证管理器),登录时认证使用
*
* @param authenticationConfiguration 认证配置
* @return AuthenticationManager 认证管理器
* @throws Exception 认证异常
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
/**
* 跨域配置
*
* @return CorsConfigurationSource 跨域配置源
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许所有请求头
configuration.setAllowedHeaders(Collections.singletonList("*"));
// 允许所有请求方法
configuration.setAllowedMethods(Collections.singletonList("*"));
// 允许所有源
configuration.setAllowedOrigins(Collections.singletonList("*"));
// 预检请求的缓存时间,单位秒,即在这个时间段里,对于相同的跨域请求不会再预检了
configuration.setMaxAge(3600L);
// 添加配置
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 所有请求都允许跨域
source.registerCorsConfiguration("/**", configuration);
return source;
}
/**
* 配置过滤器链
*
* @param http http
* @return SecurityFilterChain 拦截器链
* @throws Exception 认证异常
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// 基于 token,不需要 csrf
.csrf(AbstractHttpConfigurer::disable)
// 开启跨域以便前端调用接口
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authorize -> authorize
// 指定某些接口不需要通过验证即可访问。
.requestMatchers(HttpMethod.POST,"/index/login").anonymous()
// 静态资源,可匿名访问
.requestMatchers(HttpMethod.GET, "/").permitAll()
.requestMatchers(HttpMethod.GET, "/**.html").permitAll()
.requestMatchers(HttpMethod.GET, "/**.css").permitAll()
.requestMatchers(HttpMethod.GET, "/**.js").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/druid/**").permitAll()
// 其它所有接口需要认证才能访问
.anyRequest().authenticated()
)
.formLogin(form -> form
// 自定义登录页面
.loginPage("/login.html")
.permitAll()
)
// 基于 token,不需要 session
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 添加自定义认证过滤器
.addFilterBefore(new JwtAuthenticationTokenFilter(tokenService), UsernamePasswordAuthenticationFilter.class)
// 认证失败处理
.exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint))
.build();
}
}3.2 自定义 MD5 密码加密器
可以选择自定义密码加密方式来对用户密码进行加密处理。
public class CustomerMd5PasswordEncoder implements PasswordEncoder {
/**
* 进行MD5加密
*
* @param rawPassword 原始密码
* @return 加密后的密码
*/
@Override
public String encode(CharSequence rawPassword) {
return DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes(StandardCharsets.UTF_8));
}
/**
* 验证密码是否匹配
*
* @param rawPassword 要编码和匹配的原始密码
* @param encodedPassword 数据库中加密后的密码
* @return 匹配结果
*/
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
String md5OfRawPassword = DigestUtils.md5DigestAsHex(rawPassword.toString().getBytes(StandardCharsets.UTF_8));
return encodedPassword.equals(md5OfRawPassword);
}
}4.Token 管理
4.1 生成 secret
public class SecretKeyGenerator {
public static void main(String[] args) {
SecretKey key = Jwts.SIG.HS512.key().build();
String encodedKey = Encoders.BASE64.encode(key.getEncoded());
System.out.println("Generated Key: " + encodedKey);
}
}4.2 JWT 配置
在application.yml中配置 JWT 密钥和 Token 过期时间(分钟)。
jwt:
secret: bJksEnnZIgZ0nWuDZhJwikkRLXRtoeCRmPtWAkm+34h3anFzzwqjtM19towUuhB7t5G00/RH8zCnJ+HdLEl+/Q==
expireTime: 14404.3 Token 服务类
@Component
@RequiredArgsConstructor
public class TokenService {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expireTime}")
private int expireTime;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 创建Token
*
* @param loginUser 登录用户
* @return token
*/
public String createToken(LoginUser loginUser) {
String token = UUID.randomUUID().toString();
loginUser.setToken(token);
// 将用户信息缓存到Redis
cacheLoginUser(loginUser);
Map<String, Object> claims = new HashMap<>();
claims.put(TOKEN, token);
return doCreateToken(claims);
}
/**
* 缓存登录用户
*
* @param loginUser 登录用户
*/
private void cacheLoginUser(LoginUser loginUser) {
loginUser.setLoginTime(System.currentTimeMillis());
loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
String tokenKey = REDIS_LOGIN_TOKEN_PREFIX + loginUser.getToken();
redisTemplate.opsForValue().set(tokenKey, loginUser, expireTime, TimeUnit.MINUTES);
}
/**
* 刷新Token
*
* @param loginUser 登录用户
*/
public void refreshToken(LoginUser loginUser) {
Long loginTime = loginUser.getLoginTime();
Long expireTime = loginUser.getExpireTime();
if (expireTime - loginTime <= TWENTY_MILLIS_MINUTE) {
cacheLoginUser(loginUser);
}
}
/**
* 创建Token
*
* @param claims 参数
* @return token
*/
private String doCreateToken(Map<String, Object> claims) {
SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.builder()
.claims(claims)
.signWith(secretKey, Jwts.SIG.HS512)
.compact();
}
/**
* 获取登录用户
*
* @param request 请求
* @return 登录用户
*/
public LoginUser getLoginUser(HttpServletRequest request) {
String token = getToken(request);
if (StringUtils.isNotEmpty(token)) {
String uuid = parseToken(token);
String tokenKey = REDIS_LOGIN_TOKEN_PREFIX + uuid;
return (LoginUser) redisTemplate.opsForValue().get(tokenKey);
}
return null;
}
/**
* 从请求中获取Token
*
* @param request 请求
* @return Token
*/
private String getToken(HttpServletRequest request) {
String token = request.getHeader(REQUEST_TOKEN_HEADER);
if (StringUtils.isNotEmpty(token) && token.startsWith(REQUEST_TOKEN_PREFIX)) {
token = token.replace(REQUEST_TOKEN_PREFIX, "");
}
return token;
}
/**
* 解析Token
*
* @param token 令牌
* @return 解析结果
*/
public String parseToken(String token) {
SecretKey secretKey = Keys.hmacShaKeyFor(secret.getBytes());
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get(TOKEN).toString();
}
}5.实体类
5.1 用户实体类
数据库中新建用户表,此类与数据库中表的字段相对应。
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("`sys_user`")
public class SysUser implements Serializable {
/**
* 主键ID
*/
@TableId(type = IdType.ASSIGN_UUID)
private Long id;
/**
* 昵称
*/
private String nickname;
/**
* 年龄
*/
private Integer age;
/**
* 邮箱
*/
private String email;
/**
* 用户名
*/
private String username;
/**
* 密码,不进行序列化
*/
@JSONField(serialize = false)
private String password;
/**
* 状态:0-正常、-1-停用
*/
private Integer status;
}5.2 登录实体类
要使用 Spring Security 完成登录功能,需要一个实体类实现 UserDetails 接口,再将数据库中的实体存入此类。
重要
- 默认情况下,FastJSON2 会通过
getter方法来序列化对象属性,对于重写的字段需要添加@JSONField(serialize = false)注解以取消序列化这些不必要的属性。也可以在Redis 序列化器中配置JSONWriter.Feature.FieldBased特性,基于字段而非getter方法进行序列化,提高性能和一致性。 - 在使用 FastJSON 反序列化 Redis 中的对象时,由于 DevTools 使用了不同的类加载器(RestartClassLoader)加载应用类,而 FastJSON 使用默认类加载器,这可能导致反序列化后得到的是
JSONObject而非期望的对象类型。在Redis 序列化器中已配置JSONWriter.Feature.WriteClassName特性,通过写入类名信息确保反序列化时能正确还原对象类型。此外,也可以从 Redis 中查询到数据后,先通过toString()方法转换为字符串,再使用JSONObject.parseObject()来避免反序列化失败的问题。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
/**
* 登录令牌
*/
private String token;
/**
* 登录时间
*/
private Long loginTime;
/**
* 过期时间
*/
private Long expireTime;
/**
* 用户信息
*/
private SysUser sysUser;
/**
* 权限信息
*/
private Set<String> permissions;
public LoginUser(SysUser sysUser, Set<String> permissions) {
this.sysUser = sysUser;
this.permissions = permissions;
}
/**
* 权限信息
*
* @return 权限信息
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
/**
* 密码
*
* @return 密码
*/
@Override
public String getPassword() {
return sysUser.getPassword();
}
/**
* 用户名
*
* @return 用户名
*/
@Override
public String getUsername() {
return sysUser.getUsername();
}
/**
* 账户是否未过期,过期无法验证
*
* @return 状态
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 账户是否未锁定,锁定的用户无法进行身份验证
*
* @return 状态
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 密码是否未过期,过期无法验证
*
* @return 状态
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 是否可用,禁用的用户不能身份验证
*
* @return 状态
*/
@Override
public boolean isEnabled() {
return true;
}
}5.3 统一响应结果
统一响应结果类,将状态码、提示信息、数据封装为 Result 对象返回给前端。
public class Result extends HashMap<String, Object> {
/**
* 状态码
*/
public static final String CODE_TAG = "code";
/**
* 提示信息
*/
public static final String MSG_TAG = "msg";
/**
* 数据
*/
public static final String DATA_TAG = "data";
/**
* 初始化一个新创建的 Result 对象,使其表示一个空消息。
*/
public Result() {
}
/**
* 初始化一个新创建的 Result 对象
*
* @param code 状态码
* @param msg 返回内容
*/
public Result(int code, String msg) {
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
}
/**
* 初始化一个新创建的 Result 对象
*
* @param code 状态码
* @param msg 返回内容
* @param data 数据对象
*/
public Result(int code, String msg, Object data) {
super.put(CODE_TAG, code);
super.put(MSG_TAG, msg);
if (Objects.nonNull(data)) {
super.put(DATA_TAG, data);
}
}
/**
* 返回成功消息
*
* @return 成功消息
*/
public static Result success() {
return Result.success("操作成功");
}
/**
* 返回成功数据
*
* @return 成功消息
*/
public static Result success(Object data) {
return Result.success("操作成功", data);
}
/**
* 返回成功消息
*
* @param msg 返回内容
* @return 成功消息
*/
public static Result success(String msg) {
return Result.success(msg, null);
}
/**
* 返回成功消息
*
* @param msg 返回内容
* @param data 数据对象
* @return 成功消息
*/
public static Result success(String msg, Object data) {
return new Result(HttpStatus.OK.value(), msg, data);
}
/**
* 返回错误消息
*
* @return 警告消息
*/
public static Result error()
{
return Result.error("操作失败");
}
/**
* 返回错误消息
*
* @param msg 返回内容
* @return 警告消息
*/
public static Result error(String msg)
{
return Result.error(msg, null);
}
/**
* 返回错误消息
*
* @param msg 返回内容
* @param data 数据对象
* @return 警告消息
*/
public static Result error(String msg, Object data)
{
return new Result(HttpStatus.INTERNAL_SERVER_ERROR.value(), msg, data);
}
/**
* 返回错误消息
*
* @param code 状态码
* @param msg 返回内容
* @return 警告消息
*/
public static Result error(int code, String msg)
{
return new Result(code, msg, null);
}
}5.4 登录参数
@Data
public class LoginDTO {
private String username;
private String password;
}6.登录接口
@Tag(name = "登录")
@RestController
@RequestMapping("/index")
@RequiredArgsConstructor
public class IndexController {
private final SysUserService sysUserService;
@Operation(summary = "登录接口")
@PostMapping("/login")
public Result login(@RequestBody LoginDTO loginDTO) {
return sysUserService.login(loginDTO);
}
}在 Service 中将前端传递的登录用户名和密码封装到 UsernamePasswordAuthenticationToken 中,再将其传递到 AuthenticationManager 进行认证,会调用 UserDetailsService 的 loadUserByUsername 方法查询数据库中的用户并封装到 LoginUser,生成 Token 后返回给前端。
@Service
@RequiredArgsConstructor
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
/**
* 登录
*
* @param loginDTO 登录参数
* @return 登录结果
*/
@Override
public Result login(LoginDTO loginDTO) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(loginDTO.getUsername(), loginDTO.getPassword());
Authentication authenticate;
try {
authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
} catch (AuthenticationException e) {
throw new RuntimeException(e.getMessage());
}
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String token = tokenService.createToken(loginUser);
return Result.success(token);
}
}在此类中会根据前端传递的用户名查询数据库中的用户,UsernameNotFoundException 会被 SpringSecurity 内部捕获,再将 BadCredentialsException(用户名或密码错误) 抛出,避免用户根据提示信息猜测数据库中是否存在此用户,因此这里的自定义异常信息无效,如果需要提示给用户,可以抛出 RuntimeException。
@Service
public class UserDetailServiceImpl implements UserDetailsService {
/**
* 根据用户名查询用户信息
*
* @param username 登录传入的用户名
* @return 用户信息
* @throws UsernameNotFoundException 用户不存在
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = Db.lambdaQuery(SysUser.class).eq(SysUser::getUsername, username).one();
// UsernameNotFoundException会被内部捕获,转换为BadCredentialsException(用户名或密码错误)再抛出,防止根据异常信息判断用户是否存在,因此这里的自定义异常信息无效,可以抛出RuntimeException提示用户
if (Objects.isNull(sysUser)) {
throw new UsernameNotFoundException("用户不存在");
}
if (sysUser.getStatus() == -1) {
throw new LockedException("账号已停用");
}
return new LoginUser(sysUser, Collections.emptySet());
}
}7.JWT 认证过滤器
登录成功后,前端请求时会携带 Token,需要在过滤器中获取到 Token 并检查用户是否成功登录,同时需要在 SecurityConfig 中进行配置。如果没有从缓存中获取到用户,说明用户没有登录成功,则不会向 SecurityContextHolder 中存储用户信息,否则会先检查用户的过期时间是否小于 30 分钟,如果小于 30 分钟则会刷新 Token,并将用户信息保存到 SecurityContextHolder。
@Component
@RequiredArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private final TokenService tokenService;
/**
* 拦截请求
*
* @param request 请求
* @param response 响应
* @param filterChain 过滤器链
* @throws ServletException Servlet异常
* @throws IOException IO异常
*/
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
LoginUser loginUser = tokenService.getLoginUser(request);
if (loginUser != null && SecurityContextHolder.getContext().getAuthentication() == null) {
tokenService.refreshToken(loginUser);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
}8.认证失败处理器
如果上一步过滤器中没有获取到用户,在 doFilter 的过程中会进入此处理器,向前端返回 401,同样需要在 SecurityConfig 中进行配置。
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
/**
* 认证失败处理
*
* @param request 请求
* @param response 响应
* @param authException 认证异常
* @throws IOException IO异常
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
int code = HttpStatus.UNAUTHORIZED.value();
Result result = Result.error(code, AUTHENTICATION_FAILED);
response.setContentType("application/json;charset=UTF-8");
response.setStatus(code);
response.getWriter().write(JSON.toJSONString(result));
}
}9.测试认证功能
完成上述认证授权功能的开发后,可通过前端页面进行功能验证。请将页面文件放置在 /resources/statics 目录下。
前端页面代码及完整验证流程详见 认证功能测试。
关键验证步骤
- 访问
http://localhost:8888/test-db.html,使用生成的测试密码登录 - 登录后在 Redis 中查看缓存的用户数据
- 点击按钮验证 Token 传递和接口认证
- 清除 Token 后验证拦截跳转
- 修改 Redis 中过期时间至 30 分钟内,验证 Token 刷新
建议在 SysUserServiceImpl 中从 authenticationManager.authenticate() 开始设置断点,结合 Spring Security 登录流程 进行调试。
10.退出登录
退出登录功能的实现相对简单。只需实现LogoutSuccessHandler接口,在onLogoutSuccess方法中清除 Redis 中的用户缓存,并将成功的响应结果返回给前端。最后,还需在SecurityConfig类中进行相应的配置。
10.1 TokenService
在TokenService类中新增deleteLoginUser方法,用于删除 Redis 缓存中的用户信息。
public class TokenService {
/**
* 删除用户缓存信息
*
* @param loginUser 登录用户
*/
public void deleteLoginUser(LoginUser loginUser) {
String token = loginUser.getToken();
if (StringUtils.isNotEmpty(token)) {
String key = REDIS_LOGIN_TOKEN_PREFIX + token;
redisTemplate.delete(key);
}
}
}10.2 LogoutSuccessHandler 实现类
@Component
@RequiredArgsConstructor
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler {
private final TokenService tokenService;
/**
* 登出成功处理
*
* @param request 请求
* @param response 响应
* @param authentication 认证
* @throws IOException IO异常
*/
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
LoginUser loginUser = tokenService.getLoginUser(request);
if (Objects.nonNull(loginUser)) {
tokenService.deleteLoginUser(loginUser);
}
Result success = Result.success(LOGOUT_SUCCESS);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSON.toJSONString(success));
}
}10.3 配置 SecurityConfig
在 3.1 安全配置类 的 filterChain() 方法中补充登出配置:
// SecurityConfig.filterChain() 中新增:
.logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))通过以上步骤,就完成了退出登录的功能实现。当用户发起登出请求时,系统会自动清除其在 Redis 中的缓存信息,并向前端返回操作成功的结果。
10.4 测试
前端测试页面的退出登录功能已包含在 认证功能测试 中。
