springboot+jwt+shiro 登录认证授权
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"); 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); // 提交给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
本文地址:https://blog.csdn.net/weixin_42830314/article/details/108244561