springboot3+Spring Security+JWT+redis实现登陆验证
完整代码连接:gitee完整代码跳转
背景
多年来,我虽积累了丰富的工作经验,但从未有机会独立负责实现一个完整的登录注册功能。由于登录注册功能是系统架构中的基石,通常在公司项目初期就已构建完成,并作为标准模块集成至后续开发的系统中。即便在技术迭代升级的过程中,这类核心功能也鲜少进行大幅调整,更多的是通过增加配置选项或优化技术框架来保持其稳定性和兼容性。鉴于此,我决定借此机会,运用最新的技术框架(如SpringBoot 3),亲自梳理并实现这一基础功能,以加深对系统底层逻辑的理解与掌握。
环境
JDK17、maven3.x、SpringBoot3
流程
<?xml version="1.0" standalone="no"?><?xml-stylesheet type="text/css" href="https://wps.processon.com/themes/default/wps/mind/icons/icons.css" ?>
1.引入依赖
3.配置Spring Security
(SecurityConfig)
4. 创建JWT实现类
(JwtServiceImpl)
5. 创建JWT过滤器
(JwtAuthenticationFilter
)
2.定义角色和权限实体类
6.创建登陆用户bean
(User)
7.User实现UserDetails接口
拦截访问
是否携带
token
Y
N
token有效性
校验
8.实现登陆接口
(Controller,Service等)
9.配置认证策略
(AuthenticationConfig
)
10创建一个普通接口(测
试token有效性)
开始
结束
token有效性校
验逻辑
返回403
结束
redis中的token是否过期
&&Token是否过期
N过期
Y有效
其他流程
1.引入依赖
<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>
2.定义用户实体类
@Data
@TableName("sys_user")
public class SysUserDO {
/**
* id
*/
private Integer id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 状态
*/
private Integer status;
}
3.配置Spring Security(SecurityConfig)
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
/**
* 白名单路径
*/
private static final String[] WHITE_LIST_URL = {"/user/login"};
/**
* 过滤器:请求处理之前验证JWT
*/
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
System.out.println(" =======安全策略启动===== ");
System.out.printf(" =======白名单%s===== %n", Arrays.toString(WHITE_LIST_URL));
// 禁用CSRF保护 globally
http.csrf(AbstractHttpConfigurer::disable)
// 除白名单外,都需要身份验证
.authorizeHttpRequests(req ->
req.requestMatchers(WHITE_LIST_URL)
.permitAll()
// 拒绝访问除以上URL以外的所有请求
.anyRequest()
// 要求身份验证
.authenticated())
// 使用不存储会话的会话策略
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS))
// 使用JWT认证提供者
.authenticationProvider(authenticationProvider)
// 在JWT认证过滤器之前添加用户名密码认证过滤器
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
// 配置注销流程
.logout(logout ->
// 设置注销URL
logout.logoutUrl("/user/logout")
// 在注销成功后清除安全上下文
.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()));
//单用户登录,如果有一个登录了,同一个用户在其他地方不能登录
//http.sessionManagement().maximumSessions(1).maxSessionsPreventsLogin(true);
//单用户登录,如果有一个登录了,同一个用户在其他地方登录将前一个剔除下线
//http.sessionManagement().maximumSessions(1).expiredUrl("/toLogin");
// 返回配置好的HTTP安全策略
return http.build();
}
}
4. 创建JWT实现类(JwtServiceImpl)
@Service
public class JwtServiceImpl {
@Value("${application.security.jwt.secret-key}")
private String secretKey;
@Value("${application.security.jwt.expiration}")
private long jwtExpiration;
@Value("${application.security.jwt.refresh-token.expiration}")
private long refreshExpiration;
@Resource
private RedisTemplate<String, String> redisTemplate;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return buildToken(extraClaims, userDetails, jwtExpiration);
}
/**
* 刷新token:token续命且刷新redis
*
* @param token
* @return
*/
public String generateRefreshToken(String token) {
System.out.println("=== generateRefreshToken === " );
try {
DecodedJWT jwt = JWT.decode(token);
String username = jwt.getSubject();
Algorithm algorithm = Algorithm.HMAC256(secretKey);
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshExpiration);
String redisKey = generateUserRedisKey(username, token);
redisTemplate.opsForValue().set(redisKey, username, refreshExpiration, TimeUnit.MILLISECONDS);
return JWT.create().withSubject(username).withIssuedAt(now).withExpiresAt(expiryDate).sign(algorithm);
} catch (JWTDecodeException e) {
return null;
}
}
private String buildToken(Map<String, Object> extraClaims, UserDetails userDetails, long expiration) {
System.out.println("=== 创建token === " );
//结合自身业务场景,有些系统将用户的一些基本信息(id,username,roles)全部放在了以json的形式放在了token中
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
Algorithm algorithm = Algorithm.HMAC256(secretKey);
String token = JWT.create()
.withSubject(JSON.toJSONString(userDetails))
.withIssuedAt(now)
.withExpiresAt(expiryDate)
.sign(algorithm);
//将token存入redis中,并设置过期时间
String redisKey = generateUserRedisKey(userDetails.getUsername(), token);
redisTemplate.opsForValue().set(redisKey, userDetails.getUsername(), expiration, TimeUnit.MILLISECONDS);
return token;
}
public boolean isTokenValid(String token) {
System.out.println("=== token有效性校验 === " );
try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
JWT.require(algorithm).build().verify(token);
System.out.println(" === 有效的token === " );
return true;
} catch (Exception e) {
System.err.println(" === 无效的token === " );
return false;
}
}
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(getSignInKey()).build().parseClaimsJws(token).getBody();
}
public String getUsernameFromToken(String token) {
System.out.println("=== getUsernameFromToken === ");
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getSubject();
} catch (JWTDecodeException e) {
return null;
}
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateUserRedisKey(String username,String token) {
return username+"::"+token;
}
}
5. 创建JWT过滤器(JwtAuthenticationFilter)
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Resource
private JwtServiceImpl jwtServiceImpl;
@Resource
private RedisTemplate<String, String> redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println(" =======拦截器启动===== ");
System.out.printf(" =======拦截器路径%s===== %n", request.getRequestURL().toString());
// 从HttpServletRequest请求中获取"Authorization"头部的值
String token = request.getHeader("Authorization");
// 判断token是否不为null,并且以"Bearer "开头
if (token != null && token.startsWith("Bearer ")) {
// 将token从"Bearer "开头截取
token = token.substring(7);
String username = jwtServiceImpl.getUsernameFromToken(token);
String redisKey = jwtServiceImpl.generateUserRedisKey(username, token);
String redisUser = redisTemplate.opsForValue().get(redisKey);
if (jwtServiceImpl.isTokenValid(token) && StringUtils.hasText(redisUser)) {
//没有用户信息,就需要从数据库中获取用户信息
//有效的token
System.out.println("=====有效的token = " + token);
// 创建UsernamePasswordAuthenticationToken类型的authentication
//将用户信息存放在SecurityContextHolder.getContext(),后面的过滤器就可以获得用户信息了。这表明当前这个用户是登录过的,后续的拦截器就不用再拦截了
UserDetails userDetails = JSON.parseObject(redisUser, UserDetails.class);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, new ArrayList<>());
// 将authentication设置到当前SecurityContextHolder中的Authentication
SecurityContextHolder.getContext().setAuthentication(authentication);
//刷新token,结合自身系统场景而定:相当于每次访问都进行续命
refreshToken(token);
} else {
System.err.println("===无效token===");
}
}
// 执行过滤器链中的下一个过滤器
chain.doFilter(request, response);
}
/**
* 刷新token
*/
private void refreshToken(String token) {
jwtServiceImpl.generateRefreshToken(token);
}
}
6.创建登陆用户bean(User)
这里要注意区别实体类SysUserDO,这个bean需要去实现security的UserDetails接口
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {
/**
* id
*/
private Integer id;
/**
* 用户名
*/
private String username;
/**
* 密码
*/
private String password;
/**
* 角色列表
*/
@TableField(exist = false)
private List<SysRoleDO> roles;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
}
@Override
public String getPassword() {
return this.password;
}
@Override
public String getUsername() {
return this.username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
8.实现登陆接口(Controller,Service等)
Controller略,
ServiceImpl主要代码:
在generateRefreshToken方法中包含有redis的刷新
@Override
public Result login(String username, String password) {
System.out.println("=== login ===" );
User user = getUserByUsername(username).get();
String token = null;
//验证密码是否正确
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(username,password);
try {
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if(authenticate==null){
throw new RuntimeException("用户名或密码错误");
}
token = jwtServiceImpl.generateToken(user);
System.out.println("token = " + token);
String refreshToken = jwtServiceImpl.generateRefreshToken(token);
System.out.println("refreshToken = " + refreshToken);
}catch (Exception e){
log.error("用户名或密码错误!",e);
throw new RuntimeException("用户名或密码错误");
}
return Result.success().ok(token);
}
9.配置认证策略(AuthenticationConfig)
主要作用:
1.从库中获取登陆用户的信息
2.密码加密
3.填充认证切面
@Configuration
@RequiredArgsConstructor
public class AuthenticationConfig {
@Resource
private SysUserService userService;
@Bean
public UserDetailsService userDetailsService() {
System.out.println(" ====userDetailsService === ");
return username -> userService.getUserByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuditorAware<Integer> auditorAware() {
return new ApplicationAuditAware();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
10创建一个普通接口(测试token有效性)
访问的使用header中需要带有token
@RequestMapping("/test")
public Result test() {
return Result.success();
}
测试
工具:postma
1.登陆接口
2.header中带Authorization访问测试接口
或者
注意: 一定要以:“Bearer ”开头,Bearer后有个空格
不带token或者错误的token
完整代码连接:gitee完整代码跳转