feat: add auth module with login and access control
This commit is contained in:
@@ -7,3 +7,16 @@
|
|||||||
- 完成了员工管理模块(后端 + 前端)
|
- 完成了员工管理模块(后端 + 前端)
|
||||||
- 添加了后端健康检查
|
- 添加了后端健康检查
|
||||||
- 整理了 UI 资源
|
- 整理了 UI 资源
|
||||||
|
|
||||||
|
- **提交 2d56bc2** (13:48)
|
||||||
|
- feat: enhance employee CRUD with search, filters, and security module
|
||||||
|
- 增强了员工搜索和筛选功能
|
||||||
|
- 添加了安全模块(security.py)
|
||||||
|
- 添加了单元测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 待处理
|
||||||
|
|
||||||
|
- [ ] 安装 PostgreSQL
|
||||||
|
- [ ] 创建 x_financial 数据库
|
||||||
21
server/src/app/api/v1/endpoints/auth.py
Normal file
21
server/src/app/api/v1/endpoints/auth.py
Normal file
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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.bootstrap import router as bootstrap_router
|
||||||
from app.api.v1.endpoints.employees import router as employees_router
|
from app.api.v1.endpoints.employees import router as employees_router
|
||||||
from app.api.v1.endpoints.health import router as health_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 = APIRouter()
|
||||||
router.include_router(health_router, tags=["health"])
|
router.include_router(health_router, tags=["health"])
|
||||||
router.include_router(bootstrap_router, tags=["bootstrap"])
|
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(employees_router, prefix="/employees", tags=["employees"])
|
||||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||||
|
|||||||
53
server/src/app/core/admin_secret.py
Normal file
53
server/src/app/core/admin_secret.py
Normal file
@@ -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)
|
||||||
24
server/src/app/schemas/auth.py
Normal file
24
server/src/app/schemas/auth.py
Normal file
@@ -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
|
||||||
144
server/src/app/services/auth.py
Normal file
144
server/src/app/services/auth.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -36,6 +36,7 @@ from app.services.employee_seed import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger("app.services.employee")
|
logger = get_logger("app.services.employee")
|
||||||
|
DEFAULT_EMPLOYEE_PASSWORD = "123456"
|
||||||
|
|
||||||
STATUS_TONE_MAP = {
|
STATUS_TONE_MAP = {
|
||||||
"在职": "success",
|
"在职": "success",
|
||||||
@@ -150,6 +151,7 @@ class EmployeeService:
|
|||||||
employment_status=payload.employment_status,
|
employment_status=payload.employment_status,
|
||||||
sync_state=payload.sync_state,
|
sync_state=payload.sync_state,
|
||||||
spotlight=payload.spotlight,
|
spotlight=payload.spotlight,
|
||||||
|
password_hash=hash_password(DEFAULT_EMPLOYEE_PASSWORD),
|
||||||
last_sync_at=datetime.now(),
|
last_sync_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -432,6 +434,9 @@ class EmployeeService:
|
|||||||
if employee.manager_id is None and manager_employee_no:
|
if employee.manager_id is None and manager_employee_no:
|
||||||
employee.manager = employees_by_no.get(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:
|
if not employee.roles:
|
||||||
employee.roles = self._sorted_roles(
|
employee.roles = self._sorted_roles(
|
||||||
[
|
[
|
||||||
|
|||||||
72
server/tests/test_auth_service.py
Normal file
72
server/tests/test_auth_service.py
Normal file
@@ -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")
|
||||||
BIN
web/UI/设置界面.png
Normal file
BIN
web/UI/设置界面.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
@@ -1,18 +1,20 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
loginBootstrapAdmin,
|
|
||||||
saveBootstrapConfig,
|
saveBootstrapConfig,
|
||||||
testBootstrapDatabase,
|
testBootstrapDatabase,
|
||||||
testBootstrapRuntime
|
testBootstrapRuntime
|
||||||
} from '../services/bootstrap.js'
|
} from '../services/bootstrap.js'
|
||||||
|
import { login as loginByAccount } from '../services/auth.js'
|
||||||
|
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
|
||||||
import { useToast } from './useToast.js'
|
import { useToast } from './useToast.js'
|
||||||
|
|
||||||
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
|
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
|
||||||
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
|
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 AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
||||||
const DEFAULT_USER_NAME = '系统管理员'
|
const DEFAULT_USER_NAME = '系统管理员'
|
||||||
const DEFAULT_USER_ROLE = '财务管理员'
|
const DEFAULT_USER_ROLE = '管理员'
|
||||||
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
|
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
|
||||||
const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30)
|
const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30)
|
||||||
const authIdleTimeoutMs =
|
const authIdleTimeoutMs =
|
||||||
@@ -74,6 +76,67 @@ function readStoredUsername() {
|
|||||||
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
|
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() {
|
function readLastActivityAt() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return 0
|
return 0
|
||||||
@@ -82,17 +145,6 @@ function readLastActivityAt() {
|
|||||||
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
|
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()) {
|
function isSessionExpired(now = Date.now()) {
|
||||||
if (!readAuthState()) {
|
if (!readAuthState()) {
|
||||||
return false
|
return false
|
||||||
@@ -107,19 +159,22 @@ function isSessionExpired(now = Date.now()) {
|
|||||||
return now - lastActivityAt > authIdleTimeoutMs
|
return now - lastActivityAt > authIdleTimeoutMs
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistAuthState(value, username = '') {
|
function persistAuthState(value, user = null) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||||
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
||||||
|
window.sessionStorage.removeItem(AUTH_USER_KEY)
|
||||||
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +268,7 @@ function syncAuthSession(options = {}) {
|
|||||||
|
|
||||||
if (!readAuthState()) {
|
if (!readAuthState()) {
|
||||||
loggedIn.value = false
|
loggedIn.value = false
|
||||||
currentUser.value = buildCurrentUser('')
|
currentUser.value = buildAnonymousUser()
|
||||||
clearSessionTimeout()
|
clearSessionTimeout()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -224,7 +279,7 @@ function syncAuthSession(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loggedIn.value = true
|
loggedIn.value = true
|
||||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
currentUser.value = readStoredUser()
|
||||||
scheduleSessionTimeout()
|
scheduleSessionTimeout()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -250,7 +305,7 @@ const databaseTestMessage = ref('')
|
|||||||
const loginSubmitting = ref(false)
|
const loginSubmitting = ref(false)
|
||||||
const loginError = ref('')
|
const loginError = ref('')
|
||||||
const loggedIn = ref(readAuthState() && !isSessionExpired())
|
const loggedIn = ref(readAuthState() && !isSessionExpired())
|
||||||
const currentUser = ref(buildCurrentUser(readStoredUsername()))
|
const currentUser = ref(readStoredUser())
|
||||||
|
|
||||||
if (!loggedIn.value && readAuthState()) {
|
if (!loggedIn.value && readAuthState()) {
|
||||||
persistAuthState(false)
|
persistAuthState(false)
|
||||||
@@ -288,7 +343,7 @@ function resetFromClientEnv() {
|
|||||||
applyBootstrapState(readClientBootstrapState())
|
applyBootstrapState(readClientBootstrapState())
|
||||||
clearSetupRuntimeState()
|
clearSetupRuntimeState()
|
||||||
loginError.value = ''
|
loginError.value = ''
|
||||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
currentUser.value = readStoredUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSetupSubmit(payload) {
|
async function handleSetupSubmit(payload) {
|
||||||
@@ -382,19 +437,20 @@ async function handleLogin(credentials) {
|
|||||||
loginError.value = ''
|
loginError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loginBootstrapAdmin({
|
const response = await loginByAccount({
|
||||||
username: credentials.username,
|
username: credentials.username,
|
||||||
password: credentials.password
|
password: credentials.password
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const user = response?.user || buildAnonymousUser()
|
||||||
loggedIn.value = true
|
loggedIn.value = true
|
||||||
persistAuthState(true, credentials.username)
|
persistAuthState(true, user)
|
||||||
currentUser.value = buildCurrentUser(credentials.username)
|
currentUser.value = user
|
||||||
touchAuthActivity(true)
|
touchAuthActivity(true)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logout('invalid', { redirect: false })
|
logout('invalid', { redirect: false })
|
||||||
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
|
loginError.value = error.message || '登录失败,请检查账号和密码。'
|
||||||
toast(loginError.value)
|
toast(loginError.value)
|
||||||
return false
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
@@ -408,7 +464,7 @@ function logout(reason = 'manual', options = {}) {
|
|||||||
|
|
||||||
loggedIn.value = false
|
loggedIn.value = false
|
||||||
persistAuthState(false)
|
persistAuthState(false)
|
||||||
currentUser.value = buildCurrentUser('')
|
currentUser.value = buildAnonymousUser()
|
||||||
clearSessionTimeout()
|
clearSessionTimeout()
|
||||||
|
|
||||||
if (notify) {
|
if (notify) {
|
||||||
@@ -421,7 +477,7 @@ function logout(reason = 'manual', options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRecoverPassword() {
|
function handleRecoverPassword() {
|
||||||
toast('请联系系统管理员重置密码。管理员密码不会写入 .env。')
|
toast('请联系系统管理员重置账号密码。')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSsoLogin() {
|
function handleSsoLogin() {
|
||||||
@@ -430,7 +486,7 @@ function handleSsoLogin() {
|
|||||||
|
|
||||||
function resolveEntryRoute() {
|
function resolveEntryRoute() {
|
||||||
loggedIn.value = syncAuthSession()
|
loggedIn.value = syncAuthSession()
|
||||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
currentUser.value = readStoredUser()
|
||||||
|
|
||||||
if (!isInitialized.value) {
|
if (!isInitialized.value) {
|
||||||
return { name: 'setup' }
|
return { name: 'setup' }
|
||||||
@@ -440,7 +496,7 @@ function resolveEntryRoute() {
|
|||||||
return { name: 'login' }
|
return { name: 'login' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name: 'app-overview' }
|
return resolveDefaultAuthorizedRoute(currentUser.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSystemState() {
|
export function useSystemState() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import { checkBackendHealth } from '../composables/useBackendHealth.js'
|
import { checkBackendHealth } from '../composables/useBackendHealth.js'
|
||||||
import { appViews } from '../composables/useNavigation.js'
|
import { appViews } from '../composables/useNavigation.js'
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
|
import { canAccessAppView } from '../utils/accessControl.js'
|
||||||
import AppShellRouteView from '../views/AppShellRouteView.vue'
|
import AppShellRouteView from '../views/AppShellRouteView.vue'
|
||||||
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
|
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
|
||||||
import LoginRouteView from '../views/LoginRouteView.vue'
|
import LoginRouteView from '../views/LoginRouteView.vue'
|
||||||
@@ -80,7 +81,7 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
const { isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState()
|
const { currentUser, isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState()
|
||||||
const authActive = syncAuthSession({ notify: Boolean(to.meta.requiresAuth) })
|
const authActive = syncAuthSession({ notify: Boolean(to.meta.requiresAuth) })
|
||||||
|
|
||||||
if (!isInitialized.value) {
|
if (!isInitialized.value) {
|
||||||
@@ -105,6 +106,10 @@ router.beforeEach((to) => {
|
|||||||
return resolveEntryRoute()
|
return resolveEntryRoute()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ok && typeof to.meta.appView === 'string' && !canAccessAppView(currentUser.value, to.meta.appView)) {
|
||||||
|
return resolveEntryRoute()
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
8
web/src/services/auth.js
Normal file
8
web/src/services/auth.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { apiRequest } from './api.js'
|
||||||
|
|
||||||
|
export function login(payload) {
|
||||||
|
return apiRequest('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
62
web/src/utils/accessControl.js
Normal file
62
web/src/utils/accessControl.js
Normal file
@@ -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'}` }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<SidebarRail
|
<SidebarRail
|
||||||
:nav-items="navItems"
|
:nav-items="filteredNavItems"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:current-user="currentUser"
|
:current-user="currentUser"
|
||||||
@navigate="handleNavigate"
|
@navigate="handleNavigate"
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||||
import TopBar from '../components/layout/TopBar.vue'
|
import TopBar from '../components/layout/TopBar.vue'
|
||||||
@@ -142,6 +142,7 @@ import EmployeeManagementView from './EmployeeManagementView.vue'
|
|||||||
|
|
||||||
import { useAppShell } from '../composables/useAppShell.js'
|
import { useAppShell } from '../composables/useAppShell.js'
|
||||||
import { useSystemState } from '../composables/useSystemState.js'
|
import { useSystemState } from '../composables/useSystemState.js'
|
||||||
|
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||||
|
|
||||||
const employeeSummary = ref(null)
|
const employeeSummary = ref(null)
|
||||||
|
|
||||||
@@ -183,6 +184,7 @@ const {
|
|||||||
} = useAppShell()
|
} = useAppShell()
|
||||||
|
|
||||||
const { currentUser, logout } = useSystemState()
|
const { currentUser, logout } = useSystemState()
|
||||||
|
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
logout('manual')
|
logout('manual')
|
||||||
|
|||||||
@@ -71,14 +71,14 @@
|
|||||||
|
|
||||||
<header class="card-head">
|
<header class="card-head">
|
||||||
<h2>欢迎登录</h2>
|
<h2>欢迎登录</h2>
|
||||||
<p>使用初始化时创建的管理员账号进入系统</p>
|
<p>使用员工邮箱或管理员账号进入系统</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="sr-only">账号</span>
|
<span class="sr-only">账号</span>
|
||||||
<i class="mdi mdi-account-outline"></i>
|
<i class="mdi mdi-account-outline"></i>
|
||||||
<input v-model="username" type="text" placeholder="请输入管理员账号" autocomplete="username" required />
|
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="password"
|
v-model="password"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
placeholder="请输入管理员密码"
|
placeholder="请输入登录密码"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user