feat: 添加用户登录认证功能,确保系统安全性,并提供修改密码和退出登录选项

This commit is contained in:
2026-03-14 00:11:16 +08:00
parent 847ec32144
commit d2d63d5e61
15 changed files with 437 additions and 5 deletions

View File

@@ -44,7 +44,17 @@ gunicorn -w 2 -b 127.0.0.1:5000 app:app
说明:建议只监听 `127.0.0.1`,由 Nginx/宝塔反向代理。
## 4. 每次发布更新
## 4. 生产环境变量(建议)
登录认证默认开启,建议在生产环境显式设置管理员账号和会话密钥:
```bash
export INVENTORY_ADMIN_USERNAME="your_admin"
export INVENTORY_ADMIN_PASSWORD="your_strong_password"
export INVENTORY_SECRET_KEY="replace_with_random_long_secret"
```
## 5. 每次发布更新
### 本地
@@ -66,13 +76,13 @@ pip install -r requirements.txt
然后在宝塔中重启 Python 项目(或重启 Gunicorn 进程)。
## 5. 数据库备份
## 6. 数据库备份
```bash
cp /www/wwwroot/inventory/data/inventory.db /www/backup/inventory_$(date +%F).db
```
## 6. 快速排查
## 7. 快速排查
查看服务端口是否监听:

View File

@@ -61,7 +61,48 @@ python app.py
默认访问:`http://127.0.0.1:5000`
### 2.4 可选:启用 AI 补货建议(硅基流动
### 2.4 登录认证(默认开启
系统启动后会自动启用登录保护,未登录用户会被重定向到登录页。
- 默认管理员用户名:`admin`
- 默认管理员密码:`admin123456`
可通过环境变量覆盖默认账号:
```powershell
$env:INVENTORY_ADMIN_USERNAME="你的管理员用户名"
$env:INVENTORY_ADMIN_PASSWORD="你的管理员密码"
```
首次登录后,建议立即在页面右上角进入“修改密码”完成改密。
#### 2.4.1 忘记密码救砖
如果还能登录:
- 右上角 `账号` -> `修改密码`
如果已经无法登录,可在项目根目录执行下面命令重置 `admin` 密码(将示例密码替换为你自己的强密码):
```powershell
c:/Users/BeihongWang/Desktop/inventory/.venv/Scripts/python.exe -c "from app import app,db,User,generate_password_hash; ctx=app.app_context(); ctx.push(); u=User.query.filter_by(username='admin').first(); u.password_hash=generate_password_hash('NewPass123!'); db.session.commit(); print('admin password reset ok'); ctx.pop();"
```
执行完成后,用新密码重新登录。
如果要重置非 `admin` 账号,可用通用模板(把 `target_username``NewPass123!` 改成你的值):
```powershell
c:/Users/BeihongWang/Desktop/inventory/.venv/Scripts/python.exe -c "from app import app,db,User,generate_password_hash; target_username='your_username'; new_password='NewPass123!'; ctx=app.app_context(); ctx.push(); u=User.query.filter_by(username=target_username).first(); print('user found:', bool(u)); (setattr(u, 'password_hash', generate_password_hash(new_password)), db.session.commit(), print('password reset ok')) if u else print('skip reset'); ctx.pop();"
```
注意:
- `INVENTORY_ADMIN_PASSWORD` 仅在“系统中还没有任何用户”时用于创建默认管理员。
- 如果数据库里已经有用户,修改该环境变量不会自动改已有账号密码。
### 2.5 可选:启用 AI 补货建议(硅基流动)
在启动前设置环境变量:

209
app.py
View File

@@ -24,8 +24,9 @@ from copy import deepcopy
from io import StringIO
from datetime import datetime, timedelta
from flask import Flask, Response, redirect, render_template, request, url_for
from flask import Flask, Response, redirect, render_template, request, session, url_for
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import check_password_hash, generate_password_hash
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -37,12 +38,18 @@ DB_PATH = os.path.join(DB_DIR, "inventory.db")
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{DB_PATH}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.config["SECRET_KEY"] = os.environ.get(
"INVENTORY_SECRET_KEY",
hashlib.sha256(f"inventory-local::{BASE_DIR}".encode("utf-8")).hexdigest(),
)
db = SQLAlchemy(app)
# 这里集中放全局常量,避免后面函数里散落硬编码。
LOW_STOCK_THRESHOLD = 5
BOX_TYPES_OVERRIDE_PATH = os.path.join(DB_DIR, "box_types.json")
AI_SETTINGS_PATH = os.path.join(DB_DIR, "ai_settings.json")
DEFAULT_ADMIN_USERNAME = os.environ.get("INVENTORY_ADMIN_USERNAME", "admin").strip() or "admin"
DEFAULT_ADMIN_PASSWORD = os.environ.get("INVENTORY_ADMIN_PASSWORD", "admin123456")
LCSC_BASE_URL = "https://open-api.jlc.com"
LCSC_BASIC_PATH = "/lcsc/openapi/sku/product/basic"
AI_SETTINGS_DEFAULT = {
@@ -520,6 +527,15 @@ class InventoryEvent(db.Model):
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), nullable=False, unique=True)
password_hash = db.Column(db.String(255), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
def _add_column_if_missing(table_name: str, column_name: str, ddl: str) -> None:
columns = {
row[1]
@@ -563,6 +579,116 @@ def ensure_schema() -> None:
db.session.commit()
def _get_session_user() -> User | None:
user_id = session.get("user_id")
if not user_id:
return None
try:
return User.query.get(int(user_id))
except (TypeError, ValueError):
return None
def _is_authenticated() -> bool:
return _get_session_user() is not None
def _login_user(user: User) -> None:
now_iso = datetime.utcnow().isoformat(timespec="seconds")
session["user_id"] = int(user.id)
session["username"] = user.username
session["login_at"] = now_iso
session["last_active_at"] = now_iso
def _logout_user() -> None:
session.pop("user_id", None)
session.pop("username", None)
session.pop("login_at", None)
session.pop("last_active_at", None)
def _parse_iso_datetime(raw_value: str) -> datetime | None:
value = (raw_value or "").strip()
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def _build_session_status() -> tuple[str, str, str, str]:
raw_login_at = (session.get("login_at") or "").strip()
raw_last_active_at = (session.get("last_active_at") or "").strip()
login_at = _parse_iso_datetime(raw_login_at)
last_active_at = _parse_iso_datetime(raw_last_active_at)
if login_at is None:
return "-", "-", "-", "-"
if last_active_at is None:
last_active_at = login_at
online_minutes = max(0, int((datetime.utcnow() - login_at).total_seconds() // 60))
idle_minutes = max(0, int((datetime.utcnow() - last_active_at).total_seconds() // 60))
login_label = login_at.strftime("%Y-%m-%d %H:%M:%S")
last_active_label = last_active_at.strftime("%Y-%m-%d %H:%M:%S")
online_label = "刚刚" if online_minutes <= 0 else f"{online_minutes} 分钟"
idle_label = "刚刚" if idle_minutes <= 0 else f"{idle_minutes} 分钟"
return login_label, online_label, last_active_label, idle_label
def _ensure_default_admin_user() -> None:
"""确保系统至少存在一个可登录用户。
中文说明:首次初始化时自动创建管理员账号,避免系统开启登录保护后无人可进。
用户名和密码可通过环境变量 INVENTORY_ADMIN_USERNAME / INVENTORY_ADMIN_PASSWORD 覆盖。
"""
existing_user = User.query.order_by(User.id.asc()).first()
if existing_user:
return
admin = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=generate_password_hash(DEFAULT_ADMIN_PASSWORD),
)
db.session.add(admin)
db.session.commit()
@app.context_processor
def inject_auth_context():
current_user = _get_session_user()
login_label, online_label, last_active_label, idle_label = _build_session_status()
return {
"auth_username": current_user.username if current_user else "",
"auth_logged_in": current_user is not None,
"auth_login_at": login_label,
"auth_online_for": online_label,
"auth_last_active_at": last_active_label,
"auth_idle_for": idle_label,
}
@app.before_request
def require_login_for_app_routes():
open_endpoints = {
"login_page",
"logout_page",
"static",
}
endpoint = request.endpoint or ""
if endpoint in open_endpoints or endpoint.startswith("static"):
return None
if _is_authenticated():
session["last_active_at"] = datetime.utcnow().isoformat(timespec="seconds")
return None
next_path = request.full_path if request.query_string else request.path
return redirect(url_for("login_page", next=next_path.rstrip("?")))
def slot_code_for_box(box: Box, slot_index: int) -> str:
serial = box.start_number + slot_index - 1
return f"{box.slot_prefix}{serial}"
@@ -2689,6 +2815,86 @@ def _call_siliconflow_chat(
raise RuntimeError("AI 返回格式无法解析") from exc
def _is_safe_next_path(path: str) -> bool:
candidate = (path or "").strip()
if not candidate:
return False
return candidate.startswith("/") and not candidate.startswith("//")
@app.route("/login", methods=["GET", "POST"])
def login_page():
if _is_authenticated():
return redirect(url_for("types_page"))
error = ""
notice = request.args.get("notice", "").strip()
next_path = request.args.get("next", "").strip()
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
next_path = request.form.get("next", "").strip()
user = User.query.filter_by(username=username).first()
if not user or not check_password_hash(user.password_hash, password):
error = "用户名或密码错误"
else:
_login_user(user)
if _is_safe_next_path(next_path):
return redirect(next_path)
return redirect(url_for("types_page"))
if not _is_safe_next_path(next_path):
next_path = ""
return render_template("login.html", error=error, notice=notice, next_path=next_path)
@app.route("/logout")
def logout_page():
_logout_user()
return redirect(url_for("login_page"))
@app.route("/account/password", methods=["GET", "POST"])
def change_password_page():
"""登录后修改当前账号密码。
中文说明:为了避免长期使用默认密码,提供页面自助改密。
改密成功后会强制重新登录,确保会话状态干净。
"""
current_user = _get_session_user()
if current_user is None:
return redirect(url_for("login_page"))
error = ""
notice = ""
if request.method == "POST":
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
if not check_password_hash(current_user.password_hash, current_password):
error = "当前密码不正确"
elif len(new_password) < 8:
error = "新密码至少需要 8 位"
elif new_password != confirm_password:
error = "两次输入的新密码不一致"
elif check_password_hash(current_user.password_hash, new_password):
error = "新密码不能与当前密码相同"
else:
current_user.password_hash = generate_password_hash(new_password)
db.session.commit()
_logout_user()
return redirect(url_for("login_page", notice="密码修改成功,请使用新密码重新登录"))
if not error:
notice = "建议使用强密码(字母+数字+符号),并定期更换。"
return render_template("change_password.html", error=error, notice=notice)
@app.route("/")
def index():
return redirect(url_for("types_page"))
@@ -4602,6 +4808,7 @@ def bootstrap() -> None:
db.create_all()
ensure_schema()
normalize_legacy_data()
_ensure_default_admin_user()
bootstrap()

View File

@@ -555,6 +555,64 @@ body {
background: color-mix(in srgb, var(--danger) 85%, #000 15%);
}
.account-menu {
position: relative;
}
.account-menu > summary {
list-style: none;
}
.account-menu > summary::-webkit-details-marker {
display: none;
}
.account-menu > summary::after {
content: "▾";
margin-left: 8px;
font-size: 12px;
color: var(--muted);
}
.account-menu[open] > summary::after {
content: "▴";
}
.account-menu-list {
position: absolute;
right: 0;
top: calc(100% + 8px);
min-width: 160px;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 6px;
z-index: 20;
display: grid;
gap: 4px;
}
.account-menu-meta {
margin: 0;
padding: 4px 10px;
color: var(--muted);
font-size: 12px;
}
.account-menu-item {
display: block;
color: var(--text);
text-decoration: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 8px 10px;
}
.account-menu-item:hover {
background: color-mix(in srgb, var(--card-alt) 76%, var(--accent) 24%);
}
.box-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
@@ -1420,4 +1478,9 @@ th {
.chart-row {
grid-template-columns: 100px 1fr 52px;
}
.account-menu-list {
right: auto;
left: 0;
}
}

View File

@@ -0,0 +1,11 @@
<details class="account-menu">
<summary class="btn btn-light">账号:{{ auth_username or '未登录' }}</summary>
<div class="account-menu-list" role="menu">
<p class="account-menu-meta">登录时间:{{ auth_login_at }}</p>
<p class="account-menu-meta">在线时长:{{ auth_online_for }}</p>
<p class="account-menu-meta">最后活动:{{ auth_last_active_at }}</p>
<p class="account-menu-meta">空闲时长:{{ auth_idle_for }}</p>
<a class="account-menu-item" href="{{ url_for('change_password_page') }}" role="menuitem">修改密码</a>
<a class="account-menu-item" href="{{ url_for('logout_page') }}" role="menuitem">退出登录</a>
</div>
</details>

View File

@@ -14,6 +14,7 @@
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
{% include '_account_menu.html' %}
</div>
</header>

View File

@@ -22,6 +22,7 @@
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn btn-light" href="{{ url_for('export_box_labels_csv', box_id=box.id) }}">导出打标CSV</a>
{% include '_account_menu.html' %}
</nav>
</header>

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>修改密码</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="hero slim">
<div>
<h1>修改密码</h1>
<p>当前用户:{{ auth_username }}</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
<a class="btn btn-light" href="{{ url_for('logout_page') }}">退出登录</a>
</div>
</header>
<main class="container">
<section class="panel" style="max-width: 560px; margin: 0 auto;">
{% if error %}
<p class="alert">{{ error }}</p>
{% endif %}
{% if notice %}
<p class="notice">{{ notice }}</p>
{% endif %}
<form class="form-grid" method="post" action="{{ url_for('change_password_page') }}">
<label>
当前密码
<input type="password" name="current_password" required autocomplete="current-password" placeholder="请输入当前密码">
</label>
<label>
新密码至少8位
<input type="password" name="new_password" required minlength="8" autocomplete="new-password" placeholder="请输入新密码">
</label>
<label>
确认新密码
<input type="password" name="confirm_password" required minlength="8" autocomplete="new-password" placeholder="请再次输入新密码">
</label>
<div class="actions full">
<button class="btn" type="submit">保存新密码</button>
</div>
</form>
</section>
</main>
</body>
</html>

View File

@@ -16,6 +16,7 @@
<a class="btn btn-light" href="{{ url_for('search_page', q=search_query) if search_query else url_for('search_page') }}">返回快速搜索</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
<a class="btn btn-light" href="{{ url_for('view_box', box_id=box.id) }}">返回宫格</a>
{% include '_account_menu.html' %}
</div>
</header>

View File

@@ -17,6 +17,7 @@
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
<a class="btn btn-light" href="#quick-add">新增库存</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
{% include '_account_menu.html' %}
</div>
</header>

42
templates/login.html Normal file
View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统登录</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header class="hero slim">
<div>
<h1>库存系统登录</h1>
<p>请输入用户名和密码后继续使用系统</p>
</div>
</header>
<main class="container">
<section class="panel" style="max-width: 520px; margin: 0 auto;">
{% if error %}
<p class="alert">{{ error }}</p>
{% endif %}
{% if notice %}
<p class="notice">{{ notice }}</p>
{% endif %}
<form class="form-grid" method="post" action="{{ url_for('login_page') }}">
<input type="hidden" name="next" value="{{ next_path or '' }}">
<label>
用户名
<input type="text" name="username" required autocomplete="username" placeholder="请输入用户名">
</label>
<label>
密码
<input type="password" name="password" required autocomplete="current-password" placeholder="请输入密码">
</label>
<div class="actions full">
<button class="btn" type="submit">登录</button>
</div>
</form>
</section>
</main>
</body>
</html>

View File

@@ -15,6 +15,7 @@
<nav class="hero-actions">
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
{% include '_account_menu.html' %}
</nav>
</header>

View File

@@ -15,6 +15,7 @@
<nav class="hero-actions">
<a class="btn btn-light" href="{{ url_for('index') }}">返回首页</a>
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
{% include '_account_menu.html' %}
</nav>
</header>

View File

@@ -14,6 +14,7 @@
</div>
<div class="hero-actions">
<a class="btn btn-light" href="{{ url_for('types_page') }}">返回仓库概览</a>
{% include '_account_menu.html' %}
</div>
</header>

View File

@@ -16,6 +16,7 @@
<a class="btn" href="{{ url_for('type_page', box_type='custom') }}#quick-add">添加容器</a>
<a class="btn btn-light" href="{{ url_for('search_page') }}">快速搜索</a>
<a class="btn btn-light" href="{{ url_for('stats_page') }}">统计页</a>
{% include '_account_menu.html' %}
</div>
</header>