Shiro整合JWT:实现用户认证和授权

Shiro+JWT实现认证和授权

1. JWT的简单介绍

1.1 为什么使用JWT?

通过session管理用户登录状态会出现一些弊端,因此慢慢发展成为token的方式做登录身份校验,然后通过token去取redis中的缓存的用户信息,随着之后jwt的出现,校验方式更加简单便捷化,无需通过redis缓存,而是直接根据token取出保存的用户信息,以及对token可用性校验,单点登录更为简单。(关于这个可以参考我的博文https://hengheng.blog.csdn.net/article/details/107153309)

1.2 JWT的结构

一个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 协议传输

2. 封装JWT工具类

2.1 jwt属性注入的配置文件

#JWT 密钥
jwt.secretKey=78944878877848fg)
# token过期时间
jwt.accessTokenExpireTime=PT2H
# 刷新token的过期时间
jwt.refreshTokenExpireTime=PT8H
#小程序中token过期时间
jwt.refreshTokenExpireAppTime=P30D
jwt.issuer=yingxue.org.cn

@ConfigurationProperties:告诉springboot将本类中的所有属性和配置文件中相关的配置进行绑定

2.2 JwtTokenUtil

这个工具类非常重要:签发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;
    }
}

3. Shiro框架整合JWT

其实使用普通的拦截器,拦截请求也可以实现jwt的认证和授权功能,但是Shiro已经帮我们做好了相应的封装,为什么不用呢?下面我们来看看Shiro如何整合JWT?

3.1 封装JWT替换shiro的 UsernamePasswordToken

通过用户名和密码进行认证,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;
    }
}

3.2 自定义访问控制拦截器

AccessControlFilter抽象类里面有两个必须要实现的方法:isAccessAllowed()方法和onAccessDenied()方法;

在执行登录的时候会调用AccessControlFilter类里面的onPreHandle方法,所有的请求经过过滤器都会来到onPreHandle方法,该方法会自动调用下面两个两个方法决定是否继续处理:

onPreHandle()方法:

  • isAccessAllowed:判断是否登录,在登录的情况下会走此方法
  • onAccessDenied:是否是拒绝登录,没有登录的情况下会走此方法

如果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;
    }
}

3.3 自定义Realm

我们先来看看源码:

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;
    }
}

3.4 自定义认证方式

之前我们使用用户名和密码认证时,需要指明用户在注册时密码的加密方式然后交给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;
    }
}

3.5 ShiroConfig

@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;
    }
}
  • 7
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我一直在流浪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值