From c293f3d4acc7f9efa1488cb2637b40f869731fb9 Mon Sep 17 00:00:00 2001 From: Jeason <1710884619@qq.com> Date: Thu, 2 Apr 2026 12:11:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首次使用强制设置密码(至少6位) - 密码存数据库(SHA256哈希),前端可修改 - 登录失败5次锁定5分钟,防爆破 - 导航栏添加修改密码入口 - 移除硬编码默认密码 --- Dockerfile | 1 - docker-compose.yml | 2 - server/app.py | 201 ++++++++++++++++++++++++++++----- server/database.py | 9 ++ templates/base.html | 3 +- templates/change_password.html | 33 ++++++ templates/login.html | 25 ++-- templates/setup.html | 50 ++++++++ 8 files changed, 278 insertions(+), 46 deletions(-) create mode 100644 templates/change_password.html create mode 100644 templates/setup.html diff --git a/Dockerfile b/Dockerfile index 4a9fe2d..dd47ea9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ VOLUME ["/app/data"] ENV FLASK_DEBUG=0 ENV TZ=Asia/Shanghai -ENV ADMIN_PASSWORD=admin123 RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone CMD ["python", "run.py"] diff --git a/docker-compose.yml b/docker-compose.yml index ba514a4..2b28768 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,4 @@ services: - "9000:9000" volumes: - ./data:/app/data - environment: - - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} restart: unless-stopped diff --git a/server/app.py b/server/app.py index 1117efe..be188f8 100644 --- a/server/app.py +++ b/server/app.py @@ -1,61 +1,214 @@ import os import sys import hashlib +import time sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from flask import Flask, redirect, url_for, request, session, render_template +from flask import (Flask, redirect, url_for, request, session, + render_template, jsonify) from flask_socketio import SocketIO -from server.database import init_db -from server.routers import accounts, tasks, orders +from server.database import init_db, get_db socketio = SocketIO(cors_allowed_origins="*") -# 访问密码,可通过环境变量 ADMIN_PASSWORD 设置,默认 admin123 -ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'admin123') +# 防爆破:最大失败次数 & 锁定时间(秒) +MAX_FAIL = 5 +LOCK_SECONDS = 300 # 5分钟 + + +def _hash_pwd(pwd): + return hashlib.sha256(pwd.encode()).hexdigest() + + +def _get_admin(): + db = get_db() + row = db.execute('SELECT * FROM admin WHERE id = 1').fetchone() + db.close() + return row + + +def _is_locked(): + admin = _get_admin() + if not admin: + return False + if admin['fail_count'] >= MAX_FAIL and admin['locked_until']: + try: + from datetime import datetime + locked = datetime.strptime(admin['locked_until'], + '%Y-%m-%d %H:%M:%S') + if datetime.now() < locked: + return True + # 锁定已过期,重置 + db = get_db() + db.execute("UPDATE admin SET fail_count=0, locked_until='' " + "WHERE id=1") + db.commit() + db.close() + except Exception: + pass + return False + + +def _record_fail(): + from datetime import datetime, timedelta + db = get_db() + admin = db.execute('SELECT fail_count FROM admin WHERE id=1').fetchone() + if admin: + new_count = (admin['fail_count'] or 0) + 1 + locked_until = '' + if new_count >= MAX_FAIL: + locked_until = (datetime.now() + timedelta( + seconds=LOCK_SECONDS)).strftime('%Y-%m-%d %H:%M:%S') + db.execute("UPDATE admin SET fail_count=?, locked_until=?, " + "updated_at=datetime('now','localtime') WHERE id=1", + (new_count, locked_until)) + db.commit() + db.close() + + +def _reset_fail(): + db = get_db() + db.execute("UPDATE admin SET fail_count=0, locked_until='', " + "updated_at=datetime('now','localtime') WHERE id=1") + db.commit() + db.close() def create_app(): app = Flask( __name__, - template_folder=os.path.join(os.path.dirname(__file__), '..', 'templates'), - static_folder=os.path.join(os.path.dirname(__file__), '..', 'static'), + template_folder=os.path.join(os.path.dirname(__file__), '..', + 'templates'), + static_folder=os.path.join(os.path.dirname(__file__), '..', + 'static'), ) app.secret_key = os.environ.get( - 'SECRET_KEY', 'snatcher-secret-key-change-me') + 'SECRET_KEY', 'snatcher-' + _hash_pwd('change-me')[:16]) init_db() - # ── 登录认证 ── + # ── 登录 & 初始设置密码 ── @app.route('/login', methods=['GET', 'POST']) def login(): + admin = _get_admin() + # 首次使用:跳转设置密码 + if not admin: + return redirect(url_for('setup')) + if request.method == 'POST': + if _is_locked(): + remain = '' + try: + from datetime import datetime + locked = datetime.strptime( + _get_admin()['locked_until'], '%Y-%m-%d %H:%M:%S') + remain = f'{int((locked - datetime.now()).total_seconds())}秒' + except Exception: + remain = f'{LOCK_SECONDS}秒' + return render_template( + 'login.html', + error=f'登录失败次数过多,请{remain}后再试') + pwd = request.form.get('password', '') - if pwd == ADMIN_PASSWORD: - session['authenticated'] = _hash(pwd) + if _hash_pwd(pwd) == admin['password_hash']: + _reset_fail() + session['authenticated'] = True session.permanent = True app.permanent_session_lifetime = __import__( 'datetime').timedelta(days=7) return redirect(request.args.get('next', '/')) - return render_template('login.html', error='密码错误') + else: + _record_fail() + admin = _get_admin() + remaining = MAX_FAIL - (admin['fail_count'] or 0) + if remaining <= 0: + return render_template( + 'login.html', + error=f'账号已锁定{LOCK_SECONDS // 60}分钟') + return render_template( + 'login.html', + error=f'密码错误,还剩{remaining}次机会') + return render_template('login.html') + @app.route('/setup', methods=['GET', 'POST']) + def setup(): + admin = _get_admin() + if admin: + return redirect(url_for('login')) + + if request.method == 'POST': + pwd = request.form.get('password', '') + pwd2 = request.form.get('password2', '') + if len(pwd) < 6: + return render_template('setup.html', + error='密码至少6位') + if pwd != pwd2: + return render_template('setup.html', + error='两次密码不一致') + db = get_db() + db.execute( + "INSERT INTO admin (id, password_hash) VALUES (1, ?)", + (_hash_pwd(pwd),)) + db.commit() + db.close() + session['authenticated'] = True + session.permanent = True + app.permanent_session_lifetime = __import__( + 'datetime').timedelta(days=7) + return redirect('/') + + return render_template('setup.html') + @app.route('/logout') def logout(): session.clear() return redirect('/login') + @app.route('/change_password', methods=['GET', 'POST']) + def change_password(): + if not session.get('authenticated'): + return redirect(url_for('login')) + if request.method == 'POST': + old = request.form.get('old_password', '') + new = request.form.get('new_password', '') + new2 = request.form.get('new_password2', '') + admin = _get_admin() + if _hash_pwd(old) != admin['password_hash']: + return render_template('change_password.html', + error='原密码错误') + if len(new) < 6: + return render_template('change_password.html', + error='新密码至少6位') + if new != new2: + return render_template('change_password.html', + error='两次密码不一致') + db = get_db() + db.execute( + "UPDATE admin SET password_hash=?, " + "updated_at=datetime('now','localtime') WHERE id=1", + (_hash_pwd(new),)) + db.commit() + db.close() + return render_template('change_password.html', + success='密码修改成功') + return render_template('change_password.html') + @app.before_request def require_auth(): - # 放行登录页和静态资源 - if request.path in ('/login',) or request.path.startswith('/static'): + allowed = ('/login', '/setup') + if request.path in allowed or request.path.startswith('/static'): return - # 放行 SocketIO 的内部路径 if request.path.startswith('/socket.io'): return - if session.get('authenticated') != _hash(ADMIN_PASSWORD): + admin = _get_admin() + if not admin: + return redirect(url_for('setup')) + if not session.get('authenticated'): return redirect(url_for('login', next=request.path)) + from server.routers import accounts, tasks, orders app.register_blueprint(accounts.bp) app.register_blueprint(tasks.bp) app.register_blueprint(orders.bp) @@ -70,10 +223,6 @@ def create_app(): return app -def _hash(s): - return hashlib.sha256(s.encode()).hexdigest()[:16] - - def _register_socketio_events(): import asyncio from server.services.remote_browser import get_session @@ -92,19 +241,15 @@ def _register_socketio_events(): def handle_keyboard(data): s = get_session(data.get('session_id', '')) if s and s.loop and s.loop.is_running(): - text = data.get('text', '') - if text: - asyncio.run_coroutine_threadsafe( - s.handle_keyboard(text), s.loop) + asyncio.run_coroutine_threadsafe( + s.handle_keyboard(data.get('text', '')), s.loop) @socketio.on('rb_key', namespace='/rb') def handle_key(data): s = get_session(data.get('session_id', '')) if s and s.loop and s.loop.is_running(): - key = data.get('key', '') - if key: - asyncio.run_coroutine_threadsafe( - s.handle_key(key), s.loop) + asyncio.run_coroutine_threadsafe( + s.handle_key(data.get('key', '')), s.loop) @socketio.on('rb_close', namespace='/rb') def handle_close(data): diff --git a/server/database.py b/server/database.py index 91e8327..a35bfe6 100644 --- a/server/database.py +++ b/server/database.py @@ -54,6 +54,15 @@ def init_db(): FOREIGN KEY (task_id) REFERENCES tasks(id), FOREIGN KEY (account_id) REFERENCES accounts(id) ); + + CREATE TABLE IF NOT EXISTS admin ( + id INTEGER PRIMARY KEY CHECK (id = 1), + password_hash TEXT NOT NULL, + fail_count INTEGER DEFAULT 0, + locked_until TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now', 'localtime')), + updated_at TEXT DEFAULT (datetime('now', 'localtime')) + ); ''') conn.commit() conn.close() diff --git a/templates/base.html b/templates/base.html index ef30da7..bfdfece 100644 --- a/templates/base.html +++ b/templates/base.html @@ -35,7 +35,8 @@ 任务 账号 订单 - + + diff --git a/templates/change_password.html b/templates/change_password.html new file mode 100644 index 0000000..9f0a2a8 --- /dev/null +++ b/templates/change_password.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} +{% block content %} +
请设置管理密码(至少6位)
+