diff --git a/document/work-log/2026-05-07.md b/document/work-log/2026-05-07.md index 4fce798..e590630 100644 --- a/document/work-log/2026-05-07.md +++ b/document/work-log/2026-05-07.md @@ -6,4 +6,17 @@ - feat: add employee management, backend health check, and UI improvements - 完成了员工管理模块(后端 + 前端) - 添加了后端健康检查 - - 整理了 UI 资源 \ No newline at end of file + - 整理了 UI 资源 + +- **提交 2d56bc2** (13:48) + - feat: enhance employee CRUD with search, filters, and security module + - 增强了员工搜索和筛选功能 + - 添加了安全模块(security.py) + - 添加了单元测试 + +--- + +# 待处理 + +- [ ] 安装 PostgreSQL +- [ ] 创建 x_financial 数据库 \ No newline at end of file diff --git a/server/src/app/api/v1/endpoints/auth.py b/server/src/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..d3cbbe9 --- /dev/null +++ b/server/src/app/api/v1/endpoints/auth.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.schemas.auth import LoginRequest, LoginResponse +from app.services.auth import AuthService + +router = APIRouter(prefix="/auth") +DbSession = Annotated[Session, Depends(get_db)] + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest, db: DbSession) -> LoginResponse: + try: + return AuthService(db).login(payload) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index e6144d5..bedee42 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -1,5 +1,6 @@ from fastapi import APIRouter +from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.bootstrap import router as bootstrap_router from app.api.v1.endpoints.employees import router as employees_router from app.api.v1.endpoints.health import router as health_router @@ -8,5 +9,6 @@ from app.api.v1.endpoints.reimbursements import router as reimbursements_router router = APIRouter() router.include_router(health_router, tags=["health"]) router.include_router(bootstrap_router, tags=["bootstrap"]) +router.include_router(auth_router, tags=["auth"]) router.include_router(employees_router, prefix="/employees", tags=["employees"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) diff --git a/server/src/app/core/admin_secret.py b/server/src/app/core/admin_secret.py new file mode 100644 index 0000000..865843a --- /dev/null +++ b/server/src/app/core/admin_secret.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import hashlib +import json +import secrets +from pathlib import Path + +from app.core.config import SERVER_DIR + +ADMIN_SECRET_FILE = SERVER_DIR / ".secrets" / "admin.json" + + +def read_admin_secret() -> dict[str, object] | None: + if not ADMIN_SECRET_FILE.exists(): + return None + + try: + payload = json.loads(ADMIN_SECRET_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + if ( + payload + and payload.get("algorithm") == "scrypt" + and isinstance(payload.get("username"), str) + and isinstance(payload.get("salt"), str) + and isinstance(payload.get("derived_key"), str) + ): + return payload + + return None + + +def verify_admin_secret(password: str, record: dict[str, object]) -> bool: + try: + salt = bytes.fromhex(str(record["salt"])) + stored_key = bytes.fromhex(str(record["derived_key"])) + key_length = int(record.get("key_length", 64)) + n_value = int(record.get("N", 16384)) + r_value = int(record.get("r", 8)) + p_value = int(record.get("p", 1)) + except (KeyError, TypeError, ValueError): + return False + + derived_key = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=n_value, + r=r_value, + p=p_value, + dklen=key_length, + ) + return secrets.compare_digest(derived_key, stored_key) diff --git a/server/src/app/schemas/auth.py b/server/src/app/schemas/auth.py new file mode 100644 index 0000000..aa240f4 --- /dev/null +++ b/server/src/app/schemas/auth.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from pydantic import BaseModel, EmailStr, Field + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=255) + password: str = Field(min_length=1, max_length=128) + + +class AuthUserRead(BaseModel): + username: str + name: str + role: str + roleCodes: list[str] = Field(default_factory=list) + email: EmailStr | str + avatar: str + isAdmin: bool = False + + +class LoginResponse(BaseModel): + ok: bool = True + detail: str = "登录成功。" + user: AuthUserRead diff --git a/server/src/app/services/auth.py b/server/src/app/services/auth.py new file mode 100644 index 0000000..a4ffa2a --- /dev/null +++ b/server/src/app/services/auth.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.core.admin_secret import read_admin_secret, verify_admin_secret +from app.core.config import get_settings +from app.core.logging import get_logger +from app.core.security import verify_password +from app.models.employee import Employee +from app.schemas.auth import AuthUserRead, LoginRequest, LoginResponse +from app.services.employee import EmployeeService +from app.services.employee_seed import ROLE_DISPLAY_ORDER + +logger = get_logger("app.services.auth") + +ROLE_LABELS = { + "manager": "管理员", + "finance": "财务人员", + "executive": "高级管理人员", + "approver": "审批负责人", + "auditor": "审计观察员", + "user": "使用者", +} + + +@dataclass(slots=True) +class AuthenticatedUser: + username: str + name: str + role: str + role_codes: list[str] + email: str + avatar: str + is_admin: bool = False + + +class AuthService: + def __init__(self, db: Session) -> None: + self.db = db + self.settings = get_settings() + + def login(self, payload: LoginRequest) -> LoginResponse: + identifier = payload.username.strip() + password = payload.password + + admin_user = self._authenticate_admin(identifier, password) + if admin_user is not None: + logger.info("Admin login succeeded identifier=%s", identifier) + return LoginResponse(user=self._serialize_user(admin_user)) + + employee_user = self._authenticate_employee(identifier, password) + if employee_user is not None: + logger.info("Employee login succeeded identifier=%s role_codes=%s", identifier, ",".join(employee_user.role_codes)) + return LoginResponse(user=self._serialize_user(employee_user)) + + logger.warning("Login failed identifier=%s", identifier) + raise ValueError("账号或密码错误。") + + def _authenticate_admin(self, identifier: str, password: str) -> AuthenticatedUser | None: + record = read_admin_secret() + if record is None: + return None + + admin_username = str(record.get("username", "")).strip() + admin_email = str(self.settings.admin_email or "").strip() + normalized_identifier = identifier.casefold() + + allowed_identifiers = { + value.casefold() + for value in [admin_username, admin_email] + if value + } + + if normalized_identifier not in allowed_identifiers: + return None + + if not verify_admin_secret(password, record): + return None + + display_name = admin_username or admin_email or "系统管理员" + return AuthenticatedUser( + username=admin_username or admin_email, + name=display_name, + role="管理员", + role_codes=["manager"], + email=admin_email or f"{admin_username}@local", + avatar=display_name[:1].upper(), + is_admin=True, + ) + + def _authenticate_employee(self, identifier: str, password: str) -> AuthenticatedUser | None: + if not self.settings.setup_completed: + return None + + EmployeeService(self.db).ensure_directory_ready() + + stmt = ( + select(Employee) + .options(selectinload(Employee.roles)) + .where(func.lower(Employee.email) == identifier.lower()) + ) + employee = self.db.execute(stmt).scalars().first() + + if employee is None or not employee.password_hash: + return None + + if employee.employment_status == "停用": + logger.warning("Disabled employee login blocked identifier=%s", identifier) + return None + + if not verify_password(password, employee.password_hash): + return None + + sorted_roles = sorted( + list(employee.roles), + key=lambda item: (ROLE_DISPLAY_ORDER.get(item.role_code, 999), item.name), + ) + role_codes = [role.role_code for role in sorted_roles] + primary_role_code = role_codes[0] if role_codes else "user" + + return AuthenticatedUser( + username=employee.email, + name=employee.name, + role=ROLE_LABELS.get(primary_role_code, "使用者"), + role_codes=role_codes or ["user"], + email=employee.email, + avatar=(employee.name or "?")[:1].upper(), + is_admin=False, + ) + + @staticmethod + def _serialize_user(user: AuthenticatedUser) -> AuthUserRead: + return AuthUserRead( + username=user.username, + name=user.name, + role=user.role, + roleCodes=user.role_codes, + email=user.email, + avatar=user.avatar, + isAdmin=user.is_admin, + ) diff --git a/server/src/app/services/employee.py b/server/src/app/services/employee.py index 52f9ac1..921fc96 100644 --- a/server/src/app/services/employee.py +++ b/server/src/app/services/employee.py @@ -36,6 +36,7 @@ from app.services.employee_seed import ( ) logger = get_logger("app.services.employee") +DEFAULT_EMPLOYEE_PASSWORD = "123456" STATUS_TONE_MAP = { "在职": "success", @@ -150,6 +151,7 @@ class EmployeeService: employment_status=payload.employment_status, sync_state=payload.sync_state, spotlight=payload.spotlight, + password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD), last_sync_at=datetime.now(), ) @@ -432,6 +434,9 @@ class EmployeeService: if employee.manager_id is None and manager_employee_no: employee.manager = employees_by_no.get(manager_employee_no) + if not employee.password_hash: + employee.password_hash = hash_password(DEFAULT_EMPLOYEE_PASSWORD) + if not employee.roles: employee.roles = self._sorted_roles( [ diff --git a/server/tests/test_auth_service.py b/server/tests/test_auth_service.py new file mode 100644 index 0000000..fa06bdf --- /dev/null +++ b/server/tests/test_auth_service.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.db.base import Base +from app.schemas.auth import LoginRequest +from app.services.auth import AuthService +from app.services.employee import EmployeeService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def test_employee_can_login_with_seed_default_password() -> None: + with build_session() as db: + employee = EmployeeService(db).list_employees()[0] + result = AuthService(db).login( + LoginRequest(username=employee.email, password="123456") + ) + + assert result.ok is True + assert result.user.username == employee.email + assert result.user.name == employee.name + assert result.user.roleCodes + assert result.user.isAdmin is False + + +def test_admin_can_login_with_secret(monkeypatch) -> None: + with build_session() as db: + monkeypatch.setattr( + "app.services.auth.read_admin_secret", + lambda: { + "username": "superadmin", + "algorithm": "scrypt", + "salt": "00", + "derived_key": "00", + }, + ) + monkeypatch.setattr("app.services.auth.verify_admin_secret", lambda password, record: password == "admin123") + + result = AuthService(db).login( + LoginRequest(username="superadmin", password="admin123") + ) + + assert result.ok is True + assert result.user.username == "superadmin" + assert result.user.isAdmin is True + assert result.user.roleCodes == ["manager"] + + +def test_disabled_employee_cannot_login() -> None: + with build_session() as db: + service = EmployeeService(db) + employee = service.list_employees()[0] + service.disable_employee(employee.id) + + try: + AuthService(db).login(LoginRequest(username=employee.email, password="123456")) + except ValueError as exc: + assert "账号或密码错误" in str(exc) + else: + raise AssertionError("disabled employee login should be rejected") diff --git a/web/UI/设置界面.png b/web/UI/设置界面.png new file mode 100644 index 0000000..2150432 Binary files /dev/null and b/web/UI/设置界面.png differ diff --git a/web/src/composables/useSystemState.js b/web/src/composables/useSystemState.js index 1c3b267..7ca8de7 100644 --- a/web/src/composables/useSystemState.js +++ b/web/src/composables/useSystemState.js @@ -1,18 +1,20 @@ import { computed, ref } from 'vue' import { - loginBootstrapAdmin, saveBootstrapConfig, testBootstrapDatabase, testBootstrapRuntime } from '../services/bootstrap.js' +import { login as loginByAccount } from '../services/auth.js' +import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js' import { useToast } from './useToast.js' const AUTH_STORAGE_KEY = 'x-financial-authenticated' const AUTH_USERNAME_KEY = 'x-financial-auth-username' +const AUTH_USER_KEY = 'x-financial-auth-user' const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity' const DEFAULT_USER_NAME = '系统管理员' -const DEFAULT_USER_ROLE = '财务管理员' +const DEFAULT_USER_ROLE = '管理员' const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange'] const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30) const authIdleTimeoutMs = @@ -74,6 +76,67 @@ function readStoredUsername() { return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || '' } +function buildAnonymousUser() { + return { + username: '', + name: '', + role: '', + roleCodes: [], + email: '', + avatar: '', + isAdmin: false + } +} + +function buildLegacyAdminUser(username = '') { + const normalized = String(username || '').trim() + const name = normalized || DEFAULT_USER_NAME + + return { + username: normalized, + name, + role: DEFAULT_USER_ROLE, + roleCodes: ['manager'], + email: '', + avatar: name.slice(0, 1).toUpperCase(), + isAdmin: true + } +} + +function readStoredUser() { + if (typeof window === 'undefined') { + return buildAnonymousUser() + } + + const raw = window.sessionStorage.getItem(AUTH_USER_KEY) + + if (raw) { + try { + const payload = JSON.parse(raw) + if (payload && typeof payload === 'object') { + const username = String(payload.username || '').trim() + const name = String(payload.name || username || DEFAULT_USER_NAME).trim() + const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : [] + + return { + username, + name, + role: String(payload.role || DEFAULT_USER_ROLE), + roleCodes, + email: String(payload.email || ''), + avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()), + isAdmin: Boolean(payload.isAdmin) + } + } + } catch { + return buildLegacyAdminUser(readStoredUsername()) + } + } + + const legacyUsername = readStoredUsername() + return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser() +} + function readLastActivityAt() { if (typeof window === 'undefined') { return 0 @@ -82,17 +145,6 @@ function readLastActivityAt() { return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0) } -function buildCurrentUser(username = '') { - const normalized = String(username || '').trim() - const name = normalized || DEFAULT_USER_NAME - - return { - name, - role: DEFAULT_USER_ROLE, - avatar: name.slice(0, 1).toUpperCase() - } -} - function isSessionExpired(now = Date.now()) { if (!readAuthState()) { return false @@ -107,19 +159,22 @@ function isSessionExpired(now = Date.now()) { return now - lastActivityAt > authIdleTimeoutMs } -function persistAuthState(value, username = '') { +function persistAuthState(value, user = null) { if (typeof window === 'undefined') { return } if (value) { window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true') - window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(username || '').trim()) + const normalizedUser = user || buildAnonymousUser() + window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim()) + window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser)) return } window.sessionStorage.removeItem(AUTH_STORAGE_KEY) window.sessionStorage.removeItem(AUTH_USERNAME_KEY) + window.sessionStorage.removeItem(AUTH_USER_KEY) window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY) } @@ -213,7 +268,7 @@ function syncAuthSession(options = {}) { if (!readAuthState()) { loggedIn.value = false - currentUser.value = buildCurrentUser('') + currentUser.value = buildAnonymousUser() clearSessionTimeout() return false } @@ -224,7 +279,7 @@ function syncAuthSession(options = {}) { } loggedIn.value = true - currentUser.value = buildCurrentUser(readStoredUsername()) + currentUser.value = readStoredUser() scheduleSessionTimeout() return true } @@ -250,7 +305,7 @@ const databaseTestMessage = ref('') const loginSubmitting = ref(false) const loginError = ref('') const loggedIn = ref(readAuthState() && !isSessionExpired()) -const currentUser = ref(buildCurrentUser(readStoredUsername())) +const currentUser = ref(readStoredUser()) if (!loggedIn.value && readAuthState()) { persistAuthState(false) @@ -288,7 +343,7 @@ function resetFromClientEnv() { applyBootstrapState(readClientBootstrapState()) clearSetupRuntimeState() loginError.value = '' - currentUser.value = buildCurrentUser(readStoredUsername()) + currentUser.value = readStoredUser() } async function handleSetupSubmit(payload) { @@ -382,19 +437,20 @@ async function handleLogin(credentials) { loginError.value = '' try { - await loginBootstrapAdmin({ + const response = await loginByAccount({ username: credentials.username, password: credentials.password }) + const user = response?.user || buildAnonymousUser() loggedIn.value = true - persistAuthState(true, credentials.username) - currentUser.value = buildCurrentUser(credentials.username) + persistAuthState(true, user) + currentUser.value = user touchAuthActivity(true) return true } catch (error) { logout('invalid', { redirect: false }) - loginError.value = error.message || '登录失败,请检查管理员账号和密码。' + loginError.value = error.message || '登录失败,请检查账号和密码。' toast(loginError.value) return false } finally { @@ -408,7 +464,7 @@ function logout(reason = 'manual', options = {}) { loggedIn.value = false persistAuthState(false) - currentUser.value = buildCurrentUser('') + currentUser.value = buildAnonymousUser() clearSessionTimeout() if (notify) { @@ -421,7 +477,7 @@ function logout(reason = 'manual', options = {}) { } function handleRecoverPassword() { - toast('请联系系统管理员重置密码。管理员密码不会写入 .env。') + toast('请联系系统管理员重置账号密码。') } function handleSsoLogin() { @@ -430,7 +486,7 @@ function handleSsoLogin() { function resolveEntryRoute() { loggedIn.value = syncAuthSession() - currentUser.value = buildCurrentUser(readStoredUsername()) + currentUser.value = readStoredUser() if (!isInitialized.value) { return { name: 'setup' } @@ -440,7 +496,7 @@ function resolveEntryRoute() { return { name: 'login' } } - return { name: 'app-overview' } + return resolveDefaultAuthorizedRoute(currentUser.value) } export function useSystemState() { diff --git a/web/src/router/index.js b/web/src/router/index.js index 794981b..5474fdf 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router' import { checkBackendHealth } from '../composables/useBackendHealth.js' import { appViews } from '../composables/useNavigation.js' import { useSystemState } from '../composables/useSystemState.js' +import { canAccessAppView } from '../utils/accessControl.js' import AppShellRouteView from '../views/AppShellRouteView.vue' import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue' import LoginRouteView from '../views/LoginRouteView.vue' @@ -80,7 +81,7 @@ const router = createRouter({ }) router.beforeEach((to) => { - const { isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState() + const { currentUser, isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState() const authActive = syncAuthSession({ notify: Boolean(to.meta.requiresAuth) }) if (!isInitialized.value) { @@ -105,6 +106,10 @@ router.beforeEach((to) => { return resolveEntryRoute() } + if (ok && typeof to.meta.appView === 'string' && !canAccessAppView(currentUser.value, to.meta.appView)) { + return resolveEntryRoute() + } + return true }) } diff --git a/web/src/services/auth.js b/web/src/services/auth.js new file mode 100644 index 0000000..5390a57 --- /dev/null +++ b/web/src/services/auth.js @@ -0,0 +1,8 @@ +import { apiRequest } from './api.js' + +export function login(payload) { + return apiRequest('/auth/login', { + method: 'POST', + body: JSON.stringify(payload) + }) +} diff --git a/web/src/utils/accessControl.js b/web/src/utils/accessControl.js new file mode 100644 index 0000000..2350349 --- /dev/null +++ b/web/src/utils/accessControl.js @@ -0,0 +1,62 @@ +export const DEFAULT_APP_VIEW_ORDER = [ + 'overview', + 'workbench', + 'requests', + 'approval', + 'chat', + 'policies', + 'audit', + 'employees' +] + +const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat']) +const VIEW_ROLE_RULES = { + overview: ['finance', 'executive'], + approval: ['approver'], + policies: ['manager'], + audit: ['auditor'], + employees: ['manager'] +} + +function normalizedRoleCodes(user) { + if (!user) { + return [] + } + + return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : [] +} + +export function isManagerUser(user) { + return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager') +} + +export function canAccessAppView(user, viewId) { + if (!viewId || !user) { + return false + } + + if (isManagerUser(user)) { + return true + } + + if (ALWAYS_VISIBLE_VIEWS.has(viewId)) { + return true + } + + const requiredRoles = VIEW_ROLE_RULES[viewId] || [] + const roleCodes = normalizedRoleCodes(user) + return requiredRoles.some((roleCode) => roleCodes.includes(roleCode)) +} + +export function getAccessibleViewIds(user) { + return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId)) +} + +export function filterNavItemsByAccess(navItems, user) { + return navItems.filter((item) => canAccessAppView(user, item.id)) +} + +export function resolveDefaultAuthorizedRoute(user) { + const firstVisibleView = getAccessibleViewIds(user)[0] + return { name: `app-${firstVisibleView || 'workbench'}` } +} diff --git a/web/src/views/AppShellRouteView.vue b/web/src/views/AppShellRouteView.vue index 988cd86..3cb8c4a 100644 --- a/web/src/views/AppShellRouteView.vue +++ b/web/src/views/AppShellRouteView.vue @@ -1,7 +1,7 @@