Featured image of post shiro整合JWT

shiro整合JWT

记录项目中JWT身份认证

在我现在在做的项目中需要进行身份和权限认证,在网上找了些教程,推荐都是使用JWT来进行身份认证,于是便决定使用此方法来实现。(然而用完了才发现JWT也有缺点)

# 何为JWT

所谓JWT,全称是JSON Web Token。下面是从官网摘抄的定义:

JWT是一个开放的标准(RFC 7519),它定义了一种紧凑和独立的方式,以JSON对象的形式在各方之间安全地传输信息。这种信息可以被验证和信任,因为它是经过数字签名的。JWTs可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公共/私人密钥对进行签名。

按我的理解,JWT其实就是将传统的session认证中的token存储位置从服务器上下发给用户,服务器只需要判断传来的token是否合法而无需存储。这样做的好处就是可以做分布式的服务器,无需考虑用户是在哪一台服务器上登录的。

详细的定义和构成我这里就不展开了,这里主要关注JWT中Payload(载荷)。这是有效信息存放的地方,我们一般关注这里就行了。

下面是官方提供且建议(并不强制)使用的声明:

  • iss: jwt签发者

  • sub: 主题

  • aud: 接收jwt的一方

  • exp: jwt的过期时间,这个过期时间必须要大于签发时间

  • nbf: 生效时间,在此时间之前该jwt都是不可用的.

  • iat: jwt的签发时间

  • jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。

当然,在载荷中还可以存放自定义的信息,在本项目中使用官方提供的就足以了,故不展开。

# 认证流程

  • 用户使用用户名密码来请求服务器
  • 服务器进行验证用户的信息
  • 服务器通过验证发送给用户一个token
  • 客户端存储token,并在每次请求时附送上这个token值
  • 服务端验证token值,并返回数据

JWT认证流程图

# 具体实现

# 导入依赖包

第一步当然是导入相关依赖了,使用Maven进行包管理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- shiro -->
<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-spring</artifactId>
    <version>1.8.0</version>
</dependency>
<!-- jwt -->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.18.1</version>
</dependency>

# 封装JWT工具类

这里主要实现JWT的生成、验证、提取用户名三个功能

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
@Slf4j
public class JwtUtil {
    /**
     * 服务器私钥
     */
    private static final Algorithm ALGORITHM = Algorithm.HMAC256("test");

    /**
     * 生成JSON Web Token
     *
     * @param username  用户名
     * @param issuer    签发者
     * @param subject   面向主体
     * @param ttlMillis 生效时长(单位:毫秒)
     */
    public static String creatJwt(String username, String issuer, String subject, long ttlMillis) {
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);

        JWTCreator.Builder builder = JWT.create()
                .withAudience(username)
                .withIssuedAt(now)
                .withSubject(subject)
                .withIssuer(issuer);

        if (ttlMillis >= 0) {
            long expMillis = nowMillis + ttlMillis;
            Date exp = new Date(expMillis);
            builder.withExpiresAt(exp);
        }

        return builder.sign(ALGORITHM);
    }

    /**
     * 生成JSON Web Token
     *
     * @param username 用户名
     * @param issuer   签发者
     * @param subject  面向主体
     * @param expDate  失效日期
     */
    public static String creatJwt(String username, String issuer, String subject, Date expDate) {
        Date now = new Date();

        JWTCreator.Builder builder = JWT.create()
                .withAudience(username)
                .withIssuedAt(now)
                .withSubject(subject)
                .withIssuer(issuer);

        if (now.before(expDate)) {
            builder.withExpiresAt(expDate);
        }

        return builder.sign(ALGORITHM);
    }
    
    /**
     * 获取签发对象
     *
     * @param token 需要解密的token
     * @return 解密后获得的对象,失败返回null
     */
    public static String getAudience(String token) {
        String audience;
        try {
            audience = JWT.decode(token).getAudience().get(0);
        } catch (JWTDecodeException exception) {
            log.error("输入的token无法解析");
            return null;
        }
        return audience;
    }

    /**
     * 验证token是否正确
     *
     * @param token 需要验证的token
     * @return 通过验证返回true,反之抛出异常
     */
    public static Boolean verifyToken(String token) throws JWTVerificationException {
        JWTVerifier verifier = JWT.require(ALGORITHM).build();
        verifier.verify(token);
        return true;
    }
}

# 配置Shiro

这里的ShiroConfig.java与一般Shiro项目的配置有以下几点不同:

  • 禁用Session
  • 使用自定义的jwtFilter过滤器,用来拦截并处理携带JWT token的请求
  • 使用自定义的Realm认证器,用于验证用户是否存在及查询用户权限
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Configuration
public class ShiroConfig {

    @Autowired
    private MyRealm myRealm;

    @Bean
    public DefaultWebSecurityManager getDefaultWebSecurityManager() {
        DefaultWebSecurityManager manger = new DefaultWebSecurityManager();
        manger.setRealm(myRealm);
        // 关闭shiro自带的session
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        subjectDAO.setSessionStorageEvaluator(sessionStorageEvaluator());
        manger.setSubjectDAO(subjectDAO);
        return manger;
    }

    @Bean
    public ShiroFilterFactoryBean getShiroFilterFactoryBean(DefaultWebSecurityManager manger) {
        ShiroFilterFactoryBean bean = new CustomShiroFilterFactoryBean();
        bean.setSecurityManager(manger);
        Map<String, Filter> filterMap = new HashMap<>();
        filterMap.put("jwt", getJwtFilter());
        bean.setFilters(filterMap);
        Map<String, String> map = new LinkedHashMap<>();
        //设置过滤规则,anon表示无需认证,其余的请求都通过自定义的jwt认证器
        map.put("/", "anon");
        map.put("/swagger-ui/**", "anon");
        map.put("/v3/api-docs", "anon");
        map.put("/swagger-resources/**", "anon");
        map.put("/unauthorized/**", "anon");
        map.put("/**", "jwt");
        bean.setFilterChainDefinitionMap(map);
        bean.setLoginUrl("/login");
        // 设置无权限时跳转的 url
        bean.setUnauthorizedUrl("/unauthorized/无权限");
        return bean;
    }


    public JwtFilter getJwtFilter() {
        return new JwtFilter();
    }

    /**
     * 开启注解代理
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
        authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
        return authorizationAttributeSourceAdvisor;
    }

    /**
     * 禁用session, 不保存用户登录状态。保证每次请求都重新认证
     */
    @Bean
    protected SessionStorageEvaluator sessionStorageEvaluator() {
        DefaultSessionStorageEvaluator sessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        sessionStorageEvaluator.setSessionStorageEnabled(false);
        return sessionStorageEvaluator;
    }
}

# 自定义Token

由于使用了JWT当token,自然要写自定义的Token

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class JwtToken implements AuthenticationToken {
    private String token;

    public JwtToken(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

# 自定义过滤器

由于使用了JWT,所以不能使用shiro中自带的过滤器,而是自定义自己的过滤器 JWTFilterJWTFilter 继承了 BasicHttpAuthenticationFilter,并部分原方法进行了重写

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        //判断请求的请求头是否带上 "Token"
        if (isLoginAttempt(request, response)) {
            //如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
            try {
                executeLogin(request, response);
                return true;
            } catch (Exception e) {
                //token 错误
                responseError(response, e.getMessage());
            }
        }
        //如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
        return true;
    }


    /**
     * 判断用户是否想要登入。
     * 检测 header 里面是否包含 Token 字段
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String token = req.getHeader("Token");
        return token != null;
    }

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Token");
        JwtToken jwtToken = new JwtToken(token);
        // 提交给realm进行登入,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(jwtToken);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

    /**
     * 将非法请求跳转到 /unauthorized/**
     */
    private void responseError(ServletResponse response, String message) {
        try {
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            //设置编码,否则中文字符在重定向时会变为空字符串
            message = URLEncoder.encode(message, "UTF-8");
            //允许跨域请求
            httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true");
            httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET, POST");
            httpServletResponse.setHeader("Access-Control-Allow-Origin", "*");
            httpServletResponse.setHeader("Access-Control-Max-Age", "3600");
            
            httpServletResponse.sendRedirect("/unauthorized/" + message);
        } catch (IOException e) {
            log.error(e.getMessage());
        }
    }
}

该过滤器有这几大步骤:

  1. 检验请求头是否带有Token
  2. 如果带有 token,执行 shiro 的 login() 方法,将 token 提交到 Realm 中进行检验;如果没有 token,说明当前状态为游客状态(或者其他一些不需要进行认证的接口)
  3. 如果在 token 校验的过程中出现错误,如 token 校验失败,那么我会将该请求视为认证不通过,则重定向到 /unauthorized/**

# 自定义Realm

这里主要是进行身份认证和权限认证

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Component
public class MyRealm extends AuthorizingRealm {
    @Autowired
    UserService userService;
    @Autowired
    RoleService roleService;
    @Autowired
    PermissionService permissionService;

    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 权限认证
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String userName = (String) principals.iterator().next();
        Set<String> roles = roleService.getAllRoleByUserName(userName);
        Set<String> permissions = permissionService.getAllPermissionByUserName(userName);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        info.setRoles(roles);
        info.setStringPermissions(permissions);
        return info;
    }

    /**
     * 身份认证
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        JwtToken jwtToken = (JwtToken) token;
        String jwtTokenPrincipal = (String) jwtToken.getPrincipal();
        String userName = JwtUtil.getAudience(jwtTokenPrincipal);
        if (userName == null) {
            throw new AuthenticationException("token认证失败!");
        }
        try {
            JwtUtil.verifyToken(jwtTokenPrincipal);
        } catch (JWTDecodeException e) {
            throw new AuthenticationException("不是有效的JWT格式");
        } catch (SignatureVerificationException e) {
            throw new AuthenticationException("无效的签名");
        } catch (TokenExpiredException e) {
            throw new AuthenticationException("token已过期");
        }
        User user = userService.getUserByUserName(userName);
        if (user == null) {
            throw new AuthenticationException("该用户不存在!");
        }
        return new SimpleAuthenticationInfo(userName, jwtTokenPrincipal, user.getUserId());
    }
}

这里有个细节,在Controller中要求进行身份认证时,Shiro会自动把上面doGetAuthenticationInfo方法中返回的Info存起来。这样我们就可以在Controller中调用这里面的值了。

userName可以通过(String) SecurityUtils.getSubject().getPrincipal()取出来

userId可以通过SecurityUtils.getSubject().getPrincipals().getRealmNames().iterator().next()取出来。当然,这里根据Shiro的设计,SimpleAuthenticationInfo的第三个构造参数应该填入RealmName,用来区分用户该使用哪一个Realm进行验证的。但我这个项目就只有一个Realm,所以在这里就偷了个懒,就没按官方标准来了。

# 异常处理

第一个是专门处理身份认证时的异常

1
2
3
4
5
@ApiOperation(value = "接收未授权错误", notes = "返回错误信息")
@GetMapping("/unauthorized/{message}")
public Result unauthorized(@PathVariable String message) {
    return new Result().setCode(233).setMessage(message);
}

第二个则是全局接管Shiro的异常,在其中进行处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@RestControllerAdvice
public class ExceptionController {
    /**
     * 捕捉shiro的异常
     */
    @ExceptionHandler(ShiroException.class)
    public Result handle401(ShiroException e) {
        Result result = new Result();
        result.setCode(666);
        if(e instanceof UnauthenticatedException){
            result.setMessage("您没有登录!");
        }else if(e instanceof UnauthorizedException){
            result.setMessage("您没有权限访问!");
        }
        else {
            result.setMessage(e.toString());
        }
        return result;
    }
}

# 解决中文报错

在Shiro1.7版本之后增加了url校验,如果有中文字符就不通过。然而我们项目里返回错误信息就是通过url来实现的。参考这篇文章知道,需要自己重写ShiroFilterFactoryBean来实现关闭url校验

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public class CustomShiroFilterFactoryBean extends ShiroFilterFactoryBean {
    @Override
    protected FilterChainManager createFilterChainManager() {
        FilterChainManager manager = super.createFilterChainManager();
        // URL携带中文400,servletPath中文校验bug
        Map<String, Filter> filterMap = manager.getFilters();
        Filter invalidRequestFilter = filterMap.get(DefaultFilter.invalidRequest.name());
        if (invalidRequestFilter instanceof InvalidRequestFilter) {
            ((InvalidRequestFilter) invalidRequestFilter).setBlockNonAscii(false);
        }
        return manager;
    }
}

# 开始使用

  • 身份认证在Controller上添加@RequiresAuthentication
  • 角色认证在Controller上添加@RequiresRoles("xxx")
  • 权限认证在Controller上添加@RequiresPermissions("xxx")

# 测试

测试接口的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@GetMapping("/test")
@ResponseBody
public Result test() {
    return new Result().setCode(200).setMessage("当你看到这段话时,说明服务成功运行了");
}

@GetMapping("/test2")
@ResponseBody
@RequiresPermissions("user:insert")
public Result test2() {
    return new Result().setCode(200).setMessage("当你看到这段话时,说明你已经通过了验证");
}

@GetMapping("/test3")
@ResponseBody
@RequiresPermissions("noBody")
public Result test3() {
    return new Result().setCode(200).setMessage("当你看到这段话时,说明认证系统出现了问题");
}

游客访问,不带Token

不带token访问

带上token

带上错误的token

访问无权限的接口

# 参考文章

# 更新日志

2021.11.7:

  • 增加解决跳转时的跨域请求
  • 更细粒度的展示JWT错误原因
  • 增加SecurityUtils的使用
  • 增加测试接口的代码
Licensed under CC BY-NC-SA 4.0
最后更新于 2021-11-07 19:30:00