强大!轻松整合JWT,实现Spring Boot统一跨站登录!
在现代应用程序中,用户身份验证和安全性至关重要。随着技术的不断发展,传统的基于会话的认证方式逐渐无法满足高并发和分布式系统的需求。因此,JSON Web Token(JWT)作为一种轻量级的认证方案,越来越受到开发者的青睐。JWT不仅能够提供无状态的身份验证,还能有效地保护用户数据,简化跨域请求的处理。在本篇文章中,我们将深入探讨如何在Spring Boot项目中实现基于JWT的用户认证机制。通过具体的代码示例,我们将展示如何创建和验证JWT、处理跨域请求,以及如何利用拦截器确保接口的安全性。这将为构建安全、可靠的微服务架构提供坚实的基础。
JWT的全称是JSON Web Token。目前,它是最流行的跨域认证解决方案之一!
在介绍JWT之前,首先让我们回顾一下传统的session
和token
认证方案及其瓶颈。
传统的session
交互过程如下图所示:
当浏览器向服务器发送登录请求时,经过验证后,用户信息会被存储在session
中。然后,服务器会生成一个sessionId
并将其放入cookie
中,然后返回给浏览器。
当浏览器再次发送请求时,它会在请求头的cookie
中放入sessionId
,并将请求数据一起发送到服务器。
服务器可以通过这个sessionId
查询session
中的用户信息!
同时,在服务器端,您也可以使用session
来判断当前用户是否已登录。如果为空,意味着用户尚未登录,将直接跳转到登录页面。
如果不为空,您可以从session
中获取用户信息以进行后续操作。
在单服务器的情况下,这种交互方式是可行的。
然而,如果业务请求量变得非常大,而单服务器可以支持的请求量有限,此时,请求可能会变慢或发生OOM
(内存溢出)。
解决方案要么是增加单服务器的配置,要么是添加新服务器,通过负载均衡满足业务需求。
如果增加单服务器的配置,而请求量持续增加,它仍然无法支持业务处理。
显然,增加新服务器可以实现无限的横向扩展。
但在添加新服务器后,不同服务器之间的sessionId
是不同的。可能在服务器A
上登录成功,并且可以从该服务器的session
中获取用户信息,但在服务器B
上无法找到session
信息。这时,用户只能重新登录,这对用户体验非常不利!
面对这种情况,一些专家提出了我们之前提到的token
方案。
请求数据将一起发送到服务器。服务器验证token
是否存在于redis
中。如果存在,意味着验证通过;如果不存在,则告知浏览器跳转到登录页面,过程结束。
token
方案确保了服务的无状态性,所有信息存储在分布式缓存中。基于分布式存储,这可以横向扩展以支持高并发。
然而,在我们讨论的session
和token
方案中,在集群环境下,它们都依赖于第三方缓存数据库redis
来实现数据共享。
有没有一种方案不使用缓存数据库redis
来实现用户信息的共享,从而达到一次登录、随处可见的效果?
什么是JWT?
JWT
的全称是JSON Web Token
。其主要原理是在服务器通过认证后,生成一个JSON对象并发送回用户,如下所示。
{
"sub": "login",
"userName": "Dylan Smith",
"userId": 1,
"userEmail": "junfeng0828@gmail.com",
"iat": 1730049826
}
在未来,当用户与服务器通信时,必须发送这个JSON对象。服务器完全依赖这个对象来识别用户的身份。为了防止用户篡改数据,服务器在生成这个对象时,会添加一个签名(后面会详细说明)。
交互过程如下:
这样,客户端和服务器都可以从token
中获取用户的基本信息,而无需保存任何会话数据。也就是说,服务器变得无状态,因此相对容易实现扩展。
由于客户端可以获取这些信息,敏感信息绝不能存储,因为浏览器可以直接从token
中获取用户信息。
JWT的格式
接下来,我们将讨论JWT的组成和格式。
JWT主要由三个部分组成。每个部分用.
分隔。各个部分为:
Header
Payload
Signature
因此,JWT的一个简单组成如下所示。
接下来分别讨论不同的部分。
Header
Header是JWT的第一部分。它通常由两部分组成:token的类型(即JWT)
和所使用的签名算法
,例如HMAC SHA256或RSA。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
在指定了类型和签名算法后,JSON块通过Base64Url
编码形成JWT的第一部分。
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
Payload
Payload部分也是一个JSON对象,用于存储实际需要传输的数据。JWT规定了七个官方字段供选择。
iss(发行者)
exp(过期时间)
sub(主题)
aud(受众)
nbf(生效时间)
iat(签发时间)
jti(JWT ID)
除了官方字段外,您还可以在此部分定义私有字段。以下是一个示例。
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
请注意,JWT默认不加密,任何人都可以读取,因此不要将敏感信息放在此部分。
这个JSON对象也需要使用Base64URL算法转换为字符串,以获得Jwt
的第二部分:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
Signature
Signature部分是对前两部分的签名,以防止数据篡改。
首先,需要指定一个密钥。这个密钥仅服务器知道,不能泄露给用户。然后,使用在Header中指定的签名算法(默认是HMAC SHA256),按照以下公式生成签名。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
计算完签名后,获得signature
签名信息。
TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
将Header、Payload和Signature的三部分组合成一个字符串,每部分之间用点(.
)分隔,然后返回给客户端。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
Base64URL
如前所述,Header和Payload的序列化算法是Base64URL。这个算法与Base64算法基本相似,但有一些小的区别。
作为一个token,JWT在某些情况下可能会放在URL中(例如api.example.com/?token=xxx)。Base64有三个字符+
、/
和=
,在URL中具有特殊含义,因此需要替换:=
被省略,+
替换为-
,/
替换为_
。这就是Base64URL算法。
方案实践
介绍了这么多,如何在实践中应用呢?不再废话,直接开始吧!
首先,我们创建一个 springboot
项目,并添加 JWT
依赖库。
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
然后,创建一个用户信息类,用于加密并存储在 token
中。
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class UserToken implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private String userEmail;
private String userName;
}
接下来,创建一个 JwtTokenUtil
工具类,用于创建 tokens
和验证 tokens
。
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
public class JwtTokenUtil {
// 定义 token 返回头部
public static final String AUTH_HEADER_KEY = "Jwt";
public static final String TOKEN_PREFIX = "Dylan ";
public static final String KEY = "test_key";
public static final Long EXPIRATION_TIME = 1000L * 60 * 60;
/**
* 创建 TOKEN。
* @param content
* @return
*/
public static String createToken(String content) {
return TOKEN_PREFIX + JWT.create()
.withSubject(content)
.withExpiresAt(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.sign(Algorithm.HMAC512(KEY));
}
/**
* 验证 token。
* @param token
*/
public static String verifyToken(String token) throws Exception {
try {
return JWT.require(Algorithm.HMAC512(KEY))
.build()
.verify(token.replace(TOKEN_PREFIX, ""))
.getSubject();
} catch (TokenExpiredException e) {
throw new Exception("Token 已过期,请重新登录。", e);
} catch (JWTVerificationException e) {
throw new Exception("Token 验证失败!", e);
}
}
}
编写一个配置类以允许跨域请求,并创建一个权限拦截器。
@Slf4j
@Configuration
public class GlobalWebMvcConfig implements WebMvcConfigurer {
/**
* 重写父类提供的接口以处理跨域请求。
* @param registry
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
// 添加映射路径。
registry.addMapping("/**")
.allowedOrigins("*")
.allowCredentials(true)
.allowedMethods("GET", "POST", "DELETE", "PUT", "OPTIONS", "HEAD")
.allowedHeaders("*")
.exposedHeaders("Server", "Content-Length", "Authorization", "Access-Token", "Access-Control-Allow-Origin", "Access-Control-Allow-Credentials");
}
/**
* 添加拦截器。
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new AuthenticationInterceptor()).addPathPatterns("/**").excludePathPatterns("/static/**");
}
}
使用 AuthenticationInterceptor
拦截器来验证接口参数。
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
@Slf4j
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 从 HTTP 请求头中获取 token。
final String token = request.getHeader(JwtTokenUtil.AUTH_HEADER_KEY);
// 如果不是映射到方法,则直接通过。
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 如果是方法探测,则直接通过。
if (HttpMethod.OPTIONS.equals(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return true;
}
// 如果方法有 JwtIgnore 注解,则直接通过。
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
if (method.isAnnotationPresent(JwtIgnore.class)) {
JwtIgnore jwtIgnore = method.getAnnotation(JwtIgnore.class);
if (jwtIgnore.value()) {
return true;
}
}
// 断言 token 不为空。
LocalAssert.isStringEmpty(token, "Token 为空,认证失败!");
// 验证 token 并获取 token 的内部信息。
String userToken = JwtTokenUtil.verifyToken(token);
// 将 token 放入本地缓存。
WebContextUtil.setUserToken(userToken);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 方法结束后,移除缓存的 token。
WebContextUtil.removeUserToken();
}
}
对于不熟悉拦截器的朋友,可以参考我这篇文章。
Spring Boot:什么是拦截器?如何在项目中配置?
我的文章对所有人开放;非会员读者可以通过点击此链接阅读完整文章。
最后,在 controller
层用户登录后,创建一个 token
并存储在响应头中。
/**
* 登录。
* @param userDto
* @return
*/
@JwtIgnore
@RequestMapping(value = "/login", method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
public void login(@RequestBody UserDto userDto, HttpServletResponse response) {
//... 参数有效性验证。
// 从数据库中获取用户信息。
User dbUser = userService.selectByUserId(userDto.getUserId());
//... 用户和密码验证。
// 创建 token 并将 token 放入响应头中。
UserToken userToken = new UserToken();
userToken.setUserId(dbUser.getUserId());
userToken.setUserEmail(dbUser.getUserEmail());
userToken.setUserName(dbUser.getUserName());
String token = JwtTokenUtil.createToken(JSONObject.toJSONString(userToken));
response.setHeader(JwtTokenUtil.AUTH_HEADER_KEY, token);
}
基本上完成了!
其中,在 AuthenticationInterceptor
中使用的 JwtIgnore
是一个用于标注不需要验证 token
的方法的注解,比如验证码获取接口。
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface JwtIgnore {
boolean value() default true;
}
而 WebContextUtil
是一个 ThreadLocal 工具类。其他接口可以通过该方法从 token
中获取用户信息。
public class WebContextUtil {
// token 的本地线程缓存。
private static ThreadLocal<String> local = new ThreadLocal<>();
/**
* 设置 token 信息。
* @param content
*/
public static void setUserToken(String content) {
removeUserToken();
local.set(content);
}
/**
* 获取 token 信息。
* @return
*/
public static UserToken getUserToken() {
if (local.get() != null) {
UserToken userToken = JSONObject.parseObject(local.get(), UserToken.class);
return userToken;
}
return null;
}
/**
* 移除 token 信息。
* @return
*/
public static void removeUserToken() {
if (local.get() != null) {
local.remove();
}
}
}
最后,启动项目。让我们使用 postman
来测试一下,看看返回结果。
可以看到,返回的 headers 已经包含了在登录接口生成的 Jwt。
当我们需要请求其他服务接口时,只需在请求头中添加 Jwt
参数即可。
结论
通过本文的实践,我们成功实现了一个基于JWT的用户认证系统。这一系统不仅确保了接口的安全性,还大大提升了用户体验。JWT的使用使得我们能够在不依赖于服务器会话的情况下,轻松地管理用户身份验证和状态。我们还通过拦截器实现了对接口请求的细粒度控制,确保只有经过认证的用户才能访问敏感资源。此外,跨域请求的配置为我们在不同客户端之间的交互提供了灵活性。
在实际开发中,JWT的优势不止于此。它的无状态特性使得应用程序能够更好地扩展,同时减少了服务器的负担。未来,随着技术的不断演进,我们可以期待更多基于JWT的创新应用。因此,掌握JWT的使用,将为开发者在构建安全、高效的应用程序上打下坚实的基础。希望本文能为你的项目提供有价值的参考和启示。