feat: 实现 Token 认证系统
- 后端:Lua 实现 Token 验证、权限控制、限流、审计日志 - API:创建/列出/查看/更新/删除 Token - Web UI:Token 管理界面(列表、创建、详情、编辑、删除弹框) - 向后兼容:保留 X-Password 超级管理员认证 - 安全增强:失败次数限制、过期检测、双因素验证(敏感操作) 需求文档:TOKEN_AUTH_REQUIREMENT.md
This commit is contained in:
1 parent
1567a3dea3
commit
2cf807b59c
4 files changed
+1870
-15
No files matched your search
+537
-13
@@ -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"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user