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:
Steven 2026-06-14 23:22:53 +08:00
parent 521cf0269c
commit cd41e59afc
14 changed files with 430 additions and 11 deletions

View File

@ -100,8 +100,21 @@ def upgrade() -> None:
sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), 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: def downgrade() -> None:
op.drop_table("users")
op.drop_table("system_config") op.drop_table("system_config")
op.drop_table("alerts") op.drop_table("alerts")
op.drop_table("alert_rules") op.drop_table("alert_rules")

39
backend/app/api/auth.py Normal file
View File

@ -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__)

View File

@ -10,14 +10,16 @@ from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api.agents import router as agents_router 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.tasks import router as tasks_router
from app.api.executions import router as executions_router from app.api.executions import router as executions_router
from app.api.notifications import router as notifications_router from app.api.notifications import router as notifications_router
from app.api.dashboard import router as dashboard_router from app.api.dashboard import router as dashboard_router
from app.api.system import router as system_router from app.api.system import router as system_router
from app.config import settings 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.scheduler import SchedulerService
from app.services.auth import AuthService
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger("taskpulse") logger = logging.getLogger("taskpulse")
@ -31,6 +33,9 @@ async def lifespan(app: FastAPI):
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables ensured") logger.info("Database tables ensured")
# Ensure default admin
async with async_session_factory() as db:
await AuthService.ensure_default_admin(db)
# Start background scheduler # Start background scheduler
task = asyncio.create_task(scheduler.start()) task = asyncio.create_task(scheduler.start())
yield yield
@ -49,6 +54,7 @@ app = FastAPI(
# API routers # API routers
app.include_router(agents_router) app.include_router(agents_router)
app.include_router(auth_router)
app.include_router(tasks_router) app.include_router(tasks_router)
app.include_router(executions_router) app.include_router(executions_router)
app.include_router(notifications_router) app.include_router(notifications_router)

View File

@ -5,5 +5,7 @@ from app.models.task import ScheduledTask # noqa: F401
from app.models.execution import TaskExecution # noqa: F401 from app.models.execution import TaskExecution # noqa: F401
from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401 from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401
from app.models.system_config import SystemConfig # 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"]

View File

@ -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}>"

View File

@ -1,6 +1,7 @@
"""Pydantic schema registry.""" """Pydantic schema registry."""
from app.schemas.agent import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate # noqa: F401 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.execution import ExecutionOut, ExecutionReport # noqa: F401
from app.schemas.notification import ( # noqa: F401 from app.schemas.notification import ( # noqa: F401
AlertAcknowledge, AlertAcknowledge,

View File

@ -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}

View File

@ -1,6 +1,7 @@
"""Service layer __init__.""" """Service layer __init__."""
from app.services.agent import AgentService # noqa: F401 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.task import TaskService # noqa: F401
from app.services.execution import ExecutionService # noqa: F401 from app.services.execution import ExecutionService # noqa: F401
from app.services.scheduler import SchedulerService # noqa: F401 from app.services.scheduler import SchedulerService # noqa: F401
@ -8,6 +9,7 @@ from app.services.notification import NotificationService # noqa: F401
__all__ = [ __all__ = [
"AgentService", "AgentService",
"AuthService",
"TaskService", "TaskService",
"ExecutionService", "ExecutionService",
"SchedulerService", "SchedulerService",

View File

@ -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()

View File

@ -12,3 +12,6 @@ apscheduler==3.10.4
httpx==0.27.2 httpx==0.27.2
pycryptodome==3.20.0 pycryptodome==3.20.0
gunicorn==23.0.0 gunicorn==23.0.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1

View File

@ -1,5 +1,12 @@
<template> <template>
<div id="app-container"> <div id="app-container">
<!-- Login page: standalone without sidebar -->
<template v-if="isLoginPage">
<router-view />
</template>
<!-- App pages: with sidebar -->
<template v-else>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar"> <aside class="sidebar">
<div class="logo"> <div class="logo">
@ -46,22 +53,44 @@
<header class="topbar"> <header class="topbar">
<h2 class="page-title">{{ pageTitle }}</h2> <h2 class="page-title">{{ pageTitle }}</h2>
<div class="topbar-right"> <div class="topbar-right">
<span class="version-badge">v1.0</span> <template v-if="isLoggedIn">
<span class="user-name">{{ userName }}</span>
<button class="btn-logout" @click="handleLogout">退出</button>
</template>
<span v-else class="version-badge">v1.0</span>
</div> </div>
</header> </header>
<main class="content"> <main class="content">
<router-view /> <router-view />
</main> </main>
</div> </div>
</template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
const route = useRoute() const route = useRoute()
const router = useRouter()
const currentRoute = computed(() => route.path) const currentRoute = computed(() => route.path)
const isLoginPage = computed(() => route.path === '/login')
const isLoggedIn = computed(() => !!localStorage.getItem('token'))
const userName = ref('')
// Load user info from localStorage
try {
const userData = JSON.parse(localStorage.getItem('user') || '{}')
userName.value = userData.display_name || userData.username || ''
} catch {}
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
const routeTitles = { const routeTitles = {
'/': '仪表盘', '/': '仪表盘',
@ -195,6 +224,24 @@ const pageTitle = computed(() => routeTitles[route.path] || 'TaskPulse')
color: rgba(255,255,255,0.4); color: rgba(255,255,255,0.4);
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
} }
.user-name {
font-size: 13px;
color: var(--text-secondary);
}
.btn-logout {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.04);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
}
.btn-logout:hover {
color: #f87171;
border-color: rgba(239,68,68,0.2);
background: rgba(239,68,68,0.08);
}
.content { .content {
flex: 1; flex: 1;

View File

@ -5,6 +5,34 @@ const api = axios.create({
timeout: 10000, timeout: 10000,
}) })
// Auto-attach token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Auto-redirect on 401
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(err)
},
)
// Auth
export const login = (data) => api.post('/auth/login', data)
export const getMe = () => api.get('/auth/me')
// Dashboard // Dashboard
export const getDashboardSummary = () => api.get('/dashboard/summary') export const getDashboardSummary = () => api.get('/dashboard/summary')
export const getDashboardTasks = () => api.get('/dashboard/tasks') export const getDashboardTasks = () => api.get('/dashboard/tasks')

View File

@ -5,14 +5,16 @@ import TaskDetail from '../views/TaskDetail.vue'
import Agents from '../views/Agents.vue' import Agents from '../views/Agents.vue'
import Alerts from '../views/Alerts.vue' import Alerts from '../views/Alerts.vue'
import Settings from '../views/Settings.vue' import Settings from '../views/Settings.vue'
import Login from '../views/Login.vue'
const routes = [ const routes = [
{ path: '/', component: Dashboard }, { path: '/login', component: Login, meta: { public: true } },
{ path: '/tasks', component: Tasks }, { path: '/', component: Dashboard, meta: { requiresAuth: true } },
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail' }, { path: '/tasks', component: Tasks, meta: { requiresAuth: true } },
{ path: '/agents', component: Agents }, { path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail', meta: { requiresAuth: true } },
{ path: '/alerts', component: Alerts }, { path: '/agents', component: Agents, meta: { requiresAuth: true } },
{ path: '/settings', component: Settings }, { path: '/alerts', component: Alerts, meta: { requiresAuth: true } },
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
] ]
const router = createRouter({ const router = createRouter({
@ -20,4 +22,15 @@ const router = createRouter({
routes, routes,
}) })
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/')
} else {
next()
}
})
export default router export default router

View File

@ -0,0 +1,133 @@
<template>
<div class="login-page">
<div class="login-card">
<div class="login-header">
<div class="login-logo">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<h1>TaskPulse</h1>
<p>AI Agent Task Monitor</p>
</div>
<form @submit.prevent="handleLogin" class="login-form">
<div class="field">
<label>用户名</label>
<input v-model="username" class="input" placeholder="admin" autocomplete="username" />
</div>
<div class="field">
<label>密码</label>
<input v-model="password" type="password" class="input" placeholder="admin123" autocomplete="current-password" />
</div>
<p v-if="error" class="login-error">{{ error }}</p>
<button type="submit" class="btn-login" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { login } from '../api/index.js'
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const handleLogin = async () => {
error.value = ''
if (!username.value || !password.value) {
error.value = '请输入用户名和密码'
return
}
loading.value = true
try {
const res = await login({ username: username.value, password: password.value })
const { access_token, user } = res.data
localStorage.setItem('token', access_token)
localStorage.setItem('user', JSON.stringify(user))
router.push('/')
} catch (e) {
if (e.response?.status === 401) {
error.value = '用户名或密码错误'
} else {
error.value = '登录失败,请检查网络连接'
}
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #0a0a0f;
}
.login-card {
width: 380px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 40px 36px;
}
.login-header { text-align: center; margin-bottom: 32px; }
.login-logo {
width: 56px; height: 56px;
margin: 0 auto 12px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
color: #fff;
}
.login-header h1 {
font-size: 22px; font-weight: 700;
background: linear-gradient(135deg, #a8b4ff, #c084fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 4px;
}
.login-header p { font-size: 13px; color: var(--text-muted); margin: 0; }
.login-form { display: flex; flex-direction: column; gap: 16px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--text-secondary); }
.input {
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(255,255,255,0.04);
color: var(--text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.input:focus { border-color: var(--accent-1); }
.input::placeholder { color: var(--text-muted); }
.login-error { font-size: 13px; color: #f87171; margin: 0; }
.btn-login {
padding: 11px 0;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-login:hover { opacity: 0.9; }
.btn-login:disabled { opacity: 0.5; cursor: not-allowed; }
</style>