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 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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@
|
||||||
<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">
|
||||||
|
|
|
||||||
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