SpringBoot整合JWT

JWT 概述

JWT 是什么

JWT 全称 JSON Web Token,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准

它将用户信息加密到 Token 里,服务器不保存任何用户信息。服务器通过使用保存的密钥验证 Token 的正确性,只要正确即通过验证

JWT 详细讲解请见 githubhttps://github.com/jwtk/jjwt

为什么使用 JWT

随着技术的发展,分布式 web 应用的普及,通过 session 管理用户登录状态成本越来越高,因此慢慢发展成为 Token 的方式做登录身份校验,然后通过 Token 去取 redis 中的缓存的用户信息,随着之后 JWT 的出现,校验方式更加简单便捷化,无需通过 redis 缓存,而是直接根据 Token取出保存的用户信息,以及对 Token可用性校验,单点登录更为简单

传统的 CookieSessionJWT 对比

传统 CookieSession

  • 服务端需要存储 Session,由于 Session 经常需要快速查找,通常将其存储在内存或内存数据库中,当同时在线用户较多时会占用大量的服务器资源
  • 在分布式架构下,需要考虑在多个节点间同步 Session 数据
  • 由于客户端使用 Cookie 存储 SessionID,在跨域场景下需要进行兼容性处理,同时这种方式也难以防范 CSRF 攻击
  • 不支持 Android,IOS,小程序等移动端

JWT

  • JWT 需要服务端生成 Token,客户端保存这个 Token,每次请求携带这个 Token 即可,服务端认证解析即可
  • JWT 方式校验方式更加简单便捷化,无需通过 redis 缓存,而是直接根据 Token取出保存的用户信息,以及对 token 可用性校验,单点登录,验证 token 更为简单

JWT 的组成

JWT3 部分组成。第 1 部分为头部(Header),第 2 部分我们称其为载荷(Payload),第 3 部分是签证(Signature)。结合 JWT 的格式即:Header.Payload.Signature

Header

Header 是由以下这个格式的 Json 通过 Base64 编码(编码不是加密,是可以通过反编码的方式获取到这个原来的 Json,所以 JWT 中存放的一般是不敏感的信息)生成的字符串,Header 中存放的内容是说明编码对象是一个 JWT 以及使用 SHA-256 的算法进行加密(加密用于生成 Signature

{ 
"typ":"JWT", 
"alg":"HS256" 
} 

Claim

Claim 是描述 Json 的信息的一个 Json,将 Claim 转码之后生成 Payload**

Claim 是一个 JsonClaim 中存放的内容是 JWT 自身的标准属性,所有的标准属性都是可选的,可以自行添加,比如:JWT 的签发者、JWT 的接收者、JWT 的持续时间等;同时 Claim 中也可以存放一些自定义的属性,这个自定义的属性就是在用户认证中用于标明用户身份的一个属性,比如用户存放在数据库中的 id,为了安全起见,一般不会将用户名及密码这类敏感的信息存放在 Claim

{ 
"iss":"Issuer —— 用于说明该JWT是由谁签发的", 
"sub":"Subject —— 用于说明该JWT面向的对象", 
"aud":"Audience —— 用于说明该JWT发送给的用户", 
"exp":"Expiration Time —— 数字类型,说明该JWT过期的时间", 
"nbf":"Not Before —— 数字类型,说明在该时间之前JWT不能被接受与处理", 
"iat":"Issued At —— 数字类型,说明该JWT何时被签发", 
"jti":"JWT ID —— 说明标明JWT的唯一ID", 
"user-definde1":"自定义属性举例", 
"user-definde2":"自定义属性举例" 
} 

Signature

Signature 是由 HeaderPayload 组合而成,将 HeaderClaim 这两个 Json 分别使用 Base64方 式进行编码,生成字符串 HeaderPayload,然后将 HeaderPayloadHeader.Payload 的格式组合在一起形成一个字符串,然后使用上面定义好的加密算法和一个密匙(这个密匙存放在服务器上,用于进行验证)对这个字符串进行加密,形成一个新的字符串,这个字符串就是 Signature

在这里插入图片描述

JWT 实现用户认证的流程

在这里插入图片描述

JWT 优缺点

优点

  • 可扩展性好:应用程序分布式部署的情况下,Session 需要做多机数据共享,通常可以存在数据库或者 Redis 里面。而 JWT 不需要
  • 无状态:JWT 不在服务端存储任何状态。RESTful API 的原则之一是无状态,发出请求时,总会返回带有参数的响应,不会产生附加影响。用户的认证状态引入这种附加影响,这破坏了这一原则。另外 JWT 的载荷中可以存储一些常用信息,用于交换信息,有效地使用 JWT,可以降低服务器查询数据库的次数

缺点

  • 安全性:由于 JWTpayload 是使用 Base64 编码的,并没有加密,因此 JWT 中不能存储敏感数据。而 Session 的信息是存在服务端的,相对来说更安全
  • 性能:JWT 太长。由于是无状态使用 JWT,所有的数据都被放到 JWT 里,如果还要进行一些数据交换,那载荷会更大,经过编码之后导致 JWT 非常长,Cookie 的限制大小一般是 4kcookie 很可能放不下,所以 JWT 一般放在 LocalStorage 里面。并且用户在系统中的每一次 Http 请求都会把 JWT 携带在 Header 里面,Http 请求的 Header 可能比 Body 还要大。而 SessionId 只是很短的一个字符串,因此使用 JWTHttp 请求比使用 Session 的开销大得多
  • 一次性:无状态是 JWT 的特点,但也导致了这个问题,JWT 是一次性的。想修改里面的内容,就必须签发一个新的 JWT。即缺陷是一旦下发,服务后台无法拒绝携带该 JWT的请求(如踢除用户)

而正是 JWT 是一次性的,所以也产生了以下问题

  • 无法废弃:通过 JWT 的验证机制可以看出来,一旦签发一个 JWT,在到期之前就会始终有效,无法中途废弃。例如你在 payload 中存储了一些信息,当信息需要更新时,则重新签发一个 JWT,但是由于旧的 JWT 还没过期,拿着这个旧的 JWT 依旧可以登录,那登录后服务端从 JWT 中拿到的信息就是过时的。为了解决这个问题,我们就需要在服务端部署额外的逻辑,例如设置一个黑名单,一旦签发了新的 JWT,那么旧的就加入黑名单(比如存到 redis 里面),避免被再次使用
  • 续签:如果你使用 JWT 做会话管理,传统的 Cookie 续签方案一般都是框架自带的,Session 有效期 30 分钟,30 分钟内如果有访问,有效期被刷新至 30 分钟。一样的道理,要改变 JWT 的有效时间,就要签发新的 JWT。最简单的一种方式是每次请求刷新JWT,即每个 HTTP 请求都返回一个新的 JWT。这个方法不仅暴力不优雅,而且每次请求都要做 JWT 的加密解密,会带来性能问题。另一种方法是在 Redis 中单独为每个 JWT 设置过期时间,每次访问时刷新 JWT 的过期时间

可以看出想要破解 JWT 一次性的特性,就需要在服务端存储 JWT 的状态。但是引入 redis 之后,就把无状态的 JWT 硬生生变成了有状态了,违背了 JWT 的初衷。而且这个方案和 Session 都差不多了

SpringBoot 方式整合 JWT 示例

Maven 依赖

<parent>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-parent</artifactId>
	<version>2.0.6.RELEASE</version>
</parent>
<dependency>
	<groupId>io.jsonwebtoken</groupId>
	<artifactId>jjwt</artifactId>
	<version>0.9.1</version>
</dependency>

JwtTokenUtil 工具类

public class JwtTokenUtil {

	private static final Logger log = LoggerFactory.getLogger(JwtTokenUtil.class);

	private static String SECRET_KEY = "78944878877848fg)";
	private static long ACCESS_TOKEN_EXPIRETIME = 24 * 60 * 60 * 1000;// 1:设置1小时过期
	private static long REFRESH_TOKEN_EXPIRETIME = 72 * 60 * 60 * 1000;// 2:设置24小时过期
	private static String ISSUER = "yingxue.com";

	// 生成 access_token
	public static String getAccessToken(String subject, Map<String, Object> claims) {
		return generateToken(ISSUER, subject, claims, ACCESS_TOKEN_EXPIRETIME, SECRET_KEY);
	}

	// 生成 refresh_token,用于 JWT 续签  
	public static String getRefreshToken(String subject, Map<String, Object> claims) {
		return generateToken(ISSUER, subject, claims, REFRESH_TOKEN_EXPIRETIME, SECRET_KEY);
	}

	/**
	 * 签发token
	 * @param issuer:签发人
	 * @param subject:代表这个JWT的主体,即它的所有人 一般是用户id
	 * @param claims:存储在JWT里面的信息 一般放些用户的权限/角色信息
	 * @param ttlMillis:有效时间(毫秒)
	 * @param secret
	 */
	public static String generateToken(String issuer, String subject, Map<String, Object> claims, long ttlMillis,
			String secret) {
		// HS256算法加密
		SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

		byte[] signingKey = DatatypeConverter.parseBase64Binary(secret);

		JwtBuilder builder = Jwts.builder();
		if (null != claims) {
			builder.setClaims(claims);
		}
		if (!StringUtils.isEmpty(subject)) {
			builder.setSubject(subject);
		}
		if (!StringUtils.isEmpty(issuer)) {
			builder.setIssuer(issuer);
		}
		long nowMillis = System.currentTimeMillis();
		Date now = new Date(nowMillis);// 计算当前时间毫秒数
		builder.setIssuedAt(now);
		if (ttlMillis >= 0) {
			// 过期的毫秒数 = 当前时间毫秒数 + 配置文件设置的过期毫秒数
			long expMillis = nowMillis + ttlMillis;
			Date exp = new Date(expMillis);
			builder.setExpiration(exp);// 设置过期时间
		}
		builder.signWith(signatureAlgorithm, signingKey);
		return builder.compact();
	}

	// 获取用户 id
	public static String getUserId(String token) {
		String userId = null;
		try {
			Claims claims = getClaimsFromToken(token);
			userId = claims.getSubject();
		} catch (Exception e) {
			log.error("------>获取用户id失败<------");
		}
		return userId;
	}

	// 获取用户名
	public static String getUserName(String token) {
		String username = null;
		try {
			Claims claims = getClaimsFromToken(token);
			username = (String) claims.get(Constant.JWT_USER_NAME);
		} catch (Exception e) {
			log.error("------>获取用户名失败<------");
		}
		return username;
	}

	// 从令牌中获取数据声明
	public static Claims getClaimsFromToken(String token) {
		Claims claims;
		try {
			claims = Jwts.parser().setSigningKey(DatatypeConverter.parseBase64Binary(SECRET_KEY)).parseClaimsJws(token)
					.getBody();
		} catch (Exception e) {
			claims = null;
		}
		return claims;
	}

	// 校验令牌
	public static Boolean validateToken(String token) {
		Claims claimsFromToken = getClaimsFromToken(token);
		return (null != claimsFromToken && !isTokenExpired(token));
	}

	// 验证 token 是否过期
	public static Boolean isTokenExpired(String token) {
		try {
			Claims claims = getClaimsFromToken(token);
			Date expiration = claims.getExpiration();
			// 当当前的时间在 expiration 时间之前时,返回 false,也就是 token 还未过期
			boolean before = expiration.before(new Date());
			log.info("------>before的值为:" + before + "<------");
			return before;
		} catch (Exception e) {
			log.error("------>验证token结果为:已经过期<------");
			return true;
		}
	}

	// 刷新 token
	public static String refreshToken(String refreshToken, Map<String, Object> claims) {
		String refreshedToken;
		try {
			Claims parserclaims = getClaimsFromToken(refreshToken);
			// 刷新token的时候如果为空说明原先的 用户信息不变 所以就引用上个token里的内容
			if (null == claims) {
				claims = parserclaims;
			}
			refreshedToken = generateToken(parserclaims.getIssuer(), parserclaims.getSubject(), claims,
					ACCESS_TOKEN_EXPIRETIME, SECRET_KEY);
		} catch (Exception e) {
			refreshedToken = null;
			log.error("------>刷新token出现错误<------");
		}
		return refreshedToken;
	}

	// 获取 token 的剩余过期时间
	public static long getRemainingTime(String token) {
		long result = 0;
		try {
			long nowMillis = System.currentTimeMillis();
			result = getClaimsFromToken(token).getExpiration().getTime() - nowMillis;
		} catch (Exception e) {
			log.error("------>获取token的剩余过期时间失败<------", e);
		}
		return result;
	}
}

springboot 配置拦截器

可以配置一些需要用户进行鉴权验证的接口,从而对这些接口进行请求拦截

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

	// 注册一个拦截器到 spring 中
	@Override
	public void addInterceptors(@NotNull InterceptorRegistry registry) {
		registry.addInterceptor(myInterceptor()) // 添加拦截器
				.addPathPatterns("/**") // 拦截所有请求
				.excludePathPatterns("/error/login") // 不拦截的请求
				.excludePathPatterns("/error")
				.excludePathPatterns("/static/**")// 排除静态资源
				.excludePathPatterns("/user/publicKey")// 获取公钥接口
				.excludePathPatterns("/user/register")// 用户注册页面
				.excludePathPatterns("/user/userRegister")// 用户注册
				.excludePathPatterns("/user/doLogin"); // 用户登录
	}

	@Bean
	public MyInterceptor myInterceptor() {
		return new MyInterceptor();
	}
	
	@Override
	public void addResourceHandlers(@NotNull ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
		WebMvcConfigurer.super.addResourceHandlers(registry);
	}
}

自定义的拦截器

从拦截的 Http 请求的头部 Header 中获取 JWT,进行校验。成功则放行请求,失败则提示用户

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

	// 注册一个拦截器到 spring 中
	@Override
	public void addInterceptors(@NotNull InterceptorRegistry registry) {
		registry.addInterceptor(myInterceptor()) // 添加拦截器
				.addPathPatterns("/**") // 拦截所有请求
				.excludePathPatterns("/error/login") // 不拦截的请求
				.excludePathPatterns("/error")
				.excludePathPatterns("/static/**")// 排除静态资源
				.excludePathPatterns("/user/publicKey")// 获取公钥接口
				.excludePathPatterns("/user/register")// 用户注册页面
				.excludePathPatterns("/user/userRegister")// 用户注册
				.excludePathPatterns("/user/doLogin"); // 用户登录
	}

	@Bean
	public MyInterceptor myInterceptor() {
		return new MyInterceptor();
	}
	
	@Override
	public void addResourceHandlers(@NotNull ResourceHandlerRegistry registry) {
		registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
		WebMvcConfigurer.super.addResourceHandlers(registry);
	}
}

用户登录 Service

分别获取 access_tokenrefresh_token 并返回给客户端,返回refresh_token 是为了 JWT 的续签

@Service
public class UserServiceImpl implements UserService {

	@Autowired
	private UserRepository userRepository;

	@Override
	public LoginResponse userDoLogin(@NotNull UserDTO userDTO) {
		LoginResponse loginResponse = new LoginResponse();

		// 使用PasswordEncoder工具类解析加密密码,拿到明文密码
		String password = PasswordEncoder.decryptPassword(userDTO.getPassword());
		String emailRegExp = "^\\w+([-+.]\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*$";
		// 判断用户用的是登录名还是邮箱,进行登录
		if (userDTO.getUserName().matches(emailRegExp)) {// 邮箱登录
			User emailUser = userRepository.findByEmail(userDTO.getEmail());
			if (StringUtils.isEmpty(emailUser)) {
				// 该用户不存在,请先注册
				throw new BusinessException(ResponseCode.USER_NOT_EXIST);
			}
			String decodePassword = DesUtil.getInstance(Constant.DES_KEY).getDecodeString(emailUser.getPassword());
			if (!password.equals(decodePassword)) {
				// 密码错误
				throw new BusinessException(ResponseCode.PASSWORD_ERROR);
			}
			if (emailUser.getIsActive() == Constant.USER_STATUS_ERROR) {
				// 该用户已被锁定,请联系运营人员
				throw new BusinessException(ResponseCode.USER_IS_ACTIVE);
			}
			// 构建该方法的返回数据
			Map<String, Object> claimsMap = new HashMap<String, Object>();
			claimsMap.put(Constant.JWT_USER_ID, emailUser.getId());
			// 分别获取 access_token 和 refresh_token 返回给客户端
			String access_token = JwtTokenUtil.getAccessToken(emailUser.getId().toString(), claimsMap);
			String refresh_token = JwtTokenUtil.getRefreshToken(emailUser.getId().toString(), claimsMap);
			loginResponse.setAccessToken(access_token);
			loginResponse.setRefreshToken(refresh_token);
			loginResponse.setId(emailUser.getId());
			return loginResponse;
		} else {// 登录名登录
			User loginUsername = userRepository.findByUserName(userDTO.getUserName());
			if (StringUtils.isEmpty(loginUsername)) {
				// 该用户不存在,请先注册
				throw new BusinessException(ResponseCode.USER_NOT_EXIST);
			}
			String decodePassword = DesUtil.getInstance(Constant.DES_KEY).getDecodeString(loginUsername.getPassword());
			if (!password.equals(decodePassword)) {
				// 密码错误
				throw new BusinessException(ResponseCode.PASSWORD_ERROR);
			}
			if (loginUsername.getIsActive() == Constant.USER_STATUS_ERROR) {
				// 该用户已被锁定,请联系运营人员
				throw new BusinessException(ResponseCode.USER_IS_ACTIVE);
			}
			// 构建该方法的返回数据
			Map<String, Object> claimsMap = new HashMap<String, Object>();
			claimsMap.put(Constant.JWT_USER_ID, loginUsername.getId());
			// 分别获取 access_token 和 refresh_token 返回给客户端
			String access_token = JwtTokenUtil.getAccessToken(loginUsername.getId().toString(), claimsMap);
			String refresh_token = JwtTokenUtil.getRefreshToken(loginUsername.getId().toString(), claimsMap);
			loginResponse.setAccessToken(access_token);
			loginResponse.setRefreshToken(refresh_token);
			loginResponse.setId(loginUsername.getId());
			return loginResponse;
		}
	}
}

用户登录 Controller

@Controller
@RequestMapping(path = { "/user" })
public class UserController {

	@Autowired
	private UserService userService;

	// 用户登录
	@RequestMapping(path = { "/doLogin" }, method = { RequestMethod.POST })
	@ResponseBody
	public Map<String, Object> doLogin(UserDTO userDTO) {
		LoginResponse loginResponse = userService.userDoLogin(userDTO);
		return new ResponseMap().success().message("登录成功").data(loginResponse);
	}
}

用户登录页面

将服务器返回的 access_tokenrefresh_token 保存到 localStorage 中,在之后的每一次用户 Http 请求的 Header 中需要携带 access_tokenrefresh_token,完成用户的鉴权验证

$.ajax({// 用户登录
	type : 'post',
	url : '/user/doLogin',
	dataType : 'json',
	data : ({
		'userName' : username,
		'password' : rsa_password
	}),
	success : function(resp) {
		if(resp.code !== 200) {
			layer.msg(resp.message,function(){});
		} else if(resp.code == 200) {
			layer.msg('登录成功');
			// 将获取到的 access_token 和 refresh_token 保存到 localStorage 中
			localStorage.setItem('authorization', resp.data.accessToken);
			localStorage.setItem('refresh_token', resp.data.accessToken);
			var id = localStorage.getItem('itemId');
			window.location.href = 'http://127.0.0.1:8080/itemKill/info?id=' + id;
		}
	},
});

参考:https://blog.csdn.net/AkiraNicky/article/details/99307713

原文链接:https://blog.csdn.net/weixin_38192427/article/details/115154600
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值