Kaynağa Gözat

fix: give every JWT a random jti so refresh tokens stop colliding

issued_at is second-resolution and there was no nonce, so two logins by the
same user in the same second minted byte-identical refresh tokens.
AuthStore::refreshTokens finds a session by filtering on the token value, so
two sessions sharing one token made rotation or logout land on an arbitrary
row.

Add a random jti (common::UUID, backed by OpenSSL RAND_bytes) to both access
and refresh tokens - access tokens have the same second-resolution collision
window (e.g. a login immediately followed by a refresh). Verification treats
a missing jti as empty rather than rejecting the token, so tokens minted
before this change keep working; sessions were cleared today so in practice
none exist.

Verified: 20 rapid logins for the same user produced 20 distinct refresh
tokens, several sharing the same iat second. Login -> refresh -> new access
token authenticates -> old refresh token is rejected (401) -> new refresh
token still works.
fszontagh 1 ay önce
ebeveyn
işleme
3cc1136f27

+ 15 - 1
src/webserver/auth/jwt_utils.cpp

@@ -1,6 +1,7 @@
 #include "jwt_utils.hpp"
 #include "common/time_utils.hpp"
 #include "common/string_utils.hpp"
+#include "common/uuid.hpp"
 #include <openssl/hmac.h>
 #include <openssl/evp.h>
 #include <openssl/crypto.h>
@@ -17,7 +18,8 @@ nlohmann::json TokenPayload::toJson() const {
         {"role", role},
         {"iat", issued_at},
         {"exp", expires_at},
-        {"type", token_type}
+        {"type", token_type},
+        {"jti", jti}
     };
 }
 
@@ -29,6 +31,10 @@ TokenPayload TokenPayload::fromJson(const nlohmann::json& j) {
     payload.issued_at = j.value("iat", int64_t{0});
     payload.expires_at = j.value("exp", int64_t{0});
     payload.token_type = j.value("type", "");
+    // Missing on any token minted before jti existed - defaults to empty
+    // rather than being rejected, so every session issued before this change
+    // does not go instantly invalid.
+    payload.jti = j.value("jti", "");
     return payload;
 }
 
@@ -47,6 +53,10 @@ std::string JwtUtils::generateAccessToken(const std::string& user_id,
     payload.issued_at = now;
     payload.expires_at = now + config_.access_token_lifetime_sec;
     payload.token_type = "access";
+    // Same collision as the refresh token: two access tokens minted for the
+    // same user in the same second would otherwise be identical too (e.g. a
+    // login followed immediately by a refresh).
+    payload.jti = common::UUID::generate();
 
     return generateToken(payload);
 }
@@ -63,6 +73,10 @@ std::string JwtUtils::generateRefreshToken(const std::string& user_id,
     payload.issued_at = now;
     payload.expires_at = now + config_.refresh_token_lifetime_sec;
     payload.token_type = "refresh";
+    // A random UUID (backed by OpenSSL RAND_bytes, see common::UUID), not a
+    // counter or a finer-grained timestamp - the goal is a value nobody can
+    // predict or coincide with, not merely one with more digits.
+    payload.jti = common::UUID::generate();
 
     return generateToken(payload);
 }

+ 8 - 0
src/webserver/auth/jwt_utils.hpp

@@ -15,6 +15,14 @@ struct TokenPayload {
     int64_t issued_at = 0;
     int64_t expires_at = 0;
     std::string token_type;  // "access" or "refresh"
+    // Random per-token identifier. issued_at is only second-resolution and
+    // there is nothing else that varies between two tokens minted for the
+    // same user in the same second, so without this two such tokens are
+    // byte-identical - and AuthStore::refreshTokens, which finds a session by
+    // filtering on the token value, then matches whichever row the query
+    // happens to return. Empty when decoding a token minted before this field
+    // existed; verification does not require it to be present.
+    std::string jti;
 
     nlohmann::json toJson() const;
     static TokenPayload fromJson(const nlohmann::json& j);