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
|
*.tmp
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# 密钥/环境变量(真实 .env 含 token/密钥,绝不提交)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
|
||||||
bin/adb/*
|
bin/adb/*
|
||||||
@@ -4,12 +4,34 @@
|
|||||||
放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。
|
放各自 tasks/xxx.py 顶部,保持每个任务自包含、互不干扰。
|
||||||
|
|
||||||
改 STF 地址/token、adb 路径、web 端口都改这里。
|
改 STF 地址/token、adb 路径、web 端口都改这里。
|
||||||
|
|
||||||
|
安全:STF_TOKEN / WEB_SECRET_KEY 建议用环境变量注入(生产不要提交到 git)。
|
||||||
|
支持 `.env` 文件(项目根目录),也可直接 `export STF_TOKEN=xxx`。
|
||||||
"""
|
"""
|
||||||
import os
|
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 配置 ==================
|
||||||
STF_URL = "http://192.168.20.220:7100"
|
STF_URL = _env("STF_URL", "http://192.168.20.220:7100")
|
||||||
STF_TOKEN = "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b"
|
STF_TOKEN = _env("STF_TOKEN", "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b")
|
||||||
|
|
||||||
# ================== adb 路径 ==================
|
# ================== adb 路径 ==================
|
||||||
# adb 二进制及依赖统一放在 bin/adb/ 下
|
# adb 二进制及依赖统一放在 bin/adb/ 下
|
||||||
|
|||||||
+16
-3
@@ -22,6 +22,7 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from flask_login import UserMixin
|
from flask_login import UserMixin
|
||||||
from sqlalchemy import event, text
|
from sqlalchemy import event, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
|
|
||||||
@@ -47,14 +48,26 @@ class User(UserMixin, db.Model):
|
|||||||
"""后台用户。"""
|
"""后台用户。"""
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
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)
|
is_admin = db.Column(db.Boolean, default=True)
|
||||||
|
|
||||||
def set_password(self, password):
|
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):
|
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):
|
def __repr__(self):
|
||||||
return f"<User {self.username}>"
|
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]));}
|
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){
|
async function apiGet(url){
|
||||||
const r=await fetch(url);
|
const r=await fetch(url);
|
||||||
if(r.status===401){window.location='/login';return null;}
|
if(r.status===401){window.location='/login';return null;}
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
async function apiPost(url,body){
|
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;}
|
if(r.status===401){window.location='/login';return null;}
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
async function apiPut(url,body){
|
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;}
|
if(r.status===401){window.location='/login';return null;}
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@@ -204,7 +218,7 @@ async function refreshShot(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function apiDelete(url){
|
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;}
|
if(r.status===401){window.location='/login';return null;}
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
@@ -1856,6 +1870,7 @@ async function uploadApk(input){
|
|||||||
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
|
// 用 XMLHttpRequest 才能拿到上传进度(fetch 不支持上传进度事件)
|
||||||
const xhr=new XMLHttpRequest();
|
const xhr=new XMLHttpRequest();
|
||||||
xhr.open('POST','/api/apks/upload');
|
xhr.open('POST','/api/apks/upload');
|
||||||
|
if(_csrfToken)xhr.setRequestHeader('X-CSRF-Token',_csrfToken);
|
||||||
xhr.upload.onprogress=function(e){
|
xhr.upload.onprogress=function(e){
|
||||||
if(e.lengthComputable){
|
if(e.lengthComputable){
|
||||||
const pct=Math.round(e.loaded*100/e.total);
|
const pct=Math.round(e.loaded*100/e.total);
|
||||||
@@ -2053,4 +2068,5 @@ async function scanForeground(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ================== 初始化 ==================
|
// ================== 初始化 ==================
|
||||||
|
initCsrf(); // 获取 CSRF token(非 GET 请求需携带)
|
||||||
showTab('monitor');
|
showTab('monitor');
|
||||||
|
|||||||
+36
-2
@@ -17,10 +17,12 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import atexit
|
import atexit
|
||||||
|
import secrets
|
||||||
import subprocess
|
import subprocess
|
||||||
from datetime import datetime
|
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,
|
from flask_login import (LoginManager, login_user, logout_user,
|
||||||
login_required, current_user)
|
login_required, current_user)
|
||||||
|
|
||||||
@@ -36,7 +38,9 @@ from tasks import list_task_types, get_task_class
|
|||||||
_log = get_logger("web")
|
_log = get_logger("web")
|
||||||
|
|
||||||
app = Flask(__name__)
|
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(
|
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
|
os.path.dirname(os.path.abspath(__file__)), "data", "users.db")
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
@@ -56,6 +60,35 @@ def load_user(user_id):
|
|||||||
return User.query.get(int(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("/")
|
@app.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
@@ -75,6 +108,7 @@ def login():
|
|||||||
user = User.query.filter_by(username=username).first()
|
user = User.query.filter_by(username=username).first()
|
||||||
if user and user.check_password(password):
|
if user and user.check_password(password):
|
||||||
login_user(user)
|
login_user(user)
|
||||||
|
_csrf_token() # 建立 CSRF token,前端通过 /api/csrf 获取
|
||||||
_log.info(f"用户 {username} 登录")
|
_log.info(f"用户 {username} 登录")
|
||||||
return redirect(request.args.get("next") or url_for("index"))
|
return redirect(request.args.get("next") or url_for("index"))
|
||||||
return render_template("admin/login.html", error="用户名或密码错误")
|
return render_template("admin/login.html", error="用户名或密码错误")
|
||||||
|
|||||||
Reference in New Issue
Block a user