From 5d0c5739ad511b7a7dc08f1cd2c4f99623095070 Mon Sep 17 00:00:00 2001 From: dvs-dvsxt Date: Fri, 28 Aug 2026 10:15:33 +0800 Subject: [PATCH] Initial commit: Captcha Service v1.0.0 --- .gitignore | 16 ++ LICENSE | 21 +++ README.md | 85 ++++++++++ captcha_service.py | 380 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 502 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 captcha_service.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6242ce0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +env/ +.env +*.key + +# System files +Thumbs.db +.DS_Store +desktop.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fddd2dd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DVS (dvs-dvsxt) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d762c0c --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# 🔢 Captcha Service — Verification Code Generation & Verification + +> A Flask captcha/verification-code service with cookie binding, token flow, rate limiting, and code expiry. + +**Captcha Service** implements a complete verification-code (captcha) flow: issue a code bound to a client cookie, verify it, and issue a short-lived token for further authorization. Includes rate limiting and expiry for security. + +--- + +## ✨ Features + +| Feature | Description | +|---------|-------------| +| 🍪 **Cookie Binding** | Codes are bound to client cookies & IP | +| 🔢 **Code Generation** | Generates verification codes with configurable expiry (default 120s) | +| 🔑 **Token Flow** | Issues short-lived tokens (default 300s) after code verification | +| ⏱️ **Rate Limiting** | Prevents spam (default 60s between requests) | +| 🏥 **Health Check** | `/health` endpoint for monitoring | + +--- + +## 🔌 API Endpoints + +### POST `/apply` +Apply for a captcha (obtain a code). +- **Params**: client cookie / info +- **Returns**: captcha code & cookie binding + +### POST `/request_code` +Request a verification code. +- **Params**: cookie / IP / target +- **Returns**: code (subject to rate limit) + +### POST `/verify` +Verify a code. +- **Params**: cookie, code +- **Returns**: success / failure + +### POST `/check_token` +Validate an issued token. +- **Params**: token +- **Returns**: token validity + bound info + +### GET `/health` +Health check. +- **Returns**: `{status: ok}` + +--- + +## 🚀 Quick Start + +### Prerequisites +- Python 3.7+ +- `flask` + +### Configure + +Edit the config section in `captcha_service.py`: +```python +VERIFY_CODE_EXPIRE_SECONDS = 120 # code expiry (s) +RATE_LIMIT_SECONDS = 60 # request interval limit (s) +TOKEN_EXPIRE_SECONDS = 300 # token expiry (s) +``` + +### Run + +```bash +python captcha_service.py +``` + +--- + +## 🗂️ Storage (in-memory) + +| Store | Purpose | +|-------|---------| +| `COOKIE_STORE` | Tracks cookies → cudid / ip / created_at | +| `CODE_STORE` | Holds issued codes with expiry & last-request | +| `TOKEN_STORE` | Holds issued tokens with expiry & usage status | +| `REQUEST_RECORD` | Rate-limiting record by cookie / IP | + +--- + +## 📄 License + +Licensed under the **MIT License**. See [LICENSE](LICENSE). diff --git a/captcha_service.py b/captcha_service.py new file mode 100644 index 0000000..4c172f3 --- /dev/null +++ b/captcha_service.py @@ -0,0 +1,380 @@ +from flask import Flask, request, jsonify +import secrets +import string +import time +import requests +import hashlib + +app = Flask(__name__) + +# ==================== 配置 ==================== +VERIFY_CODE_EXPIRE_SECONDS = 120 # 验证码过期时间(秒) +RATE_LIMIT_SECONDS = 60 # 申请间隔限制(秒) +TOKEN_EXPIRE_SECONDS = 300 # token过期时间(秒) + +# ==================== AID ==================== +AID = "填写" +# ==================== 存储 ==================== +COOKIE_STORE = {} # {cookie: {'cudid': xxx, 'ip': xxx, 'created_at': timestamp}} +CODE_STORE = {} # {cookie: {'code': xxx, 'expire_at': timestamp, 'last_request': timestamp}} +TOKEN_STORE = {} # {token: {'cookie': xxx, 'cudid': xxx, 'expire_at': timestamp, 'used': False, 'code': xxx}} +REQUEST_RECORD = {} # {key: timestamp} key可以是cookie或ip + +# ==================== 工具函数 ==================== + +def generate_cookie(): + """生成高熵Cookie""" + return secrets.token_hex(64) + secrets.token_hex(32) + +def generate_verify_code(): + """生成10位高熵动态验证密码""" + chars = string.ascii_letters + string.digits + "!@#$%^&*()_+-=[]{}|;:,.<>?" + return ''.join(secrets.choice(chars) for _ in range(10)) + +def generate_token(): + """生成32位一次性token""" + return secrets.token_hex(32) + +def build_verify_email_html(code): + """构建验证码邮件HTML""" + return f""" + + + + + + + + + + + +
+

验证您的电子邮件地址。

+

感谢您注册我们的账户。为确保当前是您本人操作,请您输入此邮件中提示的验证码以完成账户注册。如您没有注册账户,请放心忽略该信息。

+
+
DVSWebSVC-动态验证密码
+
{code}
+
(此验证码将在发送后 {VERIFY_CODE_EXPIRE_SECONDS} 秒过期。)
+
+ + + """ + +def send_email_via_local(to_email, html_content): + """调用本地:5000/send 发送邮件""" + try: + login_resp = requests.post( + "http://localhost:5000/login", + json={ + "aid": AID, + "username": "dvsadmin", + "password": "G7#kLp$QwZ@2xR&yM9!nVb*C5^jHf%T" + }, + timeout=10 + ) + if login_resp.status_code != 200: + return False, "登录本地邮件服务失败" + + result = login_resp.json() + if result.get('code') != 200: + return False, f"登录失败: {result.get('message')}" + + cookie = result.get('cookie') + if not cookie: + return False, "获取邮件服务cookie失败" + + send_resp = requests.post( + "http://localhost:5000/send", + json={ + "cookie": cookie, + "to": to_email, + "subject": "【DVSWebSVC】验证您的电子邮件地址", + "content": html_content + }, + timeout=30 + ) + + if send_resp.status_code == 200: + result = send_resp.json() + if result.get('code') == 200: + return True, "邮件发送成功" + else: + return False, result.get('message', '邮件发送失败') + else: + return False, f"邮件服务返回错误: {send_resp.status_code}" + + except requests.exceptions.Timeout: + return False, "调用邮件服务超时" + except requests.exceptions.ConnectionError: + return False, "无法连接到邮件服务 (localhost:5000)" + except Exception as e: + return False, f"调用邮件服务异常: {str(e)}" + +# ==================== API 接口 ==================== + +@app.route('/apply', methods=['POST']) +def apply_cookie(): + """ + 申请Cookie(只需CUDID) + 请求体: {"cudid": "256位设备唯一标识符"} + 返回: {"code": 200, "cookie": "xxx", "message": "申请成功"} + """ + try: + data = request.get_json() or {} + cudid = data.get('cudid') + + # 验证CUDID + if not cudid: + return jsonify({"code": 400, "message": "缺少 cudid"}), 400 + + # 验证CUDID是否为256位哈希(64位十六进制字符) + if len(cudid) != 64: + return jsonify({"code": 400, "message": "cudid 必须是64位十六进制字符串(256位)"}), 400 + + try: + int(cudid, 16) # 验证是否为十六进制 + except ValueError: + return jsonify({"code": 400, "message": "cudid 必须是有效的十六进制字符串"}), 400 + + # 生成永久Cookie + cookie = generate_cookie() + + # 存储cookie信息 + COOKIE_STORE[cookie] = { + 'cudid': cudid, + 'ip': request.remote_addr, + 'created_at': time.time() + } + + return jsonify({ + "code": 200, + "message": "申请成功", + "cookie": cookie + }) + + except Exception as e: + return jsonify({"code": 500, "message": f"服务器错误: {str(e)}"}), 500 + + +@app.route('/request_code', methods=['POST']) +def request_verify_code(): + """ + 申请验证码 + 请求体: {"cookie": "xxx", "cudid": "256位设备标识符", "to": "收件邮箱"} + 返回: {"code": 200, "token": "xxx", "message": "验证码已发送"} + """ + try: + data = request.get_json() + if not data: + return jsonify({"code": 400, "message": "请求体不能为空"}), 400 + + cookie = data.get('cookie') + cudid = data.get('cudid') + to_email = data.get('to') + + # 参数校验 + if not cookie: + return jsonify({"code": 400, "message": "缺少 cookie"}), 400 + if not cudid: + return jsonify({"code": 400, "message": "缺少 cudid"}), 400 + if not to_email: + return jsonify({"code": 400, "message": "缺少 to"}), 400 + + # 验证CUDID格式 + if len(cudid) != 64: + return jsonify({"code": 400, "message": "cudid 必须是64位十六进制字符串(256位)"}), 400 + try: + int(cudid, 16) + except ValueError: + return jsonify({"code": 400, "message": "cudid 必须是有效的十六进制字符串"}), 400 + + # 验证Cookie是否存在 + if cookie not in COOKIE_STORE: + return jsonify({"code": 401, "message": "Cookie无效,请重新申请"}), 401 + + # 验证CUDID是否匹配 + stored = COOKIE_STORE[cookie] + if stored.get('cudid') != cudid: + return jsonify({"code": 401, "message": "CUDID不匹配"}), 401 + + # 限流检查(IP或Cookie任一限制) + ip = request.remote_addr + now = time.time() + + # 检查IP限制 + if ip in REQUEST_RECORD: + if now - REQUEST_RECORD[ip] < RATE_LIMIT_SECONDS: + wait = int(RATE_LIMIT_SECONDS - (now - REQUEST_RECORD[ip])) + return jsonify({"code": 429, "message": f"IP限制,请等待 {wait} 秒"}), 429 + + # 检查Cookie限制 + if cookie in REQUEST_RECORD: + if now - REQUEST_RECORD[cookie] < RATE_LIMIT_SECONDS: + wait = int(RATE_LIMIT_SECONDS - (now - REQUEST_RECORD[cookie])) + return jsonify({"code": 429, "message": f"Cookie限制,请等待 {wait} 秒"}), 429 + + # 生成验证码 + code = generate_verify_code() + + # 构建HTML邮件 + html_content = build_verify_email_html(code) + + # 发送邮件 + success, msg = send_email_via_local(to_email, html_content) + if not success: + return jsonify({"code": 500, "message": f"发送失败: {msg}"}), 500 + + # 记录申请时间(IP和Cookie都记录) + REQUEST_RECORD[ip] = now + REQUEST_RECORD[cookie] = now + + # 存储验证码 + CODE_STORE[cookie] = { + 'code': code, + 'expire_at': now + VERIFY_CODE_EXPIRE_SECONDS, + 'last_request': now + } + + # 生成一次性token + token = generate_token() + TOKEN_STORE[token] = { + 'cookie': cookie, + 'cudid': cudid, + 'code': code, + 'expire_at': now + TOKEN_EXPIRE_SECONDS, + 'used': False + } + + return jsonify({ + "code": 200, + "message": "验证码已发送", + "token": token + }) + + except Exception as e: + return jsonify({"code": 500, "message": f"服务器错误: {str(e)}"}), 500 + + +@app.route('/verify', methods=['POST']) +def verify_code(): + """ + 验证验证码 + 请求体: {"token": "xxx", "code": "用户输入的验证码"} + 返回: {"code": 200, "message": "验证成功"} + """ + try: + data = request.get_json() + if not data: + return jsonify({"code": 400, "message": "请求体不能为空"}), 400 + + token = data.get('token') + user_code = data.get('code') + + if not token: + return jsonify({"code": 400, "message": "缺少 token"}), 400 + if not user_code: + return jsonify({"code": 400, "message": "缺少 code"}), 400 + + # 验证token是否存在 + if token not in TOKEN_STORE: + return jsonify({"code": 401, "message": "Token无效"}), 401 + + token_data = TOKEN_STORE[token] + + # 检查是否已使用 + if token_data.get('used'): + return jsonify({"code": 401, "message": "Token已使用"}), 401 + + # 检查是否过期 + if time.time() > token_data.get('expire_at', 0): + TOKEN_STORE[token]['used'] = True + return jsonify({"code": 401, "message": "Token已过期"}), 401 + + # 验证验证码 + stored_code = token_data.get('code') + if user_code != stored_code: + return jsonify({"code": 401, "message": "验证码错误"}), 401 + + # 验证成功,标记token已使用 + TOKEN_STORE[token]['used'] = True + + return jsonify({ + "code": 200, + "message": "验证成功" + }) + + except Exception as e: + return jsonify({"code": 500, "message": f"服务器错误: {str(e)}"}), 500 + + +@app.route('/check_token', methods=['POST']) +def check_token(): + """ + 检查token状态 + 请求体: {"token": "xxx"} + 返回: {"code": 200, "valid": true/false, "used": true/false, "expired": true/false} + """ + try: + data = request.get_json() or {} + token = data.get('token') + + if not token: + return jsonify({"code": 400, "message": "缺少 token"}), 400 + + if token not in TOKEN_STORE: + return jsonify({"code": 200, "valid": False, "message": "Token不存在"}) + + token_data = TOKEN_STORE[token] + is_expired = time.time() > token_data.get('expire_at', 0) + is_used = token_data.get('used', False) + + return jsonify({ + "code": 200, + "valid": not is_expired and not is_used, + "used": is_used, + "expired": is_expired + }) + + except Exception as e: + return jsonify({"code": 500, "message": f"服务器错误: {str(e)}"}), 500 + + +@app.route('/health', methods=['GET']) +def health(): + """健康检查""" + return jsonify({ + "code": 200, + "message": "服务运行正常", + "stats": { + "cookies": len(COOKIE_STORE), + "codes": len(CODE_STORE), + "tokens": len(TOKEN_STORE), + "records": len(REQUEST_RECORD) + } + }) + + +if __name__ == '__main__': + print("=" * 60) + print("DVSWebSVC 验证码服务") + print("=" * 60) + print(f"验证码有效期: {VERIFY_CODE_EXPIRE_SECONDS} 秒") + print(f"限流间隔: {RATE_LIMIT_SECONDS} 秒") + print(f"Token有效期: {TOKEN_EXPIRE_SECONDS} 秒") + print("=" * 60) + print("\n📍 申请Cookie: POST /apply") + print(" 请求: {\"cudid\": \"64位十六进制(256位)\"}") + print(" 返回: {\"code\": 200, \"cookie\": \"xxx\"}") + print("\n📍 申请验证码: POST /request_code") + print(" 请求: {\"cookie\": \"xxx\", \"cudid\": \"xxx\", \"to\": \"邮箱\"}") + print(" 返回: {\"code\": 200, \"token\": \"xxx\"}") + print("\n📍 验证验证码: POST /verify") + print(" 请求: {\"token\": \"xxx\", \"code\": \"验证码\"}") + print(" 返回: {\"code\": 200, \"message\": \"验证成功\"}") + print("\n📍 检查Token: POST /check_token") + print(" 请求: {\"token\": \"xxx\"}") + print("📍 健康检查: GET /health") + print("=" * 60) + + app.run(host='0.0.0.0', port=888, debug=True)