Merge pull request #3 from TronoSfera/codex/add-user-login-page-for-admin-panel

Add admin login/logout and cookie-based auth for HTML admin UI
This commit is contained in:
TronoSfera 2026-01-19 11:26:55 +03:00 committed by GitHub
commit 737f40a242
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 102 additions and 8 deletions

View file

@ -19,7 +19,7 @@ from typing import Optional
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -36,7 +36,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")
# Password hashing context # Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/login", auto_error=False)
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
@ -58,7 +58,9 @@ def create_access_token(data: dict, expires_delta: Optional[datetime.timedelta]
async def get_current_user( async def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) request: Request,
token: str | None = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> models.User: ) -> models.User:
"""Retrieve the current user from a JWT token.""" """Retrieve the current user from a JWT token."""
credentials_exception = HTTPException( credentials_exception = HTTPException(
@ -66,8 +68,11 @@ async def get_current_user(
detail="Could not validate credentials", detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
raw_token = token or request.cookies.get("access_token")
if not raw_token:
raise credentials_exception
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(raw_token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int | None = payload.get("sub") user_id: int | None = payload.get("sub")
if user_id is None: if user_id is None:
raise credentials_exception raise credentials_exception
@ -93,4 +98,4 @@ async def get_current_admin(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough privileges", detail="Not enough privileges",
) )
return current_user return current_user

View file

@ -37,9 +37,10 @@ from fastapi import (
Request, Request,
Response, Response,
) )
from fastapi.responses import HTMLResponse, FileResponse from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from fastapi.exception_handlers import http_exception_handler
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import models, schemas, auth, storage as storage_module, database from . import models, schemas, auth, storage as storage_module, database
@ -50,6 +51,17 @@ templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "t
storage = storage_module.get_storage() storage = storage_module.get_storage()
@app.exception_handler(HTTPException)
async def custom_http_exception_handler(request: Request, exc: HTTPException) -> Response:
if (
exc.status_code == status.HTTP_401_UNAUTHORIZED
and "text/html" in request.headers.get("accept", "")
and not request.url.path.startswith("/api")
):
return RedirectResponse(url="/login")
return await http_exception_handler(request, exc)
@app.on_event("startup") @app.on_event("startup")
async def on_startup() -> None: async def on_startup() -> None:
# Create database tables if they do not exist # Create database tables if they do not exist
@ -649,6 +661,53 @@ async def update_client_config(
# ======== Web interface routes ========= # ======== Web interface routes =========
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> Response:
return templates.TemplateResponse(
"login.html",
{
"request": request,
"title": "Admin Login",
},
)
@app.post("/login", response_class=HTMLResponse)
async def login_submit(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(database.get_db),
) -> Response:
user = db.query(models.User).filter(models.User.username == username).first()
if not user or not auth.verify_password(password, user.hashed_password):
return templates.TemplateResponse(
"login.html",
{
"request": request,
"title": "Admin Login",
"error": "Invalid username or password.",
},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access_token = auth.create_access_token(data={"sub": user.id, "is_admin": user.is_admin})
response = RedirectResponse(url="/clients", status_code=status.HTTP_303_SEE_OTHER)
response.set_cookie(
"access_token",
access_token,
httponly=True,
samesite="lax",
)
return response
@app.get("/logout")
async def logout() -> Response:
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie("access_token")
return response
@app.get("/clients", response_class=HTMLResponse) @app.get("/clients", response_class=HTMLResponse)
async def list_clients_page( async def list_clients_page(
request: Request, request: Request,
@ -745,4 +804,4 @@ async def root_redirect(request: Request) -> Response:
redirect keeps the root endpoint simple and ensures there is no redirect keeps the root endpoint simple and ensures there is no
ambiguity with multiple handlers for ``/``. ambiguity with multiple handlers for ``/``.
""" """
return Response(status_code=303, headers={"Location": "/clients"}) return Response(status_code=303, headers={"Location": "/clients"})

View file

@ -12,10 +12,17 @@
<nav class="container mx-auto px-4 py-3 flex items-center space-x-4"> <nav class="container mx-auto px-4 py-3 flex items-center space-x-4">
<a href="/" class="text-xl font-semibold">Backup Service</a> <a href="/" class="text-xl font-semibold">Backup Service</a>
<a href="/clients" class="hover:underline">Clients</a> <a href="/clients" class="hover:underline">Clients</a>
<div class="ml-auto">
{% if user %}
<a href="/logout" class="hover:underline">Logout</a>
{% else %}
<a href="/login" class="hover:underline">Login</a>
{% endif %}
</div>
</nav> </nav>
</header> </header>
<main class="container mx-auto p-4"> <main class="container mx-auto p-4">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </body>
</html> </html>

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto bg-white shadow rounded-lg p-6 mt-8">
<h1 class="text-2xl font-bold mb-2">Admin Login</h1>
<p class="text-sm text-gray-600 mb-6">Sign in to manage clients and backups.</p>
{% if error %}
<div class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{{ error }}
</div>
{% endif %}
<form action="/login" method="post" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="username">Username</label>
<input id="username" type="text" name="username" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1" for="password">Password</label>
<input id="password" type="password" name="password" required class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-2" />
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">Sign In</button>
</form>
</div>
{% endblock %}