mirror of
https://github.com/TronoSfera/backup_service.git
synced 2026-05-18 10:03:32 +03:00
Add admin login page for web UI
This commit is contained in:
parent
d4256d6d77
commit
dc1fadd928
4 changed files with 102 additions and 8 deletions
|
|
@ -19,7 +19,7 @@ from typing import Optional
|
|||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60")
|
|||
# Password hashing context
|
||||
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:
|
||||
|
|
@ -58,7 +58,9 @@ def create_access_token(data: dict, expires_delta: Optional[datetime.timedelta]
|
|||
|
||||
|
||||
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:
|
||||
"""Retrieve the current user from a JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
|
|
@ -66,8 +68,11 @@ async def get_current_user(
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
raw_token = token or request.cookies.get("access_token")
|
||||
if not raw_token:
|
||||
raise credentials_exception
|
||||
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")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
|
|
|
|||
|
|
@ -37,9 +37,10 @@ from fastapi import (
|
|||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from fastapi.exception_handlers import http_exception_handler
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@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")
|
||||
async def on_startup() -> None:
|
||||
# Create database tables if they do not exist
|
||||
|
|
@ -649,6 +661,53 @@ async def update_client_config(
|
|||
|
||||
# ======== 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)
|
||||
async def list_clients_page(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,13 @@
|
|||
<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="/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>
|
||||
</header>
|
||||
<main class="container mx-auto p-4">
|
||||
|
|
|
|||
23
server/templates/login.html
Normal file
23
server/templates/login.html
Normal 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 %}
|
||||
Loading…
Reference in a new issue