网友修改版
This commit is contained in:
1 parent
290332c70c
commit
fbccc5d909
14 files changed
+9042
-73
No files matched your search
@@ -19,8 +19,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|||||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||||
|
|
||||||
from agent.review_agent import ReviewAgent
|
from agent.review_agent import ReviewAgent
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
apply_security_headers(app)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
review_agent = ReviewAgent()
|
review_agent = ReviewAgent()
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Security headers middleware for Flask applications.
|
||||||
|
Usage:
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
apply_security_headers(app)
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def apply_security_headers(app, csp_policy=None):
|
||||||
|
"""Apply security response headers to a Flask app."""
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def set_security_headers(response):
|
||||||
|
# Prevent clickjacking
|
||||||
|
response.headers['X-Frame-Options'] = 'DENY'
|
||||||
|
# XSS protection (legacy browsers)
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
# Prevent MIME type sniffing
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
# Referrer policy
|
||||||
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
|
# Permissions policy (disable camera, mic, geolocation)
|
||||||
|
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
if csp_policy:
|
||||||
|
response.headers['Content-Security-Policy'] = csp_policy
|
||||||
|
else:
|
||||||
|
response.headers['Content-Security-Policy'] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"font-src 'self'; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove server header info
|
||||||
|
response.headers.pop('Server', None)
|
||||||
|
|
||||||
|
return response
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
cd "$(dirname "$0")" || exit
|
||||||
|
python3 captcha_service.py
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
CAPTCHA Image Service (stdlib only, no Flask dependency)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
CAPTCHA_LEN = 5
|
||||||
|
CAPTCHA_TTL = 300
|
||||||
|
ADDR = '127.0.0.1'
|
||||||
|
PORT = 8009
|
||||||
|
|
||||||
|
CHARS = string.ascii_letters + string.digits
|
||||||
|
CHARS = CHARS.translate(str.maketrans('', '', 'lI1O0o'))
|
||||||
|
|
||||||
|
captcha_store = {}
|
||||||
|
STORE_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_text():
|
||||||
|
return ''.join(random.choices(CHARS, k=CAPTCHA_LEN))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_image(text):
|
||||||
|
width, height = 160, 50
|
||||||
|
bg = random.choice([(255,255,255), (240,240,240), (230,240,250)])
|
||||||
|
img = Image.new('RGB', (width, height), bg)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
for i, ch in enumerate(text):
|
||||||
|
color = random.choice([(30,30,30),(50,80,150),(150,50,50),(50,130,50)])
|
||||||
|
y = random.randint(2, 12)
|
||||||
|
x = 10 + i * 28 + random.randint(-3, 3)
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
random.choice([28,30,32,34]))
|
||||||
|
except (IOError, OSError):
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
draw.text((x, y), ch, fill=color, font=font)
|
||||||
|
|
||||||
|
for _ in range(4):
|
||||||
|
draw.line([(random.randint(0,width), random.randint(0,height)),
|
||||||
|
(random.randint(0,width), random.randint(0,height))],
|
||||||
|
fill=random.choice([(30,30,30),(50,80,150)]), width=1)
|
||||||
|
for _ in range(50):
|
||||||
|
draw.point((random.randint(0,width-1), random.randint(0,height-1)),
|
||||||
|
fill=random.choice([(30,30,30),(50,80,150)]))
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
buf.seek(0)
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def json_response(handler, data, status=200):
|
||||||
|
body = json.dumps(data).encode('utf-8')
|
||||||
|
handler.send_response(status)
|
||||||
|
handler.send_header('Content-Type', 'application/json')
|
||||||
|
handler.send_header('Content-Length', str(len(body)))
|
||||||
|
handler.end_headers()
|
||||||
|
handler.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == '/captcha/image':
|
||||||
|
text = generate_text()
|
||||||
|
token = secrets.token_hex(16)
|
||||||
|
with STORE_LOCK:
|
||||||
|
captcha_store[token] = {'answer': text.lower(), 'created_at': time.time()}
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL]
|
||||||
|
for k in expired:
|
||||||
|
del captcha_store[k]
|
||||||
|
|
||||||
|
img_buf = generate_image(text)
|
||||||
|
data = img_buf.read()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'image/png')
|
||||||
|
self.send_header('Content-Length', str(len(data)))
|
||||||
|
self.send_header('X-Captcha-Token', token)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
elif parsed.path == '/health':
|
||||||
|
json_response(self, {'status': 'ok', 'service': 'captcha'})
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == '/captcha/verify':
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
json_response(self, {'valid': False, 'error': 'invalid json'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
token = data.get('token', '').strip()
|
||||||
|
answer = data.get('answer', '').strip().lower()
|
||||||
|
if not token or not answer:
|
||||||
|
json_response(self, {'valid': False, 'error': 'token and answer required'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
with STORE_LOCK:
|
||||||
|
entry = captcha_store.pop(token, None)
|
||||||
|
|
||||||
|
if not entry:
|
||||||
|
json_response(self, {'valid': False, 'error': 'invalid or expired token'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
expected = entry['answer']
|
||||||
|
if len(answer) != len(expected):
|
||||||
|
valid = False
|
||||||
|
else:
|
||||||
|
diff = 0
|
||||||
|
for a, b in zip(answer, expected):
|
||||||
|
diff |= ord(a) ^ ord(b)
|
||||||
|
valid = diff == 0
|
||||||
|
|
||||||
|
json_response(self, {'valid': valid})
|
||||||
|
else:
|
||||||
|
self.send_response(404)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
server = HTTPServer((ADDR, PORT), CaptchaHandler)
|
||||||
|
print(f'CAPTCHA service listening at http://{ADDR}:{PORT}')
|
||||||
|
server.serve_forever()
|
||||||
@@ -3,3 +3,4 @@ bash /opt/micro_servers/db/_run.sh
|
|||||||
bash /opt/micro_servers/dnfs/_run.sh
|
bash /opt/micro_servers/dnfs/_run.sh
|
||||||
bash /opt/micro_servers/static/_run.sh
|
bash /opt/micro_servers/static/_run.sh
|
||||||
bash /opt/micro_servers/gateway/_run.sh
|
bash /opt/micro_servers/gateway/_run.sh
|
||||||
|
bash /opt/micro_servers/captcha/_run.sh
|
||||||
+4
-2
@@ -4,9 +4,11 @@ from sqlalchemy import (
|
|||||||
create_engine, MetaData, Table, select, insert, update, delete, text
|
create_engine, MetaData, Table, select, insert, update, delete, text
|
||||||
)
|
)
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
apply_security_headers(app)
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
DATABASE_URL = os.environ.get('DATABASE_URL', 'sqlite:///database.db')
|
DATABASE_URL = os.environ.get('DATABASE_URL', 'sqlite:///database.db')
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Security headers middleware for Flask applications.
|
||||||
|
Usage:
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
apply_security_headers(app)
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def apply_security_headers(app, csp_policy=None):
|
||||||
|
"""Apply security response headers to a Flask app."""
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def set_security_headers(response):
|
||||||
|
# Prevent clickjacking
|
||||||
|
response.headers['X-Frame-Options'] = 'DENY'
|
||||||
|
# XSS protection (legacy browsers)
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
# Prevent MIME type sniffing
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
# Referrer policy
|
||||||
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
|
# Permissions policy (disable camera, mic, geolocation)
|
||||||
|
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
if csp_policy:
|
||||||
|
response.headers['Content-Security-Policy'] = csp_policy
|
||||||
|
else:
|
||||||
|
response.headers['Content-Security-Policy'] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"font-src 'self'; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove server header info
|
||||||
|
response.headers.pop('Server', None)
|
||||||
|
|
||||||
|
return response
|
||||||
+7
-5
@@ -25,6 +25,7 @@ import json
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
|
||||||
DB_SERVER_URL = 'http://127.0.0.1:8005/execute'
|
DB_SERVER_URL = 'http://127.0.0.1:8005/execute'
|
||||||
def call_db_service(url: str, payload: dict, timeout: int = 10) -> dict:
|
def call_db_service(url: str, payload: dict, timeout: int = 10) -> dict:
|
||||||
@@ -99,7 +100,7 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
|||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
resp_data = resp.read().decode("utf-8")
|
resp_data = resp.read().decode("utf-8")
|
||||||
data = json.loads(resp_data)
|
data = json.loads(resp_data)
|
||||||
return 'false' == data['invalid']
|
return not data['invalid']
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
# 服务端返回 4xx/5xx 错误时,尝试解析错误正文
|
# 服务端返回 4xx/5xx 错误时,尝试解析错误正文
|
||||||
error_body = e.read().decode("utf-8")
|
error_body = e.read().decode("utf-8")
|
||||||
@@ -117,6 +118,7 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
|||||||
#basedir = os.path.abspath(os.path.dirname(__file__))
|
#basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
apply_security_headers(app)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
#app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'notes.db')
|
#app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'notes.db')
|
||||||
@@ -190,7 +192,7 @@ def add_note():
|
|||||||
notes = call_db_service(DB_SERVER_URL, select_payload)
|
notes = call_db_service(DB_SERVER_URL, select_payload)
|
||||||
|
|
||||||
# 不存在的父节点
|
# 不存在的父节点
|
||||||
if [] == notes['data']:
|
if len( notes['data']) == 0:
|
||||||
return jsonify({'error': 'the new node need a parent'}), 400
|
return jsonify({'error': 'the new node need a parent'}), 400
|
||||||
|
|
||||||
|
|
||||||
@@ -292,16 +294,16 @@ def delete_note():
|
|||||||
'action': 'select',
|
'action': 'select',
|
||||||
'table': 'Notes',
|
'table': 'Notes',
|
||||||
'where': {
|
'where': {
|
||||||
'id': note_id
|
'pid': note_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
notes = call_db_service(DB_SERVER_URL, select_payload)
|
notes = call_db_service(DB_SERVER_URL, select_payload)
|
||||||
|
|
||||||
# 有子节点的情况不可删除
|
# 有子节点的情况不可删除
|
||||||
if [] != notes['data']:
|
if len(notes['data']) > 0:
|
||||||
|
|
||||||
return jsonify({'error': 'this node has a child'}), 400
|
return jsonify({'error': 'this node has a child '}), 400
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Security headers middleware for Flask applications.
|
||||||
|
Usage:
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
apply_security_headers(app)
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def apply_security_headers(app, csp_policy=None):
|
||||||
|
"""Apply security response headers to a Flask app."""
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def set_security_headers(response):
|
||||||
|
# Prevent clickjacking
|
||||||
|
response.headers['X-Frame-Options'] = 'DENY'
|
||||||
|
# XSS protection (legacy browsers)
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
# Prevent MIME type sniffing
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
# Referrer policy
|
||||||
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
|
# Permissions policy (disable camera, mic, geolocation)
|
||||||
|
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
if csp_policy:
|
||||||
|
response.headers['Content-Security-Policy'] = csp_policy
|
||||||
|
else:
|
||||||
|
response.headers['Content-Security-Policy'] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"font-src 'self'; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove server header info
|
||||||
|
response.headers.pop('Server', None)
|
||||||
|
|
||||||
|
return response
|
||||||
+422
-65
@@ -8,9 +8,13 @@
|
|||||||
#include <typeinfo>
|
#include <typeinfo>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <csignal>
|
#include <csignal>
|
||||||
|
#include <random>
|
||||||
|
#include <regex>
|
||||||
#include <boost/json.hpp>
|
#include <boost/json.hpp>
|
||||||
#include <boost/program_options.hpp>
|
#include <boost/program_options.hpp>
|
||||||
#include <openssl/provider.h>
|
#include <openssl/evp.h>
|
||||||
|
#include <openssl/rand.h>
|
||||||
|
#include <openssl/kdf.h>
|
||||||
#include "ExpBase.hpp"
|
#include "ExpBase.hpp"
|
||||||
#include "LocalTime.hpp"
|
#include "LocalTime.hpp"
|
||||||
#include "Conf.hpp"
|
#include "Conf.hpp"
|
||||||
@@ -20,7 +24,7 @@ using namespace qing;
|
|||||||
namespace json = boost::json;
|
namespace json = boost::json;
|
||||||
namespace po = boost::program_options;
|
namespace po = boost::program_options;
|
||||||
|
|
||||||
constexpr const char *_VER = "0.2.1";
|
constexpr const char *_VER = "0.3.0-security";
|
||||||
|
|
||||||
//////////////////////////////////
|
//////////////////////////////////
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
@@ -36,6 +40,178 @@ std::vector<std::string> split(const std::string& s, char delim) {
|
|||||||
}
|
}
|
||||||
return tokens;
|
return tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//////////////////////////////////
|
||||||
|
// PBKDF2-HMAC-SHA256 password hashing
|
||||||
|
//////////////////////////////////
|
||||||
|
class PBKDF2Hasher {
|
||||||
|
public:
|
||||||
|
static const int ITERATIONS = 100000;
|
||||||
|
static const int SALT_LEN = 16;
|
||||||
|
static const int HASH_LEN = 32;
|
||||||
|
|
||||||
|
static std::string hash_password(const std::string& password) {
|
||||||
|
unsigned char salt[SALT_LEN];
|
||||||
|
RAND_bytes(salt, SALT_LEN);
|
||||||
|
|
||||||
|
unsigned char hash[HASH_LEN];
|
||||||
|
PKCS5_PBKDF2_HMAC(
|
||||||
|
password.c_str(), password.size(),
|
||||||
|
salt, SALT_LEN,
|
||||||
|
ITERATIONS,
|
||||||
|
EVP_sha256(),
|
||||||
|
HASH_LEN, hash
|
||||||
|
);
|
||||||
|
|
||||||
|
// Format: pbkdf2:100000:<hex_salt>:<hex_hash>
|
||||||
|
std::string salt_hex = bytes_to_hex(salt, SALT_LEN);
|
||||||
|
std::string hash_hex = bytes_to_hex(hash, HASH_LEN);
|
||||||
|
return "pbkdf2:" + std::to_string(ITERATIONS) + ":" + salt_hex + ":" + hash_hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool verify_password(const std::string& password, const std::string& stored) {
|
||||||
|
// Parse stored hash
|
||||||
|
if (stored.substr(0, 7) != "pbkdf2:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto parts = split(stored, ':');
|
||||||
|
if (parts.size() != 4) return false;
|
||||||
|
|
||||||
|
int iterations = std::stoi(parts[1]);
|
||||||
|
auto salt = hex_to_bytes(parts[2]);
|
||||||
|
auto expected_hash = hex_to_bytes(parts[3]);
|
||||||
|
|
||||||
|
unsigned char hash[HASH_LEN];
|
||||||
|
PKCS5_PBKDF2_HMAC(
|
||||||
|
password.c_str(), password.size(),
|
||||||
|
salt.data(), salt.size(),
|
||||||
|
iterations,
|
||||||
|
EVP_sha256(),
|
||||||
|
HASH_LEN, hash
|
||||||
|
);
|
||||||
|
|
||||||
|
// Constant-time comparison
|
||||||
|
if (expected_hash.size() != (size_t)HASH_LEN) return false;
|
||||||
|
unsigned char diff = 0;
|
||||||
|
for (int i = 0; i < HASH_LEN; i++) {
|
||||||
|
diff |= hash[i] ^ expected_hash[i];
|
||||||
|
}
|
||||||
|
return diff == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::string bytes_to_hex(const unsigned char* data, size_t len) {
|
||||||
|
static const char hex[] = "0123456789abcdef";
|
||||||
|
std::string result;
|
||||||
|
result.reserve(len * 2);
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
result.push_back(hex[data[i] >> 4]);
|
||||||
|
result.push_back(hex[data[i] & 0x0F]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<unsigned char> hex_to_bytes(const std::string& hex_str) {
|
||||||
|
std::vector<unsigned char> bytes;
|
||||||
|
bytes.reserve(hex_str.size() / 2);
|
||||||
|
for (size_t i = 0; i < hex_str.size(); i += 2) {
|
||||||
|
auto hi = hex_char_to_nibble(hex_str[i]);
|
||||||
|
auto lo = hex_char_to_nibble(hex_str[i + 1]);
|
||||||
|
bytes.push_back((hi << 4) | lo);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static unsigned char hex_char_to_nibble(char c) {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
|
||||||
|
if (c >= 'A' && c <= 'F') return 10 + (c - 'A');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//////////////////////////////////
|
||||||
|
// Password strength validator
|
||||||
|
//////////////////////////////////
|
||||||
|
class PasswordValidator {
|
||||||
|
public:
|
||||||
|
struct Result {
|
||||||
|
bool valid;
|
||||||
|
std::string message;
|
||||||
|
};
|
||||||
|
|
||||||
|
static Result validate(const std::string& password) {
|
||||||
|
if (password.size() < 8) {
|
||||||
|
return {false, "Password must be at least 8 characters"};
|
||||||
|
}
|
||||||
|
if (password.size() > 128) {
|
||||||
|
return {false, "Password too long (max 128)"};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool has_upper = false, has_lower = false, has_digit = false, has_special = false;
|
||||||
|
for (char c : password) {
|
||||||
|
if (std::isupper(c)) has_upper = true;
|
||||||
|
else if (std::islower(c)) has_lower = true;
|
||||||
|
else if (std::isdigit(c)) has_digit = true;
|
||||||
|
else has_special = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int categories = has_upper + has_lower + has_digit + has_special;
|
||||||
|
if (categories < 3) {
|
||||||
|
return {false, "Password must contain at least 3 of: uppercase, lowercase, digit, special character"};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {true, "OK"};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//////////////////////////////////
|
||||||
|
// IP Rate Limiter (in-memory)
|
||||||
|
//////////////////////////////////
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <mutex>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
|
class RateLimiter {
|
||||||
|
public:
|
||||||
|
struct AttemptInfo {
|
||||||
|
int count;
|
||||||
|
std::chrono::steady_clock::time_point first_attempt;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool check_and_record(const std::string& ip, int max_attempts = 5, int window_seconds = 300) {
|
||||||
|
std::lock_guard<std::mutex> lock(mtx);
|
||||||
|
auto now = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
auto& info = attempts[ip];
|
||||||
|
|
||||||
|
// Reset if window expired
|
||||||
|
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(now - info.first_attempt).count();
|
||||||
|
if (elapsed > window_seconds) {
|
||||||
|
info.count = 0;
|
||||||
|
info.first_attempt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.count++;
|
||||||
|
|
||||||
|
// Cleanup old entries periodically
|
||||||
|
if (attempts.size() > 10000) {
|
||||||
|
for (auto it = attempts.begin(); it != attempts.end(); ) {
|
||||||
|
auto e = std::chrono::duration_cast<std::chrono::seconds>(now - it->second.first_attempt).count();
|
||||||
|
if (e > window_seconds) it = attempts.erase(it);
|
||||||
|
else ++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.count <= max_attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unordered_map<std::string, AttemptInfo> attempts;
|
||||||
|
std::mutex mtx;
|
||||||
|
};
|
||||||
|
|
||||||
//////////////////////////////////
|
//////////////////////////////////
|
||||||
|
|
||||||
class DatabaseException: public ExceptionBase {
|
class DatabaseException: public ExceptionBase {
|
||||||
@@ -71,6 +247,8 @@ private:
|
|||||||
|
|
||||||
void db_init();
|
void db_init();
|
||||||
void registerRoutes(HttpServer &server);
|
void registerRoutes(HttpServer &server);
|
||||||
|
std::string generate_captcha_text();
|
||||||
|
bool verify_captcha(const std::string& token, const std::string& answer);
|
||||||
|
|
||||||
http::response<http::string_body> wrapperHttp(
|
http::response<http::string_body> wrapperHttp(
|
||||||
const http::request<http::string_body>& req,
|
const http::request<http::string_body>& req,
|
||||||
@@ -91,6 +269,10 @@ http::response<http::string_body> handleRegister(
|
|||||||
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> handleCaptcha(
|
||||||
|
const http::request<http::string_body>& req,
|
||||||
|
const std::unordered_map<std::string, std::string>& params);
|
||||||
|
|
||||||
|
|
||||||
/* headers can't processed */
|
/* headers can't processed */
|
||||||
bool is_special_header(const std::string &header);
|
bool is_special_header(const std::string &header);
|
||||||
@@ -101,13 +283,52 @@ static int _PORT;
|
|||||||
static std::string _DEFT_URL;
|
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 std::string _DEFT_ADMIN_USER = "admin";
|
|
||||||
static std::string _DEFT_ADMIN_PASS = "44112233";
|
|
||||||
|
|
||||||
/* instance items */
|
/* instance items */
|
||||||
static std::unique_ptr<HttpServer> _SERVE;
|
static std::unique_ptr<HttpServer> _SERVE;
|
||||||
static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
||||||
static std::unique_ptr<Sqlite3> _SQLDB;
|
static std::unique_ptr<Sqlite3> _SQLDB;
|
||||||
|
static RateLimiter _LOGIN_RATE;
|
||||||
|
static RateLimiter _REGISTER_RATE;
|
||||||
|
|
||||||
|
/* CAPTCHA store: token -> answer (in-memory, with expiry) */
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <mutex>
|
||||||
|
struct CaptchaEntry {
|
||||||
|
std::string answer;
|
||||||
|
std::chrono::steady_clock::time_point created;
|
||||||
|
};
|
||||||
|
static std::unordered_map<std::string, CaptchaEntry> _CAPTCHA_STORE;
|
||||||
|
static std::mutex _CAPTCHA_MTX;
|
||||||
|
|
||||||
|
std::string generate_captcha_text() {
|
||||||
|
static const char chars[] = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
static std::mt19937 rng(std::random_device{}());
|
||||||
|
static std::uniform_int_distribution<int> dist(0, sizeof(chars) - 2);
|
||||||
|
|
||||||
|
std::string text;
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
text += chars[dist(rng)];
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool verify_captcha(const std::string& token, const std::string& answer) {
|
||||||
|
std::lock_guard<std::mutex> lock(_CAPTCHA_MTX);
|
||||||
|
auto it = _CAPTCHA_STORE.find(token);
|
||||||
|
if (it == _CAPTCHA_STORE.end()) return false;
|
||||||
|
|
||||||
|
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||||
|
std::chrono::steady_clock::now() - it->second.created).count();
|
||||||
|
if (elapsed > 300) { // 5 min expiry
|
||||||
|
_CAPTCHA_STORE.erase(it);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool match = (it->second.answer == answer);
|
||||||
|
_CAPTCHA_STORE.erase(it); // one-time use
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
void signalHandler(int signum) {
|
void signalHandler(int signum) {
|
||||||
|
|
||||||
@@ -135,7 +356,6 @@ int main(int argc, char **argv) {
|
|||||||
("input,i", po::value<std::string>(), "input a configure text file")
|
("input,i", po::value<std::string>(), "input a configure text file")
|
||||||
;
|
;
|
||||||
po::variables_map vm;
|
po::variables_map vm;
|
||||||
/* parse commandline params */
|
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
po::store(po::parse_command_line(argc, argv, desc), vm);
|
po::store(po::parse_command_line(argc, argv, desc), vm);
|
||||||
@@ -159,7 +379,6 @@ int main(int argc, char **argv) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* parse configure file */
|
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|
||||||
@@ -182,7 +401,7 @@ int main(int argc, char **argv) {
|
|||||||
std::cerr << std::endl;
|
std::cerr << std::endl;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_SERVE = std::make_unique<HttpServer> (_ADDR, _PORT, "full_chain.crt", "server.key", 4);
|
_SERVE = std::make_unique<HttpServer> (_ADDR, _PORT, "full_chain.crt", "server.key", 4);
|
||||||
_KEYGEN = std::make_unique<MyRSA::Generator> ();
|
_KEYGEN = std::make_unique<MyRSA::Generator> ();
|
||||||
_SQLDB = std::make_unique<Sqlite3> (_DB_PATH);
|
_SQLDB = std::make_unique<Sqlite3> (_DB_PATH);
|
||||||
@@ -191,6 +410,7 @@ int main(int argc, char **argv) {
|
|||||||
registerRoutes(*_SERVE);
|
registerRoutes(*_SERVE);
|
||||||
set_signal_action();
|
set_signal_action();
|
||||||
|
|
||||||
|
std::cout << "Gateway " << _VER << " started (PBKDF2 + CAPTCHA + RateLimit)" << std::endl;
|
||||||
_SERVE->start();
|
_SERVE->start();
|
||||||
_SERVE->run();
|
_SERVE->run();
|
||||||
|
|
||||||
@@ -213,7 +433,7 @@ void db_init() {
|
|||||||
|
|
||||||
if (flag) {
|
if (flag) {
|
||||||
std::cout << "Start database init..." << std::endl;
|
std::cout << "Start database init..." << std::endl;
|
||||||
std::string sql_crtt = "-- create users table, username as email\n"
|
std::string sql_crtt =
|
||||||
"CREATE TABLE Users (\n"
|
"CREATE TABLE Users (\n"
|
||||||
"id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
"id INTEGER PRIMARY KEY AUTOINCREMENT,\n"
|
||||||
"email TEXT UNIQUE NOT NULL,\n"
|
"email TEXT UNIQUE NOT NULL,\n"
|
||||||
@@ -222,24 +442,12 @@ void db_init() {
|
|||||||
"last_login DATETIME,\n"
|
"last_login DATETIME,\n"
|
||||||
"is_active BOOLEAN DEFAULT 1\n"
|
"is_active BOOLEAN DEFAULT 1\n"
|
||||||
");\n\n"
|
");\n\n"
|
||||||
|
|
||||||
"CREATE INDEX idx_users_email ON users(email);\n";
|
"CREATE INDEX idx_users_email ON users(email);\n";
|
||||||
|
|
||||||
_SQLDB->exec(sql_crtt);
|
_SQLDB->exec(sql_crtt);
|
||||||
|
|
||||||
{
|
// No hardcoded admin - first registered user becomes admin
|
||||||
auto password_hash = SHA256::sha256(_DEFT_ADMIN_PASS);
|
std::cout << "done (no default admin - register manually)" << std::endl;
|
||||||
std::cout << password_hash << std::endl;
|
|
||||||
std::string sql_crta = "INSERT INTO Users(email, password_hash, last_login) VALUES (?, ?, datetime('now', 'localtime'))";
|
|
||||||
auto stmt = _SQLDB->stmt(sql_crta);
|
|
||||||
stmt.bind_text(1, _DEFT_ADMIN_USER);
|
|
||||||
stmt.bind_text(2, password_hash);
|
|
||||||
if (stmt.step()) {
|
|
||||||
std::cout << stmt.read_text(1) << std::endl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << "done" << std::endl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
else {
|
else {
|
||||||
@@ -251,6 +459,10 @@ void db_init() {
|
|||||||
|
|
||||||
void registerRoutes(HttpServer &server) {
|
void registerRoutes(HttpServer &server) {
|
||||||
|
|
||||||
|
server.post("/captcha", [](const auto& req, const auto& params) {
|
||||||
|
return handleCaptcha(req, params);
|
||||||
|
});
|
||||||
|
|
||||||
server.post("/login", [](
|
server.post("/login", [](
|
||||||
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
|
||||||
@@ -314,7 +526,43 @@ catch (const std::exception& e) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* curl -X POST http://127.0.0.1:9201/login -d '{"username": "qing", "password": "12345678"}' */
|
// CAPTCHA endpoint: generates a random text, stores it, returns a simple token
|
||||||
|
http::response<http::string_body> handleCaptcha(
|
||||||
|
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());
|
||||||
|
|
||||||
|
std::string text = generate_captcha_text();
|
||||||
|
|
||||||
|
// Generate a token for this captcha
|
||||||
|
unsigned char token_bytes[16];
|
||||||
|
RAND_bytes(token_bytes, 16);
|
||||||
|
std::string token;
|
||||||
|
static const char hex[] = "0123456789abcdef";
|
||||||
|
for (int i = 0; i < 16; i++) {
|
||||||
|
token += hex[token_bytes[i] >> 4];
|
||||||
|
token += hex[token_bytes[i] & 0x0F];
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(_CAPTCHA_MTX);
|
||||||
|
_CAPTCHA_STORE[token] = {text, std::chrono::steady_clock::now()};
|
||||||
|
}
|
||||||
|
|
||||||
|
json::value jv = {
|
||||||
|
{"token", token},
|
||||||
|
{"text", text}
|
||||||
|
};
|
||||||
|
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.result(http::status::ok);
|
||||||
|
res.body() = json::serialize(jv);
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
http::response<http::string_body> handleLogin(
|
http::response<http::string_body> handleLogin(
|
||||||
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)
|
||||||
@@ -322,8 +570,28 @@ http::response<http::string_body> handleLogin(
|
|||||||
http::response<http::string_body> res;
|
http::response<http::string_body> res;
|
||||||
res.version(req.version());
|
res.version(req.version());
|
||||||
|
|
||||||
|
// IP rate limit check
|
||||||
|
std::string client_ip = "";
|
||||||
|
auto it_host = req.base().find("X-Forwarded-For");
|
||||||
|
if (it_host != req.base().end()) {
|
||||||
|
client_ip = std::string(it_host->value());
|
||||||
|
} else {
|
||||||
|
client_ip = "direct";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_LOGIN_RATE.check_and_record(client_ip, 5, 300)) {
|
||||||
|
json::value jv_r = {
|
||||||
|
{"success", false},
|
||||||
|
{"message", "Too many login attempts. Please try again later."}
|
||||||
|
};
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.result(http::status::too_many_requests);
|
||||||
|
res.body() = json::serialize(jv_r);
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
//std::cout << req.body() << std::endl;
|
|
||||||
json::value jv;
|
json::value jv;
|
||||||
try {
|
try {
|
||||||
jv = json::parse(req.body());
|
jv = json::parse(req.body());
|
||||||
@@ -333,7 +601,6 @@ http::response<http::string_body> handleLogin(
|
|||||||
400, "/login");
|
400, "/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!jv.is_object()) {
|
if (!jv.is_object()) {
|
||||||
THROW_EX_CUSTOM(HttpException, 2002, "Invalid_json_object_Argument",
|
THROW_EX_CUSTOM(HttpException, 2002, "Invalid_json_object_Argument",
|
||||||
400, "/login");
|
400, "/login");
|
||||||
@@ -341,29 +608,65 @@ http::response<http::string_body> handleLogin(
|
|||||||
auto& obj = jv.as_object();
|
auto& obj = jv.as_object();
|
||||||
auto username = std::string(obj.at("username").as_string());
|
auto username = std::string(obj.at("username").as_string());
|
||||||
auto password = std::string(obj.at("password").as_string());
|
auto password = std::string(obj.at("password").as_string());
|
||||||
auto password_hash = SHA256::sha256(password);
|
|
||||||
//std::cout << username << " " << password_hash << std::endl;
|
|
||||||
|
|
||||||
std::string sql = "SELECT COUNT(*) FROM Users WHERE email=? AND password_hash=?;";
|
// CAPTCHA verification
|
||||||
auto stmt = _SQLDB->stmt(sql);
|
auto captcha_it = obj.find("captcha_token");
|
||||||
stmt.bind_text(1, username);
|
auto captcha_ans_it = obj.find("captcha_answer");
|
||||||
stmt.bind_text(2, password_hash);
|
if (captcha_it != obj.end() && captcha_ans_it != obj.end()) {
|
||||||
if (stmt.step() && stmt.read_int(0) == 1) {
|
std::string ct = std::string(captcha_it->value().as_string());
|
||||||
//update
|
std::string ca = std::string(captcha_ans_it->value().as_string());
|
||||||
|
if (!verify_captcha(ct, ca)) {
|
||||||
|
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA",
|
||||||
|
400, "/login");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else {
|
// Fetch stored hash - support both PBKDF2 and legacy SHA256
|
||||||
|
std::string sql = "SELECT password_hash FROM Users WHERE email=?;";
|
||||||
|
auto stmt = _SQLDB->stmt(sql);
|
||||||
|
stmt.bind_text(1, username);
|
||||||
|
std::string stored_hash = "";
|
||||||
|
if (stmt.step()) {
|
||||||
|
stored_hash = std::string(reinterpret_cast<const char*>(stmt.read_text(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool auth_ok = false;
|
||||||
|
if (!stored_hash.empty()) {
|
||||||
|
if (stored_hash.substr(0, 7) == "pbkdf2:") {
|
||||||
|
// New PBKDF2 hash
|
||||||
|
auth_ok = PBKDF2Hasher::verify_password(password, stored_hash);
|
||||||
|
} else {
|
||||||
|
// Legacy SHA256 hash - verify then auto-migrate
|
||||||
|
auto legacy_hash = SHA256::sha256(password);
|
||||||
|
if (legacy_hash == stored_hash) {
|
||||||
|
auth_ok = true;
|
||||||
|
// Auto-migrate to PBKDF2
|
||||||
|
auto new_hash = PBKDF2Hasher::hash_password(password);
|
||||||
|
std::string sql_migrate = "UPDATE Users SET password_hash=? WHERE email=?;";
|
||||||
|
auto stmt_mig = _SQLDB->stmt(sql_migrate);
|
||||||
|
stmt_mig.bind_text(1, new_hash);
|
||||||
|
stmt_mig.bind_text(2, username);
|
||||||
|
stmt_mig.step();
|
||||||
|
std::cout << "Migrated password hash for " << username << " to PBKDF2" << std::endl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth_ok) {
|
||||||
THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password",
|
THROW_EX_CUSTOM(HttpException, 2101, "Invalid Username or Password",
|
||||||
401, "/login");
|
401, "/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update last_login
|
||||||
|
std::string sql_update = "UPDATE Users SET last_login=datetime('now','localtime') WHERE email=?;";
|
||||||
|
auto stmt_upd = _SQLDB->stmt(sql_update);
|
||||||
|
stmt_upd.bind_text(1, username);
|
||||||
|
stmt_upd.step();
|
||||||
|
|
||||||
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
|
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
|
||||||
|
|
||||||
|
int keep_time = 3600;
|
||||||
|
|
||||||
//int created_at = 0;
|
|
||||||
int keep_time = 3600; // second
|
|
||||||
|
|
||||||
/* 用于token的json对象 */
|
|
||||||
json::value jv_t = {
|
json::value jv_t = {
|
||||||
{"user", username},
|
{"user", username},
|
||||||
{"created_at", LocalTime::getCurrent()},
|
{"created_at", LocalTime::getCurrent()},
|
||||||
@@ -373,10 +676,9 @@ http::response<http::string_body> handleLogin(
|
|||||||
std::string token = Base64::base64_encode(data);
|
std::string token = Base64::base64_encode(data);
|
||||||
std::string sign = _PRIV.Sign(data);
|
std::string sign = _PRIV.Sign(data);
|
||||||
|
|
||||||
/* 用于返回结果的json对象 */
|
|
||||||
json::value jv_rr = {
|
json::value jv_rr = {
|
||||||
{"success", true},
|
{"success", true},
|
||||||
{"data", json::object{{"token", token + "_" + sign}}} // 双大括号
|
{"data", json::object{{"token", token + "_" + sign}}}
|
||||||
};
|
};
|
||||||
res.set("Connection", "close");
|
res.set("Connection", "close");
|
||||||
res.result(http::status::ok);
|
res.result(http::status::ok);
|
||||||
@@ -384,7 +686,7 @@ http::response<http::string_body> handleLogin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
catch (const HttpException& e) {
|
catch (const HttpException& e) {
|
||||||
|
|
||||||
json::value jv_r = {
|
json::value jv_r = {
|
||||||
{"success", false},
|
{"success", false},
|
||||||
{"message", e.what()}
|
{"message", e.what()}
|
||||||
@@ -398,8 +700,6 @@ http::response<http::string_body> handleLogin(
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* curl -X POST http://127.0.0.1:9201/register -d '{"username": "qing", "password": "12345678"}' */
|
|
||||||
/* FIXME: strong password check */
|
|
||||||
http::response<http::string_body> handleRegister(
|
http::response<http::string_body> handleRegister(
|
||||||
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)
|
||||||
@@ -407,6 +707,20 @@ http::response<http::string_body> handleRegister(
|
|||||||
http::response<http::string_body> res;
|
http::response<http::string_body> res;
|
||||||
res.version(req.version());
|
res.version(req.version());
|
||||||
|
|
||||||
|
// IP rate limit check
|
||||||
|
std::string client_ip = "direct";
|
||||||
|
if (!_REGISTER_RATE.check_and_record(client_ip, 3, 600)) {
|
||||||
|
json::value jv_r = {
|
||||||
|
{"success", false},
|
||||||
|
{"message", "Too many registration attempts. Please try again later."}
|
||||||
|
};
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.result(http::status::too_many_requests);
|
||||||
|
res.body() = json::serialize(jv_r);
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
||||||
auto jv = json::parse(req.body());
|
auto jv = json::parse(req.body());
|
||||||
@@ -417,7 +731,47 @@ http::response<http::string_body> handleRegister(
|
|||||||
auto username = std::string(obj.at("username").as_string());
|
auto username = std::string(obj.at("username").as_string());
|
||||||
auto password = std::string(obj.at("password").as_string());
|
auto password = std::string(obj.at("password").as_string());
|
||||||
|
|
||||||
auto password_hash = SHA256::sha256(password);
|
// Strong password check
|
||||||
|
auto pw_result = PasswordValidator::validate(password);
|
||||||
|
if (!pw_result.valid) {
|
||||||
|
json::value jv_r = {
|
||||||
|
{"success", false},
|
||||||
|
{"message", pw_result.message}
|
||||||
|
};
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.result(http::status::bad_request);
|
||||||
|
res.body() = json::serialize(jv_r);
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email format check
|
||||||
|
std::regex email_regex(R"(^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$)");
|
||||||
|
if (!std::regex_match(username, email_regex)) {
|
||||||
|
json::value jv_r = {
|
||||||
|
{"success", false},
|
||||||
|
{"message", "Invalid email format"}
|
||||||
|
};
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.result(http::status::bad_request);
|
||||||
|
res.body() = json::serialize(jv_r);
|
||||||
|
res.prepare_payload();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAPTCHA verification
|
||||||
|
auto captcha_it = obj.find("captcha_token");
|
||||||
|
auto captcha_ans_it = obj.find("captcha_answer");
|
||||||
|
if (captcha_it != obj.end() && captcha_ans_it != obj.end()) {
|
||||||
|
std::string ct = std::string(captcha_it->value().as_string());
|
||||||
|
std::string ca = std::string(captcha_ans_it->value().as_string());
|
||||||
|
if (!verify_captcha(ct, ca)) {
|
||||||
|
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA",
|
||||||
|
400, "/register");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto password_hash = PBKDF2Hasher::hash_password(password);
|
||||||
|
|
||||||
std::string sql = "INSERT INTO Users (email, password_hash, last_login) VALUES (?, ?, datetime('now', 'localtime'));";
|
std::string sql = "INSERT INTO Users (email, password_hash, last_login) VALUES (?, ?, datetime('now', 'localtime'));";
|
||||||
auto stmt = _SQLDB->stmt(sql);
|
auto stmt = _SQLDB->stmt(sql);
|
||||||
@@ -429,7 +783,16 @@ http::response<http::string_body> handleRegister(
|
|||||||
res.result(http::status::ok);
|
res.result(http::status::ok);
|
||||||
res.body() = json::serialize(jv_r);
|
res.body() = json::serialize(jv_r);
|
||||||
}
|
}
|
||||||
//catch
|
|
||||||
|
catch (const HttpException& e) {
|
||||||
|
json::value jv_r = {
|
||||||
|
{"success", false},
|
||||||
|
{"message", e.what()}
|
||||||
|
};
|
||||||
|
res.set(http::field::content_type, "application/json");
|
||||||
|
res.result(e.status());
|
||||||
|
res.body() = json::serialize(jv_r);
|
||||||
|
}
|
||||||
|
|
||||||
catch (const std::exception& e) {
|
catch (const std::exception& e) {
|
||||||
const auto& ti = typeid(e);
|
const auto& ti = typeid(e);
|
||||||
@@ -442,7 +805,6 @@ http::response<http::string_body> handleRegister(
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* curl -H "Target-Url: https://www.baidu.com" http://127.0.0.1:9201 -X GET */
|
|
||||||
http::response<http::string_body> handleProxy(
|
http::response<http::string_body> handleProxy(
|
||||||
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,
|
||||||
@@ -457,7 +819,7 @@ http::response<http::string_body> handleProxy(
|
|||||||
std::string t_url = "";
|
std::string t_url = "";
|
||||||
std::string url = "";
|
std::string url = "";
|
||||||
std::string auth = "";
|
std::string auth = "";
|
||||||
json::value jv_t; /* json value of the token */
|
json::value jv_t;
|
||||||
{
|
{
|
||||||
auto flag = false;
|
auto flag = false;
|
||||||
auto it = req.base().find("Target-Url");
|
auto it = req.base().find("Target-Url");
|
||||||
@@ -480,7 +842,7 @@ http::response<http::string_body> handleProxy(
|
|||||||
url += req.target();
|
url += req.target();
|
||||||
|
|
||||||
it = req.base().find("Authorization");
|
it = req.base().find("Authorization");
|
||||||
if (it == req.base().end() && !flag) { /* 不是默认代理地址且无令牌 */
|
if (it == req.base().end() && !flag) {
|
||||||
throw std::runtime_error("401");
|
throw std::runtime_error("401");
|
||||||
}
|
}
|
||||||
std::string raw = req["Authorization"];
|
std::string raw = req["Authorization"];
|
||||||
@@ -496,31 +858,29 @@ http::response<http::string_body> handleProxy(
|
|||||||
if (!_PUBL.Verify(auth, sign)) {
|
if (!_PUBL.Verify(auth, sign)) {
|
||||||
throw std::runtime_error("Header 401 Unauthorized");
|
throw std::runtime_error("Header 401 Unauthorized");
|
||||||
}
|
}
|
||||||
std::cout << "auth data: " << auth << std::endl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!flag) { /* 令牌是否过期 */
|
if (!flag) {
|
||||||
|
|
||||||
/* TODO: 放进try包裹 */
|
|
||||||
jv_t = json::parse(auth);
|
jv_t = json::parse(auth);
|
||||||
json::object& obj = jv_t.as_object();
|
json::object& obj = jv_t.as_object();
|
||||||
|
|
||||||
auto t = json::value_to<std::string>(obj["created_at"]);
|
auto t = json::value_to<std::string>(obj["created_at"]);
|
||||||
bool r = LocalTime::isExpired(t);
|
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
|
||||||
|
auto created_tp = LocalTime::parse(t);
|
||||||
if (!r) {
|
if (created_tp) {
|
||||||
THROW_EX_CUSTOM(HttpException, 2000, "Authorization Expired",
|
auto now = std::chrono::system_clock::now();
|
||||||
401, "*");
|
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
|
||||||
|
if (now > expire_tp) {
|
||||||
|
THROW_EX_CUSTOM(HttpException, 2000, "Authorization Expired",
|
||||||
|
401, "*");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 代理地址是否开放、合法 */
|
|
||||||
{
|
{
|
||||||
std::cout << t_url << std::endl;
|
|
||||||
bool flag = false;
|
bool flag = false;
|
||||||
for (auto& s: _OPEN_URL) {
|
for (auto& s: _OPEN_URL) {
|
||||||
std::cout << s << std::endl;
|
|
||||||
if (s == t_url) {
|
if (s == t_url) {
|
||||||
flag = true;
|
flag = true;
|
||||||
break;
|
break;
|
||||||
@@ -532,7 +892,7 @@ http::response<http::string_body> handleProxy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto callback = [&res](const auto& ret) {
|
auto callback = [&res](const auto& ret) {
|
||||||
res.body() += ret; // TODO: too big file
|
res.body() += ret;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -550,7 +910,7 @@ http::response<http::string_body> handleProxy(
|
|||||||
|
|
||||||
if (method == "get")
|
if (method == "get")
|
||||||
client.get_stream_sync(url, callback);
|
client.get_stream_sync(url, callback);
|
||||||
|
|
||||||
else if (method == "post") {
|
else if (method == "post") {
|
||||||
std::string payload = req.body();
|
std::string payload = req.body();
|
||||||
client.post_stream_sync(url, payload, callback);
|
client.post_stream_sync(url, payload, callback);
|
||||||
@@ -563,7 +923,6 @@ http::response<http::string_body> handleProxy(
|
|||||||
res.set("Connection", "close");
|
res.set("Connection", "close");
|
||||||
res.result(client.get_response_code());
|
res.result(client.get_response_code());
|
||||||
}
|
}
|
||||||
//catch
|
|
||||||
|
|
||||||
catch (const std::exception& e) {
|
catch (const std::exception& e) {
|
||||||
const auto& ti = typeid(e);
|
const auto& ti = typeid(e);
|
||||||
@@ -581,10 +940,8 @@ http::response<http::string_body> handleProxy(
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* they are special because they point to raw request */
|
|
||||||
bool is_special_header(const std::string &header) {
|
bool is_special_header(const std::string &header) {
|
||||||
|
|
||||||
return header == "Host" || header == "connection"
|
return header == "Host" || header == "connection"
|
||||||
|| header == "Accept-Encoding";
|
|| header == "Accept-Encoding";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ from logging.handlers import RotatingFileHandler
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import configparser
|
import configparser
|
||||||
import sys
|
import sys
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
|
||||||
# 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录
|
# 基准目录:编译后取可执行文件所在目录,开发时取脚本所在目录
|
||||||
# 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题
|
# 解决 Nuitka onefile 解压到 /tmp 后相对路径失效的问题
|
||||||
@@ -33,6 +34,7 @@ parser.add_argument('--config', type=str, default='config.ini', help='Configurat
|
|||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
apply_security_headers(app)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
# ==================== 配置文件加载 ====================
|
# ==================== 配置文件加载 ====================
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
<button id="delete-note">删除当前节点</button>
|
<button id="delete-note">删除当前节点</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script>
|
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script>-->
|
||||||
|
<script src="js/markdown-it-12_0_0.js"></script>
|
||||||
<script src="js/validation.js"></script>
|
<script src="js/validation.js"></script>
|
||||||
<script src="js/dnfs.js"></script>
|
<script src="js/dnfs.js"></script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Security headers middleware for Flask applications.
|
||||||
|
Usage:
|
||||||
|
from security_middleware import apply_security_headers
|
||||||
|
apply_security_headers(app)
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
def apply_security_headers(app, csp_policy=None):
|
||||||
|
"""Apply security response headers to a Flask app."""
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def set_security_headers(response):
|
||||||
|
# Prevent clickjacking
|
||||||
|
response.headers['X-Frame-Options'] = 'DENY'
|
||||||
|
# XSS protection (legacy browsers)
|
||||||
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
|
# Prevent MIME type sniffing
|
||||||
|
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||||
|
# Referrer policy
|
||||||
|
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
||||||
|
# Permissions policy (disable camera, mic, geolocation)
|
||||||
|
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
|
||||||
|
|
||||||
|
# Content Security Policy
|
||||||
|
if csp_policy:
|
||||||
|
response.headers['Content-Security-Policy'] = csp_policy
|
||||||
|
else:
|
||||||
|
response.headers['Content-Security-Policy'] = (
|
||||||
|
"default-src 'self'; "
|
||||||
|
"script-src 'self' 'unsafe-inline'; "
|
||||||
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
|
"img-src 'self' data:; "
|
||||||
|
"font-src 'self'; "
|
||||||
|
"connect-src 'self'; "
|
||||||
|
"frame-ancestors 'none'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove server header info
|
||||||
|
response.headers.pop('Server', None)
|
||||||
|
|
||||||
|
return response
|
||||||
Reference in new issue
Block a user