security: 环境变量密钥、密码加盐哈希、CSRF 防护
- config.py/web_server.py 支持环境变量注入 STF_TOKEN/WEB_SECRET_KEY,新增 .env 加载和 .env.example;.env 加入 gitignore - 用户密码从裸 SHA-256 改为 werkzeug 加盐哈希(scrypt),旧哈希登录时自动升级 - CSRF:session token + X-CSRF-Token 请求头校验非 GET 请求,前端自动携带
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# 环境变量示例:复制为 .env 并按需修改(.env 不进 git)
|
||||
# 生产环境建议用环境变量/密钥管理注入,不要把 token 写进 git。
|
||||
|
||||
# STF 服务地址
|
||||
STF_URL=http://192.168.20.220:7100
|
||||
|
||||
# STF API Token(安全敏感,生产必须设置)
|
||||
STF_TOKEN=请填写你的STF_token
|
||||
|
||||
# Web 会话密钥(安全敏感,生产必须设置为随机长字符串)
|
||||
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
|
||||
WEB_SECRET_KEY=请填写随机密钥
|
||||
@@ -36,5 +36,9 @@ desktop.ini
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# 密钥/环境变量(真实 .env 含 token/密钥,绝不提交)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
|
||||
bin/adb/*
|
||||
@@ -4,12 +4,34 @@
|
||||
放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。
|
||||
|
||||
改 STF 地址/token、adb 路径、web 端口都改这里。
|
||||
|
||||
安全:STF_TOKEN / WEB_SECRET_KEY 建议用环境变量注入(生产不要提交到 git)。
|
||||
支持 `.env` 文件(项目根目录),也可直接 `export STF_TOKEN=xxx`。
|
||||
"""
|
||||
import os
|
||||
|
||||
# 可选加载 .env 文件(不存在则忽略)
|
||||
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
|
||||
if os.path.exists(_env_path):
|
||||
try:
|
||||
with open(_env_path, encoding="utf-8") as _f:
|
||||
for _line in _f:
|
||||
_line = _line.strip()
|
||||
if _line and not _line.startswith("#") and "=" in _line:
|
||||
_k, _v = _line.split("=", 1)
|
||||
os.environ.setdefault(_k.strip(), _v.strip())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _env(key, default):
|
||||
"""读环境变量,未设置则返回默认值。"""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
# ================== STF 配置 ==================
|
||||
STF_URL = "http://192.168.20.220:7100"
|
||||
STF_TOKEN = "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b"
|
||||
STF_URL = _env("STF_URL", "http://192.168.20.220:7100")
|
||||
STF_TOKEN = _env("STF_TOKEN", "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b")
|
||||
|
||||
# ================== adb 路径 ==================
|
||||
# adb 二进制及依赖统一放在 bin/adb/ 下
|
||||
|
||||
+16
-3
@@ -22,6 +22,7 @@ from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import event, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from core.logger import get_logger
|
||||
|
||||
@@ -47,14 +48,26 @@ class User(UserMixin, db.Model):
|
||||
"""后台用户。"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(120), nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
is_admin = db.Column(db.Boolean, default=True)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = hashlib.sha256(password.encode()).hexdigest()
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return self.password_hash == hashlib.sha256(password.encode()).hexdigest()
|
||||
"""校验密码。兼容旧 SHA-256 哈希(匹配则自动升级为新哈希)。"""
|
||||
ph = self.password_hash or ""
|
||||
if ph and not ph.startswith(("pbkdf2:", "scrypt:")):
|
||||
# 旧版裸 SHA-256:比对通过则升级为加盐哈希
|
||||
if hashlib.sha256(password.encode()).hexdigest() == ph:
|
||||
self.set_password(password)
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
return False
|
||||
return check_password_hash(ph, password)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.username}>"
|
||||
|
||||
+19
-3
@@ -6,18 +6,32 @@ const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.l
|
||||
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
|
||||
// ===== CSRF =====
|
||||
let _csrfToken='';
|
||||
// 页面加载后调用:获取 CSRF token,非 GET 请求需携带
|
||||
async function initCsrf(){
|
||||
try{
|
||||
const r=await fetch('/api/csrf');
|
||||
const d=await r.json();
|
||||
_csrfToken=d.token||'';
|
||||
}catch(e){}
|
||||
}
|
||||
function _csrfHeaders(){
|
||||
return _csrfToken?{'X-CSRF-Token':_csrfToken}:{};
|
||||
}
|
||||
|
||||
async function apiGet(url){
|
||||
const r=await fetch(url);
|
||||
if(r.status===401){window.location='/login';return null;}
|
||||
return r.json();
|
||||
}
|
||||
async function apiPost(url,body){
|
||||
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'});
|
||||
const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||||
if(r.status===401){window.location='/login';return null;}
|
||||
return r.json();
|
||||
}
|
||||
async function apiPut(url,body){
|
||||
const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):'{}'});
|
||||
const r=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json',..._csrfHeaders()},body:body?JSON.stringify(body):'{}'});
|
||||
if(r.status===401){window.location='/login';return null;}
|
||||
return r.json();
|
||||
}
|
||||
@@ -204,7 +218,7 @@ async function refreshShot(){
|
||||
}
|
||||
}
|
||||
async function apiDelete(url){
|
||||
const r=await fetch(url,{method:'DELETE'});
|
||||
const r=await fetch(url,{method:'DELETE',headers:_csrfHeaders()});
|
||||
if(r.status===401){window.location='/login';return null;}
|
||||
return r.json();
|
||||
}
|
||||
@@ -1856,6 +1870,7 @@ async function uploadApk(input){
|
||||
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
|
||||
const xhr=new XMLHttpRequest();
|
||||
xhr.open('POST','/api/apks/upload');
|
||||
if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken);
|
||||
xhr.upload.onprogress=function(e){
|
||||
if(e.lengthComputable){
|
||||
const pct=Math.round(e.loaded*100/e.total);
|
||||
@@ -2053,4 +2068,5 @@ async function scanForeground(){
|
||||
}
|
||||
|
||||
// ================== 初始化 ==================
|
||||
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
|
||||
showTab('monitor');
|
||||
|
||||
+36
-2
@@ -17,10 +17,12 @@ import sys
|
||||
import time
|
||||
import uuid
|
||||
import atexit
|
||||
import secrets
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, jsonify, request, redirect, url_for, render_template, Response, make_response
|
||||
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
|
||||
Response, make_response, session)
|
||||
from flask_login import (LoginManager, login_user, logout_user,
|
||||
login_required, current_user)
|
||||
|
||||
@@ -36,7 +38,9 @@ from tasks import list_task_types, get_task_class
|
||||
_log = get_logger("web")
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = "dev-secret-key-change-in-production"
|
||||
# 生产必须通过环境变量 WEB_SECRET_KEY 设置强密钥;缺省用开发密钥(不安全)
|
||||
app.config["SECRET_KEY"] = os.environ.get(
|
||||
"WEB_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
@@ -56,6 +60,35 @@ def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
|
||||
# ================== CSRF 防护 ==================
|
||||
def _csrf_token():
|
||||
"""获取或生成当前会话的 CSRF token(非 GET 请求需在 X-CSRF-Token 头携带)。"""
|
||||
if "csrf_token" not in session:
|
||||
session["csrf_token"] = secrets.token_hex(16)
|
||||
return session["csrf_token"]
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _csrf_protect():
|
||||
"""非安全方法(POST/PUT/DELETE/PATCH)校验 X-CSRF-Token 请求头。"""
|
||||
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||||
return
|
||||
# 登录表单(未登录,尚无 token)和静态文件跳过
|
||||
if request.endpoint in ("login", "static"):
|
||||
return
|
||||
sess = session.get("csrf_token", "")
|
||||
header = request.headers.get("X-CSRF-Token", "")
|
||||
if not sess or header != sess:
|
||||
return jsonify({"ok": False, "error": "CSRF 校验失败"}), 403
|
||||
|
||||
|
||||
@app.route("/api/csrf")
|
||||
@login_required
|
||||
def api_csrf():
|
||||
"""获取 CSRF token(前端非 GET 请求需携带 X-CSRF-Token 头)。"""
|
||||
return jsonify({"ok": True, "token": _csrf_token()})
|
||||
|
||||
|
||||
# ================== 页面路由 ==================
|
||||
@app.route("/")
|
||||
@login_required
|
||||
@@ -75,6 +108,7 @@ def login():
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user and user.check_password(password):
|
||||
login_user(user)
|
||||
_csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取
|
||||
_log.info(f"用户 {username} 登录")
|
||||
return redirect(request.args.get("next") or url_for("index"))
|
||||
return render_template("admin/login.html", error="用户名或密码错误")
|
||||
|
||||
Reference in New Issue
Block a user