公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、 PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。 图片相关: - 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张 - 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG - 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷 - 详情页缩略图宫格与全屏 lightbox,公开看板同样可见 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
115 lines
3.9 KiB
Python
115 lines
3.9 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .config import settings
|
|
from .models import EntrySession, LoginSession, User
|
|
|
|
|
|
PBKDF2_ITERATIONS = 310_000
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
salt = secrets.token_bytes(16)
|
|
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
|
|
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
|
|
|
|
|
|
def verify_password(password: str, encoded: str) -> bool:
|
|
try:
|
|
algorithm, iterations, salt, expected = encoded.split("$", 3)
|
|
if algorithm != "pbkdf2_sha256":
|
|
return False
|
|
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iterations))
|
|
return hmac.compare_digest(actual.hex(), expected)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
def validate_password(password: str) -> str | None:
|
|
if len(password) < 10:
|
|
return "密码至少需要 10 个字符"
|
|
if not any(c.isalpha() for c in password) or not any(c.isdigit() for c in password):
|
|
return "密码必须同时包含字母和数字"
|
|
return None
|
|
|
|
|
|
def token_hash(token: str) -> str:
|
|
return hashlib.sha256((settings.secret + token).encode()).hexdigest()
|
|
|
|
|
|
def entry_key_hash(key: str) -> str:
|
|
return hmac.new(settings.secret.encode(), f"entry-key:{key}".encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def create_session(db: Session, user: User, ip: str | None, user_agent: str | None) -> str:
|
|
token = secrets.token_urlsafe(32)
|
|
expires = datetime.now().astimezone() + timedelta(hours=settings.session_hours)
|
|
db.add(LoginSession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip, user_agent=(user_agent or "")[:300]))
|
|
db.commit()
|
|
return token
|
|
|
|
|
|
def get_session_user(db: Session, token: str | None) -> User | None:
|
|
if not token:
|
|
return None
|
|
current = datetime.now().astimezone()
|
|
session = db.scalar(select(LoginSession).where(LoginSession.token_hash == token_hash(token)))
|
|
if not session:
|
|
return None
|
|
expires = session.expires_at
|
|
if expires.tzinfo is None:
|
|
expires = expires.replace(tzinfo=current.tzinfo)
|
|
if expires <= current or not session.user.is_active:
|
|
db.delete(session)
|
|
db.commit()
|
|
return None
|
|
return session.user
|
|
|
|
|
|
def revoke_session(db: Session, token: str | None) -> None:
|
|
if token:
|
|
db.execute(delete(LoginSession).where(LoginSession.token_hash == token_hash(token)))
|
|
db.commit()
|
|
|
|
|
|
def create_entry_session(db: Session, user: User, ip: str | None) -> str:
|
|
token = secrets.token_urlsafe(32)
|
|
expires = datetime.now().astimezone() + timedelta(minutes=30)
|
|
db.add(EntrySession(token_hash=token_hash(token), user_id=user.id, expires_at=expires, ip_address=ip))
|
|
db.commit()
|
|
return token
|
|
|
|
|
|
def get_entry_session_user(db: Session, token: str | None) -> User | None:
|
|
if not token:
|
|
return None
|
|
current = datetime.now().astimezone()
|
|
session = db.scalar(select(EntrySession).where(EntrySession.token_hash == token_hash(token)))
|
|
if not session:
|
|
return None
|
|
expires = session.expires_at
|
|
if expires.tzinfo is None:
|
|
expires = expires.replace(tzinfo=current.tzinfo)
|
|
if expires <= current or not session.user.is_active or session.user.role not in {"admin", "uploader"} or not session.user.entry_key_hash:
|
|
db.delete(session)
|
|
db.commit()
|
|
return None
|
|
return session.user
|
|
|
|
|
|
def revoke_entry_session(db: Session, token: str | None) -> None:
|
|
if token:
|
|
db.execute(delete(EntrySession).where(EntrySession.token_hash == token_hash(token)))
|
|
db.commit()
|
|
|
|
|
|
def revoke_user_entry_sessions(db: Session, user_id: int) -> None:
|
|
db.execute(delete(EntrySession).where(EntrySession.user_id == user_id))
|