build(security): add migration and CI security gates
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import base64
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from defusedxml import ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
@@ -38,17 +40,20 @@ class BindConfirmRequest(BaseModel):
|
||||
def _verify_signature(token: str, timestamp: str, nonce: str, encrypted: str, signature: str) -> bool:
|
||||
items = sorted([token, timestamp, nonce, encrypted])
|
||||
raw = "".join(items)
|
||||
return hashlib.sha1(raw.encode()).hexdigest() == signature
|
||||
# The WeCom callback protocol mandates SHA-1; compare_digest avoids timing leaks.
|
||||
expected = hashlib.sha1(raw.encode(), usedforsecurity=False).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _decrypt_msg(encrypted: str) -> str:
|
||||
"""Decrypt WeChat Work callback message. Returns plaintext XML."""
|
||||
key = base64.b64decode(settings.WECOM_ENCODING_AES_KEY + "=")
|
||||
ciphertext = base64.b64decode(encrypted)
|
||||
from Crypto.Cipher import AES
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=key[:16])
|
||||
plaintext = cipher.decrypt(ciphertext)
|
||||
decryptor = Cipher(algorithms.AES(key), modes.CBC(key[:16])).decryptor()
|
||||
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
pad_len = plaintext[-1]
|
||||
if pad_len < 1 or pad_len > 32 or plaintext[-pad_len:] != bytes([pad_len]) * pad_len:
|
||||
raise ValueError("Invalid callback padding")
|
||||
plaintext = plaintext[:-pad_len]
|
||||
msg_len = struct.unpack(">I", plaintext[16:20])[0]
|
||||
return plaintext[20:20 + msg_len].decode("utf-8")
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
# App
|
||||
APP_NAME: str = "企迹-政企周报管理系统"
|
||||
ENVIRONMENT: str = "production"
|
||||
@@ -49,11 +50,6 @@ class Settings(BaseSettings):
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
|
||||
+1
-66
@@ -3,7 +3,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
from app.config import settings, validate_security_settings
|
||||
from app.database import engine, Base, async_session
|
||||
from app.database import engine, async_session
|
||||
from app.api import router as api_router
|
||||
from app.api import auth, users, customers, visits, work_plans, mini_business, key_visits
|
||||
from app.api import dashboard, upload, export, import_data, wecom, daily_notes, ai_summary, system_config, leaves
|
||||
@@ -15,71 +15,6 @@ from app.services.scheduler_manager import start_scheduler, shutdown_scheduler
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
validate_security_settings()
|
||||
# Startup: create tables if not exists (for dev convenience)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns that may be missing from older tables
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS remarks TEXT DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_name VARCHAR(50) DEFAULT ''"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS visitor_phone VARCHAR(20) DEFAULT ''"
|
||||
))
|
||||
# edit_log columns for change tracking
|
||||
for tbl in ["visits", "daily_notes", "work_plans", "mini_business", "key_visits"]:
|
||||
await conn.run_sync(lambda c, t=tbl: c.exec_driver_sql(
|
||||
f"ALTER TABLE {t} ADD COLUMN IF NOT EXISTS edit_log JSONB DEFAULT '[]'"
|
||||
))
|
||||
# New columns for v0.3
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS require_report BOOLEAN DEFAULT TRUE"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_date DATE"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE customers ADD COLUMN IF NOT EXISTS last_visit_manager_id UUID"
|
||||
))
|
||||
# New columns for v0.4
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE visits ADD COLUMN IF NOT EXISTS companion_names TEXT[] DEFAULT '{}'"
|
||||
))
|
||||
# v0.6 — manager color tag
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS color VARCHAR(7)"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_wecom_userid "
|
||||
"ON users (wecom_userid) WHERE wecom_userid IS NOT NULL"
|
||||
))
|
||||
# system_config table for v0.5
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE TABLE IF NOT EXISTS system_config (key VARCHAR(64) PRIMARY KEY, value TEXT DEFAULT '')"
|
||||
))
|
||||
# leaves table for v0.7
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE TABLE IF NOT EXISTS leaves ("
|
||||
" id UUID PRIMARY KEY DEFAULT gen_random_uuid(),"
|
||||
" manager_id UUID NOT NULL REFERENCES users(id),"
|
||||
" leave_type VARCHAR(20) NOT NULL DEFAULT '事假',"
|
||||
" start_date DATE NOT NULL,"
|
||||
" end_date DATE NOT NULL,"
|
||||
" reason VARCHAR(500) DEFAULT '',"
|
||||
" submitted_by UUID NOT NULL REFERENCES users(id),"
|
||||
" created_at TIMESTAMPTZ DEFAULT now(),"
|
||||
" updated_at TIMESTAMPTZ DEFAULT now()"
|
||||
")"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leaves_manager_id ON leaves(manager_id)"
|
||||
))
|
||||
await conn.run_sync(lambda c: c.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leaves_dates ON leaves(start_date, end_date)"
|
||||
))
|
||||
|
||||
# Read notification_time from DB (or use default 17:30)
|
||||
notification_hour, notification_minute = 17, 30
|
||||
async with async_session() as db:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -91,14 +92,14 @@ class WecomClient:
|
||||
|
||||
if errcode == 45009:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(1 * (attempt + 1))
|
||||
await asyncio.sleep(1 * (attempt + 1))
|
||||
continue
|
||||
|
||||
last_error = data
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||
last_error = {"errcode": -1, "errmsg": str(e)}
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
await asyncio.sleep(0.5 * (attempt + 1))
|
||||
|
||||
raise Exception(f"WeCom API error after {max_retries} retries: {last_error}")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import jwt, JWTError
|
||||
import jwt
|
||||
from jwt import InvalidTokenError
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@@ -15,5 +16,5 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user