什么是 JWT?它是如何工作的? jwt作用
yuyutoo 2024-10-14 16:19 3 浏览 0 评论
松哥最近辅导了几个小伙伴秋招,有小伙伴在面小红书时遇到这个问题,这个问题想回答全面还是有些挑战,松哥结合之前的一篇旧文和大伙一起来聊聊。
一 无状态登录
1.1 什么是有状态
有状态服务,即服务端需要记录每次会话的客户端信息,从而识别客户端身份,根据用户身份进行请求的处理,典型的设计如 Tomcat 中的 Session。例如登录:用户登录后,我们把用户的信息保存在服务端 session 中,并且给用户一个 cookie 值,记录对应的 session,然后下次请求,用户携带 cookie 值来(这一步有浏览器自动完成),我们就能识别到对应 session,从而找到用户的信息。这种方式目前来看最方便,但是也有一些缺陷,如下:
- 服务端保存大量数据,增加服务端压力
- 服务端保存用户状态,不支持集群化部署
1.2 什么是无状态
微服务集群中的每个服务,对外提供的都使用 RESTful 风格的接口。而 RESTful 风格的一个最重要的规范就是:服务的无状态性,即:
- 服务端不保存任何客户端请求者信息
- 客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
那么这种无状态性有哪些好处呢?
- 客户端请求不依赖服务端的信息,多次请求不需要必须访问到同一台服务器
- 服务端的集群和状态对客户端透明
- 服务端可以任意的迁移和伸缩(可以方便的进行集群化部署)
- 减小服务端存储压力
1.3 如何实现无状态
无状态登录的流程:
- 首先客户端发送账户名/密码到服务端进行认证
- 认证通过后,服务端将用户信息加密并且编码成一个 token(一般是 JWT),返回给客户端
- 以后客户端每次发送请求,都需要携带认证的 token
- 服务端对客户端发送来的 token 进行解密,判断是否有效,并且获取用户登录信息
二 JWT
2.1 JWT 简介
JWT,全称是 Json Web Token, 是一种 JSON 风格的轻量级的授权和身份认证规范,可实现无状态、分布式的 Web 应用授权:
JWT 作为一种规范,并没有和某一种语言绑定在一起,常用的 Java 实现是 GitHub 上的开源项目 jjwt,地址如下:https://github.com/jwtk/jjwt
2.2 JWT 数据格式
JWT 包含三部分数据:
- Header:头部,通常头部有两部分信息:
- 声明类型,这里是JWT
- 加密算法,自定义
我们会对头部进行 Base64Url 编码(可解码),得到第一部分数据。
- Payload:载荷,就是有效数据,在官方文档中(RFC7519),这里给了7个示例信息:
- iss (issuer):表示签发人
- exp (expiration time):表示token过期时间
- sub (subject):主题
- aud (audience):受众
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
这部分也会采用 Base64Url 编码,得到第二部分数据。
- Signature:签名,是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥secret(密钥保存在服务端,不能泄露给客户端),通过 Header 中配置的加密算法生成。用于验证整个数据完整和可靠性。
生成的数据格式如下图:
注意,这里的数据通过 . 隔开成了三部分,分别对应前面提到的三部分,另外,这里数据是不换行的,图片换行只是为了展示方便而已。
2.3 JWT 交互流程
流程图:
步骤翻译:
- 应用程序或客户端向授权服务器请求授权
- 获取到授权后,授权服务器会向应用程序返回访问令牌
- 应用程序使用访问令牌来访问受保护资源(如 API)
因为 JWT 签发的 token 中已经包含了用户的身份信息,并且每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,这样就完全符合了 RESTful 的无状态规范。
2.4 JWT 存在的问题
说了这么多,JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:
- 续签问题,这是被很多人诟病的问题之一,传统的 cookie+session 的方案天然的支持续签,但是 jwt 由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入 redis,虽然可以解决问题,但是 jwt 也变得不伦不类了。
- 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改 secret 来实现注销,服务端 secret 修改后,已经颁发的未过期的 token 就会认证失败,进而实现注销,不过毕竟没有传统的注销方便。
- 密码重置,密码重置后,原本的 token 依然可以访问系统,这时候也需要强制修改 secret。
- 基于第 2 点和第 3 点,一般建议不同用户取不同 secret。
对于上面提到的这些问题,在目前具体的项目实践中,更多的是通过引入 Redis 来解决这些问题。
三 实战
说了这么久,接下来我们就来看看这个东西到底要怎么用?
3.1 环境搭建
首先我们来创建一个 Spring Boot 项目,创建时需要添加 Spring Security 依赖,创建完成后,添加 jjwt 依赖,完整的 pom.xml 文件如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
</dependency>
然后在项目中创建一个简单的 User 对象实现 UserDetails 接口,如下:
public class User implements UserDetails {
private String username;
private String password;
private List<GrantedAuthority> authorities;
public String getUsername() {
return 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;
}
//省略getter/setter
}
这个就是我们的用户对象,先放着备用,再创建一个 HelloController,内容如下:
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello jwt !";
}
@GetMapping("/admin")
public String admin() {
return "hello admin !";
}
}
HelloController 很简单,这里有两个接口,设计是 /hello 接口可以被具有 user 角色的用户访问,而 /admin 接口则可以被具有 admin 角色的用户访问。
3.2 JWT 配置
先准备一个 JWT 操作的工具类:
public class JwtUtils {
public static final Integer EXPIRE_TIME = 7 * 24 * 60 * 60;
public static final Integer REDIS_TOKEN_EXPIRE_TIME = 30 * 60;
public static final String TOKEN_ISSUER = "javaboy";
/**
* 生成 JWT 字符串
*
* @return
*/
public static String createJWT() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String jws = Jwts.builder()
//设置头部信息(相当于 JWT 字符串第一部分的内容)
.header().add("type", "JWT")
.and()
//相当于 JWT 字符串第二部分的内容
//签名密钥
.signWith(getPrivateKey())
//设置过期时间
.expiration(java.util.Date.from(java.time.Instant.now().plusSeconds(EXPIRE_TIME)))
//设置用户名
.subject(authentication.getName())
//设置用户权限,相当于把用户权限转为字符串 admin,user,xxx
.claim("authorities", authentication.getAuthorities().stream().map(a -> a.getAuthority()).collect(Collectors.joining(",")))
//令牌签发时间
.issuedAt(new Date())
.issuer(TOKEN_ISSUER)
.compact();
return jws;
}
public static PrivateKey getPrivateKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
ClassPathResource resource = new ClassPathResource("private_key.pem");
byte[] privateKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream());
String key = new String(privateKeyBytes, StandardCharsets.UTF_8);
String privateStr = key
.replace("-----BEGIN PRIVATE KEY-----", "")
.replaceAll(System.lineSeparator(), "")
.replace("-----END PRIVATE KEY-----", "");
byte[] privateKeyDecodedBytes = Base64.getDecoder().decode(privateStr);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyDecodedBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
return privateKey;
}
public static RSAPublicKey getPublicKey() throws IOException, NoSuchAlgorithmException, InvalidKeySpecException {
ClassPathResource resource = new ClassPathResource("public_key.pem");
byte[] publicKeyBytes = FileCopyUtils.copyToByteArray(resource.getInputStream());
String key = new String(publicKeyBytes, StandardCharsets.UTF_8);
String publicStr = key
.replace("-----BEGIN PUBLIC KEY-----", "")
.replaceAll(System.lineSeparator(), "")
.replace("-----END PUBLIC KEY-----", "");
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
byte[] publicKeyBase64Bytes = Base64.getDecoder().decode(publicStr);
X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(publicKeyBase64Bytes);
PublicKey rsaPublicKey = keyFactory.generatePublic(publicKeySpec);
return (RSAPublicKey) rsaPublicKey;
}
public static Authentication verifyToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(getPublicKey()).build()
.parseSignedClaims(token).getPayload();
String username = claims.getSubject();
// user,admin,xxx
String authorities = claims.get("authorities", String.class);
// 解析用户权限
List<SimpleGrantedAuthority> list = Arrays.stream(authorities.split(",")).map(s -> new SimpleGrantedAuthority(s)).collect(Collectors.toList());
return UsernamePasswordAuthenticationToken.authenticated(username, null, list);
} catch (Exception e) {
//令牌校验失败
return null;
}
}
}
接下来,在用户登录成功的时候,生成 JWT 字符串,并存入到 Redis 中,如下:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.csrf(c -> c.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(f -> f
.successHandler((req, resp, auth) -> {
//登录成功,生成 JWT 字符串
try {
String token = JwtUtils.createJWT();
//将 JWT 字符串保存到 redis 中
redisTemplate.opsForValue().set(auth.getName() + ":" + token, auth.getName());
// 设置过期时间
redisTemplate.expire(auth.getName() + ":" + token, JwtUtils.REDIS_TOKEN_EXPIRE_TIME, TimeUnit.SECONDS);
resp.setContentType("application/json;charset=utf-8");
RespBean respBean = new RespBean(200, token, null);
resp.getWriter().write(new ObjectMapper().writeValueAsString(respBean));
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (InvalidKeySpecException e) {
throw new RuntimeException(e);
}
})
.permitAll())
.logout(l -> l.logoutSuccessHandler((req, resp, auth) -> {
// 删除 redis 中的 token
String key = auth.getName() + ":" + req.getHeader("Authorization").replace("Bearer ", "");
System.out.println("key = " + key);
redisTemplate.delete(key);
resp.getWriter().write("logout success");
}).logoutUrl("/logout").permitAll());
http.addFilterAfter(new JwtFilter(redisTemplate), SecurityContextHolderFilter.class);
return http.build();
}
最后再来一个校验的过滤器,在用法发送请求的时候,校验 JWT 字符串,如下:
public class JwtFilter extends GenericFilterBean {
StringRedisTemplate redisTemplate;
public JwtFilter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) {
RespBean result = null;
HttpServletRequest req = (HttpServletRequest) servletRequest;
HttpServletResponse resp = (HttpServletResponse) servletResponse;
resp.setContentType("application/json;charset=utf-8");
try {
//如果是登录请求,则直接放行
if (req.getRequestURI().equals("/login")) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
//获取请求头中的token
String token = req.getHeader("Authorization");
if (token == null) {
result = new RespBean(500, "令牌为空,请求失败", null);
PrintWriter out = resp.getWriter();
out.write(new ObjectMapper().writeValueAsString(result));
} else {
//验证token
token = token.replace("Bearer ", "");
//验证token是否有效
//校验通过,则返回认证后的 Auhtentication 对象,否则返回 null
Authentication authentication = JwtUtils.verifyToken(token);
String s = redisTemplate.opsForValue().get(authentication.getName() + ":" + token);
if (s == null) {
// 令牌失效
result = new RespBean(501, "令牌失效,请重新登录", null);
resp.getWriter().write(new ObjectMapper().writeValueAsString(result));
} else {
//令牌有效,检查令牌过期时间,如果临近过期,则为令牌续期
Long expire = redisTemplate.getExpire(authentication.getName() + ":" + token);
if (expire != null && expire < 10 * 60) {
//说明令牌还有 10 分钟过期,则重新设置新的过期时间
redisTemplate.expire(authentication.getName() + ":" + token, Duration.ofSeconds(JwtUtils.REDIS_TOKEN_EXPIRE_TIME));
}
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(servletRequest, servletResponse);
}
}
} catch (Exception e) {
} finally {
// 清除上下文
SecurityContextHolder.clearContext();
}
}
}
关于这个过滤器,我说如下几点:
- 首先从请求头中提取出 Authorization 字段,这个字段对应的 value 就是用户的 token。
- 将提取出来的 token 字符串调用 JwtUtils.verifyToken 方法进行验证,将验证结果放到当前的 SecurityContext 中,然后执行过滤链使请求继续执行下去。
3.3 Spring Security 配置
最后,在 Spring Security 中配置这个过滤器。
http.addFilterAfter(new JwtFilter(redisTemplate), SecurityContextHolderFilter.class);
搞定!
3.4 测试
做完这些之后,我们的环境就算完全搭建起来了,接下来启动项目然后在 POSTMAN 中进行测试,如下:
登录成功后返回的字符串就是经过 base64url 转码的 token,一共有三部分,通过一个 . 隔开,我们可以对第一个 . 之前的字符串进行解码,即 Header,如下:
再对两个 . 之间的字符解码,即 payload:
可以看到,我们设置信息,由于 base64 并不是加密方案,只是一种编码方案,因此,不建议将敏感的用户信息放到 token 中。
接下来再去访问 /hello 接口,注意认证方式选择 Bearer Token,Token 值为刚刚获取到的值,如下:
可以看到,访问成功。
四 总结
这就是 JWT 结合 Spring Security 的一个简单用法。
相关推荐
- 自卑的人容易患抑郁症吗?(自卑会导致抑郁吗)
-
Filephoto[Photo/IC]Lowself-esteemmakesusfeelbadaboutourselves.Butdidyouknowthatovert...
- 中考典型同(近)义词组(同义词考题)
-
中考典型同(近)义词组...
- BroadcastReceiver的原理和使用(broadcast-suppression)
-
一、使用中注意的几点1.动态注册、静态注册的优先级在AndroidManifest.xml中静态注册的receiver比在代码中用registerReceiver动态注册的优先级要低。发送方在send...
- Arduino通过串口透传ESP 13板与java程序交互
-
ESP13---是一个无线板子,配置通过热点通信Arduino通过串口透传ESP13板与java程序交互...
- zookeeper的Leader选举源码解析(zookeeper角色选举角色包括)
-
作者:京东物流梁吉超zookeeper是一个分布式服务框架,主要解决分布式应用中常见的多种数据问题,例如集群管理,状态同步等。为解决这些问题zookeeper需要Leader选举进行保障数据的强一致...
- 接待外国人英文口语(接待外国友人的英语口语对话)
-
接待外国人英文口语询问访客身份: MayIhaveyourname,please? 请问您贵姓? Whatcompanyareyoufrom? 您是哪个公司的? Could...
- 一文深入理解AP架构Nacos注册原理
-
Nacos简介Nacos是一款阿里巴巴开源用于管理分布式微服务的中间件,能够帮助开发人员快速实现动态服务发现、服务配置、服务元数据及流量管理等。这篇文章主要剖析一下Nacos作为注册中心时其服务注册与...
- Android面试宝典之终极大招(android面试及答案)
-
以下内容来自兆隆IT云学院就业部,根据多年成功就业服务经验,以及职业素养课程部分内容,归纳总结:18.请描述一下Intent和IntentFilter。Android中通过Intent...
- 除了Crontab,Swoole Timer也可以实现定时任务的
-
一般的定时器是怎么实现的呢?我总结如下:1.使用Crontab工具,写一个shell脚本,在脚本中调用PHP文件,然后定期执行该脚本;2.ignore_user_abort()和set_time_li...
- Spark源码阅读:DataFrame.collect 作业提交流程思维导图
-
本文分为两个部分:作业提交流程思维导图关键函数列表作业提交流程思维导图...
- 使用Xamarin和Visual Studio开发Android可穿戴设备应用
-
搭建开发环境我们需要做的第一件事情是安装必要的工具。因此,你需要首先安装VisualStudio。如果您使用的是VisualStudio2010,2012或2013,那么请确保它是一个专业版本或...
- Android开发者必知的5个开源库(android 开发相关源码精编解析)
-
过去的时间里,Android开发逐步走向成熟,一个个与Android相关的开发工具也层出不穷。不过,在面对各种新鲜事物时,不要忘了那些我们每天使用的大量开源库。在这里,向大家介绍的就是,在这个任劳任怨...
- Android事件总线还能怎么玩?(android实现事件处理的步骤)
-
顾名思义,AndroidEventBus是一个Android平台的事件总线框架,它简化了Activity、Fragment、Service等组件之间的交互,很大程度上降低了它们之间的耦合,使我们的代码...
- Android 开发中文引导-应用小部件
-
应用小部件是可以嵌入其它应用(例如主屏幕)并收到定期更新的微型应用视图。这些视图在用户界面中被叫做小部件,并可以用应用小部件提供者发布。可以容纳其他应用部件的应用组件叫做应用部件的宿主(1)。下面的截...
你 发表评论:
欢迎- 一周热门
-
-
前端面试:iframe 的优缺点? iframe有那些缺点
-
带斜线的表头制作好了,如何填充内容?这几种方法你更喜欢哪个?
-
漫学笔记之PHP.ini常用的配置信息
-
推荐7个模板代码和其他游戏源码下载的网址
-
其实模版网站在开发工作中很重要,推荐几个参考站给大家
-
[干货] JAVA - JVM - 2 内存两分 [干货]+java+-+jvm+-+2+内存两分吗
-
正在学习使用python搭建自动化测试框架?这个系统包你可能会用到
-
织梦(Dedecms)建站教程 织梦建站详细步骤
-
【开源分享】2024PHP在线客服系统源码(搭建教程+终身使用)
-
2024PHP在线客服系统源码+完全开源 带详细搭建教程
-
- 最近发表
-
- 自卑的人容易患抑郁症吗?(自卑会导致抑郁吗)
- 中考典型同(近)义词组(同义词考题)
- WPF 消息传递简明教程(wpf messagebox.show)
- BroadcastReceiver的原理和使用(broadcast-suppression)
- Arduino通过串口透传ESP 13板与java程序交互
- zookeeper的Leader选举源码解析(zookeeper角色选举角色包括)
- 接待外国人英文口语(接待外国友人的英语口语对话)
- 一文深入理解AP架构Nacos注册原理
- Android面试宝典之终极大招(android面试及答案)
- 除了Crontab,Swoole Timer也可以实现定时任务的
- 标签列表
-
- mybatis plus (70)
- scheduledtask (71)
- css滚动条 (60)
- java学生成绩管理系统 (59)
- 结构体数组 (69)
- databasemetadata (64)
- javastatic (68)
- jsp实用教程 (53)
- fontawesome (57)
- widget开发 (57)
- vb net教程 (62)
- hibernate 教程 (63)
- case语句 (57)
- svn连接 (74)
- directoryindex (69)
- session timeout (58)
- textbox换行 (67)
- extension_dir (64)
- linearlayout (58)
- vba高级教程 (75)
- iframe用法 (58)
- sqlparameter (59)
- trim函数 (59)
- flex布局 (63)
- contextloaderlistener (56)