feat(gateway): add /refresh_token API with grace window + refresh limit
- POST /refresh_token: accept current Bearer token (or Cookie fallback), verify RSA signature, check grace window (iat + keep_time * multiplier) and refresh count limit, then issue a new token. - Token payload extended with iat, refresh_count, max_refresh fields. - iat preserves original issue time; created_at updates on each refresh. - Rate limited: 5 req / 5 min / IP via RateLimiter. - Config: Token.refresh_grace_multiplier (default 2), Token.max_refresh (default 10). - Backward compatible: old tokens without new fields still pass proxy checks.
This commit is contained in:
1 parent
aa3a99825c
commit
a8a190571c
2 files changed
+175
-1
No files matched your search
@@ -14,6 +14,10 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
|
|||||||
[Token]
|
[Token]
|
||||||
# Token 有效期(秒),默认 3600 = 1 小时
|
# Token 有效期(秒),默认 3600 = 1 小时
|
||||||
keep_time = 3600
|
keep_time = 3600
|
||||||
|
# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2
|
||||||
|
refresh_grace_multiplier = 2
|
||||||
|
# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10
|
||||||
|
max_refresh = 10
|
||||||
|
|
||||||
[Admin]
|
[Admin]
|
||||||
# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true,
|
# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true,
|
||||||
|
|||||||
+171
-1
@@ -302,6 +302,10 @@ http::response<http::string_body> handleLogout(
|
|||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
const std::unordered_map<std::string, std::string>& params);
|
const std::unordered_map<std::string, std::string>& params);
|
||||||
|
|
||||||
|
http::response<http::string_body> handleRefreshToken(
|
||||||
|
const http::request<http::string_body>& req,
|
||||||
|
const std::unordered_map<std::string, std::string>& params);
|
||||||
|
|
||||||
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
|
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
|
||||||
http::response<http::string_body> handleCaptchaImage(
|
http::response<http::string_body> handleCaptchaImage(
|
||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
@@ -325,6 +329,8 @@ static std::string _DEFT_URL;
|
|||||||
static std::vector<std::string> _OPEN_URL;
|
static std::vector<std::string> _OPEN_URL;
|
||||||
static std::string _DB_PATH;
|
static std::string _DB_PATH;
|
||||||
static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置
|
static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置
|
||||||
|
static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新
|
||||||
|
static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数,可由 [Token] max_refresh 配置
|
||||||
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
||||||
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
||||||
|
|
||||||
@@ -335,6 +341,7 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
|||||||
static RateLimiter _LOGIN_RATE;
|
static RateLimiter _LOGIN_RATE;
|
||||||
static RateLimiter _REGISTER_RATE;
|
static RateLimiter _REGISTER_RATE;
|
||||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
||||||
|
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流
|
||||||
|
|
||||||
/* pymsg 下游调用超时(秒):
|
/* pymsg 下游调用超时(秒):
|
||||||
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
|
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
|
||||||
@@ -499,6 +506,8 @@ int main(int argc, char **argv) {
|
|||||||
_OPEN_URL = split(cf.get<std::string>("Proxy.opened", ""), ';');
|
_OPEN_URL = split(cf.get<std::string>("Proxy.opened", ""), ';');
|
||||||
_DB_PATH = cf.get<std::string>("Database.path", "");
|
_DB_PATH = cf.get<std::string>("Database.path", "");
|
||||||
_TOKEN_KEEP_TIME = cf.get<int>("Token.keep_time", 3600);
|
_TOKEN_KEEP_TIME = cf.get<int>("Token.keep_time", 3600);
|
||||||
|
_TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get<int>("Token.refresh_grace_multiplier", 2);
|
||||||
|
_TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
|
||||||
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
||||||
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
||||||
}
|
}
|
||||||
@@ -621,6 +630,11 @@ void registerRoutes(HttpServer &server) {
|
|||||||
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
[](const auto& req, const auto& params) { return handleLogout(req, params); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.post("/refresh_token", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||||
|
return wrapperHttp(req, params,
|
||||||
|
[](const auto& req, const auto& params) { return handleRefreshToken(req, params); });
|
||||||
|
});
|
||||||
|
|
||||||
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
|
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
|
||||||
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||||
return wrapperHttp(req, params,
|
return wrapperHttp(req, params,
|
||||||
@@ -876,9 +890,12 @@ http::response<http::string_body> handleLogin(
|
|||||||
|
|
||||||
json::value jv_t = {
|
json::value jv_t = {
|
||||||
{"user", username},
|
{"user", username},
|
||||||
|
{"iat", LocalTime::getCurrent()},
|
||||||
{"created_at", LocalTime::getCurrent()},
|
{"created_at", LocalTime::getCurrent()},
|
||||||
{"keep_time", _TOKEN_KEEP_TIME},
|
{"keep_time", _TOKEN_KEEP_TIME},
|
||||||
{"is_admin", is_admin}
|
{"is_admin", is_admin},
|
||||||
|
{"refresh_count", 0},
|
||||||
|
{"max_refresh", _TOKEN_MAX_REFRESH}
|
||||||
};
|
};
|
||||||
std::string data = json::serialize(jv_t);
|
std::string data = json::serialize(jv_t);
|
||||||
std::string auth = Base64::base64_encode(data);
|
std::string auth = Base64::base64_encode(data);
|
||||||
@@ -929,6 +946,159 @@ http::response<http::string_body> handleLogout(
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 刷新 Token:POST /refresh_token
|
||||||
|
*
|
||||||
|
* 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。
|
||||||
|
* 仅更新 created_at 与 refresh_count;iat 保持不变以限制整条链的总生命周期。
|
||||||
|
*
|
||||||
|
* 宽限期:iat + keep_time * refresh_grace_multiplier
|
||||||
|
* 例:keep_time=3600, multiplier=2 → 首次签发后 2 小时内可刷新
|
||||||
|
* 刷新上限:refresh_count < max_refresh
|
||||||
|
* 限流:5 次 / 5 分钟 / IP
|
||||||
|
* Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token
|
||||||
|
*
|
||||||
|
* 响应:{ success, data:{ token, refresh_count, refreshes_remaining } }
|
||||||
|
*/
|
||||||
|
http::response<http::string_body> handleRefreshToken(
|
||||||
|
const http::request<http::string_body>& req,
|
||||||
|
const std::unordered_map<std::string, std::string>& params)
|
||||||
|
{
|
||||||
|
http::response<http::string_body> res;
|
||||||
|
res.version(req.version());
|
||||||
|
|
||||||
|
/* IP 速率限制:5 次 / 300 秒 */
|
||||||
|
std::string client_ip = "direct";
|
||||||
|
auto it_host = req.base().find("X-Forwarded-For");
|
||||||
|
if (it_host != req.base().end())
|
||||||
|
client_ip = std::string(it_host->value());
|
||||||
|
if (!_REFRESH_TOKEN_RATE.check_and_record(client_ip, 5, 300))
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.",
|
||||||
|
429, "/refresh_token");
|
||||||
|
|
||||||
|
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||||
|
|
||||||
|
/* 获取 Token:Authorization 头优先,回退 Cookie */
|
||||||
|
std::string raw;
|
||||||
|
auto it_auth = req.base().find("Authorization");
|
||||||
|
if (it_auth != req.base().end()) {
|
||||||
|
raw = std::string(it_auth->value());
|
||||||
|
} else {
|
||||||
|
auto cit = req.base().find("Cookie");
|
||||||
|
if (cit != req.base().end()) {
|
||||||
|
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||||
|
if (!cookie_token.empty())
|
||||||
|
raw = "Bearer " + cookie_token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.empty())
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token");
|
||||||
|
|
||||||
|
/* 解析 Bearer <base64>_<signature> */
|
||||||
|
if (raw.substr(0, 7) != "Bearer ")
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||||
|
raw = raw.substr(7);
|
||||||
|
|
||||||
|
size_t idx = raw.find("_");
|
||||||
|
if (idx == std::string::npos)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||||
|
|
||||||
|
std::string data = raw.substr(0, idx);
|
||||||
|
std::string sign = raw.substr(idx + 1);
|
||||||
|
std::string auth = Base64::base64_decode_to_string(data);
|
||||||
|
|
||||||
|
/* RSA 验签 */
|
||||||
|
if (!_PUBL.Verify(auth, sign))
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token");
|
||||||
|
|
||||||
|
/* 解析 Token payload */
|
||||||
|
json::value jv_t;
|
||||||
|
try { jv_t = json::parse(auth); }
|
||||||
|
catch (const std::exception&) {
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||||
|
}
|
||||||
|
if (!jv_t.is_object())
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||||
|
|
||||||
|
json::object& obj = jv_t.as_object();
|
||||||
|
|
||||||
|
/* 提取关键字段 */
|
||||||
|
std::string username = json::value_to<std::string>(obj.at("user"));
|
||||||
|
std::string iat_str = json::value_to<std::string>(obj.at("iat"));
|
||||||
|
int keep_time = json::value_to<int>(obj.at("keep_time"));
|
||||||
|
int refresh_count = json::value_to<int>(obj.at("refresh_count"));
|
||||||
|
int max_refresh = json::value_to<int>(obj.at("max_refresh"));
|
||||||
|
|
||||||
|
/* 刷新次数检查 */
|
||||||
|
if (refresh_count >= max_refresh)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4005, "Refresh limit exceeded. Please re-login.",
|
||||||
|
400, "/refresh_token");
|
||||||
|
|
||||||
|
/* 宽限期检查:iat + keep_time * grace_multiplier > now */
|
||||||
|
auto iat_tp = LocalTime::parse(iat_str);
|
||||||
|
if (!iat_tp)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4004, "Bad token iat", 400, "/refresh_token");
|
||||||
|
|
||||||
|
auto now = std::chrono::system_clock::now();
|
||||||
|
auto grace_expire = *iat_tp + std::chrono::seconds(keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER);
|
||||||
|
if (now > grace_expire)
|
||||||
|
THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.",
|
||||||
|
401, "/refresh_token");
|
||||||
|
|
||||||
|
/* 保留 is_admin(旧 Token 未携带时默认为 false) */
|
||||||
|
bool is_admin = false;
|
||||||
|
auto it_admin = obj.find("is_admin");
|
||||||
|
if (it_admin != obj.end() && it_admin->value().is_bool())
|
||||||
|
is_admin = it_admin->value().as_bool();
|
||||||
|
|
||||||
|
/* 签发新 Token:iat 不变,created_at 更新,refresh_count++ */
|
||||||
|
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
|
||||||
|
json::value jv_new = {
|
||||||
|
{"user", username},
|
||||||
|
{"iat", iat_str},
|
||||||
|
{"created_at", LocalTime::getCurrent()},
|
||||||
|
{"keep_time", keep_time},
|
||||||
|
{"is_admin", is_admin},
|
||||||
|
{"refresh_count", refresh_count + 1},
|
||||||
|
{"max_refresh", max_refresh}
|
||||||
|
};
|
||||||
|
std::string new_data = json::serialize(jv_new);
|
||||||
|
std::string new_auth = Base64::base64_encode(new_data);
|
||||||
|
std::string new_sign = _PRIV.Sign(new_data);
|
||||||
|
std::string new_token = new_auth + "_" + new_sign;
|
||||||
|
|
||||||
|
/* Set-Cookie */
|
||||||
|
{
|
||||||
|
std::string ck = "q4_token=" + new_token +
|
||||||
|
"; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" +
|
||||||
|
std::to_string(keep_time);
|
||||||
|
res.set("Set-Cookie", ck);
|
||||||
|
}
|
||||||
|
|
||||||
|
logs().info("gateway", "Token refreshed", std::nullopt,
|
||||||
|
json::serialize(json::value{
|
||||||
|
{"user", username},
|
||||||
|
{"ip", client_ip},
|
||||||
|
{"refresh_count", refresh_count + 1},
|
||||||
|
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
|
||||||
|
}));
|
||||||
|
|
||||||
|
json::value jv_rr = {
|
||||||
|
{"success", true},
|
||||||
|
{"data", json::object{
|
||||||
|
{"token", new_token},
|
||||||
|
{"refresh_count", refresh_count + 1},
|
||||||
|
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
|
||||||
|
}}
|
||||||
|
};
|
||||||
|
res.set("Connection", "close");
|
||||||
|
res.result(http::status::ok);
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.body() = json::serialize(jv_rr);
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
/* 代理 captcha 图形验证码:GET /captcha/image
|
/* 代理 captcha 图形验证码:GET /captcha/image
|
||||||
*
|
*
|
||||||
* captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问,
|
* captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问,
|
||||||
|
|||||||
Reference in new issue
Block a user