后端接口 token

71 阅读7分钟

一、token简介

1、什么是token

        在 Spring Boot 的接口中,Token 通常指的是用于接口访问的 身份验证和授权凭证
它是一段字符串,客户端(前端或其他系统)在调用后端 API 时将这个 Token 携带过去,后端用它来判断:

  • 你是谁(身份认证)

  • 你能做什么(权限控制)

  • 是否过期(安全性)

2、为什么要使用token

        在 Token 出现之前,很多系统使用的是传统的 会话管理方式(Session + Cookie),模式大致是这样:

  1. 用户登录时,后端验证用户名/密码,在服务器上存一个 Session(会话信息)
  2. 服务器把一个 Session ID 通过 Cookie 发给客户端。
  3. 客户端访问时把这个 Session ID 带上,服务器查自己的 Session 存储,看这个用户是谁。

这种方式的缺点:

  • 要在服务器保存会话信息(状态),占用内存或数据库空间,清理和管理也比较复杂。
  • 不方便分布式部署:如果后端有多台服务器,需要共享 Session 数据,不然会出现“登录了但访问另一台服务器还要重新登录”的情况。
  • 主要依赖 Cookie,对于移动端、跨域调用会比较麻烦(移动端没有浏览器帮你“自动存 Cookie 再自动发送”;同源策略下,浏览器只会自动发送同源域名的 Cookie,跨域是不会带上Cookie的)。

使用token之后,可以解决上述问题,带来的优点如下:

  1. 无状态

  • 服务器不需要保存每个用户的会话数据,直接通过 验证 Token 是否有效 来判断身份。
  • 因为不依赖服务器状态,很容易实现 多台服务器的负载均衡

  2. 跨平台 & 跨域方便

  • Token 可以放在请求头(Authorization),不依赖 Cookie,不依赖浏览器。
  • 无论是网页、手机 APP、桌面软件甚至其他后端系统,都能统一用 Token 来认证。

  3. 安全可控

  • Token 通常是加密签名的,不能随意伪造。

  • 可以设置过期时间,防止长期有效带来的风险。

  • 有些 Token 还能携带用户角色、权限等信息,方便后端直接判断。

3、token由谁生产

通常情况下,由后端服务器生产

  1. 用户在客户端(前端、APP等)输入账号密码登录
  2. 客户端把用户名+密码发给后端(走 HTTPS)
  3. 后端验证正确后,用自己掌握的 密钥(secret key)  把用户信息和过期时间等内容加密/签名,生成 Token
  4. 把 Token 返回给客户端
  5. 客户端此后每次请求接口,就把这个 Token 带上(一般放在 HTTP Header 的 Authorization 中)
  6. 验证 Token 时,后端用 相同的密钥 去解密/重新计算签名,验证是否一致
    • 解析 Token
      后端拿到 Token,先按规则拆成 Header、Payload、Signature 三段。

    • 重新计算签名
      用同样的算法,把解析出来的 Header & Payload 再用密钥加密生成一个新的签名。

    • 对比签名

      • 如果新签名和 Token 里带的 Signature 完全一致 → Token 没被篡改(Payload没改)
      • 如果不一致 → Token内容被修改过(比如 userId 或 exp 被人为改成别的),直接拒绝。
    • 检查过期时间
      从 Payload 里读 exp(过期时间),对比当前时间,若已过期 → 拒绝。

二、token方案

1、传递方式

        目前token的三大传递方式就是 Header vs Query vs Cookie,基于前文的讨论已经看到了cookie的不适配性,故此处不再赘述。

        关于 Query 传递,其实就是 URL 参数传递,把 Token 放在 URL 里,缺点显而易见,就是

    • URL 长度限制问题
    • 安全性差(容易被日志、浏览器历史记录、Referer 泄露)

故目前最流行的传递方案还是使用 http header 的 Authorization头。

2、token格式

方案类型/特点优点缺点适用场景
JWT(JSON Web Token)无状态、可携带声明解析快;跨语言支持好体积大;可暴露数据;撤销困难Web API、跨域授权
Opaque Token纯随机、不含信息安全、易控制失效验证需查数据库OAuth 2.0、企业内部API
PasetoJWT安全替代安全默认值、无算法坑社区较小高安全要求系统
MAC Token消息签名验证性能好、简单只能验证完整性、不能携带信息内部服务调用
Bearer+Refresh双 Token 机制降低泄露风险多带一次刷新逻辑移动应用、长时会话

3、协议

协议类型/特点优点缺点适用场景
OAuth 2.0 / 2.1业界最主流授权协议,支持多种授权模式,颁发 Access/Refresh Token标准化、生态成熟、跨平台;支持多级授权和细粒度权限在简单场景显得复杂;需要额外的授权服务器实现对外 API、第三方接入、移动端/PC授权、SaaS
OpenID Connect (OIDC)OAuth 2.0 扩展,提供身份认证功能不仅授权,还能返回用户身份信息(Profile、Email 等);兼容 OAuth 生态增加实现复杂度需要“登录+授权”一体的系统,如单点登录、用户中心
SAML 2.0基于 XML 的联邦身份认证协议成熟稳定,适合跨组织/企业的单点登录XML冗长、解析复杂;前后端分离适配麻烦传统企业与合作方的 SSO(比如企业门户对接)
自定义 Token 校验不依赖协议,登录直接颁发 Token(JWT/Opaque),后续校验签名或存储实现简单、性能好,适合内网不够标准化,扩展对外授权需要重构内网系统、低安全要求接口
Kerberos基于对称加密的网络身份认证协议双向认证、防重放攻击、广泛用于Windows域环境依赖时间同步;部署较复杂企业内 Windows 域、Hadoop 集群安全认证

4、方案选择

        本系统位于公司内网,仅供内部用户与内部系统调用,主要安全威胁来自误调用或非授权的内部脚本,而非外部攻击。鉴于此,我们选择基于 JWT 格式的 Token,并结合自定义的轻量级认证协议实现访问控制。

        该方案在 Spring Boot 环境中开发与集成非常简单,无需引入完整的 OAuth2 流程即可满足当前安全合规要求。同时,设计上参考了标准协议的核心思路(Access Token 与过期机制),为后续迁移到更复杂或对外开放的认证框架留出了扩展空间。

三、代码开发

1、自定义 JWT 工具类,实现自定义认证协议

public class JwtParserUtil {
    private static final byte[] SECRET_KEY = "ai-data".getBytes(StandardCharsets.UTF_8);
    private static final Logger log = LoggerFactory.getLogger(JwtParserUtil.class);

    private static final Cache<String, String> tokenCache = CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(30, TimeUnit.MINUTES).build();

    public static boolean verifyToken(String token) {
        JWTValidator jwtValidator;
        try {
            jwtValidator = JWTValidator.of(token);
        } catch (JSONException e) {
            throw new ServiceException("token格式异常");
        }
        try {
            //1,验证算法类型和签名
            jwtValidator.validateAlgorithm(JWTSignerUtil.hs256(SECRET_KEY));
        } catch (JWTException | ValidateException e) {
            log.error("jwt-签名验证异常:{}", e.getMessage());
            throw new ServiceException("token认证失败");
        }
        try {
            // 2. 使用 UTC 时间进行验证
            jwtValidator.validateDate(DateTime.now());
        } catch (ValidateException e) {
            log.error("jwt-时间验证异常:{}", e.getMessage());
            throw new ServiceException("token失效");
        }
        return false;
    }

    public static String parseUsername(String token) {
        try {
            // 基础格式校验
            if (org.apache.commons.lang.StringUtils.isBlank(token) || token.split("\.").length != 3) {
                throw new ServiceException("Token格式异常");
            }

            // 解析前先验证签名有效性
            if (verifyToken(token)) {
                throw new ServiceException("Token签名验证失败");
            }

            // 详细解析Token内容
            JWT jwt = JWT.of(token);
            Map<String, Object> payloads = jwt.getPayloads();

            // 获取用户名字段
            Object usernameObj = payloads.get("username");
            if (usernameObj == null) {
                throw new ServiceException("Token缺少用户信息");
            }

            // 类型安全校验
            if (!(usernameObj instanceof String)) {
                throw new ServiceException("用户名字段类型错误");
            }

            return (String) usernameObj;
        } catch (JWTException e) {
            log.error("JWT解析异常: {}", e.getMessage());
            throw new ServiceException("Token解析失败");
        }
    }

    public static Map<String, Object> parseUser(String token) {
        // 基础格式校验
        if (StringUtils.isBlank(token) || token.split("\.").length != 3) {
            throw new ServiceException("Token格式异常");
        }

        // 解析前先验证签名有效性
        if (verifyToken(token)) {
            throw new ServiceException("Token签名验证失败");
        }

        // 详细解析Token内容
        JWT jwt = JWT.of(token);
        return jwt.getPayloads();
    }

    public static UserVo convertUser(String token) {
        Map<String, Object> userMap = parseUser(token);
        UserVo user = new UserVo();
        user.setUserId(Convert.toInt(userMap.get("userId")));
        user.setUserName(Convert.toStr(userMap.get("username")));
        return user;
    }

    public static String parseUsernameWithCache(String token) {
        try {
            return tokenCache.get(token, () -> parseUsername(token));
        } catch (ExecutionException e) {
            throw new ServiceException("用户信息获取失败");
        }
    }


}

2、编写拦截器

@Slf4j
@Component
public class WebInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception {
        String token = request.getHeader("token");
        String requestURI = request.getRequestURI();
        if (StringUtils.isEmpty(token)) {
            log.warn("用户访问token不能为空,当前访问路径:{}", requestURI);
            return false;
        }
        return !JwtParserUtil.verifyToken(token);
    }
}

3、注册拦截器,实现黑/白名单过滤

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //注册loginInterceptor拦截器
        InterceptorRegistration registration = registry.addInterceptor(new WebInterceptor());
        //添加拦截路径
        registration.addPathPatterns("/logs/**", "/metrics/**");
    }


    @Bean
    public TimeoutCallableProcessingInterceptor timeoutInterceptor() {
        return new TimeoutCallableProcessingInterceptor();
    }

    /**
     * 添加跨域问题
     *
     * @param registry 拦截器注册表
     */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                //设置允许跨域请求的域名
                //是否允许证书(cookies)
                .allowCredentials(true)
                .allowedOriginPatterns("*")
                .allowedHeaders("*")
                //设置请求的方式
                .allowedMethods("*")
                .maxAge(3600);
    }
}

4、登录/获取 Token 的 Controller