JWT
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).定义了一种简洁的,自包含的方法用于通信双方之间以JSON对象的形式安全的传递信息。因为数字签名的存在,这些信息是可信的,JWT可以使用HMAC算法或者是RSA的公私秘钥对进行签名。
基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利。
流程:
- 用户使用用户名密码来请求服务器
- 服务器进行验证用户的信息
- 服务器通过验证发送给用户一个token
- 客户端存储token,并在每次请求时附送上这个token值
- 服务端验证token值,并返回数据
这个token必须要在每次请求时传递给服务端,它应该保存在请求头里, 另外,服务端要支持CORS(跨来源资源共享)策略,一般我们在服务端这么做就可以了Access-Control-Allow-Origin: *。
JWT的构成
JWT是由三段信息构成的,将这三段信息文本用.链接一起就构成了Jwt字符串。
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签证(signature).
header
jwt的头部承载两部分信息:
声明类型,这里是jwt
声明加密的算法 通常直接使用 HMAC SHA256
{
"alg": "HS256",
"typ": "JWT"
}
对应base64UrlEncode编码为:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
说明:该字段为json格式。alg字段指定了生成signature的算法,默认值为 HS256,typ默认值为JWT
通过header中声明的加密方式进行加密(该加密是可以对称解密的),构成了第一部分.
playload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
标准中注册的声明
公共的声明
私有的声明
标准中注册的声明 (建议但不强制使用) :
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
对应base64UrlEncode编码为:eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
说明:该字段为json格式,表明用户身份的数据,可以自己自定义字段,很灵活。sub 面向的用户,name 姓名 ,iat 签发时间。例如可自定义示例如下:
通过header中声明的加密方式加密(该加密是可以对称解密的),构成了第一部分.
signature
jwt的第三部分是一个签证信息,这个签证信息由三部分组成:
header (base64后的)
payload (base64后的)
secret
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
123456
)
对应的签名为:keH6T3x1z7mmhKL1T3r9sQdAxxdzB6siemGMr_6ZOwU
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.keH6T3x1z7mmhKL1T3r9sQdAxxdzB6siemGMr_6ZOwU
说明:对header和payload进行base64UrlEncode编码后进行拼接。通过key(这里是123456)进行HS256算法签名。
最终得到的JWT的Token为(header.payload.signature):
这个部分需要base64加密后的header和base64加密后的payload使用.连接组成的字符串,然后通过header中声明的加密方式加上secret组合加密,然后就构成了jwt的第三部分。
JWT与Session的区别
相同点是,它们都是存储用户信息; Session 在服务器端 JWT 在客户端
Session方式存储用户信息的最大问题在于要占用大量服务器内存,增加服务器的开销。
JWT方式将用户状态分散到了客户端中,可以明显减轻服务端的内存压力。
安全相关
secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret, 那就意味着客户端是可以自我签发jwt了
shiro (java安全框架)
Apache Shiro是一个强大且易用的Java安全框架,执行身份验证、授权、密码和会话管理。使用Shiro的易于理解的API,您可以快速、轻松地获得任何应用程序,从最小的移动应用程序到最大的网络和企业应用程序。
三个核心组件:
Subject: 即“当前操作用户”。但是,在Shiro中,Subject这一概念并不仅仅指人,也可以是第三方进程、后台帐户(Daemon Account)或其他类似事物。它仅仅意味着“当前跟软件交互的东西”。Subject代表了当前用户的安全操作,SecurityManager则管理所有用户的安全操作。
SecurityManager: 它是Shiro框架的核心,典型的Facade模式,Shiro通过SecurityManager来管理内部组件实例,并通过它来提供安全管理的各种服务。
Realm: Realm充当了Shiro与应用安全数据间的“桥梁”或者“连接器”。也就是说,当对用户执行认证(登录)和授权(访问控制)验证时,Shiro会从应用配置的Realm中查找用户及其权限信息。
从这个意义上讲,Realm实质上是一个安全相关的DAO:它封装了数据源的连接细节,并在需要时将相关数据提供给Shiro。当配置Shiro时,你必须至少指定一个Realm,用于认证和(或)授权。配置多个Realm是可以的,但是至少需要一个。
Shiro内置了可以连接大量安全数据源(又名目录)的Realm,如LDAP、关系数据库(JDBC)、类似INI的文本配置资源以及属性文件等。如果缺省的Realm不能满足需求,你还可以插入代表自定义数据源的自己的Realm实现。
项目目录:
为了测试认证和授权,创建了3个实体类,分别对应用户、角色和权限
项目流程:
如果是login请求,不经过JwtFilter,直接通过JwtUtls生成token;如果是其他请求,通过JwtFilter判断token是否为空,委托Myrealm对token进行认证授权,认证失败就报错,成功就授予用户角色权限。
maven依赖
<!--引入JWT依赖-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
<!--引入shiro依赖-->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.1</version>
</dependency>
JwtToken封装类
import org.apache.shiro.authc.AuthenticationToken;
/**
* 封装了需要传递的信息
* 类似UsernamePasswordToken
*/
public class JwtToken implements AuthenticationToken {
private String jwtoken;
public JwtToken(String jwtoken) {
this.jwtoken = jwtoken;
}
//获取身份
@Override
public Object getPrincipal() {
return jwtoken;
}
//获取凭证
@Override
public Object getCredentials() {
return jwtoken;
}
}
**JwtUtils工具类:**生成、校验token
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTCreationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;
public class JwtUtils {
private static final long EXPIRE_TIME = 60 * 1000;
private static final String SECRET = "huangwc";
/**
* @Description: 校验token
* @Date: 2020/8/26 11:24
**/
public static boolean verify(String token, String username){
try{
//获取加密算法对象(密钥)
Algorithm algorithm = Algorithm.HMAC256(SECRET);
//获取JWT 验证对象
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username",username)
.build();
DecodedJWT jwt = verifier.verify(token);
return true;
}catch (Exception e){
return false;
}
}
/**
* @Description: 创建token
* @Date: 2020/8/26 11:25
**/
public static String sign(String username){
try{
Date data = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(SECRET);
return JWT.create()
.withClaim("username",username)
.withExpiresAt(data)
.sign(algorithm);
}catch (Exception e){
return null;
}
}
/**
* @Description: 通过token,获取用户名
* @Date: 2020/8/26 11:25
**/
public static String getUsername(String token){
if (token == null || "".equals(token)){
return null;
}
try{
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
}catch (JWTCreationException e){
return null;
}
}
}
JwtFilter
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
/**
* 自定义一个Filter,用来拦截所有的请求判断是否携带Token
* */
public class JwtFilter extends BasicHttpAuthenticationFilter {
/**
* 这里我们详细说明下为什么最终返回的都是true,即允许访问
* 例如我们提供一个地址 GET /article
* 登入用户和游客看到的内容是不同的
* 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西
* 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入
* 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
*/
/**
* @Description: isAccessAllowed()判断是否携带了有效的JwtToken
* @Date: 2020/8/26 13:39
**/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginAttempt(request, response)) {
try {
return executeLogin(request, response);
} catch (Exception e) {
throw new AuthorizationException("权限不足", e);
}
}
return true;
}
/**
* 判断用户是否想要登入。
* 检测header里面是否包含Authorization字段即可
*/
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
//判断是否是登录请求
//与前端约定,要求前端将jwtToken放在请求的Header部分
//所以以后发起请求的时候就需要在Header中放一个token,值就是对应的Token
String authorization = req.getHeader("token");
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
//String token = this.getAuthzHeader(request);
return authorization != null;
}
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest req = (HttpServletRequest) request;
Map<String, String> map = new HashMap<>(2);
String header = req.getHeader("token");
JwtToken token = new JwtToken(header);
// 拿到当前Header中Authorization的AccessToken(Shiro中getAuthzHeader方法已经实现)
//JwtToken token = new JwtToken(this.getAuthzHeader(request));
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
try {
getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
} catch (Exception e) {
e.printStackTrace();
//调用下面的方法向客户端返回错误信息
return false;
}
return true;
}
/**
* 此方法相当于isLoginAttempt()和executeLogin()
* @Description: onAccessDenied()是没有携带JwtToken的时候进行账号密码登录,登录成功允许访问,登录失败拒绝访问
* @Date: 2020/8/26 13:40
**/
/*@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
String jwt = request.getHeader("Authorization");
JwtToken jwtToken = new JwtToken(jwt);
try {
// 委托 realm 进行登录认证
//所以这个地方最终还是调用JwtRealm进行的认证
getSubject(servletRequest, servletResponse).login(jwtToken);
//也就是subject.login(token)
} catch (Exception e) {
e.printStackTrace();
return false;
}
//执行方法中没有抛出异常就表示登录成功
return true;
}*/
/**
* 对跨域提供支持
*/
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
}
MyRealm
import com.example.jwtshirodemo.dao.UserRepository;
import com.example.jwtshirodemo.entity.Role;
import com.example.jwtshirodemo.entity.User;
import com.example.jwtshirodemo.jwt.JwtToken;
import com.example.jwtshirodemo.jwt.JwtUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.HashSet;
import java.util.Set;
/**
* doGetAuthenticationInfo() 方法:用来验证当前登录的用户,获取认证信息。
* doGetAuthorizationInfo() 方法:为当前登录成功的用户授予权限和分配角色。
*/
public class MyRealm extends AuthorizingRealm {
@Autowired
private UserRepository userRepository;
/**
* 多重写一个support
* 标识这个Realm是专门用来验证JwtToken
* 不负责验证其他的token(UsernamePasswordToken)
* */
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* @Description: 授权
* @Date: 2020/8/26 14:09
**/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
// 获取token
String token = principalCollection.getPrimaryPrincipal().toString();
System.out.println("token:" + token);
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// 给该用户设置角色,角色信息存在 t_role 表中取
String username = JwtUtils.getUsername(token);
User user = userRepository.getByUsername(username);
Set<String> roles = new HashSet<>();
for (Role role : user.getRoles()){
roles.add(role.getRolename());
}
authorizationInfo.setRoles(roles);
// 给该用户设置权限,权限信息存在 t_permission 表中取
authorizationInfo.setStringPermissions(userRepository.getPermissions(roles));
return authorizationInfo;
}
/**
* @Description: 认证
* @Date: 2020/8/26 14:09
**/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
// 获取token
String token = authenticationToken.getPrincipal().toString();
System.out.println("token:" + token);
if (token == null) {
throw new NullPointerException("token 不允许为空");
}
String username = JwtUtils.getUsername(token);
//判断
if (!JwtUtils.verify(token,username)) {
throw new UnknownAccountException();
}
// 根据用户名从数据库中查询该用户,判断是否真实存在
User user = userRepository.getByUsername(username);
if(user != null) {
// 传入用户名和密码进行身份认证,并返回认证信息
// 这里返回的是账号密码,但是JwtToken都是jwt字符串。还需要一个该Realm(MyRealm)的类名
AuthenticationInfo authcInfo = new SimpleAuthenticationInfo(token, token, getName());
return authcInfo;
} else {
return null;
}
}
}
ShiroConfig
import com.example.jwtshirodemo.jwt.JwtFilter;
import com.example.jwtshirodemo.shiro.MyRealm;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
@Configuration
public class ShiroConfig {
private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);
/**
* 注入自定义的 Realm
* @return MyRealm
*/
@Bean
public MyRealm myAuthRealm() {
MyRealm myRealm = new MyRealm();
return myRealm;
}
/**
* 注入安全管理器
* @return SecurityManager
*/
@Bean
public SecurityManager securityManager() {
// 将自定义 Realm 加进来
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(myAuthRealm());
logger.info("====securityManager注册完成====");
securityManager.setRealm(myAuthRealm());
return securityManager;
}
/**
* 注入 Shiro 过滤器
* @param securityManager 安全管理器
* @return ShiroFilterFactoryBean
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
// 定义 shiroFactoryBean
ShiroFilterFactoryBean shiroFilterFactoryBean=new ShiroFilterFactoryBean();
// 设置自定义的 securityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
// 设置默认登录的 URL,身份认证失败会访问该 URL
shiroFilterFactoryBean.setLoginUrl("/login1");
// 设置成功之后要跳转的链接
shiroFilterFactoryBean.setSuccessUrl("/success");
// 设置未授权界面,权限认证失败会访问该 URL
shiroFilterFactoryBean.setUnauthorizedUrl("/unauthorized");
//添加jwtFilter注册到shiro的Filter中,指定除了login和logout之外的请求都先经过jwtFilter
Map<String, Filter> filterMap = new HashMap<>();
//这个地方其实另外两个filter可以不设置,默认就是
filterMap.put("jwt", new JwtFilter());
//filterMap.put("anon", new AnonymousFilter());
//filterMap.put("logout", new LogoutFilter());
shiroFilterFactoryBean.setFilters(filterMap);
// LinkedHashMap 是有序的,进行顺序拦截器配置
// 拦截器
Map<String, String> filterRuleMap = new LinkedHashMap<>();
filterRuleMap.put("/login", "anon");
filterRuleMap.put("/logout", "logout");
// “/student” 开头的用户需要角色认证,是“admin”才允许
filterRuleMap.put("/student*/**", "roles[admin]");
// “/teacher” 开头的用户需要权限认证,是“user:create”才允许
filterRuleMap.put("/teacher*/**", "perms[\"user:create\"]");
filterRuleMap.put("/**", "jwt");
// 以“/admin” 开头的用户需要身份认证,authc 表示要进行身份认证
// filterRuleMap.put("/admin*", "anon");
// 设置 shiroFilterFactoryBean 的 FilterChainDefinitionMap
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterRuleMap);
return shiroFilterFactoryBean;
}
}
LoginController
import com.example.jwtshirodemo.dao.UserRepository;
import com.example.jwtshirodemo.entity.User;
import com.example.jwtshirodemo.jwt.JwtUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.HashMap;
import java.util.Map;
/**
* @Author: huangwc
* @Description: 操作测试类
* @Date: 2020/08/26 14:50:39
* @Version: 1.0
*/
@Controller
public class LoginController {
@Autowired
UserRepository userRepository;
@RequestMapping("/login")
public ResponseEntity<Map<String, String>> login(String username, String password) {
Map<String, String> map = new HashMap<>(2);
User user = userRepository.getByUsername(username);
if (user.getUsername().equals(username) && user.getPassword().equals(password)) {
String token = JwtUtils.sign(username);
map.put("msg", "登录成功");
map.put("token", token);
return ResponseEntity.ok(map);
}
map.put("msg", "用户名密码错误");
return ResponseEntity.ok(map);
}
/**
* 身份认证测试接口
*/
@RequestMapping("/admin")
public String admin() {
return "success";
}
/**
* 角色认证测试接口
*/
@RequestMapping("/student")
public String student() {
return "success";
}
/**
* 权限认证测试接口
*/
@RequestMapping("/teacher")
public String teacher() {
return "success";
}
}
使用Postman测试接口
登录生成token: 由于在ShiroConfig设置了过滤条件,login请求不用经过JwtFilter过滤器,直接调用JwtUtils生成token
realm认证授权: 除了login请求不经过JwtFilter,其他请求都要经过JwtFilter,判断token是否存在,然后通过getSubject().login() 委托realm进行认证授予权限。
下图我使用的是teacher角色访问/teacher开头的url,需要的是user.*权限,但realm授予它的权限是student.*所以是这个unauthorized这个页面
token时间过期报错: token过期时间设置60秒,时间一到token失效
Github项目链接:https://github.com/smallfatsheep/jwt-shiro-demo.git