← 中文资源

简体中文版本

令牌吊销

令牌吊销在到期前使授权失效,用于切断被盗会话,确保登出真正结束跨客户端和服务的访问。

阅读英文原文

吊销是一种状态

签名令牌之所以有效,是因为签名和声明校验通过,而不是因为服务器记得签发过它。吊销重新引入状态:记录某个授权不再被接受。设计问题是状态放在哪里,以及所有验证方多快能看到它。

工作机制

RFC 7009 定义了吊销端点。客户端向 POST /revoke 发送 token=... 和可选的 token_type_hint,并以拥有该令牌的客户端身份认证。服务器使 refresh token 失效,并按策略使其派生的 access token 失效。即使令牌未知也返回 200,避免被用来探测令牌是否存在。

对自包含 access token,提前吊销需要按 jti 维护拒绝列表、每次请求检查用户级令牌版本号,或使用内省。拒绝列表条目只需保留到令牌 exp,因此规模有界。

何时使用与取舍

在产品承诺服务端登出时、修改密码或邮箱后、设备报失时、员工离职时,以及 refresh token 可能泄露时执行吊销。检测到 refresh token 轮换复用攻击时,应吊销整个授权族。

状态会在热路径上增加一次查询,并带来跨区域协调问题。拒绝列表简单,但必须正确复制和过期;令牌版本号能撤销某用户的全部会话,安全但打扰用户。纯本地 JWT 验证看不到吊销,令牌寿命就成了吊销窗口。

示例与常见误解

用户在共用笔记本上登出,客户端删除了令牌,但 refresh token 已被复制。没有服务端吊销调用,攻击者可以持续换取 access token;调用 /revoke 并吊销授权族后,所有位置的会话才真正结束。

常见误解是吊销对所有令牌类型都立即生效。对 refresh token 和内省校验的令牌是即时的,对本地验证的 JWT 则受到期时间限制。相关概念:令牌内省、JSON Web Token、会话与令牌认证。

代码示例

package main

import (
	"crypto/rand"
	"encoding/base64"
	"fmt"
	"math/big"
)

func RandomToken(size int) (string, error) {
	if size < 16 {
		return "", fmt.Errorf("token entropy too low: %d bytes", size)
	}
	buffer := make([]byte, size)
	if _, err := rand.Read(buffer); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(buffer), nil
}

func RandomDigits(length int) (string, error) {
	if length < 1 {
		return "", fmt.Errorf("length must be positive: %d", length)
	}
	limit := big.NewInt(10)
	out := make([]byte, length)
	for index := range out {
		digit, err := rand.Int(rand.Reader, limit)
		if err != nil {
			return "", err
		}
		out[index] = byte('0' + digit.Int64())
	}
	return string(out), nil
}

func main() {
	token, tokenErr := RandomToken(24)
	fmt.Println(token, tokenErr)
	code, codeErr := RandomDigits(6)
	fmt.Println(code, codeErr)
}
import hmac
import secrets

ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'


def api_key(prefix: str = 'sk') -> str:
    body = ''.join(secrets.choice(ALPHABET) for _ in range(40))
    return f'{prefix}_{body}'


def url_token(nbytes: int = 32) -> str:
    return secrets.token_urlsafe(nbytes)


def otp_code(digits: int = 6) -> str:
    # 朴素做法 random.randint 可预测,必须用 secrets
    floor = 10 ** (digits - 1)
    return str(floor + secrets.randbelow(9 * floor))


def constant_time_match(candidate: str, expected: str) -> bool:
    if len(candidate) != len(expected):
        return False
    return hmac.compare_digest(
        candidate.encode(), expected.encode()
    )


def scoped_token(scope: str, nbytes: int = 16) -> dict:
    return {
        'token': url_token(nbytes),
        'scope': scope,
        'hint': f'scope={scope}',
    }


key = api_key()
code = otp_code()
print('key length:', len(key), 'otp:', code)
print('compare ok:', constant_time_match(code, code))
print('compare tampered:', constant_time_match(code, '123456'))