Холодный рис жареный: понимание принципа реализации и базового использования JWT

задняя часть

помещение

Это пятая статья из серии "Холодный рис по-новому обжаривается".

В этой статье будет рассмотрен стандарт с открытым исходным кодом для создания токенов доступа.JWT,вводитьJWTспецификации, лежащие в основе принципы реализации, основные сценарии использования и применения.

Спецификация JWT

Жаль, что его нельзя найти в Википедии.JWTвход, но отjwt.io, вы можете увидеть описание:

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties

Из этого текста можно извлечь, чтоJWTдокумент спецификацииRFC 7519, в котором подробно описаноJWTосновная концепцияClaimsЗначение, макет и реализация алгоритма и т. д. будут подробно описаны ниже.

Основные понятия JWT

JWTполное имяJSON Web Token, если понимать буквально, чувство основано наJSONФормат токена, используемого для передачи по сети. По факту,JWTявляется компактнымClaimsДекларативный формат, предназначенный для передачи в средах с ограниченным пространством, общих сценариях, таких какHTTPПараметры заголовка запроса на авторизацию иURIпараметры запроса.JWTположитClaimsпреобразовать вJSONформат и этоJSONКонтент будет применяться какJWSПолезная нагрузка структуры или приложенияJWEСтруктурированная (зашифрованная) необработанная строка с помощью кода аутентификации сообщения (Message Authentication Codeили для краткостиMAC) и/или пара криптографических операцийClaimsЦифровая подпись или защита целостности.

Вот кратко упомянутые три концепции в других документах спецификации:

  • JWE(Технический документRFC 7516):JSON Web Encryption, а значит на основеJSONЗашифрованное содержимое структуры данных, механизм шифрования шифрует любую последовательность октетов, обеспечивает защиту целостности и повышает сложность взлома,JWEКомпактная схема сериализации выглядит следующим образом
BASE64URL(UTF8(JWE Protected Header)) || '.' ||
BASE64URL(JWE Encrypted Key) || '.' ||
BASE64URL(JWE Initialization Vector) || '.' ||
BASE64URL(JWE Ciphertext) || '.' ||
BASE64URL(JWE Authentication Tag)
  • JWS(Технический документRFC 7515):JSON Web Signature, что означает использоватьJSONструктуры данных иBASE64URLЗакодированное представление имеет цифровую подпись или код аутентификации сообщения (MAC) сертифицированный контент, цифровая подпись илиMACспособный обеспечить защиту целостности,JWSМакет компактной сериализации выглядит следующим образом:
ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || 
BASE64URL(JWS Payload)) || '.' ||
BASE64URL(JWS Signature)
  • JWA(Технический документRFC 7518):JSON Web Algorithm,JSON Webалгоритм, цифровая подпись илиMACалгоритм, применяемый кJWSСписок доступных алгоритмов выглядит следующим образом:

В целом,JWTНа самом деле существует две реализации, основанные наJWEРеализация зависит от алгоритма шифрования и дешифрования,BASE64URLКодирование и аутентификация личности для улучшения передачиClaimsсложность взлома, и на основеJWSВ реализации используетсяBASE64URLСпособ кодирования и цифровая подпись на передачеClaimsОбеспечивает защиту целостности, то есть только гарантированную передачуClaimsСодержимое не подделывается, но открывается открытый текст.текущий мейнстримJWTБольшая часть фреймворка не реализованаJWE, поэтому следующие в основном черезJWSПодробное обсуждение реализации.

Претензии в JWT

ClaimЕсть претензии, претензии, требования или претензии, но автор считает, что любой перевод не очень смысловой, что здесь оговореноClaimКлючевые слова напрямую служат именами.JWTОсновная роль заключается в защитеClaimsцелостность (или шифрование данных), гарантированнаяJWTво время передачиClaimsНе подделан (или не взломан).ClaimsсуществуетJWTв исходном содержании естьJSONстрока формата, где одинClaimдаK-Vструктура, какJsonNodeодин изfield-value, вот предопределенные в часто используемых спецификацияхClaim:

короткое имя полное имя значение
iss Issuer Эмитент
sub Subject основной корпус
aud Audience (приемная) цель
exp Expiration Time Дата истечения срока годности
nbf Not Before раньше установленного времениJWTне могут быть приняты к обработке
iat Issued At JWTВременная метка выпуска
jti JWT ID JWTуникальный идентификатор

эти предопределенныеClaimНе требует обязательного использования, когда выбирать какойClaimполностью зависит от пользователя, и для того, чтобы сделатьJWTболее компактные, этиClaimВсе они определяются с использованием коротких имен. построенный в разладеClaimВ условиях конфликта пользователи могут настраивать новые общедоступныеClaim,как:

короткое имя полное имя значение
cid Customer ID Пользовательский ИД
rid Role ID идентификатор роли

Необходимо отметить, что вJWSреализуется,Claimsбудет действовать какpayloadЧастичноBASE64Кодирование, открытый текст будет открыт напрямую, а конфиденциальная информация, как правило, не должна разрабатываться как пользовательскаяClaim.

Заголовок в JWT

существуетJWTДокумент спецификации называет ихHeaderзаJOSE Header,JOSEполное имяJavascript Object Signature Encryption, это,JavascriptПлатформа подписи и шифрования объектов,JOSE HeaderНа самом деле этоJavascriptПараметры подписи объекта и шифрования заголовка.Перечислено нижеJWSобычно используется вHeader:

короткое имя полное имя значение
alg Algorithm для защитыJWSалгоритм шифрования и дешифрования
jku JWK Set URL ГруппаJSONЗашифрованный открытый ключURL, один из которых дляJWSключ цифровой подписи
jwk JSON Web Key для сопряженияJWSОткрытый ключ, соответствующий ключу цифровой подписи
kid Key ID для защитыJWSвходящий ключ
x5u X.509 URL X.509Связанный
x5c X.509 Certificate Chain X.509Связанный
x5t X.509 Certificate SHA-1 Thumbprin X.509Связанный
x5t#S256 X.509 Certificate SHA-256 Thumbprint X.509Связанный
typ Type тип, напр.JWT,JWSилиJWEи т.д
cty Content Type Тип контента, определитесьpayloadчастьMediaType

Два самых распространенныхHeaderэтоalgиtyp,Например:

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

Схема JWT

В основном введениеJWSкомпоновка, как упоминалось ранее,JWSизкомпактная планировкаследующее:

ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || 
BASE64URL(JWS Payload)) || '.' ||
BASE64URL(JWS Signature)

На самом деле естьнекомпактная компоновка, пройдетJSONПолностью отображаетсяHeaderпараметр,Claimsи подпись группы:

{
    "payload":"<payload contents>",
    "signatures":[
    {"protected":"<integrity-protected header 1 contents>",
    "header":<non-integrity-protected header 1 contents>,
    "signature":"<signature 1 contents>"},
    ...
    {"protected":"<integrity-protected header N contents>",
    "header":<non-integrity-protected header N contents>,
    "signature":"<signature N contents>"}]
}

Существует также плоское представление некомпактного макета:

{
    "payload":"<payload contents>",
    "protected":"<integrity-protected header contents>",
    "header":<non-integrity-protected header contents>,
    "signature":"<signature contents>"
}

вHeaderЧасть параметра может относиться к предыдущему разделу, а часть подписи может относиться к следующему подразделу, а остальное кратко упоминается.payloadчасть,payload(полезная нагрузка) фактически завершенаClaims, предполагаяClaimsизJSONФорма:

{
   "iss": "throwx",
   "jid": 1
}

Затем сплющенный некомпактный форматpayloadУзлы:

{  
   ......
   "payload": {
      "iss": "throwx",
      "jid": 1
   }
   ......
}

Алгоритм подписи JWS

JWSГенерация подписи зависит от алгоритмов хеширования или шифрования и дешифрования. Алгоритмы, которые можно использовать, показаны на рисунке, опубликованном ранее, напримерHS256, в частностиHMAC SHA-256, то есть путем хешированияSHA-256для закодированногоHeaderиClaimsСтрока хэшируется один раз, и псевдокод, сгенерированный подписью, выглядит следующим образом:

## 不进行编码
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  256 bit secret key
)

## 进行编码
base64UrlEncode(
    HMACSHA256(
       base64UrlEncode(header) + "." +
       base64UrlEncode(payload)
       [256 bit secret key])
)

Операции других алгоритмов в основном аналогичны, и сгенерированная подпись напрямую добавляется с префиксом.сращенный вbase64UrlEncode(header).base64UrlEncode(payload)Затем сгенерируйте полныйJWS.

Генерация, разбор и проверка JWT

был проанализирован доJWTНекоторые основные понятия, макеты и алгоритмы подписи здесь основаны на предыдущей теории.JWTоперации генерации, синтаксического анализа и проверки. сначала импортироватьcommon-codecБиблиотека упрощает некоторые операции кодирования, шифрования и дешифрования и представляет основнойJSONФреймворк выполняет сериализацию и десериализацию:

<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
    <version>1.15</version>
</dependency>
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.11.0</version>
</dependency>

Для простоты,HeaderПараметры жестко запрограммированы как:

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

Используемый алгоритм подписиHMAC SHA-256, длина введенного ключа шифрования должна быть256 bit(Если вы используете только символы, состоящие из английского языка и цифр, вы должны32символов), здесь для простоты используйте00000000111111112222222233333333в видеKEY. определениеClaimsЧасти следующие:

{
  "iss": "throwx",
  "jid": 10087,  # <---- 这里有个笔误,本来打算写成jti,后来发现写错了,不打算改
  "exp": 1613227468168     # 20210213    
}

генерироватьJWTКод выглядит следующим образом:

@Slf4j
public class JsonWebToken {

    private static final String KEY = "00000000111111112222222233333333";

    private static final String DOT = ".";

    private static final Map<String, String> HEADERS = new HashMap<>(8);

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    static {
        HEADERS.put("alg", "HS256");
        HEADERS.put("typ", "JWT");
    }

    String generateHeaderPart() throws JsonProcessingException {
        byte[] headerBytes = OBJECT_MAPPER.writeValueAsBytes(HEADERS);
        String headerPart = new String(Base64.encodeBase64(headerBytes,false ,true), StandardCharsets.US_ASCII);
        log.info("生成的Header部分为:{}", headerPart);
        return headerPart;
    }

    String generatePayloadPart(Map<String, Object> claims) throws JsonProcessingException {
        byte[] payloadBytes = OBJECT_MAPPER.writeValueAsBytes(claims);
        String payloadPart = new String(Base64.encodeBase64(payloadBytes,false ,true), StandardCharsets.UTF_8);
        log.info("生成的Payload部分为:{}", payloadPart);
        return payloadPart;
    }

    String generateSignaturePart(String headerPart, String payloadPart) {
        String content = headerPart + DOT + payloadPart;
        Mac mac = HmacUtils.getInitializedMac(HmacAlgorithms.HMAC_SHA_256, KEY.getBytes(StandardCharsets.UTF_8));
        byte[] output = mac.doFinal(content.getBytes(StandardCharsets.UTF_8));
        String signaturePart = new String(Base64.encodeBase64(output, false ,true), StandardCharsets.UTF_8);
        log.info("生成的Signature部分为:{}", signaturePart);
        return signaturePart;
    }

    public String generate(Map<String, Object> claims) throws Exception {
        String headerPart = generateHeaderPart();
        String payloadPart = generatePayloadPart(claims);
        String signaturePart = generateSignaturePart(headerPart, payloadPart);
        String jws = headerPart + DOT + payloadPart + DOT + signaturePart;
        log.info("生成的JWT为:{}", jws);
        return jws;
    }

    public static void main(String[] args) throws Exception {
        Map<String, Object> claims = new HashMap<>(8);
        claims.put("iss", "throwx");
        claims.put("jid", 10087L);
        claims.put("exp", 1613227468168L);
        JsonWebToken jsonWebToken = new JsonWebToken();
        System.out.println("自行生成的JWT:" + jsonWebToken.generate(claims));
    }
}

Журнал вывода выполнения выглядит следующим образом:

23:37:48.743 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Header部分为:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
23:37:48.747 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Payload部分为:eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9
23:37:48.748 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Signature部分为:7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
23:37:48.749 [main] INFO club.throwable.jwt.JsonWebToken - 生成的JWT为:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
自行生成的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs

допустимыйjwt.ioПодтвердите это на:

РазобратьJWTпроцесс построенияJWTОбратный процесс , сначала основанный на записи через точку.разделить на три части, а затемBASE64Декодирование, а затем получение открытого текста, состоящего из трех частей, параметров заголовка и полезной нагрузки необходимо выполнить один раз.JSONДесериализация восстанавливаетJSONструктура:

public Map<Part, PartContent> parse(String jwt) throws Exception {
    System.out.println("当前解析的JWT:" + jwt);
    Map<Part, PartContent> result = new HashMap<>(8);
    // 这里暂且认为所有的输入JWT的格式都是合法的
    StringTokenizer tokenizer = new StringTokenizer(jwt, DOT);
    String[] jwtParts = new String[3];
    int idx = 0;
    while (tokenizer.hasMoreElements()) {
        jwtParts[idx] = tokenizer.nextToken();
        idx++;
    }
    String headerPart = jwtParts[0];
    PartContent headerContent = new PartContent();
    headerContent.setRawContent(headerPart);
    headerContent.setPart(Part.HEADER);
    headerPart = new String(Base64.decodeBase64(headerPart), StandardCharsets.UTF_8);
    headerContent.setPairs(OBJECT_MAPPER.readValue(headerPart, new TypeReference<Map<String, Object>>() {
    }));
    result.put(Part.HEADER, headerContent);
    String payloadPart = jwtParts[1];
    PartContent payloadContent = new PartContent();
    payloadContent.setRawContent(payloadPart);
    payloadContent.setPart(Part.PAYLOAD);
    payloadPart = new String(Base64.decodeBase64(payloadPart), StandardCharsets.UTF_8);
    payloadContent.setPairs(OBJECT_MAPPER.readValue(payloadPart, new TypeReference<Map<String, Object>>() {
    }));
    result.put(Part.PAYLOAD, payloadContent);
    String signaturePart = jwtParts[2];
    PartContent signatureContent = new PartContent();
    signatureContent.setRawContent(signaturePart);
    signatureContent.setPart(Part.SIGNATURE);
    result.put(Part.SIGNATURE, signatureContent);
    return result;
}

enum Part {

    HEADER,

    PAYLOAD,

    SIGNATURE
}

@Data
public static class PartContent {

    private Part part;

    private String rawContent;

    private Map<String, Object> pairs;
}

Здесь попробуйте использовать ранее произведенныйJWTДля разбора:

public static void main(String[] args) throws Exception {
    JsonWebToken jsonWebToken = new JsonWebToken();
    String jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs";
    Map<Part, PartContent> parseResult = jsonWebToken.parse(jwt);
    System.out.printf("解析结果如下:\nHEADER:%s\nPAYLOAD:%s\nSIGNATURE:%s%n",
            parseResult.get(Part.HEADER),
            parseResult.get(Part.PAYLOAD),
            parseResult.get(Part.SIGNATURE)
    );
}

Результаты анализа следующие:

当前解析的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
解析结果如下:
HEADER:PartContent(part=HEADER, rawContent=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9, pairs={typ=JWT, alg=HS256})
PAYLOAD:PartContent(part=PAYLOAD, rawContent=eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9, pairs={iss=throwx, jid=10087, exp=1613227468168})
SIGNATURE:PartContent(part=SIGNATURE, rawContent=7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs, pairs=null)

проверятьJWTпостроен на разбореJWTНа основе завершения необходимо сделать один раз на проанализированных параметрах заголовка и полезной нагрузке.MACПодпись сверяется с проанализированной подписью. Кроме того, вы можете настроить конкретные проверкиClaimтакие элементы, как время истечения срока действия и эмитент. Как правило, если проверка не пройдена, разные исключения времени выполнения будут настроены для разных ситуаций, чтобы упростить дифференциацию сценариев.Здесь они объединены для удобства.IllegalStateException:

public void verify(String jwt) throws Exception {
    System.out.println("当前校验的JWT:" + jwt);
    Map<Part, PartContent> parseResult = parse(jwt);
    PartContent headerContent = parseResult.get(Part.HEADER);
    PartContent payloadContent = parseResult.get(Part.PAYLOAD);
    PartContent signatureContent = parseResult.get(Part.SIGNATURE);
    String signature = generateSignaturePart(headerContent.getRawContent(), payloadContent.getRawContent());
    if (!Objects.equals(signature, signatureContent.getRawContent())) {
        throw new IllegalStateException("签名校验异常");
    }
    String iss = payloadContent.getPairs().get("iss").toString();
    // iss校验
    if (!Objects.equals(iss, "throwx")) {
        throw new IllegalStateException("ISS校验异常");
    }
    long exp = Long.parseLong(payloadContent.getPairs().get("exp").toString());
    // exp校验,有效期14天
    if (System.currentTimeMillis() - exp > 24 * 3600 * 1000 * 14) {
        throw new IllegalStateException("exp校验异常,JWT已经过期");
    }
    // 省略其他校验项
    System.out.println("JWT校验通过");
}

Точно так же, используя сгенерированный вышеJWTДля проверки результаты следующие:

当前校验的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
当前解析的JWT:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0aHJvd3giLCJqaWQiOjEwMDg3LCJleHAiOjE2MTMyMjc0NjgxNjh9.7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
23:33:00.174 [main] INFO club.throwable.jwt.JsonWebToken - 生成的Signature部分为:7skduDGxV-BP2p_CXyr3Na7WBvENNl--Pm4HQ8cJuEs
JWT校验通过

В приведенном выше коде есть проблема жесткого кодирования, просто чтобы использовать простейшийJWSПереработан метод реализацииJWTПроцесс генерации, разбора и проверки, алгоритм также использует очень низкую сложность и безопасностьHS256, поэтому не рекомендуется тратить много времени на продакшн для реализацииJWS, можно выбрать готовыйJWTбиблиотека классов, напримерauth0иjjwt.

Сценарии использования JWT и реальный бой

JWTСущность — это токен, и в большинстве сценариев он используется как сессия.ID(session_id) использование, эффект есть'维持会话的粘性'и нести информацию аутентификации (при использованииJWTтермины, которые следует безопасно пройтиClaims). Я помню тот, который я использовал давным-давноSession IDРешение генерируется и сохраняется серверомSession ID,возвращениеSession IDНужно написать пользовательскийCookie, а затем пользователь должен нестиCookie,Session IDБудет отображать некоторую информацию об аутентификации пользователя, все из которых управляются сервером, очень распространенным примером являетсяTomcatпоявление в контейнереJ(ava)SESSIONID. В отличие от предыдущей схемы,JWTЭто токен без сохранения состояния, его не нужно сохранять на сервере, и ни передаваемые данные, ни данные сеанса не должны сохраняться.JWTпросто следуйтеClaimsчестность и легитимностьJWTКогда все действительные данные закодированы и сохранены вJWTв строке. Только причинойJWTне имеет гражданства, после выдачи он получаетJWTЧерез него клиент может взаимодействовать с сервером,JWTПосле утечки это может вызвать серьезные проблемы с безопасностью, поэтому на практике вам обычно необходимо сделать следующее:

  • JWTВам нужно установить дату истечения срока действия, т.expэтоClaimПроверка суммы должна быть включена
  • JWTЧерный список должен быть установлен, как правило, используетсяjtiэтоClaimВсе, технически можно использовать комбинацию фильтров Блума и баз данных (в случае небольшого количества простых операций можно даже использоватьRedisизSETтип данных)
  • JWSНасколько это возможно, используйте алгоритм подписи с высоким уровнем безопасности, такой какRSXXX
  • ClaimsСтарайтесь не писать конфиденциальную информацию, насколько это возможно
  • Сценарии высокого риска, такие как платежные операции, не могут полагаться исключительно наJWTАутентификация требует вторичной аутентификации, такой как SMS, отпечаток пальца и т. д.

PS: Есть много проектов, в которых коллеги работают, сохраняя JWT, На самом деле это противоречит концепции дизайна JWT и использует JWT в качестве традиционного идентификатора сеанса.

JWTОбычно используется в сценариях аутентификации сAPIШлюз работает очень хорошо. в большинстве случаев,APIШлюз будет иметь некоторые общие интерфейсы, которые не требуют аутентификации, другие требуют аутентификации.JWTзаконность и добычаJWTСодержимое полезной нагрузки сообщения в вызове сделано для этого сценария:

  • Для записи контроллера вы можете предоставить пользовательскую аннотацию, чтобы определить конкретный интерфейс, который необходимо выполнить.JWTАутентификация, эта сцена находится вSpring Cloud GatewayТребуется индивидуальная реализация вJWTпроверенныйWebFilter
  • Для простой маршрутизации и переадресации может бытьURIСбор белого списка, попадание в белый список делать не нужноJWTАутентификация, эта сцена находится вSpring Cloud GatewayТребуется индивидуальная реализация вJWTпроверенныйGlobalFilter

нижеSpring Cloud Gatewayиjjwt, вставьте некоторый магистральный код и не раскрывайте подробности из-за нехватки места. Импорт зависимостей:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>Hoxton.SR10</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.2</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.2</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.2</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.18</version>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
</dependencies>

затем написатьJwtSpiи соответствующая реализацияHMAC256JwtSpiImpl:

@Data
public class CreateJwtDto {

    private Long customerId;

    private String customerName;

    private String customerPhone;
}

@Data
public class JwtCacheContent {

    private Long customerId;

    private String customerName;

    private String customerPhone;
}

@Data
public class VerifyJwtResultDto {

    private Boolean valid;

    private Throwable throwable;

    private long jwtId;

    private JwtCacheContent content;
}

public interface JwtSpi {

    /**
     * 生成JWT
     *
     * @param dto dto
     * @return String
     */
    String generate(CreateJwtDto dto);

    /**
     * 校验JWT
     *
     * @param jwt jwt
     * @return VerifyJwtResultDto
     */
    VerifyJwtResultDto verify(String jwt);

    /**
     * 把JWT添加到封禁名单中
     *
     * @param jwtId jwtId
     */
    void blockJwt(long jwtId);

    /**
     * 判断JWT是否在封禁名单中
     *
     * @param jwtId jwtId
     * @return boolean
     */
    boolean isInBlockList(long jwtId);
}

@Component
public class HMAC256JwtSpiImpl implements JwtSpi, InitializingBean, EnvironmentAware {

    private SecretKey secretKey;
    private Environment environment;
    private int minSeed;
    private String issuer;
    private int seed;
    private Random random;

    @Override
    public void afterPropertiesSet() throws Exception {
        String secretKey = Objects.requireNonNull(environment.getProperty("jwt.hmac.secretKey"));
        this.minSeed = Objects.requireNonNull(environment.getProperty("jwt.exp.seed.min", Integer.class));
        int maxSeed = Objects.requireNonNull(environment.getProperty("jwt.exp.seed.max", Integer.class));
        this.issuer = Objects.requireNonNull(environment.getProperty("jwt.issuer"));
        this.random = new Random();
        this.seed = (maxSeed - minSeed);
        this.secretKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA256");
    }

    @Override
    public void setEnvironment(Environment environment) {
        this.environment = environment;
    }

    @Override
    public String generate(CreateJwtDto dto) {
        long duration = this.random.nextInt(this.seed) + minSeed;
        Map<String, Object> claims = new HashMap<>(8);
        claims.put("iss", issuer);
        // 这里的jti最好用类似雪花算法之类的序列算法生成,确保唯一性
        claims.put("jti", dto.getCustomerId());
        claims.put("uid", dto.getCustomerId());
        claims.put("exp", TimeUnit.NANOSECONDS.toMillis(System.nanoTime()) + duration);
        String jwt = Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .signWith(this.secretKey, SignatureAlgorithm.HS256)
                .addClaims(claims)
                .compact();
        // 这里需要缓存uid->JwtCacheContent的信息
        JwtCacheContent content = new JwtCacheContent();
        // redis.set(KEY[uid],toJson(content),expSeconds);
        return jwt;
    }

    @Override
    public VerifyJwtResultDto verify(String jwt) {
        JwtParser parser = Jwts.parserBuilder()
                .requireIssuer(this.issuer)
                .setSigningKey(this.secretKey)
                .build();
        VerifyJwtResultDto resultDto = new VerifyJwtResultDto();
        try {
            Jws<Claims> parseResult = parser.parseClaimsJws(jwt);
            Claims claims = parseResult.getBody();
            long jti = Long.parseLong(claims.getId());
            if (isInBlockList(jti)) {
                throw new IllegalArgumentException(String.format("jti is in block list,[i:%d]", jti));
            }
            long uid = claims.get("uid", Long.class);
            // JwtCacheContent content = JSON.parse(redis.get(KEY[uid]),JwtCacheContent.class);
            // resultDto.setContent(content);
            resultDto.setValid(Boolean.TRUE);
        } catch (Exception e) {
            resultDto.setValid(Boolean.FALSE);
            resultDto.setThrowable(e);
        }
        return resultDto;
    }

    @Override
    public void blockJwt(long jwtId) {

    }

    @Override
    public boolean isInBlockList(long jwtId) {
        return false;
    }
}

ПослеJwtGlobalFilterиJwtWebFilterЧастичная реализация:

@Component
public class JwtGlobalFilter implements GlobalFilter, Ordered, EnvironmentAware {

    private final AntPathMatcher pathMatcher = new AntPathMatcher();

    private List<String> accessUriList;

    @Autowired
    private JwtSpi jwtSpi;

    private static final String JSON_WEB_TOKEN_KEY = "X-TOKEN";
    private static final String UID_KEY = "X-UID";
    private static final String JWT_ID_KEY = "X-JTI";

    @Override
    public void setEnvironment(Environment environment) {
        accessUriList = Arrays.asList(Objects.requireNonNull(environment.getProperty("jwt.access.uris"))
                .split(","));
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        // OPTIONS 请求直接放行
        HttpMethod method = request.getMethod();
        if (Objects.nonNull(method) && Objects.equals(method, HttpMethod.OPTIONS)) {
            return chain.filter(exchange);
        }
        // 获取请求路径
        String requestPath = request.getPath().value();
        // 命中请求路径白名单
        boolean matchWhiteRequestPathList = Optional.ofNullable(accessUriList)
                .map(paths -> paths.stream().anyMatch(path -> pathMatcher.match(path, requestPath)))
                .orElse(false);
        if (matchWhiteRequestPathList) {
            return chain.filter(exchange);
        }
        HttpHeaders headers = request.getHeaders();
        String token = headers.getFirst(JSON_WEB_TOKEN_KEY);
        if (!StringUtils.hasLength(token)) {
            throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), "token is null");
        }
        VerifyJwtResultDto resultDto = jwtSpi.verify(token);
        if (Objects.equals(resultDto.getValid(), Boolean.FALSE)) {
            throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), resultDto.getThrowable());
        }
        headers.set(JWT_ID_KEY, String.valueOf(resultDto.getJwtId()));
        headers.set(UID_KEY, String.valueOf(resultDto.getContent().getCustomerId()));
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return 1;
    }
}

@Component
public class JwtWebFilter implements WebFilter {

    @Autowired
    private RequestMappingHandlerMapping requestMappingHandlerMapping;

    @Autowired
    private JwtSpi jwtSpi;

    private static final String JSON_WEB_TOKEN_KEY = "X-TOKEN";
    private static final String UID_KEY = "X-UID";
    private static final String JWT_ID_KEY = "X-JTI";

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        // OPTIONS 请求直接放行
        HttpMethod method = exchange.getRequest().getMethod();
        if (Objects.nonNull(method) && Objects.equals(method, HttpMethod.OPTIONS)) {
            return chain.filter(exchange);
        }
        HandlerMethod handlerMethod = requestMappingHandlerMapping.getHandlerInternal(exchange).block();
        if (Objects.isNull(handlerMethod)) {
            return chain.filter(exchange);
        }
        RequireJWT typeAnnotation = handlerMethod.getBeanType().getAnnotation(RequireJWT.class);
        RequireJWT methodAnnotation = handlerMethod.getMethod().getAnnotation(RequireJWT.class);
        if (Objects.isNull(typeAnnotation) && Objects.isNull(methodAnnotation)) {
            return chain.filter(exchange);
        }
        HttpHeaders headers = exchange.getRequest().getHeaders();
        String token = headers.getFirst(JSON_WEB_TOKEN_KEY);
        if (!StringUtils.hasLength(token)) {
            throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), "token is null");
        }
        VerifyJwtResultDto resultDto = jwtSpi.verify(token);
        if (Objects.equals(resultDto.getValid(), Boolean.FALSE)) {
            throw new BusinessException(BusinessErrorCode.TOKEN_ERROR.getCode(), resultDto.getThrowable());
        }
        headers.set(JWT_ID_KEY, String.valueOf(resultDto.getJwtId()));
        headers.set(UID_KEY, String.valueOf(resultDto.getContent().getCustomerId()));
        return chain.filter(exchange);
    }
}

И, наконец, некоторые свойства конфигурации:

jwt.hmac.secretKey='00000000111111112222222233333333'
jwt.exp.seed.min=360000
jwt.exp.seed.max=8640000
jwt.issuer='throwx'
jwt.access.uris=/index,/actuator/*

Использование ям, с которыми столкнулся JWT

Автор несет ответственностьAPIшлюз используетJWTАлгоритм, используемый в сценариях аутентификации, использует несколько более высокий уровень безопасности.RS256,использоватьRSAАлгоритм генерации подписи. В начале запуска проектаJWTСрок годности фиксируется7дней, производственный журнал обнаружилAPIФеномен «подвешенной смерти» периодически возникает в шлюзе, а конкретные проявления заключаются в следующем:

  • NginxИнтерфейс самопроверки периодически вызывает тайм-аут, и некоторые или все сообщения запрашиваются.APIУзел шлюза не работает
  • APIмашины, на которой находится шлюзCPUЦиклически растет, стабилен при низком пользовательском трафике
  • пройти черезELKВыполните поиск и устранение неисправностей журнала и обнаружите, что ошибка возникает в течение периода времени.JWTЦентрализованные просроченные и восстановленные трассировки журналов

Результаты расследования показывают, чтоJWTИспользуется при истечении срока действия и регенерации централизованноRSAАлгоритм подписания такойCPUИнтенсивная эксплуатация при регенерации большого количестваJWTвызовет сервис на машинеCPUПерегрузка работой.Первоначальное решение:

  • JWTПри генерации добавить к сроку действия случайное число, например360000(1小时的毫秒数) ~ 8640000(24小时的毫秒数)Возьмите случайное значение между и добавьте к текущей метке времени плюс7день, чтобы получитьexpценность

Этот метод для некоторых старых сценариев пользовательского маркетинга (старые пользователи не входили в систему в течение длительного времени, их клиентская сторона кэшировалаJWTобычно просрочен) не имеет никакого эффекта. Иногда операция пробуждает старых пользователей с помощью маркетинговых мероприятий, и большое количество старых пользователей может резко повторно войти в систему и восстановиться в больших количествах.JWTВ этом случае предлагается два решения:

  • первая сборкаJWTвремя, рассмотрите возможность продления срока действия, но чем дольше время, тем выше риск
  • продвигатьAPIАппаратная конфигурация машины, на которой расположен шлюз, особенноCPUКонфигурация, многие поставщики облачных услуг теперь имеют гибкие планы расширения, которые вполне могут справиться с такими сценариями всплесков трафика.

резюме

Основной потокJWTСхемаJWS, эта схема только кодирование и подпись, а не шифрование, обязательно обратите на это внимание,JWSРешение не имеет состояния и небезопасно.Для ключевых операций необходимо выполнять несколько аутентификаций, а также должен быть реализован механизм черного списка, чтобы предотвратитьJWTПроблема безопасности возникает после утечки.JWTНе хранится на сервере, в этом его преимущество и недостаток. Многие программные архитектуры не могут быть идеальными, и в настоящее время мы можем только взвесить все за и против.

Использованная литература:

(Конец этой статьи c-3-w e-a-20210219)