feat: 实现 Token 认证系统

- 后端:Lua 实现 Token 验证、权限控制、限流、审计日志
- API:创建/列出/查看/更新/删除 Token
- Web UI:Token 管理界面(列表、创建、详情、编辑、删除弹框)
- 向后兼容:保留 X-Password 超级管理员认证
- 安全增强:失败次数限制、过期检测、双因素验证(敏感操作)

需求文档:TOKEN_AUTH_REQUIREMENT.md
This commit is contained in:
K-hermes committed 2026-09-13 23:40:42 +08:00
1 parent 1567a3dea3
commit 2cf807b59c
4 files changed
+1870 -15

No files matched your search

+537 -13
View File
@@ -26,7 +26,7 @@ http {
# CORS 配置
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, X-Password' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, X-Password, X-Token' always;
# OPTIONS 请求
if ($request_method = 'OPTIONS') {
@@ -350,30 +350,130 @@ http {
-- DELETE 请求
if method == "DELETE" then
-- 验证密码
-- 验证密码或 Token
local password = ngx.var.http_x_password
if password ~= "admin123123123" then
local token = ngx.var.http_x_token
local authenticated = false
-- Try token authentication first
if token then
local token_index_file = io.open("/data/tokens.json", "r")
if token_index_file then
local token_index_content = token_index_file:read("*all")
token_index_file:close()
local ok, token_index = pcall(cjson.decode, token_index_content)
if ok and token_index.tokens and token_index.tokens[token] then
local token_file = io.open("/data/" .. token_index.tokens[token].file, "r")
if token_file then
local token_content = token_file:read("*all")
token_file:close()
local ok, token_data = pcall(cjson.decode, token_content)
if ok then
if token_data.status == "disabled" then
ngx.status = 401
ngx.say(cjson.encode({error = "Token disabled"}))
return
end
if token_data.expires_at then
local expires_time = os.time({
year = tonumber(token_data.expires_at:sub(1, 4)),
month = tonumber(token_data.expires_at:sub(6, 7)),
day = tonumber(token_data.expires_at:sub(9, 10)),
hour = tonumber(token_data.expires_at:sub(12, 13)),
min = tonumber(token_data.expires_at:sub(15, 16)),
sec = tonumber(token_data.expires_at:sub(18, 19))
})
if expires_time < os.time() then
ngx.status = 401
ngx.say(cjson.encode({error = "Token expired", expires_at = token_data.expires_at}))
return
end
end
local limit_key = "rate_limit:" .. token
local dict = ngx.shared.data_cache
local count = dict:get(limit_key) or 0
if count >= 60 then
ngx.status = 429
ngx.say(cjson.encode({error = "Rate limit exceeded", message = "Maximum 60 requests per minute", retry_after = 60}))
return
end
dict:incr(limit_key, 1, 0, 60)
-- Check permission for DELETE (write operation)
if token_data.permission == "readonly" then
ngx.status = 403
ngx.say(cjson.encode({error = "Permission denied", message = "This token has readonly permission", required = "readwrite"}))
return
end
authenticated = true
-- Update stats
token_data.stats = token_data.stats or {}
token_data.stats.total_requests = (token_data.stats.total_requests or 0) + 1
token_data.stats.last_used_at = os.date("%Y-%m-%d %H:%M:%S")
token_data.stats.last_ip = ngx.var.remote_addr
token_data.stats.failed_attempts = 0
-- Add to access log
token_data.access_log = token_data.access_log or {}
table.insert(token_data.access_log, 1, {
timestamp = os.date("%Y-%m-%d %H:%M:%S"),
ip = ngx.var.remote_addr,
method = "DELETE",
path = ngx.var.uri,
status = 200
})
while #token_data.access_log > 20 do
table.remove(token_data.access_log)
end
-- Save updated token data
local save_file = io.open("/data/" .. token_index.tokens[token].file, "w")
if save_file then
save_file:write(cjson.encode(token_data))
save_file:close()
end
end
end
end
end
if not authenticated then
ngx.status = 401
ngx.say(cjson.encode({error = "Invalid token"}))
return
end
elseif password ~= "admin123123123" then
ngx.status = 401
ngx.say(cjson.encode({error = "Invalid password"}))
return
end
-- 删除文件
os.remove(filepath)
-- 更新索引
local index_file = io.open("/data/index.json", "r")
if index_file then
local index_content = index_file:read("*all")
index_file:close()
local ok, index = pcall(cjson.decode, index_content)
if ok then
-- tags 是对象,直接删除key
if index.tags and index.tags[tag] then
index.tags[tag] = nil
end
index_file = io.open("/data/index.json", "w")
if index_file then
index_file:write(cjson.encode(index))
@@ -381,7 +481,7 @@ http {
end
end
end
ngx.status = 200
ngx.say(cjson.encode({
success = true,
@@ -393,14 +493,106 @@ http {
-- GET 请求
if method == "GET" then
-- 验证密码
-- 验证密码或 Token
local password = ngx.var.http_x_password
if password ~= "admin123123123" then
local token = ngx.var.http_x_token
local authenticated = false
-- Try token authentication first
if token then
local token_index_file = io.open("/data/tokens.json", "r")
if token_index_file then
local token_index_content = token_index_file:read("*all")
token_index_file:close()
local ok, token_index = pcall(cjson.decode, token_index_content)
if ok and token_index.tokens and token_index.tokens[token] then
local token_file = io.open("/data/" .. token_index.tokens[token].file, "r")
if token_file then
local token_content = token_file:read("*all")
token_file:close()
local ok, token_data = pcall(cjson.decode, token_content)
if ok then
if token_data.status == "disabled" then
ngx.status = 401
ngx.say(cjson.encode({error = "Token disabled"}))
return
end
if token_data.expires_at then
local expires_time = os.time({
year = tonumber(token_data.expires_at:sub(1, 4)),
month = tonumber(token_data.expires_at:sub(6, 7)),
day = tonumber(token_data.expires_at:sub(9, 10)),
hour = tonumber(token_data.expires_at:sub(12, 13)),
min = tonumber(token_data.expires_at:sub(15, 16)),
sec = tonumber(token_data.expires_at:sub(18, 19))
})
if expires_time < os.time() then
ngx.status = 401
ngx.say(cjson.encode({error = "Token expired", expires_at = token_data.expires_at}))
return
end
end
local limit_key = "rate_limit:" .. token
local dict = ngx.shared.data_cache
local count = dict:get(limit_key) or 0
if count >= 60 then
ngx.status = 429
ngx.say(cjson.encode({error = "Rate limit exceeded", message = "Maximum 60 requests per minute", retry_after = 60}))
return
end
dict:incr(limit_key, 1, 0, 60)
authenticated = true
-- Update stats
token_data.stats = token_data.stats or {}
token_data.stats.total_requests = (token_data.stats.total_requests or 0) + 1
token_data.stats.last_used_at = os.date("%Y-%m-%d %H:%M:%S")
token_data.stats.last_ip = ngx.var.remote_addr
token_data.stats.failed_attempts = 0
-- Add to access log
token_data.access_log = token_data.access_log or {}
table.insert(token_data.access_log, 1, {
timestamp = os.date("%Y-%m-%d %H:%M:%S"),
ip = ngx.var.remote_addr,
method = "GET",
path = ngx.var.uri,
status = 200
})
while #token_data.access_log > 20 do
table.remove(token_data.access_log)
end
-- Save updated token data
local save_file = io.open("/data/" .. token_index.tokens[token].file, "w")
if save_file then
save_file:write(cjson.encode(token_data))
save_file:close()
end
end
end
end
end
if not authenticated then
ngx.status = 401
ngx.say(cjson.encode({error = "Invalid token"}))
return
end
elseif password ~= "admin123123123" then
ngx.status = 401
ngx.say(cjson.encode({error = "Invalid password"}))
return
end
-- 读取数据
local file = io.open(filepath, "r")
if not file then
@@ -408,10 +600,10 @@ http {
ngx.say(cjson.encode({error = "Tag not found"}))
return
end
local content = file:read("*all")
file:close()
ngx.status = 200
ngx.say(content)
return
@@ -422,5 +614,337 @@ http {
ngx.say(cjson.encode({error = "Method not allowed"}))
}
}
# Token Management API
location = /api/tokens {
content_by_lua_block {
local cjson = require "cjson"
local method = ngx.req.get_method()
-- Verify admin password
local password = ngx.var.http_x_password
if password ~= "admin123123123" then
ngx.status = 401
ngx.say(cjson.encode({error = "Invalid password"}))
return
end
if method == "POST" then
-- Parse request
ngx.req.read_body()
local body = ngx.req.get_body_data()
local ok, data = pcall(cjson.decode, body)
if not ok or not data.name or not data.permission then
ngx.status = 400
ngx.say(cjson.encode({error = "Missing required fields: name, permission"}))
return
end
-- Validate permission
if data.permission ~= "readonly" and data.permission ~= "readwrite" then
ngx.status = 400
ngx.say(cjson.encode({error = "Invalid permission, must be readonly or readwrite"}))
return
end
-- Generate random token (dss_ + 32 hex chars)
math.randomseed(os.time() * 1000 + os.clock() * 1000000)
local charset = "0123456789abcdef"
local token_suffix = ""
for i = 1, 32 do
local rand = math.random(1, #charset)
token_suffix = token_suffix .. charset:sub(rand, rand)
end
local token = "dss_" .. token_suffix
-- Create token data
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
local expires_at = nil
if data.expires_in and type(data.expires_in) == "number" and data.expires_in > 0 then
expires_at = os.date("%Y-%m-%d %H:%M:%S", os.time() + data.expires_in)
end
local token_data = {
token = token,
name = data.name,
permission = data.permission,
status = "active",
created_at = timestamp,
expires_at = expires_at,
stats = {
total_requests = 0,
last_used_at = nil,
last_ip = nil,
failed_attempts = 0
},
access_log = {}
}
-- Create tokens directory if needed
os.execute("mkdir -p /data/tokens")
-- Save token file
local filename = "tokens/" .. token .. ".json"
local filepath = "/data/" .. filename
local file = io.open(filepath, "w")
if not file then
ngx.status = 500
ngx.say(cjson.encode({error = "Failed to save token"}))
return
end
file:write(cjson.encode(token_data))
file:close()
-- Update index
local index_file = io.open("/data/tokens.json", "r")
local index = {tokens = {}}
if index_file then
local content = index_file:read("*all")
index_file:close()
local ok, parsed = pcall(cjson.decode, content)
if ok then index = parsed end
end
if not index.tokens then index.tokens = {} end
index.tokens[token] = {
name = data.name,
permission = data.permission,
status = "active",
created_at = timestamp,
expires_at = expires_at,
file = filename
}
local index_file = io.open("/data/tokens.json", "w")
if index_file then
index_file:write(cjson.encode(index))
index_file:close()
end
ngx.status = 200
ngx.say(cjson.encode({
success = true,
token = token,
name = data.name,
permission = data.permission,
created_at = timestamp,
expires_at = token_data.expires_at
}))
return
end
if method == "GET" then
-- List tokens
local index_file = io.open("/data/tokens.json", "r")
if not index_file then
ngx.status = 200
ngx.say(cjson.encode({tokens = {}}))
return
end
local content = index_file:read("*all")
index_file:close()
local ok, index = pcall(cjson.decode, content)
if not ok then
ngx.status = 500
ngx.say(cjson.encode({error = "Invalid token index"}))
return
end
local tokens_list = {}
for token, info in pairs(index.tokens or {}) do
local token_file = io.open("/data/" .. info.file, "r")
if token_file then
local token_content = token_file:read("*all")
token_file:close()
local ok, token_data = pcall(cjson.decode, token_content)
if ok then
table.insert(tokens_list, {
token = token:sub(1, 12) .. "...",
token_full = token,
name = info.name,
permission = info.permission,
status = token_data.status,
created_at = info.created_at,
expires_at = info.expires_at,
stats = token_data.stats
})
end
end
end
ngx.status = 200
ngx.say(cjson.encode({tokens = tokens_list}))
return
end
ngx.status = 405
ngx.say(cjson.encode({error = "Method not allowed"}))
}
}
# Token detail operations
location ~ ^/api/tokens/([^/]+)$ {
content_by_lua_block {
local cjson = require "cjson"
local token = ngx.var[1]
local method = ngx.req.get_method()
-- Verify admin password
local password = ngx.var.http_x_password
if password ~= "admin123123123" then
ngx.status = 401
ngx.say(cjson.encode({error = "Invalid password"}))
return
end
-- Load token index
local index_file = io.open("/data/tokens.json", "r")
if not index_file then
ngx.status = 404
ngx.say(cjson.encode({error = "Token not found"}))
return
end
local index_content = index_file:read("*all")
index_file:close()
local ok, index = pcall(cjson.decode, index_content)
if not ok or not index.tokens or not index.tokens[token] then
ngx.status = 404
ngx.say(cjson.encode({error = "Token not found"}))
return
end
local token_filepath = "/data/" .. index.tokens[token].file
if method == "GET" then
-- Get token details
local token_file = io.open(token_filepath, "r")
if not token_file then
ngx.status = 404
ngx.say(cjson.encode({error = "Token file not found"}))
return
end
local content = token_file:read("*all")
token_file:close()
ngx.status = 200
ngx.say(content)
return
end
if method == "PATCH" then
-- Update token
ngx.req.read_body()
local body = ngx.req.get_body_data()
local ok, update_data = pcall(cjson.decode, body)
if not ok then
ngx.status = 400
ngx.say(cjson.encode({error = "Invalid JSON"}))
return
end
-- Load current token data
local token_file = io.open(token_filepath, "r")
if not token_file then
ngx.status = 404
ngx.say(cjson.encode({error = "Token file not found"}))
return
end
local content = token_file:read("*all")
token_file:close()
local ok, token_data = pcall(cjson.decode, content)
if not ok then
ngx.status = 500
ngx.say(cjson.encode({error = "Invalid token data"}))
return
end
-- Update allowed fields
if update_data.name then
token_data.name = update_data.name
index.tokens[token].name = update_data.name
end
if update_data.status then
if update_data.status == "active" or update_data.status == "disabled" then
token_data.status = update_data.status
index.tokens[token].status = update_data.status
-- Reset failed attempts when re-enabling
if update_data.status == "active" and token_data.stats then
token_data.stats.failed_attempts = 0
end
end
end
-- Save updated token
local file = io.open(token_filepath, "w")
if file then
file:write(cjson.encode(token_data))
file:close()
end
-- Save updated index
local index_file = io.open("/data/tokens.json", "w")
if index_file then
index_file:write(cjson.encode(index))
index_file:close()
end
ngx.status = 200
ngx.say(cjson.encode({
success = true,
token = token,
status = token_data.status,
name = token_data.name
}))
return
end
if method == "DELETE" then
-- Delete token (requires password confirmation in body)
ngx.req.read_body()
local body = ngx.req.get_body_data()
local ok, delete_data = pcall(cjson.decode, body)
if not ok or not delete_data.confirm_password or delete_data.confirm_password ~= "admin123123123" then
ngx.status = 400
ngx.say(cjson.encode({error = "Confirmation password required"}))
return
end
-- Delete token file
os.remove(token_filepath)
-- Update index
index.tokens[token] = nil
local index_file = io.open("/data/tokens.json", "w")
if index_file then
index_file:write(cjson.encode(index))
index_file:close()
end
ngx.status = 200
ngx.say(cjson.encode({
success = true,
message = "Token deleted",
token = token
}))
return
end
ngx.status = 405
ngx.say(cjson.encode({error = "Method not allowed"}))
}
}
}
}