SpringBoot集成JWT实现token验证

SpringBoot集成JWT实现token验证

Scroll Down

由于最近有个微信小程序的项目需要用到这个东西, 正好写这篇文章记录一下

什么是JWT

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

起源

我们要先知道token的认证和传统的session认证的区别。

一般传统的session认证流程

  1. 用户向服务器发送用户名和密码。
  2. 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
  3. 服务器向用户返回一个 session_id,写入用户的 Cookie。
  4. 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
  5. 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

这种基于session的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来.

基于session认证所显露的问题

Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大。

扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力。

CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击。

基于token的鉴权机制

基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。

流程上是这样的:

  • 用户使用用户名密码来请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个token
  • 客户端存储token,并在每次请求时附送上这个token值
  • 服务端验证token值,并返回数据

这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,我的另一篇文章有讲SpringBoot怎么实现CORS, 附上链接: SpringBoot解决跨域问题

优点

  • 简洁(Compact): 可以通过URL,POST参数或者在HTTP header发送,因为数据量小,传输速度也很快
  • 自包含(Self-contained):负载中包含了所有用户所需要的信息,避免了多次查询数据库
  • 因为Token是以JSON加密的形式保存在客户端的,所以JWT是跨语言的,原则上任何web形式都支持。
  • 不需要在服务端保存会话信息,特别适用于分布式微服务。

JWT请求流程

jwt.png

SpringBoot与JWT集成

先引入JWT的依赖
<!-- jwt -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.4.0</version>
</dependency>
自定义两个注解
用来跳过验证的PassToken
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PassToken {
    boolean required() default true;
}
需要登录才能进行操作的Authorize
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorize {
    boolean required() default true;
}

这些注解的意思就不解释了φ(>ω<*)

自定义一个Admin实体类,这里用了lombok插件简化了实体类的编写
/**
 * @author : lwj
 * @createTime : 2020/4/18 17:53
 * @description : Reprint please indicate the source
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User {
    private String Id;
    private String username;
    private String password;
}
简要介绍一下这几个注解

@Data: 会为所有属性加入get和set方法,toString 方法,hashCode方法,equals方法
@AllArgsConstructor: 为实体类加上含有所有属性的构造方法
@NoArgsConstructor: 为实体类加上一个无参的构造方法

写一个生成token的方法
/**
 * @author : lwj
 * @createTime  : 2020/4/15 17:46
 * @description : Reprint please indicate the source
 */
public class TokenUtil {

    public static String getToken (User user) {
        String token = "";
        token = JWT.create().withAudience(user.getId())
                .sign(Algorithm.HMAC256(user.getPassword()));
        return token;
    }
}

Algorithm.HMAC256: 使用HS256生成token,密钥则是用户的密码,唯一密钥的话可以保存在服务端。
withAudience: 存入需要保存在token的信息,这里我把用户ID存入token中

接下来需要写一个拦截器去获取token并验证token

/**
 * @author : lwj
 * @createTime  : 2020/4/15 18:01
 * @description : Reprint please indicate the source
 *
 * 获取token并验证token
 */
public class AuthenticationInterceptor implements HandlerInterceptor {
    @Autowired
    UserService userService;
    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {
        String token = httpServletRequest.getHeader("token");// 从 http 请求头中取出 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;
            }
        }
        //检查有没有需要用户权限的注解
        if (method.isAnnotationPresent(UserLoginToken.class)) {
            UserLoginToken userLoginToken = method.getAnnotation(UserLoginToken.class);
            if (userLoginToken.required()) {
                // 执行认证
                if (token == null) {
                    throw new RuntimeException("无token,请重新登录");
                }
                // 获取 token 中的 user id
                String userId;
                try {
                    userId = JWT.decode(token).getAudience().get(0);
                } catch (JWTDecodeException j) {
                    throw new RuntimeException("401");
                }
                User user = userService.findUserById(userId);
                if (user == null) {
                    throw new RuntimeException("用户不存在,请重新登录");
                }
                // 验证 token
                JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(user.getPassword())).build();
                try {
                    jwtVerifier.verify(token);
                } catch (JWTVerificationException e) {
                    throw new RuntimeException("401");
                }
                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 {
    }

主要流程:

  1. 从 http 请求头中取出 token,
  2. 判断是否映射到方法
  3. 检查是否有passtoken注释,有则跳过认证
  4. 检查有没有需要用户登录的注解,有则需要取出并验证
  5. 认证通过则可以访问,不通过会报相关错误信息

配置拦截器

/**
 * @author : lwj
 * @createTime  : 2020/4/15 20:00
 * @description : Reprint please indicate the source
 */
@Configuration
public class InterceptorConfig extends WebMvcConfigurerAdapter {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor())
                .addPathPatterns("/**");    // 拦截所有请求,通过判断是否有 @Authorize 注解 决定是否需要登录
    }
    @Bean
    public AuthorizationInterceptor authenticationInterceptor() {
        return new AuthorizationInterceptor();
    }
}

在相应的Controller里面加入登录验证注解

/**
 * @author : lwj
 * @createTime  : 2020/4/15 13:31
 * @description : Reprint please indicate the source
 */
@RestController
@RequestMapping(path = "/api")
public class UserController {

    @Autowired
    private UserService userService;

    @Authorize
    @RequestMapping(path = "/getMessage")
    public String getMessage () {
        return "你已通过验证";
    }

    @PostMapping("/login")
    public Object login (@RequestBody User user) {
        JSONObject jsonObject = new JSONObject();
        User userForBase = userService.findByUsername(user);
        if (userForBase == null) {
            jsonObject.put("message", "登录失败,用户不存在");
            return jsonObject;
        } else {
            if (! userForBase.getPassword().equals(user.getPassword())) {
                jsonObject.put("message", "登录失败,密码错误");
                return jsonObject;
            } else {
                String token = tokenService.getToken(userForBase);
                jsonObject.put("token", token);
                jsonObject.put("user", userForBase);
                return jsonObject;
            }
        }
    }
}

不加注解的话默认不验证,登录接口一般是不验证的。在getMessage()中我加上了登录注解,说明该接口必须登录获取token后,在请求头中加上token并通过验证才可以访问

下面进行测试,启动项目,使用postman测试接口
在没token的情况下访问api/getMessage接口

下面进行登录,从而获取token

把token加在请求头中,再次访问api/getMessage接口

支付宝 微信