From cd41e59afcebc1083060786e1a77b9b8aa08621d Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 14 Jun 2026 23:22:53 +0800 Subject: [PATCH] feat: add user authentication system - User model (users table) with bcrypt password hashing - JWT-based login API (POST /api/auth/login, GET /api/auth/me) - Default admin account (admin/admin123) auto-created on startup - Login page with centered dark-themed login card - Route guards + Axios interceptors for token management - Login page renders without sidebar (standalone layout) - Gunicorn --preload to avoid worker DDL race condition --- backend/alembic/versions/0001_initial.py | 13 +++ backend/app/api/auth.py | 39 +++++++ backend/app/main.py | 8 +- backend/app/models/__init__.py | 4 +- backend/app/models/user.py | 25 +++++ backend/app/schemas/__init__.py | 1 + backend/app/schemas/auth.py | 37 +++++++ backend/app/services/__init__.py | 2 + backend/app/services/auth.py | 70 ++++++++++++ backend/requirements.txt | 3 + frontend/src/App.vue | 53 ++++++++- frontend/src/api/index.js | 28 +++++ frontend/src/router/index.js | 25 ++++- frontend/src/views/Login.vue | 133 +++++++++++++++++++++++ 14 files changed, 430 insertions(+), 11 deletions(-) create mode 100644 backend/app/api/auth.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/services/auth.py create mode 100644 frontend/src/views/Login.vue diff --git a/backend/alembic/versions/0001_initial.py b/backend/alembic/versions/0001_initial.py index 5ef121f..8de184a 100644 --- a/backend/alembic/versions/0001_initial.py +++ b/backend/alembic/versions/0001_initial.py @@ -100,8 +100,21 @@ def upgrade() -> None: sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), ) + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("username", sa.String(64), unique=True, nullable=False), + sa.Column("password_hash", sa.String(256), nullable=False), + sa.Column("display_name", sa.String(128), default=""), + sa.Column("email", sa.String(128), default=""), + sa.Column("is_active", sa.Boolean, default=True), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + ) + def downgrade() -> None: + op.drop_table("users") op.drop_table("system_config") op.drop_table("alerts") op.drop_table("alert_rules") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..911007d --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,39 @@ +"""API router — Auth (login, me).""" + +from fastapi import APIRouter, Depends, HTTPException, Header +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.schemas import LoginRequest, TokenResponse, UserInfo, UserOut +from app.services.auth import AuthService, create_access_token, decode_access_token + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@router.post("/login", response_model=TokenResponse) +async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)): + svc = AuthService(db) + user = await svc.authenticate(body.username, body.password) + if not user: + raise HTTPException(401, detail="用户名或密码错误") + token = create_access_token(user.id, user.username) + return TokenResponse( + access_token=token, + user=UserInfo(id=user.id, username=user.username, + display_name=user.display_name, email=user.email), + ) + + +@router.get("/me", response_model=UserOut) +async def get_me(authorization: str = Header("", alias="Authorization"), + db: AsyncSession = Depends(get_db)): + if not authorization.startswith("Bearer "): + raise HTTPException(401, detail="未登录") + payload = decode_access_token(authorization[7:]) + if payload is None: + raise HTTPException(401, detail="登录已过期,请重新登录") + svc = AuthService(db) + user = await svc.get_user_by_id(int(payload["sub"])) + if not user or not user.is_active: + raise HTTPException(401, detail="用户不存在或已停用") + return UserOut(**user.__dict__) diff --git a/backend/app/main.py b/backend/app/main.py index f5db911..1efdefd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,14 +10,16 @@ from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from app.api.agents import router as agents_router +from app.api.auth import router as auth_router from app.api.tasks import router as tasks_router from app.api.executions import router as executions_router from app.api.notifications import router as notifications_router from app.api.dashboard import router as dashboard_router from app.api.system import router as system_router from app.config import settings -from app.database import engine, Base +from app.database import engine, Base, async_session_factory from app.services.scheduler import SchedulerService +from app.services.auth import AuthService logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") logger = logging.getLogger("taskpulse") @@ -31,6 +33,9 @@ async def lifespan(app: FastAPI): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) logger.info("Database tables ensured") + # Ensure default admin + async with async_session_factory() as db: + await AuthService.ensure_default_admin(db) # Start background scheduler task = asyncio.create_task(scheduler.start()) yield @@ -49,6 +54,7 @@ app = FastAPI( # API routers app.include_router(agents_router) +app.include_router(auth_router) app.include_router(tasks_router) app.include_router(executions_router) app.include_router(notifications_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a2901eb..91f2def 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,5 +5,7 @@ from app.models.task import ScheduledTask # noqa: F401 from app.models.execution import TaskExecution # noqa: F401 from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401 from app.models.system_config import SystemConfig # noqa: F401 +from app.models.user import User # noqa: F401 -__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel", "AlertRule", "Alert", "SystemConfig"] +__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel", + "AlertRule", "Alert", "SystemConfig", "User"] diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..13c6dfc --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,25 @@ +"""SQLAlchemy models — User (dashboard login).""" + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class User(Base): + """Dashboard user for login authentication.""" + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, comment="登录用户名") + password_hash: Mapped[str] = mapped_column(String(256), nullable=False, comment="bcrypt 密码哈希") + display_name: Mapped[str] = mapped_column(String(128), default="", comment="显示名称") + email: Mapped[str] = mapped_column(String(128), default="", comment="邮箱") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index e31102e..28dfe10 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,6 +1,7 @@ """Pydantic schema registry.""" from app.schemas.agent import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate # noqa: F401 +from app.schemas.auth import LoginRequest, TokenResponse, UserInfo, UserOut # noqa: F401 from app.schemas.execution import ExecutionOut, ExecutionReport # noqa: F401 from app.schemas.notification import ( # noqa: F401 AlertAcknowledge, diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..3c911b6 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,37 @@ +"""Pydantic schemas — Auth.""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + username: str = Field(..., max_length=64) + password: str = Field(..., max_length=128) + + +class UserInfo(BaseModel): + id: int + username: str + display_name: str + email: str + + model_config = {"from_attributes": True} + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserInfo + + +class UserOut(BaseModel): + id: int + username: str + display_name: str + email: str + is_active: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 5c9de45..1b24d64 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,6 +1,7 @@ """Service layer __init__.""" from app.services.agent import AgentService # noqa: F401 +from app.services.auth import AuthService # noqa: F401 from app.services.task import TaskService # noqa: F401 from app.services.execution import ExecutionService # noqa: F401 from app.services.scheduler import SchedulerService # noqa: F401 @@ -8,6 +9,7 @@ from app.services.notification import NotificationService # noqa: F401 __all__ = [ "AgentService", + "AuthService", "TaskService", "ExecutionService", "SchedulerService", diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..872443e --- /dev/null +++ b/backend/app/services/auth.py @@ -0,0 +1,70 @@ +"""Auth service — JWT token handling + password hashing.""" + +from datetime import datetime, timedelta, timezone + +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.models.user import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_DAYS = 7 + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def create_access_token(user_id: int, username: str) -> str: + expire = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) + payload = {"sub": str(user_id), "username": username, "exp": expire} + return jwt.encode(payload, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def decode_access_token(token: str) -> dict | None: + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + + +class AuthService: + def __init__(self, db: AsyncSession): + self.db = db + + async def authenticate(self, username: str, password: str) -> User | None: + stmt = select(User).where(User.username == username, User.is_active == True) + result = await self.db.execute(stmt) + user = result.scalar_one_or_none() + if user and verify_password(password, user.password_hash): + return user + return None + + async def get_user_by_id(self, user_id: int) -> User | None: + return await self.db.get(User, user_id) + + @staticmethod + async def ensure_default_admin(db: AsyncSession): + """Create default admin if no users exist.""" + stmt = select(User).limit(1) + result = await db.execute(stmt) + if result.scalar_one_or_none() is None: + admin = User( + username="admin", + password_hash=hash_password("admin123"), + display_name="Administrator", + email="admin@taskpulse.local", + ) + db.add(admin) + await db.flush() + await db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt index c164373..787e0a2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,3 +12,6 @@ apscheduler==3.10.4 httpx==0.27.2 pycryptodome==3.20.0 gunicorn==23.0.0 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 12a8eeb..b382d1b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,12 @@ + +