本项目所将主要用到技术点:
前端:Vue2、ElementUI。
后端:Spring Boot、Spring Security、MySQL、MyBatis-Plus、Docker
为了更一步完善登录功能,这里再加入JWT验证功能,即JSON Web Token,以token令牌的形式来进行用户的验证、管理用户会话。并且使用redis来进行缓存,以提高性能和安全性。
目录
添加JWT功能
1、在pom.xml中添加jjwt依赖:
<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>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if you prefer -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
2、添加JWT工具类
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration; // token 过期时间,单位秒
// 生成 JWT
public String generateToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
}
// 验证 JWT
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
// 获取用户名
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
return claims.getSubject();
}
}
接下来配置这个工具类中的 ${jwt.secret} 和 ${jwt.expiration} 。
${jwt.secret}是用于生成token的密钥。
${jwt.expiration}是用于生成token时为它设置有效时长。
两种秘钥管理方式:
1、固定写在配置文件中。
这种方式使用方便,便于管理。
但缺点是有安全风险,因为是固定写死在文件中的,有很大的泄露风险。
2、动态生成。
即在项目启动或者运行时,动态地生成秘钥。每次重新生成后,以前的所有token都将失效,这样安全性大大提高。
但带来的缺点是:持久化和复杂化问题。
因为这样频繁地改动秘钥,导致token频繁失效,势必会影响系统的使用体验(持久化问题)。
而为了解决这个问题,需要额外的一些机制来优化这种问题,而这势必导致系统变得更加复杂。
本项目是练习项目,仅使用第一种,在配置文件固定秘钥的方式。
直接在测试类中临时生成一个可用的秘钥:
生成后,将其配置到配置类中:
3、创建JWT过滤器类
用于在请求中解析、验证JWT
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if(token != null){
// 去除自动添加的 "Bearer " 前缀,并去除首尾空格
if (token.startsWith("Bearer ")) {
token = token.substring(7).trim();
}
if (jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
4、修改spring security配置类
在securityFilterChain方法中添加:
http.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 设置无状态 Session
);
// 在 UsernamePasswordAuthenticationFilter 之前添加 JWT 过滤器
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
5、取消默认登录接口调用,重写登录功能
首先,注释掉原本的formLogin部分配置:
然后,重写一个自定义的登录接口:
@RestController
@RequestMapping("")
public class LoginController {
@Autowired
private AuthenticationManager authenticationManager;
/**
* 用于生成Token的jwt工具类
*/
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/myLogin")
public String myLogin(@RequestParam String username, @RequestParam String password, HttpServletResponse response) {
try {
// 尝试认证
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password));
// 登录成功,生成 JWT Token
String token = jwtUtil.generateToken(username);
// 返回 Token
return "登录成功, Token: " + token;
} catch (AuthenticationException ex) {
// 登录失败,返回错误信息
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return "登录失败: " + ex.getMessage();
}
}
}
6、测试JWT功能
此时进行一下测试,看是否通过了自定义的登录接口。
6.1、调用登录接口
登录成功,得到用于验证的token。
6.2、调用其他接口
从上图可以看到,此时虽然登录成功,但访问其他接口还是会提示无权限,“Full authentication is required to access this resource”。这是因为:在spring security的配置中,加的这一段:
http.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 设置无状态 Session
);
这设置了无状态 (STATELESS) 的 Session 管理方式,禁用了服务端的会话存储。它要求任何请求都必须携带JWT Token或其他认证信息来验证用户的身份,否则服务器会无法识别身份。
而在加上token之后,就能通过验证,成功调用接口:
8、添加redis功能
现在已经可以顺利使用JWT进行登录和请求验证了。接下来引入redis功能,我们把登录时获取的token放入redis中进行管理。
其实在引入redis之前,系统已可以完成完整的登录流程。
那么,既然JWT的token本身已经具有时效性和验证功能了,那为什么还要使用redis功能呢?
这主要还是因为仅仅使用JWT不够灵活、安全。
在结合了redis这类存储工具后,就可以自定义更多灵活、完善的功能:如即时控制Token的有效性、Token续期/刷新、多客户端同步控制登录状态等,此时它才是足够强大的、能够满足用户需求的。
8.1、添加Redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
8.2、本地安装并配置Redis
安装并启动Redis后(请自行搜索方法),配置application-dev.yml文件
spring:
data:
#redis配置
redis:
host: localhost
port: 6379
password:
database: 0
8.3、增加Redis验证
创建Redis配置类
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// 添加序列化设置(可选)
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
创建Redis工具类
@Component
public class RedisUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 写入缓存
*
* @param key 键
* @param value 值
*/
public void set(String key, Object value) {
redisTemplate.opsForValue().set(key, value);
}
/**
* 写入缓存并设置过期时间
*
* @param key 键
* @param value 值
* @param time 时间(秒)
*/
public void set(String key, Object value, long time) {
redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
}
/**
* 读取缓存
*
* @param key 键
* @return 值
*/
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
/**
* 删除缓存
*
* @param key 键
*/
public void delete(String key) {
redisTemplate.delete(key);
}
/**
* 判断缓存中是否有对应的key
*
* @param key 键
* @return true 存在 false 不存在
*/
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
}
修改JwtUtil类
在JwtUtil类中引入Redis工具类:
/**
* Redis工具类
*/
@Autowired
private RedisUtil redisUtil;
生成Token时存储到Redis:
// 存储到 Redis 中,设置过期时间
redisUtil.set("token:"+ username,token,expiration);
修改验证Token的方法:
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
// 检查 Redis 中是否存在该 Token
return redisUtil.hasKey("token:" + getUsernameFromToken(token));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
修改后的JwtUtil类为:
/**
* jwt工具类
*/
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
/**
* token 过期时间,单位秒
*/
@Value("${jwt.expiration}")
private long expiration;
/**
* Redis工具类
*/
@Autowired
private RedisUtil redisUtil;
/**
* 生成 token,并存入redis
*/
public String generateToken(String username) {
String token = Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(SignatureAlgorithm.HS512, secret)
.compact();
// 存储到 Redis 中,设置过期时间
redisUtil.set("token:"+ username,token,expiration);
return token;
}
/**
* 验证 token
*/
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(secret).parseClaimsJws(token);
// 检查 Redis 中是否存在该 Token
return redisUtil.hasKey("token:" + getUsernameFromToken(token));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
/**
* 获取用户名
*/
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
return claims.getSubject();
}
}
9、最终测试
以上工作完成后,现在开始测试:
登录
成功登录,产生token。
查看redis
生成的token正常地存入了redis
调用其他接口
不带token时,调用失败:
带上token后,调用成功: