本文会从Token、JWT、JWT的实现、JWTUtil封装到SpringBoot中使用JWT,如果有一定的基础,可以跳过前面的内容~
Token 是一个临时、唯一、保证不重复的令牌,例如智能门锁,它可以生成一个临时密码,具有一定时间内的有效期。
UUID具有上述的特性,所以我们可以使用UUID作为token,生产UUID后放入Redis,设置Redis的过期时间。
token和SESSIONID非常的相似,但是SESSIONID在分布式项目中不能共享,虽然SESSION可以通过Redis等技术实现共享,但是使用这类技术会降低项目的性能和可用性。所以现在普通使用Token代替Session使用。
用户在每次请求时,都会携带此Token,后端在拦截器中校验Token是否存在,如果存在找到对应的用户信息,判断其有哪些权限。
JWT,全称Json Web Token,是目前最流行的跨域认证解决方案。它的实现思想和上面的token是基本一致的,是一种更加成熟和完善的解决方案。
JWT的原理就是,当服务器认证账号密码通过后,生成一个JSON对象,返回给用户,保存在Cookie中。当用户下一次访问的时候自动携带这个JSON对象,服务器可以根据这个对象判断用户的身份。为了防止用户篡改数据信息,服务器生成这个JSON的时候,会进行一些加密操作。此时服务器中就不需要保存session数据。
JWT中的数据分为三部分,每部分都是一串很长的字符串,中间用.
间隔
完整的格式为:header.Payload.Signature
Header部分是由一个JSON对象组成,它描述JWT的元数据,通常是下面的样子:
{
'alg' : "HS256",
"typ" : "JWT"
}
alg表示签名的算法,默认为HMAC SHA256(可以写成 HS256)
typ属性表示令牌的类型,JWT令牌统一写为JWT
生成JWT后,此部分会进行BASE64编码,最终被解析为:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
jwt中的Header部分的内容默认是没有加密的,只是进行了Base64处理。可以直接使用Base64反加密获取原文。
Payload部分也是一个JSON对象,用来存放实际需要传递的数据。JWT规定了7个官方字段:
除了官方字段,还可以支持自定义字段,下面就是一个例子:
{
"name": "rayfoo",
"phone": 18338862369
}
生产JWT后的:
eyJuYW1lIjoicmF5Zm9vIiwicGhvbmUiOjE4MzM4ODYyMzY5fQ
注意,这部分的内容默认也是没有加密的,只是进行了Base64编码。可以直接使用Base64反加密获取原文。但是我们可以对其进行一些混淆操作。
Signature 部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
这一段并不是使用base64加密,而是使用header中提供的加密方式进行的加密.
可以浅显的理解为将Payload中的数据按照header,payload+密钥(secret)作为一个整体进行MD5(也可能是任意类型的)加密。在下面这段代码中,密钥就是:rayfoo。
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
rayfoo
)
jwt生成的signature
AIwKf4x_nYr1N_cmw_VQ5t_nuaX5b-gTN8RgHtkTO4w
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.
)分隔,就可以返回给用户。这就是完整的JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoicmF5Zm9vIiwicGhvbmUiOjE4MzM4ODYyMzY5fQ.AIwKf4x_nYr1N_cmw_VQ5t_nuaX5b-gTN8RgHtkTO4w
可以在https://jwt.io/#encoded-jwt进行测试
前面提到,Header 和 Payload 串型化的算法是 Base64URL。这个算法跟 Base64 算法基本类似,但有一些小的不同。
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64 有三个字符+
、/
和=
,在 URL 里面有特殊含义,所以要被替换掉:=
被省略、+
替换成-
,/
替换成_
。这就是 Base64URL 算法。
客户端收到服务器返回的 JWT,可以储存在 Cookie 里面,也可以储存在 localStorage。
此后,客户端每次与服务器通信,都要带上这个 JWT。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization
字段里面。
Authorization: Bearer <token>
另一种做法是,跨域的时候,JWT 就放在 POST 请求的数据体里面。
(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次,不容易被客户端修改。
(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。
(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。效率也比token高。
(1)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
(2)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
(3)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。
(4)如果jwt中payload的数据过多,会占用服务器的带宽资源。
了解了上面的一些概念后,我们可以自己动手实现一个jwt
对三部分内容进行拼接,使用.
间隔
建议在payload中增加一个时间戳,用于指定过期时间。
jwt提供了不止一种的实现
Auth0实现 的 java-jwt
Brian Campbell实现的 jose4j
connect2id实现的 nimbus-jose-jwt
Les Haziewood实现的 jjwt
Inversoft实现的prime-jwt
Vertx实现的vertx-auth-jwt.
几乎所有库都要求JAVA版本1.7或更高版本, 1.6或以下的版本需要二次开发(或不支持)
从易用性, 扩展性, 完整性等来看, 使用首先推荐 jose4j, 其次是 Nimbus-jose-jwt.
关于这些类库的评测:http://andaily.com/blog/?p=956
下面的代码都是基于auth0 提供的 java-jwt实现的
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
由于JWT中的payload是不安全的,没有进行加密,所以在工具类中进行了加密操作。
这里的加密操作只是一种加密思路,你也可以使用自己的任意加密方式来让payload中的内容更加安全。
package cn.rayfoo.common.util;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Calendar;
import java.util.Map;
/**
* @author rayfoo@qq.com
* @version 1.0
* <p>JSON WEB TOKEN 工具类</p>
* @date 2020/8/11 9:19
*/
public class JWTUtil {
/**
* 签名 此签名为 rayfoo 的16位 大写 MD5
*/
private static final String SIGN_KEY = "5A1332068BA9FD17";
/**
* 默认的过期时间,30分钟
*/
private static final Integer DEFAULT_EXPIRES = 60 * 30;
/**
* token默认的长度
*/
private static final Integer DEFAULT_TOKEN_SIZE = 3;
/**
* 生成令牌
*
* @param map 数据正文
* @param expires 过期时间,单位(秒)
*/
public static String getToken(Map<String, String> map, Integer expires) throws Exception {
//创建日历
Calendar instance = Calendar.getInstance();
//设置过期时间
instance.add(Calendar.SECOND, expires);
//创建jwt builder对象
JWTCreator.Builder builder = JWT.create();
//payload
map.forEach((k, v) -> {
builder.withClaim(k, v);
});
//指定过期时间
String token = builder.withExpiresAt(instance.getTime())
//设置加密方式
.sign(Algorithm.HMAC256(SIGN_KEY));
//返回tokean
return confoundPayload(token);
}
/**
* 解析token
*
* @param token 输入混淆payload后的token
*/
public static DecodedJWT verify(String token) throws Exception {
//如果token无效
if (token == null || "".equals(token)) {
throw new JWTDecodeException("无效的token!");
}
//解析token
String dToken = deConfoundPayload(token);
//创建返回结果
return JWT.require(Algorithm.HMAC256(SIGN_KEY)).build().verify(dToken);
}
/**
* 重载getToken 此方法为获取默认30分钟有效期的token
*
* @param map 数据正文
*/
public static String getToken(Map<String, String> map) throws Exception {
return getToken(map, DEFAULT_EXPIRES);
}
/**
* 对一个base64编码进行混淆 此处还可以进行replace混淆,考虑到效率问题,这里就不做啦~
* 对于加密的思路还有位移、字符替换等~
*
* @param token 混淆payload前的token
*/
private static String confoundPayload(String token) throws Exception {
//分割token
String[] split = token.split("\\.");
//如果token不符合规范
if (split.length != DEFAULT_TOKEN_SIZE) {
throw new JWTDecodeException("签名不正确");
}
//取出payload
String payload = split[1];
//获取长度
int length = payload.length() / 2;
//指定截取点
int index = payload.length() % 2 != 0 ? length + 1 : length;
//混淆处理后的token
return split[0] + "." + reversePayload(payload, index) + "." + split[2];
}
/**
* 对一个混淆后的base编码进行解析
*
* @param token 混淆后的token
*/
private static String deConfoundPayload(String token) throws Exception {
//分割token
String[] split = token.split("\\.");
//如果token不符合规范
if (split.length != DEFAULT_TOKEN_SIZE) {
throw new JWTDecodeException("签名不正确");
}
//取出payload
String payload = split[1];
//返回解析后的token
return split[0] + "." + reversePayload(payload, payload.length() / 2) + "." + split[2];
}
/**
* 将md5编码位移
*
* @param payload payload编码
* @param index 位移处
*/
private static String reversePayload(String payload, Integer index) {
return payload.substring(index) + payload.substring(0, index);
}
}
此时,我们就可以使用此工具类颁发token、解析token了~
package cn.rayfoo;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.Verification;
import org.apache.commons.codec.binary.StringUtils;
import java.util.*;
/**
* @author rayfoo@qq.com
* @version 1.0
* <p></p>
* @date 2020/8/10 16:18
*/
public class JwtTest {
//密钥
private static final String SIGN_KEY = "rayfoo";
public static void main(String[] args) throws Exception{
//创建map
Map<String, String> map = new HashMap<>();
map.put("username", "rayfoo");
//颁发token
String token = JWTUtil.getToken(map);
System.out.println(token);
//解析token
DecodedJWT verify = JWTUtil.verify(token);
System.out.println(verify.getClaim("username").asString());
}
}
此时,使用混淆后的token解析,发现无法解析到payload:
建议使用全局异常处理进行细粒度异常处理
先基于SpringBoot+MyBatis实现一个简单的查询操作,完整的代码稍后会上传到Github,这里只进行关键部分的介绍。
传统的密码校验:
@Override
public User login(User user) throws Exception {
//这里假设user、user内的username、password数据都是正确的
User example = User.builder().username(user.getUsername()).password(user.getPassword()).build();
//查询用户是否存在
List<User> reslut = userMapper.select(example);
//如果没找到代表用户名或者密码错误
if (ObjectUtils.isEmpty(reslut)) {
throw new Exception("用户名或密码错误!");
}
return reslut.get(0);
}
上面时service层代码,如果执行没有报错说明拿到了正确的查询结果,此时在Controller中可以将用户的登录信息保存到Session或者Redis中,用于校验。
@PostMapping("/login")
public Map<String, Object> login(@RequestBody User user) {
//初始化返回值
Map<String, Object> map = new HashMap<>(2);
try {
//用户登录校验
User loginUser = userService.login(user);
//没有抛出异常表示正常
map.put("code", 200);
map.put("msg", "认证成功!");
//使用session或者redis记录。。。
} catch (Exception exception) {
//如果出现异常记录错误信息
map.put("code", 500);
map.put("msg", exception.getMessage());
}
//返回结果
return map;
}
有了JWT以后,我们可以使用token来代替Session/Redis
@PostMapping("/login")
public Map<String, Object> login(@RequestBody User user) {
//初始化返回值
Map<String, Object> map = new HashMap<>(3);
try {
//用户登录校验
User loginUser = userService.login(user);
//没有抛出异常表示正常
map.put("code", 200);
map.put("msg", "认证成功!");
//声明payload
Map<String, String> payload = new HashMap<>(2);
//初始化payload
payload.put("id", loginUser.getId().toString());
payload.put("username", loginUser.getUsername());
//获取令牌
String token = JWTUtil.getToken(payload,20);
//在响应结果中添加token
map.put("token", token);
} catch (Exception exception) {
//如果出现异常记录错误信息
map.put("code", 500);
map.put("msg", exception.getMessage());
}
//返回结果
return map;
}
这里的代码没有进行优化,只是用最简单直白的方式介绍了JWT对接口的保护
@GetMapping("/list")
public Map<String, Object> userList(String token) {
//初始化返回值
Map<String, Object> map = new HashMap<>(3);
List<User> result = null;
String errorMsg = "";
//校验token
log.info("当前token为:" + token);
try {
//验证令牌
DecodedJWT verify = JWTUtil.verify(token);
//如果令牌校验成功
result = userService.userList();
//返回查询结果
map.put("code", 200);
map.put("msg", "查询成功");
map.put("result", result);
return map;
} catch (JWTDecodeException e) {
//其实是用户修改了header或者payload,但是不用告诉用户错误的细节
e.printStackTrace();
errorMsg = "token无效!";
} catch (SignatureVerificationException e) {
e.printStackTrace();
//其实是修改了签名,但是不用告诉用户错误的细节
errorMsg = "token无效!";
} catch (TokenExpiredException e) {
e.printStackTrace();
errorMsg = "token已过期!";
} catch (AlgorithmMismatchException e) {
e.printStackTrace();
//其实是修改了算法,但是不用告诉用户错误的细节
errorMsg = "token无效!";
} catch (Exception e) {
e.printStackTrace();
errorMsg = "token无效!";
}
//返回错误信息
map.put("code", 500);
map.put("msg", "token校验失败");
map.put("result", errorMsg);
return map;
}
在前面的案例中,如果每个接口都进行拦截器校验,冗余的代码会非常的多,程序的可读性也非常低。
在单体应用中,可以使用拦截器来校验token
分布式项目中,可以在网关内校验token
在上面的案例中,token在body中作为数据传递的,但是这样是不安全的,比较推荐的做法是加在请求头内,通过请求头携带.
package cn.rayfoo.modules.base.interceptor;
import cn.rayfoo.common.util.JWTUtil;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author rayfoo@qq.com
* @version 1.0
* <p></p>
* @date 2020/8/11 15:58
*/
public class JWTInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从请求头内获取token
String token = request.getHeader("authorization");
//验证令牌 如果令牌不正确会出现异常 被全局异常处理
JWTUtil.verify(token);
return true;
}
}
这里我对所有的请求进行了拦截,放行了登录接口,真实的场景下,我们一般会放行所有/user/**的请求,另外,这个拦截器中没有注入其他属性,所以可以通过此种方式创建,如果拦截器内注入了属性,需要使用@Bean+方法的形式注册拦截器。详细的内容,可以参考我博客中关于拦截器的介绍。
package cn.rayfoo.common.config;
import cn.rayfoo.modules.base.interceptor.JWTInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author rayfoo@qq.com
* @version 1.0
* <p></p>
* @date 2020/8/11 16:13
*/
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new JWTInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/user/login");
}
}
接下来的代码我会使用统一结果集来封装返回值,下面是统一结果集的代码:
package cn.rayfoo.common.response;
import lombok.Data;
/**
* @author rayfoo@qq.com
* @date 2020年8月6日
*/
@Data
public class Result<T> {
/**
* 状态码
*/
private Integer code;
/**
* 提示信息
*/
private String msg;
/**
* 数据记录
*/
private T data;
public Result() {
}
public Result(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
public Result(Integer code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
}
在拦截器中处理异常也是非常不好的习惯,我们可以将异常交由统一异常处理来管理
package cn.rayfoo.common.exception;
import cn.rayfoo.common.response.Result;
import com.auth0.jwt.exceptions.AlgorithmMismatchException;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* @author rayfoo@qq.com
* @version 1.0
* <p>全局异常处理</p>
* @date 2020/8/11 16:36
*/
@ControllerAdvice@Slf4j
public class ServiceExceptionHandler {
/**
* 默认异常的状态码
*/
private static final Integer DEFAULT_EXCEPTION = 500;
/**
* token超时异常状态码
*/
private static final Integer TOKEN_ERROR_EXCEPTION = 505;
/**
* token无效状态码
*/
private static final Integer TOKEN_EXPIRED_EXCEPTION = 506;
/**
* 处理token异常
*/
@ResponseBody
@ExceptionHandler({SignatureVerificationException.class, AlgorithmMismatchException.class, JWTDecodeException.class})
public Result<String> tokenErrorException() {
Result<String> result = new Result<>();
result.setCode(TOKEN_ERROR_EXCEPTION);
result.setMsg("无效的token!");
log.error("无效的token");
return result;
}
/**
* 处理token异常
*/
@ResponseBody
@ExceptionHandler({TokenExpiredException.class})
public Result<String> tokenExpiredException() {
Result<String> result = new Result<>();
result.setCode(TOKEN_EXPIRED_EXCEPTION);
result.setMsg("token超时!");
log.error("用户token超时");
return result;
}
/**
* 处理所有RuntimeException异常
*/
@ResponseBody
@ExceptionHandler({RuntimeException.class})
public Result<String> allException(RuntimeException e) {
Result<String> result = new Result<>();
result.setCode(DEFAULT_EXCEPTION);
result.setMsg( e.getMessage());
log.error(e.getMessage());
e.printStackTrace();
return result;
}
/**
* 处理所有Exception异常
*/
@ResponseBody
@ExceptionHandler({Exception.class})
public Result<String> allException(Exception e) {
Result<String> result = new Result<>();
result.setCode(DEFAULT_EXCEPTION);
result.setMsg( e.getMessage());
log.error(e.getMessage());
e.printStackTrace();
return result;
}
}
这里介绍一个Controller的小技巧,可以通过通用Controller来封装一些公共的属性
package cn.rayfoo.modules.base.controller;
import cn.rayfoo.modules.base.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.ModelAttribute;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* @author rayfoo@qq.com
* @version 1.0
* @date 2020/8/5 14:34
* @description 基础controller
*/
public class BaseController {
/**
* 注入全部service
*/
@Autowired
protected UserService userService;
/**
* 创建session、Request、Response等对象
*/
protected HttpServletRequest request;
protected HttpServletResponse response;
protected HttpSession session;
/**
* 在每个子类方法调用之前先调用
* 设置request,response,session这三个对象
*
* @param request
* @param response
*/
@ModelAttribute
public void setReqAndRes(HttpServletRequest request, HttpServletResponse response) {
this.request = request;
this.response = response;
this.session = request.getSession(true);
//可以在此处拿到当前登录的用户
}
}
如果需要用到token中的数据,可以使用request对象中获取token进行相关的处理。下面是优化后的Controller代码,是不是焕然一新呢?
package cn.rayfoo.modules.base.controller;
import cn.rayfoo.common.response.HttpStatus;
import cn.rayfoo.common.response.Result;
import cn.rayfoo.common.util.JWTUtil;
import cn.rayfoo.modules.base.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @author rayfoo@qq.com
* @version 1.0
* <p></p>
* @date 2020/8/11 14:12
*/
@RestController
@Slf4j
@RequestMapping("/user")
public class UserController extends BaseController {
@PostMapping("/login")
public Result<String> login(@RequestBody User user) throws Exception {
//初始化返回值
Result<String> result = new Result<>();
//用户登录校验
User loginUser = userService.login(user);
//没有抛出异常表示正常
result.setCode(HttpStatus.OK.value());
result.setMsg("认证成功!");
//声明payload
Map<String, String> payload = new HashMap<>(2);
//初始化payload
payload.put("id", loginUser.getId().toString());
payload.put("username", loginUser.getUsername());
//获取令牌
String token = JWTUtil.getToken(payload, 20);
//在响应结果中添加token
result.setData(token);
//返回结果
return result;
}
@GetMapping("/list")
public Result<List<User>> userList() throws Exception {
//初始化返回值
Result<List<User>> result = new Result<>();
//如果成功,设置状态码和查询到的结果
result.setCode(HttpStatus.OK.value());
result.setMsg("查询成功!");
List<User> users = userService.userList();
result.setData(users);
//返回结果
return result;
}
}
github:https://github.com/18338862369/SpringBoot-JWT
gitee:https://gitee.com/rayfoo/SpringBoot-JWT
如果代码对你有帮助 青帮我点个star哦~
参考:http://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。