用spring-boot开发RESTful API非常的方便,在生产环境中,对发布的API增加授权保护是非常必要的。现在我们来看如何利用JWT技术为API增加授权保护,保证只有获得授权的用户才能够访问API。
Git地址:https://github.com/X-rapido/jwt-spring-boot-restful-api
一、初探JWT
1、什么是JWT
JWT(Json Web Token),是一种工具,格式为XXXX.XXXX.XXXX的字符串,JWT以一种安全的方式在用户和服务器之间传递存放在JWT中的不敏感信息。
2、为什么要用JWT
设想这样一个场景,在我们登录一个网站之后,再把网页或者浏览器关闭,下一次打开网页的时候可能显示的还是登录的状态,不需要再次进行登录操作,通过JWT就可以实现这样一个用户认证的功能。当然使用Session可以实现这个功能,但是使用Session的同时也会增加服务器的存储压力,而JWT是将存储的压力分布到各个客户端机器上,从而减轻服务器的压力。
3、JWT长什么样
JWT由3个子字符串组成,分别为Header,Payload以及Signature,结合JWT的格式即:Header.Payload.Signature。(Claim是描述Json的信息的一个Json,将Claim转码之后生成Payload)。
Header
Header是由以下这个格式的Json通过Base64编码(编码不是加密,是可以通过反编码的方式获取到这个原来的Json,所以JWT中存放的一般是不敏感的信息)生成的字符串,Header中存放的内容是说明编码对象是一个JWT以及使用“SHA-256”的算法进行加密(加密用于生成Signature)
{ "typ":"JWT", "alg":"HS256" }
Claim
Claim是一个Json,Claim中存放的内容是JWT自身的标准属性,所有的标准属性都是可选的,可以自行添加,比如:JWT的签发者、JWT的接收者、JWT的持续时间等;同时Claim中也可以存放一些自定义的属性,这个自定义的属性就是在用户认证中用于标明用户身份的一个属性,比如用户存放在数据库中的id,为了安全起见,一般不会将用户名及密码这类敏感的信息存放在Claim中。将Claim通过Base64转码之后生成的一串字符串称作Payload。
{ "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是由Header和Payload组合而成,将Header和Claim这两个Json分别使用Base64方式进行编码,生成字符串Header和Payload,然后将Header和Payload以Header.Payload的格式组合在一起形成一个字符串,然后使用上面定义好的加密算法和一个密匙(这个密匙存放在服务器上,用于进行验证)对这个字符串进行加密,形成一个新的字符串,这个字符串就是Signature。如图所示
4、JWT实现认证的原理
服务器在生成一个JWT之后会将这个JWT会以Authorization : Bearer JWT 键值对的形式存放在cookies里面发送到客户端机器,在客户端再次访问收到JWT保护的资源URL链接的时候,服务器会获取到cookies中存放的JWT信息,首先将Header进行反编码获取到加密的算法,在通过存放在服务器上的密匙对Header.Payload 这个字符串进行加密,比对JWT中的Signature和实际加密出来的结果是否一致,如果一致那么说明该JWT是合法有效的,认证成功,否则认证失败。
二、JWT实现用户认证的流程图
三、JWT的代码实现
框架介绍
Spring Boot(版本号:1.5.10)
Apache Ignite(版本号:2.4.0)数据库
jjwt(版本号:0.9.0)
JDK(版本号:1.8)
Gradle 或 Maven
代码说明:
└── com └── tingfeng ├── AppRun.java (运行入口,包含JWT过滤器配置) ├── config │ ├── IgniteCfg.java (Ignite数据库配置与初始化) │ └── JwtConfig.java (JWT常规配置) ├── controller │ ├── IndexController.java │ ├── PersonController.java (Person注册,登录接口) │ └── SecureController.java (需要token的受限接口) ├── dao │ └── PersonRepository.java (Person增删改查Dao) ├── filter │ └── JwtFilter.java (JWT过滤器,处理与验证JWT的正确性) ├── model │ ├── Person.java │ ├── ReqPerson.java │ └── Role.java └── service ├── PersonService.java └── impl └── PersonServiceImpl.java
AppRun.java,没什么可说的,程序入口
import com.tingfeng.filter.JwtFilter; import org.apache.ignite.springdata.repository.config.EnableIgniteRepositories; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; /** * 该项目是用于将Ignite部署到SpringBoot上的一个测试性的项目 * 目前的功能包含: * 1. 启动并使用一个ignite节点 * 2. 提供api接口实现RESTful的设计,能够通过api添加与查询Cache中的相关内容 * */ @SpringBootApplication @EnableIgniteRepositories public class AppRun { /** * JWT 过滤器配置 */ @Bean public FilterRegistrationBean jwtFilter() { final FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new JwtFilter()); registrationBean.addUrlPatterns("/secure/*"); return registrationBean; } public static void main(String[] args) { SpringApplication.run(AppRun.class, args); } }
我将JWT的过滤器设置在了AppRun这里,如果你不喜欢这种模式,也可以在config包下,创建一个JwtCfg.java的文件,文件内容如下
@Configuration public class JwtCfg { @Bean public FilterRegistrationBean jwtFilter() { final FilterRegistrationBean registrationBean = new FilterRegistrationBean(); registrationBean.setFilter(new JwtFilter()); registrationBean.addUrlPatterns("/secure/*"); return registrationBean; } }
JwtFilter 类 这个类声明了一个JWT过滤器类,从Http请求中提取JWT的信息,并使用了"secretkey"这个密匙对JWT进行验证
import com.tingfeng.config.JwtConfig; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureException; import org.springframework.web.filter.GenericFilterBean; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; /** * JWT 过滤器 */ public class JwtFilter extends GenericFilterBean { @Override public void doFilter(final ServletRequest req, final ServletResponse res, final FilterChain chain) throws IOException, ServletException { final HttpServletRequest request = (HttpServletRequest) req; final HttpServletResponse response = (HttpServletResponse) res; //从Http请求获取授权 final String authHeader = request.getHeader("authorization"); // 如果Http请求是OPTIONS,那么只需返回状态码200,即代码中的HttpServletResponse.SC_OK // 除OPTIONS外,其他请求应由JWT检查 if ("OPTIONS".equals(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); chain.doFilter(req, res); } else { // Check the authorization, check if the token is started by "Bearer " if (authHeader == null || !authHeader.startsWith("Bearer ")) { throw new ServletException("Missing or invalid Authorization header"); } // 然后从授权中获取JWT令牌 final String token = authHeader.substring(7); try { // 使用JWT解析器检查签名是否对密钥“secretkey”有效 final Claims claims = Jwts.parser().setSigningKey(JwtConfig.SECRET_KEY).parseClaimsJws(token).getBody(); System.out.println("claims: " + claims); // Add the claim to request header request.setAttribute("claims", claims); } catch (final SignatureException e) { throw new ServletException("Invalid token"); } chain.doFilter(req, res); } } }
PersonController 类 这个类中在用户进行注册,登录操作成功之后,将生成一个JWT作为返回,如果你不想注册,那么在IgniteCfg中已经初始化了3条数据便于测试,启动时可见
import com.tingfeng.config.JwtConfig; import com.tingfeng.model.Person; import com.tingfeng.model.ReqPerson; import com.tingfeng.model.Role; import com.tingfeng.service.PersonService; import io.jsonwebtoken.Jwts; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import javax.servlet.ServletException; import java.util.ArrayList; import java.util.Date; import java.util.List; @RestController public class PersonController { @Autowired private PersonService personService; /** * 用户注册 */ @PostMapping(value = "/register") public String register(@RequestBody() ReqPerson reqPerson) throws ServletException { // 检查输入 if (reqPerson.getUsername() == "" || reqPerson.getUsername() == null || reqPerson.getPassword() == "" || reqPerson.getPassword() == null) { throw new ServletException("Username or Password invalid!"); } // 检查用户是否已被注册 if (personService.findPersonByUsername(reqPerson.getUsername()) != null) { throw new ServletException("Username is used!"); } // 默认权限 : MEMBER List<Role> roles = new ArrayList<>(); roles.add(Role.MEMBER); // 创建新的 Person 到 ignite DB personService.save(new Person(reqPerson.getUsername(), reqPerson.getPassword(), roles)); return "Register Success!"; } /** * 检查用户的登录信息,然后创建并返回给前端 jwt token 令牌 * * @param reqPerson * @return jwt token * @throws ServletException */ @PostMapping("/login") public String login(@RequestBody ReqPerson reqPerson) throws ServletException { // 检查输入 if (reqPerson.getUsername() == "" || reqPerson.getUsername() == null || reqPerson.getPassword() == "" || reqPerson.getPassword() == null) { throw new ServletException("Please fill in username and password"); } Person person = personService.findPersonByUsername(reqPerson.getUsername()); // 检查用户是否存在。密码是否正确 if (personService.findPersonByUsername(reqPerson.getUsername()) == null || !reqPerson.getPassword().equals(person.getPassword())) { throw new ServletException("Please fill in username and password"); } // 创建 Twt token 令牌,将username,roles写入令牌 String jwtToken = Jwts.builder() .setSubject(reqPerson.getUsername()) .claim("roles", person.getRoles()) .setIssuedAt(new Date()) .setExpiration(JwtConfig.EXPIRATION_DATE) .signWith(JwtConfig.SIGNATURE_ALGORITHM, JwtConfig.SECRET_KEY) .compact(); return jwtToken; } }
SecureController 类 这个类中只是用于测试JWT功能,当用户认证成功之后,/secure 下的资源才可以被访问
import io.jsonwebtoken.Claims; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; /** * 测试JWT,如果验证成功直接返回数据,否则会被过滤器拦截 */ @RestController @RequestMapping("/secure") public class SecureController { @RequestMapping("/users/user") public String loginSuccess() { return "Login Successful!"; } @PostMapping("/user/roles") public Object checkRoles(HttpServletRequest request) { // 从token中获取用户角色 Claims claims = request.getAttribute("claims") != null ? (Claims) request.getAttribute("claims") : null; return claims.get("roles"); } }
四、代码功能测试
本例使用Postman对代码进行测试,这里并没有考虑到安全性传递的明文密码,实际上应该用SSL进行加密
首先进行一个新的测试用户的注册,可以看到注册成功的提示返回
再让该用户进行登录,可以看到登录成功之后返回的JWT字符串
直接申请访问/secure/users/user ,这时候肯定是无法访问的,服务器返回500错误
将获取到的JWT作为Authorization属性提交(自动添加header),申请访问/secure/users/user ,可以访问成功
上图与下图类似,如果不用属性,手动添加也是一样的,都是请求添加header而已
五、错误记录
SpringBoot在2.0.1版本中会出现IgniteRepository的错误。
Error:(16, 8) java: 名称冲突: org.springframework.data.repository.CrudRepository中的deleteAll(java.lang.Iterable<? extends T>)和org.apache.ignite.springdata.repository.IgniteRepository中的deleteAll(java.lang.Iterable<ID>)具有相同疑符, 但两者均不覆盖对方
也不要使用1.5.11版本,不然会出现下面类型转换问题
com.tingfeng.config.IgniteCfg$$EnhancerBySpringCGLIB$$5b3f3d81 cannot be cast to org.apache.ignite.configuration.IgniteConfiguration
参考地址