Files
laser-data-hub/app/security.py
T
broccoliandClaude Fable 5 edfb216561 激光材料数据平台 1.3.0
公开数据看板、四位 Key 录入面板、管理后台与三级权限、动态字段配置、
PostgreSQL/SQLite 双支持、数据库备份,以及本版新增的成品图上传与预览。

图片相关:
- 录入表单支持相册选图与手机端直接拍照,每条记录最多 6 张
- 浏览器内压缩到最长边 1600 并剥离 EXIF,入库统一为 JPG/PNG
- 二进制直接入库,现有备份自动覆盖图片,部署无需新增卷
- 详情页缩略图宫格与全屏 lightbox,公开看板同样可见

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 22:06:02 +08:00

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))