在系统中将自己的API接口给相关系统调用是很平常的事,根据需求给不同的接口划分一定的权限级别也正常不过,所以我们一般会对所调用接口做一个授权动作,相当于是一个登录操作,只能登录了系统才可以进行后续的接口调用。目前比较流行的方案有几种:
- 用户名和密码鉴权,使用Session保存用户鉴权结果。
- 使用OAuth进行鉴权(其实OAuth也是一种基于Token的鉴权,只是没有规定Token的生成方式)
- 自行采用Token进行鉴权
第一种在分布式系统中需要借助第三方存储介质如redis来维护session的状态,第二种OAuth的方案和JWT的方案都是基于Token的,但是其实现较为复杂,今天我们主要了解的是:JWT基于Token的鉴权。
一、什么是JWT
JWT是Json Web Token
的缩写,基于 RFC 7519 标准定义的一种可以安全传输的小巧和自包含的JSON对象。由于数据是使用数字签名,所以是可信任的和安全的。JWT可以使用HMAC算法对secret进行加密或者使用RSA的公钥私钥对来进行签名。
二、JWT的工作流程
- 用户发送用户名、密码进行登录操作;
- 服务器验证用户发送的用户名、密码是否正确,如果用户信息合法,则在服务器端根据相关规则生成JWT token返回给用户
- 用户得到服务器端返回token后,存在客户端的localStorage、cookie或其它数据存储形式中
- 以后用户请求服务端其他接口时,需要在请求的header中加入Authorization:
Bearer xxxx(token)
。(注意token之前有一个7字符长度的 Bearer) - 服务器端对用户传过来的token进行校验,如果合法,则获取token中相关信息,响应请求的数据
- 用户获取所请求的数据
此处的token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *。
JWT所生成的token的结构如下,
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
由三部分组成,每部分用 . 分隔,每段都是用 Base64 编码的。可以使用Base64的解码器进行解析。
第一部分:header
JWT的头部包含两部分信息:
- 声明类型,这里是jwt
- 声明加密的算法 通常直接使用 HMAC SHA256
上述token中的第一部分,eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
被解析成了:
{
"alg": "HS256",
"typ": "JWT"
}
第二部分: playload
存放有效信息的部分,包括有:
- 标准中注册的声明
- 公共的声明
- 私有的声明
其中:
标准中注册的声明 (建议但不强制使用) :
- iss: jwt签发者
- sub: jwt所面向的用户
- aud: 接收jwt的一方
- exp: jwt的过期时间,这个过期时间必须要大于签发时间
- nbf: 定义在什么时间之前,该jwt都是不可用的.
- iat: jwt的签发时间
- jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明 :
公共的声明可添加任何的信息,一般添加用户相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。
私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息。
上述token中的第二部分,通过Base64的解码器解码后,数据如下:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
第三部分:signature
签证信息,由三部分组成:
- header (base64后的)
- payload (base64后的)
- secret
这部分使用base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,构成jwt的第三部分。这个地方进行解析需要secret
私钥才能计算,这个地方就是JWT的安全保障。
// javascript
var encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
var signature = HMACSHA256(encodedString, 'secret');
将这三部分用.
连接成一个完整的字符串,构成了最终的JWT。
注意:
secret
是保存在服务器端的,JWT的签发生成也是在服务器端的,secret
就是用来进行jwt的签发和JWT的验证,所以,它相当于服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发JWT了。- 基于JWT对于API的权限划分、资源的权限划分,用户的验证等等不是JWT负责的。JWT相当于只负责登录操作,登录成功后,用户所对应的权限用户角色决定的。
三、什么是Spring Security
Spring Security是一个基于Spring的通用安全框架,能够为 Spring企业应用系统提供声明式的安全访问控制。
- Spring Security 提供了若干个可扩展的、可声明式使用的过滤器处理拦截的web请求。(本例使用此功能)
在web请求处理时, Spring Security框架根据请求url和声明式配置,筛选出合适的一组过滤器集合拦截处理当前的web请求。这些请求会被转给Spring Security的安全访问控制框架处理通过之后,请求再转发应用程序处理,从而增强了应用的安全性。 - Spring Security 提供了可扩展的认证、鉴权机制对Web请求进行相应对处理。
认证:识别并构建用户对象,如:根据请求中的username,获取登录用户的详细信息,判断用户状态,缓存用户对象到请求上下文等。
决策:判断用户能否访问当前请求,如:识别请求url,根据用户、权限和资源(url)的对应关系,判断用户能否访问当前请求url。
四、使用Spring Security+JWT实现接口授权实战
1.创建springboot工程,添加相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
2.测试springboot项目请求正常
controller类:
@RestController
@RequestMapping("/api")
public class TestController {
@GetMapping("/test")
public String test() {
return "Send success!";
}
}
可以看出此时没有加校验时,无论是谁都可以请求成功。
3.添加授权机制基础类
(1)添加Spring Security+JWT的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.20</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.2</version>
</dependency>
(2)创建数据表user表
CREATE TABLE `t_user` (
`id` bigint(32) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '用户名',
`password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL COMMENT '密码',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
(3)创建user实体
@Data
@TableName("t_user")
public class User {
@TableId(type = IdType.AUTO)
private long id;
private String name;
private String password;
}
(4)用户信息插入及查询接口
public interface IUserService {
void save(User user);
}
/**
* 用户操作实现类
*/
@Service
public class UserServiceImpl implements IUserService {
@Resource
private UserMapper userMapper;
@Override
public void save(User user) {
userMapper.insert(user);
}
}
/**
* 用户数据库操作接口,继承mybatis-plus BaseMapper类
*/
@Mapper
public interface UserMapper extends BaseMapper<User> {
}
(5)用户注册接口
@Resource
private IUserService userService;
@Resource
private BCryptPasswordEncoder bCryptPasswordEncoder;
@PostMapping("/signup")
public void signUp(@RequestBody User user) {
// 对用户密码进行加密存储,此处使用BCryptPasswordEncoder进行加密,
// BCryptPasswordEncoder类在Application中定义
user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
userService.save(user);
}
@SpringBootApplication
public class StageApplication {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
public static void main(String[] args) {
SpringApplication.run(StageApplication.class, args);
}
}
4.添加JWT认证功能
用户填入用户名密码后,与数据库里存储的用户信息进行比对,如果通过,则认证成功。认证通过后,服务器生成一个token,将token返回给客户端,客户端以后的所有请求都需要在http头中指定该token。服务器接收的请求后,会对token的合法性进行验证。
(1)类JWTLoginFilter
,核心功能是在验证用户名密码正确后,生成一个token,并将token返回给客户端。
package com.kelly.stage.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kelly.stage.entity.User;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
/**
* 该类继承自UsernamePasswordAuthenticationFilter,重写其中的2个方法
* attemptAuthentication :接收并解析用户凭证。
* successfulAuthentication :用户成功登录后,调用此方法生成token。
*
* @author Kelly
* @version v1.0
* @date 2020/6/26 10:42
*/
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
private AuthenticationManager authenticationManager;
public JwtLoginFilter (AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
/**
* 接收并解析用户凭证
*/
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) {
try {
User user = new ObjectMapper().readValue(req.getInputStream(), User.class);
return authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getName(), user.getPassword(), new ArrayList<>())
);
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException();
}
}
/**
* 用户成功登录后,生成token
*/
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res,
FilterChain chain, Authentication auth) {
String token = Jwts.builder()
.setSubject(((org.springframework.security.core.userdetails.User)auth.getPrincipal()).getUsername())
.setExpiration(new Date(System.currentTimeMillis() + 60 * 60 * 24 * 1000))
.signWith(SignatureAlgorithm.HS512,"MyJwtSecret")
.compact();
res.addHeader("Authorization","Bearer " + token);
}
}
(2)授权验证,用户登录成功后拿到token,后续的请求都会带着token,服务端会验证token的合法性。此处创建JwtAuthenticationFilter
类。
package com.kelly.stage.filter;
import io.jsonwebtoken.Jwts;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
/**
* JwtAuthenticationFilter
* 实现token的校验功能。
* 继承BasicAuthenticationFilter,在doFilterInternal方法中,从http头的Authorization项读取token数据,
* 然后用Jwts包提供的方法校验token的合法性。如果校验通过,就认为这是一个取得授权的合法请求
*
* @author Kelly
* @version v1.0
* @date 2020/6/26 10:54
*/
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super(authenticationManager);
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws IOException, ServletException {
String header = req.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(req, res);
return;
}
UsernamePasswordAuthenticationToken authenticationToken = getAuthentication(req);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
chain.doFilter(req, res);
}
private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
String token = request.getHeader("Authorization");
if (token != null) {
String user = Jwts.parser()
.setSigningKey("MyJwtSecret")
.parseClaimsJws(token.replace("Bearer ",""))
.getBody()
.getSubject();
if (user != null) {
return new UsernamePasswordAuthenticationToken(user, null, new ArrayList<>());
}
return null;
}
return null;
}
}
(3)SpringSecurity配置,将JWTLoginFilter,JWTAuthenticationFilter组合在一起
package com.kelly.stage.config;
import com.kelly.stage.filter.JwtAuthenticationFilter;
import com.kelly.stage.filter.JwtLoginFilter;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
/**
* WebSecurityConfig
* 通过SpringSecurity配置,将JWTLoginFilter,JWTAuthenticationFilter组合在一起
*
* @author Kelly
* @version v1.0
* @date 2020/6/26 11:04
*/
@Configuration
@Order
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private UserDetailsService userDetailsService;
private BCryptPasswordEncoder bCryptPasswordEncoder;
public WebSecurityConfig(UserDetailsService userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder) {
this.userDetailsService = userDetailsService;
this.bCryptPasswordEncoder = bCryptPasswordEncoder;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable().authorizeRequests()
.antMatchers(HttpMethod.POST, "/api/signup").permitAll()
.antMatchers(HttpMethod.POST,"/login").permitAll()
.anyRequest()
.authenticated()
.and()
.addFilter(new JwtLoginFilter(authenticationManager()))
.addFilter(new JwtAuthenticationFilter(authenticationManager()));
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder);
}
}
(4)账号密码验证,创建UserDetailsServiceImpl,实现security中的UserDetailsService接口
package com.kelly.stage.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.kelly.stage.entity.User;
import com.kelly.stage.mapper.UserMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.ArrayList;
/**
* UserDetailsServiceImpl
*
* @author Kelly
* @version v1.0
* @date 2020/6/26 12:26
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Resource
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
QueryWrapper<User> query = new QueryWrapper<>();
query.eq("name", name);
User user = userMapper.selectOne(query);
if(user == null){
throw new UsernameNotFoundException(name);
}
return new org.springframework.security.core.userdetails.User(user.getName(), user.getPassword(), new ArrayList<>());
}
}
(5)功能验证
① 再次请求localhost:8080/stage/api/test
,此时没有登录授权,会出现403 Forbidden
提示。
② 注册用户信息,请求localhost:8080/stage/api/signup
地址,向数据库中插入用户信息。
③ 用户登录,直接请求localhost:8080/stage/login
地址,服务端返回token,在http header中,Authorization: Bearer 后面的部分就是token,用于后续接口请求。(注意:login接口是springsecurity自带的,不需要实现)。
④ 再次请求localhost:8080/stage/api/test
,此时带着token授权
,则可成功请求。
至此,使用Spring Security+JWT实现接口授权功能就实现了,主要是JwtLoginFilter
、JwtAuthenticationFilter
、WebSecurityConfig
的实现,注意不要忘记了账号密码验证的UserDetailsServiceImpl
实现类。
五、总结
- 因为json的通用性,所以JWT是可以进行跨语言支持;
- 在payload部分,JWT存储一些其他业务逻辑所必要的非敏感信息;
- JWT的构成非常简单,字节占用很小,便于传输;
- JWT不需要在服务端保存会话信息, 所以它易于应用的扩展;
- 不应该在JWT的
payload
部分存放敏感信息,因为payload
在客户端可解密; - secret私钥不可泄漏;