feat: add auth router with login/logout/me and login template
This commit is contained in:
parent
7c9c9e106a
commit
1555dd925e
57
app/modules/auth/router.py
Normal file
57
app/modules/auth/router.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.auth import clear_auth_cookie, create_access_token, set_auth_cookie
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.modules.auth.dependencies import get_current_user
|
||||||
|
from app.modules.auth.schemas import UserOut
|
||||||
|
from app.modules.auth.service import authenticate_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_NAV: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "auth/login.html", {"nav_items": _NAV, "app_version": "0.1.0"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_class=HTMLResponse)
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = authenticate_user(db, username, password, ldap_enabled=settings.LDAP_ENABLED)
|
||||||
|
if user is None:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"auth/login.html",
|
||||||
|
{"nav_items": _NAV, "app_version": "0.1.0", "error": "Ungültige Zugangsdaten."},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
token = create_access_token(username=user.username, is_admin=user.is_admin)
|
||||||
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
|
set_auth_cookie(response, token)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout():
|
||||||
|
response = RedirectResponse(url="/auth/login", status_code=303)
|
||||||
|
clear_auth_cookie(response)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def me(user=Depends(get_current_user)):
|
||||||
|
return user
|
||||||
54
app/templates/auth/login.html
Normal file
54
app/templates/auth/login.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Anmelden · EFI Hub{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-[80vh] flex items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-widest text-efi mb-1">
|
||||||
|
Fakultät EFI · TH Nürnberg
|
||||||
|
</p>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">Anmelden</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Bitte melde dich mit deinen Zugangsdaten an.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-4 px-4 py-3 rounded-md bg-red-50 border border-red-200 text-sm text-red-700">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/auth/login" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 uppercase tracking-wide mb-1" for="username">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username" name="username" type="text"
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:border-efi focus:ring-1 focus:ring-efi"
|
||||||
|
autocomplete="username" autofocus required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-gray-600 uppercase tracking-wide mb-1" for="password">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password" name="password" type="password"
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:border-efi focus:ring-1 focus:ring-efi"
|
||||||
|
autocomplete="current-password" required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-efi text-white text-sm font-semibold py-2 px-4 rounded-md hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
72
tests/test_auth_router.py
Normal file
72
tests/test_auth_router.py
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.core.database import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.modules.auth.models import User
|
||||||
|
from app.modules.auth.service import hash_password
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def override_db():
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
Session = sessionmaker(bind=engine)
|
||||||
|
session = Session()
|
||||||
|
app.dependency_overrides[get_db] = lambda: session
|
||||||
|
yield session
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
session.close()
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def alice(override_db):
|
||||||
|
user = User(username="alice", full_name="Alice Smith", pw_hash=hash_password("secret123"))
|
||||||
|
override_db.add(user)
|
||||||
|
override_db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_login_page(client):
|
||||||
|
response = client.get("/auth/login")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
assert "Anmelden" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_correct_credentials_redirects(client, alice):
|
||||||
|
response = client.post("/auth/login", data={"username": "alice", "password": "secret123"})
|
||||||
|
assert response.status_code in (302, 303, 307)
|
||||||
|
assert "access_token" in response.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_wrong_password_shows_error(client, alice):
|
||||||
|
response = client.post("/auth/login", data={"username": "alice", "password": "wrong"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Ungültige" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_user_shows_error(client):
|
||||||
|
response = client.post("/auth/login", data={"username": "ghost", "password": "any"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Ungültige" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_cookie(client, alice):
|
||||||
|
client.post("/auth/login", data={"username": "alice", "password": "secret123"})
|
||||||
|
response = client.get("/auth/logout")
|
||||||
|
assert response.status_code in (302, 303, 307)
|
||||||
|
assert response.cookies.get("access_token", "") == ""
|
||||||
Loading…
x
Reference in New Issue
Block a user