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是合法有效的,认证成功,否则认证失败。
shiro+jwt整合
导包
<!--JWT-->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.4.0</version>
</dependency>
JwtToken
@Data
public class JwtToken implements AuthenticationToken {
private String token;
public JwtToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return null;
}
@Override
public Object getCredentials() {
return null;
}
}
JwtUtil
public class JwtUtil {
private static final long EXPIRE_TIME = 30 *60*1000;
/**
* 校验token是否正确
*
* @param token 密钥
* @param secret 用户的密码
* @return 是否正确
*/
public static boolean verify(String token, String username, String secret) {
try {
//根据密码生成JWT效验器
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
//效验TOKEN
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (Exception exception) {
return false;
}
}
/**
* 获得token中的信息无需secret解密也能获得
*
* @return token中包含的用户名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 生成签名,5min后过期
*
* @param username 用户名
* @param secret 用户的密码
* @return 加密的token
*/
public static String sign(String username, String secret) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带username信息
return JWT.create()
.withClaim("username", username)
.withExpiresAt(date)
.sign(algorithm);
}
}
shiro配置
之前在springboot+shiro中,配置了一个CustomRealm用于用户名密码登录时的验证
@Bean
public CustomRealm customRealm(HashedCredentialsMatcher hashedCredentialsMatcher) {
CustomRealm customRealm=new CustomRealm();
customRealm.setCredentialsMatcher(hashedCredentialsMatcher);
return customRealm;
}
现在再创建一个JwtRealm用于 jwt token的验证
@Bean
public JwtRealm jwtRealm() {
JwtRealm jwtRealm=new JwtRealm();
jwtRealm.setCredentialsMatcher(new JwTCredentialsMatcher());
return jwtRealm;
}
其中的JwTCredentialsMatcher是需要自己实现的,跟controller登录不一样,shiro并没有实现JWT的Matcherm,代码如下:
@Slf4j
public class JwTCredentialsMatcher implements CredentialsMatcher {
/**
* Matcher中直接调用工具包中的verify方法即可
*/
@Override
public boolean doCredentialsMatch(AuthenticationToken authenticationToken, AuthenticationInfo authenticationInfo) {
String token = ((JwtToken)authenticationToken).getToken();
Object stored = authenticationInfo.getCredentials();
String salt = stored.toString();
String username=authenticationInfo.getPrincipals().toString();
try {
Algorithm algorithm = Algorithm.HMAC256(salt);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
verifier.verify(token);
return true;
} catch (Exception e) {
log.info("Token Error:{}", e.getMessage());
}
return false;
}
}
JwtRealm
public class JwtRealm extends AuthorizingRealm {
@Autowired
private UserMapper userMapper;
/**
* 必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JwtToken;
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
return new SimpleAuthorizationInfo();
}
/**
* 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = ((JwtToken)auth).getToken();
// 解密获得username,用于和数据库进行对比
String username = JwtUtil.getUsername(token);
User userBean = userMapper.selectbyUsername(username);
return new SimpleAuthenticationInfo(username, userBean.getPassword(), getName());
}
}
这里的doGetAuthorizationInfo直接返回一个SimpleAuthorizationInfo;因为之前的CustomerRealm中验证通过后就不会调用这个方法了,直接跳过。
过滤器
之前整合shiro时使用的是shiro 默认的权限拦截 Filter,而因为 JWT 的整合,我们需要自定义自己的过滤器 JWTFilter,
@Slf4j
public class JwtFilter extends AccessControlFilter {
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
try {
executeLogin(request, response);
return true;
} catch (Exception e) {
return false;
}
}
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String token = httpServletRequest.getHeader("Authorization");
System.out.println("----executeLogin------");
if(token==null)
{
log.info("token为空");
throw new Exception("token 为空");
}
else {
JwtToken jwtToken = new JwtToken(token);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(jwtToken);
return true;
}
}
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletResponse httpResponse = WebUtils.toHttp(response);
httpResponse.setCharacterEncoding("UTF-8");
httpResponse.setContentType("application/json;charset=utf-8");
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.setHeader("error","验证失败");
return false;
}
}
我这里是继承AccessControlFilter 过滤器,重写了其中的isAccessAllowed()和onAccessDenied()方法。
以下是AccessControlFilter 源码:
public abstract class AccessControlFilter extends PathMatchingFilter {
public static final String DEFAULT_LOGIN_URL = "/login.jsp";
public static final String GET_METHOD = "GET";
public static final String POST_METHOD = "POST";
private String loginUrl = DEFAULT_LOGIN_URL;
public String getLoginUrl() {
return loginUrl;
}
void setLoginUrl(String loginUrl) {
this.loginUrl = loginUrl;
}
protected Subject getSubject(ServletRequest request, ServletResponse response) {
return SecurityUtils.getSubject();
}
protected abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception;
protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return onAccessDenied(request, response);
}
protected abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception;
public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
return isAccessAllowed(request, response, mappedValue) || onAccessDenied(request, response, mappedValue);
}
protected boolean isLoginRequest(ServletRequest request, ServletResponse response) {
return pathsMatch(getLoginUrl(), request);
}
protected void saveRequestAndRedirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
saveRequest(request);
redirectToLogin(request, response);
}
protected void saveRequest(ServletRequest request) {
WebUtils.saveRequest(request);
}
protected void redirectToLogin(ServletRequest request, ServletResponse response) throws IOException {
String loginUrl = getLoginUrl();
WebUtils.issueRedirect(request, response, loginUrl);
}
}
调试的时候发现,首先进入的是onPreHandle函数,如果isAccessAllowed返回false,即认证失败,则会进入onAccessDenied方法中,如果不重写的话,就会redirect到之前shiroconfig中配置的loginUrl。这样可能就会有跨域的问题,就要重新设置header头。
配置好自定义的过滤器和jwtrealm之后,shiroconfig代码如下:
@Configuration
public class ShiroConfig {
@Bean
public RestShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
RestShiroFilterFactoryBean shiroFilterFactoryBean = new RestShiroFilterFactoryBean();
// 必须设置 SecurityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
// setLoginUrl 如果不设置值,默认会自动寻找Web工程根目录下的"/login.jsp"页面 或 "/login" 映射
shiroFilterFactoryBean.setLoginUrl("/notLogin");
// 设置无权限时跳转的 url;
shiroFilterFactoryBean.setUnauthorizedUrl("/notRole");
// 设置拦截器
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
//filterChainDefinitionMap.put("/logout","anon");
filterChainDefinitionMap.put("/guest/**","anon");
filterChainDefinitionMap.put("/test/test3","authc");
filterChainDefinitionMap.put("/test/**==POST","anon");
filterChainDefinitionMap.put("/login","anon");
// 添加自己的过滤器并且取名为jwt
Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
filterMap.put("jwt", new JwtFilter());
//filterMap.put("role",new AnyRolesAuthorizationFilter());
shiroFilterFactoryBean.setFilters(filterMap);
filterChainDefinitionMap.put("/**", "jwt");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
System.out.println("Shiro拦截器工厂类注入成功");
return shiroFilterFactoryBean;
}
/**
* 注入 securityManager
*/
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
// 设置realm.
//securityManager.setRealm(customRealm(hashedCredentialsMatcher()));
securityManager.setRealms(Arrays.asList(customRealm(hashedCredentialsMatcher()),jwtRealm()));
System.out.println("securityManager生成成功");
return securityManager;
}
@Bean
public Authenticator authenticator() {
ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
//设置两个Realm,一个用于用户登录验证和访问权限获取;一个用于jwt token的认证
authenticator.setRealms(Arrays.asList(customRealm(hashedCredentialsMatcher()),jwtRealm()));
//设置多个realm认证策略,一个成功即跳过其它的
authenticator.setAuthenticationStrategy(new FirstSuccessfulStrategy());
return authenticator;
}
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("SHA-256");//散列算法:MD2、MD5、SHA-1、SHA-256、SHA-384、SHA-512等。
hashedCredentialsMatcher.setHashIterations(1);//散列的次数,默认1次, 设置两次相当于 md5(md5(""));
System.out.println("hasedCredentialMather:"+hashedCredentialsMatcher);
return hashedCredentialsMatcher;
}
/**
* 自定义身份认证 realm;
* <p>
* 必须写这个类,并加上 @Bean 注解,目的是注入 CustomRealm,
* 否则会影响 CustomRealm类 中其他类的依赖注入
*/
@Bean
public CustomRealm customRealm(HashedCredentialsMatcher hashedCredentialsMatcher) {
CustomRealm customRealm=new CustomRealm();
customRealm.setCredentialsMatcher(hashedCredentialsMatcher);
System.out.println("customrealm生成成功");
System.out.println(customRealm);
return customRealm;
}
@Bean
public JwtRealm jwtRealm() {
JwtRealm jwtRealm=new JwtRealm();
jwtRealm.setCredentialsMatcher(new JwTCredentialsMatcher());
return jwtRealm;
}
/**
* 禁用session, 不保存用户登录状态。保证每次请求都重新认证。
* 需要注意的是,如果用户代码里调用Subject.getSession()还是可以用session,如果要完全禁用,要配合下面的noSessionCreation的Filter来实现
*/
@Bean
protected SessionStorageEvaluator sessionStorageEvaluator(){
DefaultWebSessionStorageEvaluator sessionStorageEvaluator = new DefaultWebSessionStorageEvaluator();
sessionStorageEvaluator.setSessionStorageEnabled(false);
return sessionStorageEvaluator;
}
}
自定义权限过滤器
在实际的项目中,对同一个url多个角色都有访问权限很常见,shiro默认的RoleFilter没有提供支持,比如上面的配置,如果我们配置成下面这样,那用户必须同时具备admin和manager权限才能访问,显然这个是不合理的。
filterChainDefinitionMap.put("/test/test3","roles["user","admin"]");
所以自己实现一个role filter,只要任何一个角色符合条件就通过,只需要重写AuthorizationFilter中两个方法就可以了:
public class AnyRolesAuthorizationFilter extends AuthorizationFilter {
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object mappedValue) throws Exception {
Subject subject = getSubject(servletRequest, servletResponse);
String[] rolesArray = (String[]) mappedValue;
if (rolesArray == null || rolesArray.length == 0) { //没有角色限制,有权限访问
return true;
}
for (String role : rolesArray) {
if (subject.hasRole(role)) //若当前用户是rolesArray中的任何一个,则有权限访问
return true;
}
return false;
}
/**
* 权限校验失败,错误处理
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
HttpServletResponse httpResponse = WebUtils.toHttp(response);
httpResponse.setCharacterEncoding("UTF-8");
httpResponse.setContentType("application/json;charset=utf-8");
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.setHeader("anyrole","anyrole");
//httpResponse.setStatus(HttpStatus.SC_UNAUTHORIZED);
return false;
}
}
有了自定义的过滤器后,就可以这样写了。
filterChainDefinitionMap.put("/user/**","jwt,roles[user]");
Map<String, Filter> filterMap = new HashMap<String, Filter>(1);
filterMap.put("jwt", new JwtFilter());
filterChainDefinitionMap.put("/user/**","jwt,role[user,admin]");
shiroFilterFactoryBean.setFilters(filterMap);
filterChainDefinitionMap.put("/**", "jwt");
表示需要登录认证,而且只要有user或者admin中的任意一个权限即可。
总结
大致流程为:前端发送请求,RestPathMatchingFilterChainResolver中判断匹配到哪一个过滤器。然后调用此过滤器,在过滤器中执行subject.login(token)进行验证,此时会跳转到realm中进行doGetAuthenticationInfo()身份认证和doGetAuthorizationInfo()权限认证,要是SimpleAuthenticationInfo调用Matcher方法抛出了异常,即subject.login(token)抛出了,则说明身份验证不通过。isAccessAllowed()返回false,进入onAccessDenied()中。如果SimpleAuthenticationInfo没有抛出异常,如果不需要权限认证,则请求成功,如果需要权限认证,就再调用doGetAuthorizationInfo()进行权限验证。
例子:
前端请求/user/1 (假设header中带有jwttoken)
filterChainDefinitionMap.put("/user/**","jwt,role[user,admin]");
RestPathMatchingFilterChainResolver匹配到"/user/**",先进入jwt过滤器中,调用isAccessAllowed(),里面执行
subject.login(token)进行验证,在jwtrealm中的doGetAuthenticationInfo(),验证通过。则再进入role过滤器中,继续验证。
在做项目的时候发现了一个bug:
就是当使用 filterChainDefinitionMap.put("/users/ * *==GET","jwt,role[admin]");进行权限校验时,有bug
rotected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
if (this.appliedPaths == null || this.appliedPaths.isEmpty()) {
if (log.isTraceEnabled()) {
log.trace("appliedPaths property is null or empty. This Filter will passthrough immediately.");
}
return true;
}
for (String path : this.appliedPaths.keySet()) {
// If the path does match, then pass on to the subclass implementation for specific checks
//(first match 'wins'):
if (pathsMatch(path, request)) {
log.trace("Current requestURI matches pattern '{}'. Determining filter chain execution...", path);
Object config = this.appliedPaths.get(path);
return isFilterChainContinued(request, response, path, config);
}
}
//no path matched, allow the request to go through:
return true;
}
filterChainDefinitionMap.put("/user==POST", "anon");
filterChainDefinitionMap.put("/user/*==GET","jwt");
filterChainDefinitionMap.put("/user/*==PUT", "role[user,superadmin]");//user/superadmin
filterChainDefinitionMap.put("/users==GET","jwt,role[admin]");
filterChainDefinitionMap.put("/**", "jwt");
查看源码可知,只有当path(即 filterChainDefinitionMap中所添加的url)和request中的url匹配时,才会进入权限校验,而此时path 为 “/users==GET",而request中是url为 /users,因此不会匹配到,就会返回true(即说明此url不需要进行权限校验);