JSON Web Token是目前最流行的跨域认证解决方案,,适合前后端分离项目通过Restful API进行数据交互时进行身份认证
关于Shiro整合JWT,可以看这里:Springboot实现Shiro+JWT认证
概述
由于概念性内容网上多的是,所以就不详细介绍了
具体可以看这里:阮一峰大佬的博客
我总结几个重点:
JWT,全称Json Web Token,是一种令牌认证的方式
长相:
- 头部:放有签名算法和令牌类型(这个就是JWT)
- 载荷:你在令牌上附带的信息:比如用户的id,用户的电话号码,这样以后验证了令牌之后就可以直接从这里获取信息而不用再查数据库了
- 签名:用来加令牌的
安全性:由于载荷里的内容都是用BASE64处理的,所以是没有保密性的(因为BASE64是对称的),但是由于签名认证的原因,其他人很难伪造数据。不过这也意味着,你不能把敏感信息比如密码放入载荷中,毕竟这种可以被别人直接看到的,但是像用户id这种就无所谓了
工作流程
登录阶段
用户首次登录,通过账号密码比对,判定是否登录成功,如果登录成功的话,就生成一个jwt字符串,然后放入一些附带信息,返回给客户端。
这个jwt字符串里包含了有用户的相关信息,比如这个用户是谁,他的id是多少,这个令牌的有效时间是多久等等。下次用户登录的时候,必须把这个令牌也一起带上。
认证阶段
这里需要和前端统一约定好,在发起请求的时候,会把上次的token放在请求头里的某个位置一起发送过来,后端接受到请求之后,会解析jwt,验证jwt是否合法,有没有被伪造,是否过期,到这里,验证过程就完成了。
不过服务器同样可以从验证后的jwt里获取用户的相关信息,从而减少对数据库的查询。
比如我们有这样一个业务:“通过用户电话号码查询用户余额”
如果我们在jwt的载荷里事先就放有电话号码这个属性,那么我们就可以避免先去数据库根据用户id查询用户电话号码,而直接拿到电话号码,然后执行接下里的业务逻辑。
关于有效期
由于jwt是直接给用户的,只要能验证成功的jwt都可以被视作登录成功,所以,如果不给jwt设置一个过期时间的话,用户只要存着这个jwt,就相当于永远登录了,而这是不安全的,因为如果这个令牌泄露了,那么服务器是没有任何办法阻止该令牌的持有者访问的(因为拿到这个令牌就等于随便冒充你身份访问了),所以往往jwt都会有一个有效期,通常存在于载荷部分,下面是一段生成jwt的java代码:
return JWT.create().withAudience(userId) .withIssuedAt(new Date()) <---- 发行时间 .withExpiresAt(expiresDate) <---- 有效期 .withClaim("sessionId", sessionId) .withClaim("userName", userName) .withClaim("realName", realName) .sign(Algorithm.HMAC256(userId+"HelloLehr"));
在实际的开发中,令牌的有效期往往是越短越安全,因为令牌会频繁变化,即使有某个令牌被别人盗用,也会很快失效。但是有效期短也会导致用户体验不好(总是需要重新登录),所以这时候就会出现另外一种令牌—refresh token刷新令牌
。刷新令牌的有效期会很长,只要刷新令牌没有过期,就可以再申请另外一个jwt而无需登录(且这个过程是在用户访问某个接口时自动完成的,用户不会感觉到令牌替换),对于刷新令牌的具体实现这里就不详细讲啦(其实因为我也没深入研究过XD…)
对比Session
在传统的session会话机制中,服务器识别用户是通过用户首次访问服务器的时候,给用户一个sessionId,然后把用户对应的会话记录放在服务器这里,以后每次通过sessionId来找到对应的会话记录。这样虽然所有的数据都存在服务器上是安全的,但是对于分布式的应用来说,就需要考虑session共享的问题了,不然同一个用户的sessionId的请求被自动分配到另外一个服务器上就等于失效了
而Jwt不但可以用于登录认证,也把相应的数据返回给了用户(就是载荷里的内容),通过签名来保证数据的真实性,该应用的各个服务器上都有统一的验证方法,只要能通过验证,就说明你的令牌是可信的,我就可以从你的令牌上获取你的信息,知道你是谁了,从而减轻了服务器的压力,而且也对分布式应用更为友好。(毕竟就不用担心服务器session的分布式存储问题了)
整合Springboot
导入java-jwt包
导入java-jwt
包:
这个包里实现了一系列jwt操作的api(包括上面讲到的怎么校验,怎么生成jwt等等)
如果你是Maven玩家:
pom.xml里写入
com.auth0 java-jwt 3.8.3
如果你是Gradle玩家:
build.gradle里写入
compile group: 'com.auth0', name: 'java-jwt', version: '3.8.3'
如果你是其他玩家:
maven中央仓库地址点这里
工具类的编写
代码如下:
import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; import java.io.Serializable; import java.util.Calendar; import java.util.Date; /** * @author Lehr * @create: 2020-02-04 */ public class JwtUtils { /** 签发对象:这个用户的id 签发时间:现在 有效时间:30分钟 载荷内容:暂时设计为:这个人的名字,这个人的昵称 加密密钥:这个人的id加上一串字符串 */ public static String createToken(String userId,String realName, String userName) { Calendar nowTime = Calendar.getInstance(); nowTime.add(Calendar.MINUTE,30); Date expiresDate = nowTime.getTime(); return JWT.create().withAudience(userId) //签发对象 .withIssuedAt(new Date()) //发行时间 .withExpiresAt(expiresDate) //有效时间 .withClaim("userName", userName) //载荷,随便写几个都可以 .withClaim("realName", realName) .sign(Algorithm.HMAC256(userId+"HelloLehr")); //加密 } /** * 检验合法性,其中secret参数就应该传入的是用户的id * @param token * @throws TokenUnavailable */ public static void verifyToken(String token, String secret) throws TokenUnavailable { DecodedJWT jwt = null; try { JWTVerifier verifier = JWT.require(Algorithm.HMAC256(secret+"HelloLehr")).build(); jwt = verifier.verify(token); } catch (Exception e) { //效验失败 //这里抛出的异常是我自定义的一个异常,你也可以写成别的 throw new TokenUnavailable(); } } /** * 获取签发对象 */ public static String getAudience(String token) throws TokenUnavailable { String audience = null; try { audience = JWT.decode(token).getAudience().get(0); } catch (JWTDecodeException j) { //这里是token解析失败 throw new TokenUnavailable(); } return audience; } /** * 通过载荷名字获取载荷的值 */ public static Claim getClaimByName(String token, String name){ return JWT.decode(token).getClaim(name); } }
一点小说明:
关于jwt生成时的加密和验证方法:
jwt的验证其实就是验证jwt最后那一部分(签名部分)。这里在指定签名的加密方式的时候,还传入了一个字符串来加密,所以验证的时候不但需要知道加密算法,还需要获得这个字符串才能成功解密,提高了安全性。我这里用的是id来,比较简单,如果你想更安全一点,可以把用户密码作为这个加密字符串,这样就算是这段业务代码泄露了,也不会引发太大的安全问题(毕竟我的id是谁都知道的,这样令牌就可以被伪造,但是如果换成密码,只要数据库没事那就没人知道)
关于获得载荷的方法:
可能有人会觉得奇怪,为什么不需要解密不需要verify就能够获取到载荷里的内容呢?原因是,本来载荷就只是用Base64处理了,就没有加密性,所以能直接获取到它的值,但是至于可不可以相信这个值的真实性,就是要看能不能通过验证了,因为最后的签名部分是和前面头部和载荷的内容有关联的,所以一旦签名验证过了,那就说明前面的载荷是没有被改过的。
注解类的编写
在controller层上的每个方法上,可以使用这些注解,来决定访问这个方法是否需要携带token,由于默认是全部检查,所以对于某些特殊接口需要有免验证注解
免验证注解
@PassToken
:跳过验证,通常是入口方法上用这个,比如登录接口
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * @author Lehr * @create: 2020-02-03 */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface PassToken { boolean required() default true; }
拦截器的编写
配置类
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * @author lehr */ @Configuration public class JwtInterceptorConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { //默认拦截所有路径 registry.addInterceptor(authenticationInterceptor()) .addPathPatterns("/**"); } @Bean public JwtAuthenticationInterceptor authenticationInterceptor() { return new JwtAuthenticationInterceptor(); } }
拦截器
import com.auth0.jwt.interfaces.Claim; import com.imlehr.internship.annotation.PassToken; import com.imlehr.internship.dto.AccountDTO; import com.imlehr.internship.exception.NeedToLogin; import com.imlehr.internship.exception.UserNotExist; import com.imlehr.internship.service.AccountService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.lang.reflect.Method; import java.util.Map; /** * @author Lehr * @create: 2020-02-03 */ public class JwtAuthenticationInterceptor implements HandlerInterceptor { @Autowired AccountService accountService; @Override public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception { // 从请求头中取出 token 这里需要和前端约定好把jwt放到请求头一个叫token的地方 String token = httpServletRequest.getHeader("token"); // 如果不是映射到方法直接通过 if (!(object instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) object; Method method = handlerMethod.getMethod(); //检查是否有passtoken注释,有则跳过认证 if (method.isAnnotationPresent(PassToken.class)) { PassToken passToken = method.getAnnotation(PassToken.class); if (passToken.required()) { return true; } } //默认全部检查 else { System.out.println("被jwt拦截需要验证"); // 执行认证 if (token == null) { //这里其实是登录失效,没token了 这个错误也是我自定义的,读者需要自己修改 throw new NeedToLogin(); } // 获取 token 中的 user Name String userId = JwtUtils.getAudience(token); //找找看是否有这个user 因为我们需要检查用户是否存在,读者可以自行修改逻辑 AccountDTO user = accountService.getByUserName(userId); if (user == null) { //这个错误也是我自定义的 throw new UserNotExist(); } // 验证 token JwtUtils.verifyToken(token, userId) //获取载荷内容 String userName = JwtUtils.getClaimByName(token, "userName").asString(); String realName = JwtUtils.getClaimByName(token, "realName").asString(); //放入attribute以便后面调用 request.setAttribute("userName", userName); request.setAttribute("realName", realName); return true; } return true; } @Override public void postHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, ModelAndView modelAndView) throws Exception { } @Override public void afterCompletion(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o, Exception e) throws Exception { } }
这段代码的执行逻辑大概是这样的:
- 目标方法是否有注解?如果有PassToken的话就不用执行后面的验证直接放行,不然全部需要验证
- 开始验证:有没有token?没有?那么返回错误
- 从token的audience中获取签发对象,查看是否有这个用户(有可能客户端造假,有可能这个用户的账户被冻结了),查看用户的逻辑就是调用Service方法直接比对即可
- 检验Jwt的有效性,如果无效或者过期了就返回错误
- Jwt有效性检验成功:把Jwt的载荷内容获取到,可以在接下来的controller层中直接使用了(具体使用方法看后面的代码)
接口的编写
这里设计了两个接口:登录和查询名字,来模拟一个迷你业务,其中后者需要登录之后才能使用,大致流程如下:
登录代码
/** * 用户登录:获取账号密码并登录,如果不对就报错,对了就返回用户的登录信息 * 同时生成jwt返回给用户 * * @return * @throws LoginFailed 这个LoginFailed也是我自定义的 */ @PassToken @GetMapping(value = "/login") public AccountVO login(String userName, String password) throws LoginFailed{ try{ service.login(userName,password); } catch (AuthenticationException e) { throw new LoginFailed(); } //如果成功了,聚合需要返回的信息 AccountVO account = accountService.getAccountByUserName(userName); //给分配一个token 然后返回 String jwtToken = JwtUtils.createToken(account); //我的处理方式是把token放到accountVO里去了 account.setToken(jwtToken); return account; }
业务代码
这里列举一个需要登录,用来测试用户名字的接口(其中用户的名字来源于jwt的载荷部分)
@GetMapping(value = "/username") public String checkName(HttpServletRequest req) { //之前在拦截器里设置好的名字现在可以取出来直接用了 String name = (String) req.getAttribute("userName"); return name; }
到此这篇关于利用Springboot实现Jwt认证的示例代码的文章就介绍到这了,更多相关Springboot Jwt认证内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!