diff --git a/app/modules/auth/router.py b/app/modules/auth/router.py
new file mode 100644
index 0000000..75c7df3
--- /dev/null
+++ b/app/modules/auth/router.py
@@ -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
diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html
new file mode 100644
index 0000000..adea061
--- /dev/null
+++ b/app/templates/auth/login.html
@@ -0,0 +1,54 @@
+{% extends "base.html" %}
+
+{% block title %}Anmelden · EFI Hub{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Fakultät EFI · TH Nürnberg
+
+
Anmelden
+
Bitte melde dich mit deinen Zugangsdaten an.
+
+
+ {% if error %}
+
+ {{ error }}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/tests/test_auth_router.py b/tests/test_auth_router.py
new file mode 100644
index 0000000..519e9fb
--- /dev/null
+++ b/tests/test_auth_router.py
@@ -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", "") == ""