开发常用的一种鉴权方案-JWT实践教程

背景

以前我觉得鉴权就是一种前后端一种秘钥的传输,像钥匙一样。但是并没有明确的去了解相关的技术方案,这不最近项目上遇到一个问题,两个后端应用有个接口需要鉴权,该采用哪种方案呢?

  1. 最简单的就是写死一个秘钥,然后接收方进行校验就可以,但是每个用户权限还不一样,需要区分出用户的权限来。
  2. 通过一番调研,认识到JWT,使用JWT可以完美的解决我当前的问题。
    又过了一个月遇到一个小项目的前后端授权,有了使用JWT的使用经验,这次毫不犹疑就采用JWT进行鉴权的方案,如果你遇到类似的问题,希望能对你有点帮助。

本次采用小程序服务端登录鉴权流程作为案例演示,

参考文章:
https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
https://developer.aliyun.com/article/995894
https://www.jianshu.com/p/576dbf44b2ae

介绍

JWT(JSON Web Token)是目前最流行的跨域认证解决方案,被广泛应用于认证和授权场景,尤其是在无状态的 RESTful API 中。本文将主要介绍 JWT 的概念、原理,并通过 Java 示例展示其实际应用。
官网地址:https://jwt.io/
在这里插入图片描述
官网提供简单加解密工具:
https://jwt.io/
在这里插入图片描述

JWT原理和交互的流程

  1. 客户端发送用户名和密码到服务器。
  2. 服务器验证用户信息,验证通过后生成 JWT。
  3. 服务器将 JWT 返回给客户端。
  4. 客户端保存 JWT(通常在本地存储或 Cookie 中)。
  5. 客户端在每次请求时将 JWT 发送给服务器。
  6. 服务器验证 JWT,如果有效则处理请求,否则返回错误信息。
    在这里插入图片描述
    看完这个图是不是豁然开朗了就,市面上大部分的鉴权机制都和这个方案类似,学会这个方案,其他衍生和变种方案就很容易掌握了。

JWT的数据结构

JWT的数据结构就像下面这种图一样.
在这里插入图片描述
可以看到被符号 . 分隔成了三部分,这三部分就是:

  • Header (头)
  • Payload (负载)
  • Signature (签名)

还是使用官网这张图,可以比较明显对比出来,左边的token分成三部分后对应的右边每一步分的内容
在这里插入图片描述

Header 头部

Header 通常由两部分组成:令牌的类型和使用的签名算法,alg属性表示签名的算法: HMAC SHA256 或 RSA;type属性表示这个token令牌的类型,JWT统一写为JWT。

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload 负载

Payload 是一个 JSON 对象,包含需要传输的声明。声明分为三种:注册声明(registered claims)、公共声明(public claims)和私有声明(private claims)。JWT规定了7个官方字段:

  • iss (issuer):签发人
  • exp (expiration time):过期时间
  • sub (subject):主题
  • aud (audience):受众
  • nbf (Not Before):生效时间
  • iat (Issued At):签发时间
  • jti (JWT ID):编号

除了官方字段还可以定义和扩展你自己的私有字段比如:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

注意,JWT 默认是不加密的,任何人都可以读到,所以不要把秘密信息放在这个部分。

Signature 签名

签名部分是用来对前面两部分数据的签名,防止数据篡改。用于验证消息未被篡改。通过将编码后的头部、载荷和一个密钥结合使用指定的算法生成。这块比较重要的是需要一个密钥(secret)。而且这个密钥只能服务器才知道,不能泄露,如果一旦泄露用户信息可能就会被泄露,所以公司存储这个密钥的方式非常重要。怎么存储这里不展开讲了。

首先,需要指定一个密钥(secret)。这个密钥只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。

JWT的使用方式

当客户端收到服务端返回的JWT token之后,可以将token存储在Cookie里,也可以存储在localStorage里面,之后客户端每次请求接口的时候,将token携带上。你可以把它放在 Cookie 里面自动发送,但是这样不能跨域,所以更好的做法是放在 HTTP 请求的头信息Authorization字段里面。

Authorization: Bearer <token>

JWT的生成和验证

@Component
@Slf4j
public class AuthTokenHelper {
    @Value("${aibx.auth.token.secret}")
    private String secret;

    /**
     * iss (issuer):签发人
     * exp (expiration time):过期时间
     * sub (subject):主题
     * aud (audience):受众
     * nbf (Not Before):生效时间
     * iat (Issued At):签发时间
     * jti (JWT ID):编号
     * @return
     */
    public String generateToken(AuthUser authUser) {
        String authInfo = JSON.toJSONString(authUser);
        return JWT.create()
                .withIssuer("xxxxx")
                .withSubject(authUser.getId())
                .withIssuedAt(Instant.now())
                .withClaim("user", authInfo)
                .sign(Algorithm.HMAC256(secret));
    }

    public String generateAdminToken(AuthUser authUser) {
        DateTime now = DateTime.now();
        DateTime expireDateTime = now.plusHours(1);

        String authInfo = JSON.toJSONString(authUser);
        return JWT.create()
                .withIssuer("aibx")
                .withSubject(authUser.getId())
                .withIssuedAt(Instant.now())
                .withExpiresAt(expireDateTime.toDate())
                .withClaim("user", authInfo)
                .sign(Algorithm.HMAC256(secret));
    }

    public TokenVerifyResult verifyToken(String token) {
        TokenVerifyResult result = new TokenVerifyResult();
        result.setCode(0);
        result.setMsg("success");
        try {
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret))
                    .withIssuer("xxxxxx")
                    .build();
            DecodedJWT decodedJWT = jwtVerifier.verify(token);
            String userJsonStr = decodedJWT.getClaim("user").asString();
            AuthUser authUser = JSON.parseObject(userJsonStr, AuthUser.class);
            result.setAuthUser(authUser);
        } catch (SignatureVerificationException e) {
            result.setCode(1);
            result.setMsg("签名无效");
        } catch (AlgorithmMismatchException e) {
            result.setCode(2);
            result.setMsg("签名算法不匹配");
        } catch (TokenExpiredException e) {
            result.setCode(3);
            result.setMsg("token过期");
        } catch (Exception e) {
            log.error("failed parse token, error: ", e);
            result.setCode(100);
            result.setMsg("无法解析token");
        }

        return result;
    }
}

JWT登录鉴权方案参考-微信小程序登录

这块以一个简单的鉴权的场景,场景是微信小程序的开发,获取后端接口的鉴权机制。

小程序登录

这块不是重点,也不展开讲,只简单介绍下,下面这张图是小程序官方的登录流程时序图,这个案例会展开详细讲述图中红框部分。
在这里插入图片描述

定义颁发JWT的接口

配置jar包依赖
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>4.4.0</version>
        </dependency>
step 1: 定义登录的数据结构
@Data
public class TokenGetRequest {
    /**
     * 小程序端生成的code
     */
    private String wxCode;
}
step2: 定义接口,小程序授权完成之后在小程序端获取到wxCode(小程序登录的识别码),拿到wxCode请求后端服务器进行鉴权
	@ApiOperation("获取token")
    @PostMapping(value = "/token")
    public ApiResult<TokenGetResp> token(@RequestBody TokenGetRequest request) throws ThirdException {
        String token = authService.generateAndCacheToken(request.getWxCode());
        TokenGetResp resp = new TokenGetResp();
        resp.setToken(token);
        return ApiResult.success(resp);
    }
step3: 校验wxCode是否合法,获取用户userOpenId:
    @Override
    public String generateAndCacheToken(String wxCode) throws ThirdException {
        String userOpenId = weiXinService.getUserOpenId(wxCode);
        if (StringUtils.isBlank(userOpenId)) {
            throw new ThirdException("调用微信服务出错,无法获取到用户openId");
        }

        AuthUser authUser = new AuthUser();
        authUser.setWxId(userOpenId);
        User user = userService.createOrGetUser(authUser);
        authUser.setId(user.getId());
        String token = authTokenHelper.generateToken(authUser);

        // 将token放入缓存
        stringRedisTemplate.opsForValue().set(token, "", Duration.ofSeconds(TOKEN_EXPIRE_TIMEOUT_SECONDS));
        return token;
    }

如果wxCode是合法的,就能顺利拿到userOpenId,然后将userOpenId和自己服务的用户表进行关联,后续接口鉴权会用到。

step4:生成JWT

这里需要服务端保存一个secret,这个secre非常重要,妥善保管。

/**
     * iss (issuer):签发人
     * exp (expiration time):过期时间
     * sub (subject):主题
     * aud (audience):受众
     * nbf (Not Before):生效时间
     * iat (Issued At):签发时间
     * jti (JWT ID):编号
     * @return
     */
    public String generateToken(AuthUser authUser) {
        String authInfo = JSON.toJSONString(authUser);
        return JWT.create()
                .withIssuer("xxxxx")
                .withSubject(authUser.getId())
                .withIssuedAt(Instant.now())
                .withClaim("user", authInfo)
                .sign(Algorithm.HMAC256(secret));
    }
step 5: 服务端对Token使用鉴权

这里使用了Spring种Filter的概念,定制一个AuthFilter,会对所有的接口进行拦截鉴权。

@Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
            throws IOException, ServletException {
        // 判断如果是不需要验证接口,直接放行
        if (authProperties.getExcludeUrlPaths().stream()
                .anyMatch(e -> pathMatcher.match(e, ((HttpServletRequest) req).getServletPath()))) {
            chain.doFilter(req, resp);
            return;
        }

        try {
            if (!filterParam(req, resp)) {
                return;
            }
            chain.doFilter(req, resp);
        } finally {
            ThreadParamUtil.remove();
        }
    }

    private boolean filterParam(ServletRequest req, ServletResponse resp) {
        if (req instanceof HttpServletRequest) {
            HttpServletRequest httpServletRequest = ((HttpServletRequest) req);
            String auth = httpServletRequest.getHeader(AuthConstants.HTTP_HEADER_AUTHORIZATION);
            log.info("AUTH={} path={}", auth, httpServletRequest.getServletPath());

            if (StringUtils.isBlank(auth)) {
                forbid(resp, ApiResult.state(AUTH_TOKEN_INVALID_CODE, "Authorization is required in HEADER", null));
                return false;
            }

            // 校验token是否合法,不合法直接禁止访问
            String token = StringUtils.replace(auth, "Bearer ", "");
            TokenVerifyResult result = authService.verifyToken(token);
            if (!result.isValidToken()) {
                // token不合法,直接禁止访问
                forbid(resp, ApiResult.state(AUTH_TOKEN_INVALID_CODE, "Authorization is invalid", null));
                return false;
            }

            AuthUser authUser = result.getAuthUser();
            //如果是管理员接口路径&&token是管理员的token,走到这一步,就已经通过token验证,这里直接返回就行了
            if (StringUtils.containsIgnoreCase(httpServletRequest.getServletPath(), ApiConstant.BASE_API_V1_ADMIN)) {
                if (authUser.isIfAdmin()) {
                    return true;
                } else {
                    forbid(resp, ApiResult.state(AUTH_TOKEN_INVALID_CODE, "非法的token", null));
                }
            }

            // 校验token是否过期,考虑这一步是否需要放到redis,保证活跃用户不用频繁登录,长时间不登录的用户token才会过期
            boolean checkResult = authService.checkIfTokenExistAndExtension(token);
            if (!checkResult) {
                forbid(resp, ApiResult.state(AUTH_TOKEN_INVALID_CODE, "Token is expired", null));
                return false;
            }

            // saveUser 如果不存在,创建一个, 理论上都存在,因为在登陆过程就已经创建完用户了
            if (StringUtils.isBlank(authUser.getWxId())) {
                forbid(resp, ApiResult.state(AUTH_TOKEN_INVALID_CODE, "未获取到用户微信id", null));
            }
            User user = userService.createOrGetUser(authUser);
            Assert.state(null != user);
            authUser.setUserName(user.getUsername());
            ThreadParamUtil.setLocalKeyValue(AuthConstants.THREAD_LOCAL_AUTH_USER, authUser);
            log.info("[TRACK] user:{}, operate:{}", user.getUsername(), httpServletRequest.getServletPath());
        }
        return true;
    }

    public void forbid(ServletResponse resp, Object result) {
        if (!resp.isCommitted()) {
            if (resp instanceof HttpServletResponse) {
                ((HttpServletResponse) resp).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            }
            resp.setCharacterEncoding("UTF-8");
            resp.setContentType("application/json; charset=utf-8");

            try (Writer writer = resp.getWriter()) {
                writer.append(JSON.toJSONString(result));
                writer.flush();
            } catch (IOException e) {
                log.warn("", e);
            }
        }
    }
step 6: 验证JWT是否合法

使用同一个secret进行解密

public TokenVerifyResult verifyToken(String token) {
        TokenVerifyResult result = new TokenVerifyResult();
        result.setCode(0);
        result.setMsg("success");
        try {
            JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256(secret))
                    .withIssuer("xxxxxx")
                    .build();
            DecodedJWT decodedJWT = jwtVerifier.verify(token);
            String userJsonStr = decodedJWT.getClaim("user").asString();
            AuthUser authUser = JSON.parseObject(userJsonStr, AuthUser.class);
            result.setAuthUser(authUser);
        } catch (SignatureVerificationException e) {
            result.setCode(1);
            result.setMsg("签名无效");
        } catch (AlgorithmMismatchException e) {
            result.setCode(2);
            result.setMsg("签名算法不匹配");
        } catch (TokenExpiredException e) {
            result.setCode(3);
            result.setMsg("token过期");
        } catch (Exception e) {
            log.error("failed parse token, error: ", e);
            result.setCode(100);
            result.setMsg("无法解析token");
        }

        return result;
    }

以上就能搭建一个完整的前后端鉴权机制了,以及后端鉴权的方案架构设计也都非常值得参考。

使用JWT需要注意的问题

此处参考:https://www.ruanyifeng.com/blog/2018/07/json_web_token-tutorial.html
(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。
(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。
(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。
(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。
(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。
(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

总结

JWT的整体使用是非常简单的,但是也要注意使用的过程中防止信息泄露,比如payload部分不存储敏感信息以及secret的保存都非常重要。

  • 14
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值