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
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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__)
|
||||
+7
-1
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"<User {self.username}>"
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user