通过session管理用户登录状态会出现一些弊端,因此慢慢发展成为token的方式做登录身份校验,然后通过token去取redis中的缓存的用户信息,随着之后jwt的出现,校验方式更加简单便捷化,无需通过redis缓存,而是直接根据token取出保存的用户信息,以及对token可用性校验,单点登录更为简单。(关于这个可以参考我的博文https://hengheng.blog.csdn.net/article/details/107153309)
一个JWT实际上就是一个字符串,它由三部分组成,头部、载荷与签名。
头部(header) 是一个 JSON 对象
载荷(payload) 是一个 JSON 对象,用来存放实际需要传递的数据
签名(signature) 对header和payload使用密钥进行签名,防止数据篡改
① 头部:type和加密算法,然后对头部使用base64编码:
{"typ":"JWT","alg":"HS256"}
② 载荷:用来存放有效信息,需要对这部分信息使用base64加密
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token。
{"sub":"1234567890","name":"John Doe","admin":true}
③ 签名:Signature部分是对前两部分的防篡改签名。将Header和Payload用Base64URL编码后,再用点(.)连接起来。然后使用签名算法和密钥对这个字符串进行签名
signature = HMACSHA256(header + "." + payload, secret);
secret是保存在服务器端的,jwt的签发生成也是在服务器端的,secret就是用来进行jwt的签发和jwt的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。
头部,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用"."分隔,就构成整个JWT对象。 以上三部分都是在服务器定义,当用户登陆成功后,根据用户信息,按照jwt规则生成token返回给客户端。
④ 使用时的注意事项:
JWT默认是不加密的,但也可以加密,不加密时不宜在jwt中存放敏感信息
不要泄露签名密钥(secret)
jwt签发后无法撤回,有效期不宜太长
JWT 泄露会被人冒用身份,为防止盗用,JWT应尽量使用 https 协议传输
#JWT 密钥
jwt.secretKey=78944878877848fg)
# token过期时间
jwt.accessTokenExpireTime=PT2H
# 刷新token的过期时间
jwt.refreshTokenExpireTime=PT8H
#小程序中token过期时间
jwt.refreshTokenExpireAppTime=P30D
jwt.issuer=yingxue.org.cn
@ConfigurationProperties:告诉springboot将本类中的所有属性和配置文件中相关的配置进行绑定
这个工具类非常重要:签发token,解析token,判断token是否过期,获取token的剩余过期时间
@Slf4j
@ConfigurationProperties(prefix = "jwt")
public class JwtTokenUtil {
//token的秘钥
private static String securityKey;
private static Duration accessTokenExpireTime;
private static Duration refreshTokenExpireTime;
private static Duration refreshTokenExpireAppTime;
private static String issuer;
/**
* 签发token
*
* @param issuer 签发人
* @param subject 代表这个JWT的主体,即它的所有人 一般是用户id
* @param claims 存储在JWT里面的信息 一般放些用户的权限/角色信息
* @param ttlMillis 有效时间(毫秒)
*/
public static String generateToken(String issuer, String subject, Map<String, Object> claims, long ttlMillis, String secret) {
JwtBuilder builder = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject(subject)
.setIssuer(issuer)
.setIssuedAt(System.currentTimeMillis())
.setClaims(claims)
.signWith(SignatureAlgorithm.HS256, DatatypeConverter.parseBase64Binary(secret));
if (ttlMillis >= 0) {
//过期时间=当前时间+过期时长
long nowMillis = System.currentTimeMillis();
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
return builder.compact();
}
/**
* 生成 access_token:这个过期时间比较短,,access_token过期则用refresh_token换取access_token
*/
public static String getAccessToken(String subject, Map<String, Object> claims) {
return generateToken(issuer, subject, claims, accessTokenExpireTime.toMillis(), securityKey);
}
/**
* 生成 PC refresh_token
*/
public static String getRefreshToken(String subject, Map<String, Object> claims) {
return generateToken(issuer, subject, claims, refreshTokenExpireTime.toMillis(), securityKey);
}
/**
* 生成 App端 refresh_token
*/
public static String getRefreshAppToken(String subject, Map<String, Object> claims) {
return generateToken(issuer, subject, claims, refreshTokenExpireAppTime.toMillis(), securityKey);
}
/**
* 解析token:从token中获取claims
*/
public static Claims getClaimsFromToken(String token) {
Claims claims = null;
try {
claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(securityKey))
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
if (e instanceof ClaimJwtException) {
claims = ((ClaimJwtException) e).getClaims();
}
}
return claims;
}
/**
* 获取用户id
*/
public static String getUserId(String token) {
String userId = null;
try {
Claims claims = getClaimsFromToken(token);
userId = claims.getSubject();
} catch (Exception e) {
log.error("eror={}", e);
}
return userId;
}
/**
* 获取用户名
*/
public static String getUserName(String token) {
String username = null;
try {
Claims claims = getClaimsFromToken(token);
username = (String) claims.get(Constant.JWT_USER_NAME);
} catch (Exception e) {
log.error("eror={}", e);
}
return username;
}
/**
* 验证token 是否过期(true:已过期 false:未过期)
*/
public static Boolean isTokenExpired(String token) {
try {
Claims claims = getClaimsFromToken(token);
//token的过期时间 = 签发token时的时间 + 过期时长
Date expiration = claims.getExpiration();
return expiration.before(new Date());
} catch (Exception e) {
log.error("error={}", e);
return true;
}
}
/**
* 验证token是否有效 (true:验证通过 false:验证失败)
*/
public static Boolean validateToken(String token) {
Claims claimsFromToken = getClaimsFromToken(token);
return (claimsFromToken != null && !isTokenExpired(token));
}
/**
* 获取token的剩余过期时间
*/
public static long getRemainingTime(String token) {
long result = 0;
try {
long nowMillis = System.currentTimeMillis();
//剩余过期时间 = token的过期时间-当前时间
result = getClaimsFromToken(token).getExpiration().getTime() - nowMillis;
} catch (Exception e) {
log.error("error={}", e);
}
return result;
}
}
其实使用普通的拦截器,拦截请求也可以实现jwt的认证和授权功能,但是Shiro已经帮我们做好了相应的封装,为什么不用呢?下面我们来看看Shiro如何整合JWT?
通过用户名和密码进行认证,Shiro会将其封装为 UsernamePasswordToken进行认证,而使用jwt进行认证,不再是用户名和密码,需要自定义一个CustomUsernamePasswordToken,进而认证jwt;
public class CustomUsernamePasswordToken extends UsernamePasswordToken {
//定义一个token
private String jwtToken;
//返回token
public CustomUsernamePasswordToken(String jwtToken) {
this.jwtToken = jwtToken;
}
//用户的身份信息是jwtToken(之前是数据库中的用户名)
@Override
public Object getPrincipal() {
return jwtToken;
}
//用户的凭证信息是jwtToken(之前是数据库中的密码)
@Override
public Object getCredentials() {
return jwtToken;
}
}
AccessControlFilter抽象类里面有两个必须要实现的方法:isAccessAllowed()方法和onAccessDenied()方法;
在执行登录的时候会调用AccessControlFilter类里面的onPreHandle方法,所有的请求经过过滤器都会来到onPreHandle方法,该方法会自动调用下面两个两个方法决定是否继续处理:
onPreHandle()方法:
如果isAccessAllowed方法返回True,则不会再调用onAccessDenied方法,代表已经登录;如果isAccessAllowed方法返回Flase,则会继续调用onAccessDenied方法。而onAccessDenied方法里面则是具体执行登陆的地方。
需要注意:自定义拦截器需要在ShiroConfig中配置,并配置拦截的请求路径
@Slf4j
public class CustomAccessControlFilter extends AccessControlFilter {
//方法返回false时代表没有登录,会继续访问onAccessDenied()方法
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
return false;
}
//这个方法用户执行登录认证
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
//从请求头中获取token
String accessToken = request.getHeader(Constant.ACCESS_TOKEN);
try {
//如果token为空,抛出异常
if (StringUtils.isEmpty(accessToken)) {
throw new BusinessException(BaseResponseCode.TOKEN_NOT_NULL);
}
//将这个token封装为CustomUsernamePasswordToken
CustomUsernamePasswordToken customUsernamePasswordToken
= new CustomUsernamePasswordToken(accessToken);
//执行登录认证
getSubject(servletRequest, servletResponse).login(customUsernamePasswordToken);
//在认证的过程中会抛出各种异常,这里只写一种
} catch (Exception e) {
//将异常回写
customResponse(servletResponse, e.getCode(), e.getMsg());
return false;
}
return true;
}
}
我们先来看看源码:
public class SimpleAccountRealm extends AuthorizingRealm {
//实现认证的方法
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info = getCachedAuthenticationInfo(token);
if (info == null) {
//如果用户信息为null,就去认证用户名是否正确
info = doGetAuthenticationInfo(token);
log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
if (token != null && info != null) {
cacheAuthenticationInfoIfPossible(token, info);
}
}
if (info != null) {
//如果用户信息不为null,就去认证密码是否正确,这个逻辑不需要我们实现,但是主要指明认证方式
assertCredentialsMatch(token, info);
}
return info;
}
// 实现授权的方法
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String username = getUsername(principals);
USERS_LOCK.readLock().lock();
try {
return this.users.get(username);
} finally {
USERS_LOCK.readLock().unlock();
}
}
}
也就是说我们只需要判断用户不为null,或者token不为null即可治愈认证shiro会帮助我们去做,但是需要指明认证方式,比如密码认证时需要指明密码的加密算法:
public class CustomRealm extends AuthorizingRealm {
// 自定义realm时,必须重写这个方法,不然会报错
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof CustomUsernamePasswordToken;
}
//用户认证,并返回认证信息
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
CustomUsernamePasswordToken customUsernamePasswordToken
= (CustomUsernamePasswordToken) token;
SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(
//标识与此AuthenticationInfo实例关联的用户主体。
customUsernamePasswordToken.getPrincipal(),
//验证用户主体的凭证
customUsernamePasswordToken.getCredentials(),
//自定义realm的名称
this.getName());
return info;
}
//到数据库中获取用户的角色和权限信息
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String accessToken = (String) principals.getPrimaryPrincipal();
//获取token中携带的信息
Claims claimsFromToken = JwtTokenUtil.getClaimsFromToken(accessToken);
//用户权限信息
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
if (claimsFromToken.get(Constant.PERMISSIONS_INFOS_KEY) != null) {
info.addStringPermissions((Collection<String>) claimsFromToken.get(Constant.PERMISSIONS_INFOS_KEY));
}
//用户角色信息
if (claimsFromToken.get(Constant.ROLES_INFOS_KEY) != null) {
info.addRoles((Collection<String>) claimsFromToken.get(Constant.ROLES_INFOS_KEY));
}
return info;
}
}
之前我们使用用户名和密码认证时,需要指明用户在注册时密码的加密方式然后交给realm执行认证,而现在我们需要认证token,因此因为需要自定义token的认证方式:
@Slf4j
public class CustomHashedCredentialsMatcher extends HashedCredentialsMatcher {
@Autowired
private RedisService redisService;
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
CustomUsernamePasswordToken customUsernamePasswordToken
= (CustomUsernamePasswordToken) token;
String accessToken = (String) customUsernamePasswordToken.getCredentials();
String userId = JwtTokenUtil.getUserId(accessToken);
//判断用户是否被删除
if (redisService.hasKey(Constant.DELETED_USER_KEY + userId)) {
throw new BusinessException(BaseResponseCode.ACCOUNT_HAS_DELETED_ERROR);
}
//判断是否被锁定
if (redisService.hasKey(Constant.ACCOUNT_LOCK_KEY + userId)) {
throw new BusinessException(BaseResponseCode.ACCOUNT_LOCK);
}
//校验token
if (!JwtTokenUtil.validateToken(accessToken)) {
throw new BusinessException(BaseResponseCode.TOKEN_PAST_DUE);
}
return true;
}
}
@Configuration
public class ShiroConfig {
//配置自定义realm,相当于一个数据源
@Bean
public CustomRealm customRealm() {
CustomRealm customRealm = new CustomRealm();
customRealm.setCredentialsMatcher(customHashedCredentialsMatcher());
return customRealm;
}
//配置安全管理器
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager defaultWebSecurityManager = new DefaultWebSecurityManager();
defaultWebSecurityManager.setRealm(customRealm());
return defaultWebSecurityManager;
}
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager);
//添加自定义的过滤器
LinkedHashMap<String, Filter> linkedHashMap = new LinkedHashMap<>();
linkedHashMap.put("token", new CustomAccessControlFilter());
shiroFilterFactoryBean.setFilters(linkedHashMap);
LinkedHashMap<String, String> hashMap = new LinkedHashMap<>();
hashMap.put("/api/user/login", "anon");
//添加放行地址
hashMap.put("/swagger/**", "anon");
hashMap.put("/v2/api-docs", "anon");
hashMap.put("/swagger-ui.html", "anon");
hashMap.put("/swagger-resources/**", "anon");
hashMap.put("/webjars/**", "anon");
hashMap.put("/favicon.ico", "anon");
hashMap.put("/captcha.jpg", "anon");
hashMap.put("/druid/**", "anon");
//所有的请求都要经过自定义的拦截器进行登录认证
hashMap.put("/**", "token");
//所有的请求都要经过授权认证
hashMap.put("/**", "authc");
shiroFilterFactoryBean.setFilterChainDefinitionMap(hashMap);
return shiroFilterFactoryBean;
}
}