diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4a28a16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +# =========================== +# Python +# =========================== +.venv/ +venv/ +__pycache__/ +*.py[cod] +*.pyo +.pytest_cache/ +.mypy_cache/ + +# =========================== +# Next.js +# =========================== +.next/ +node_modules/ +out/ + +# =========================== +# Environment +# =========================== +.env +.env.local +.env.development.local +.env.production.local + +# =========================== +# macOS +# =========================== +.DS_Store + +# =========================== +# VS Code +# =========================== +.vscode/ + +# =========================== +# Logs +# =========================== +*.log + +# =========================== +# Coverage +# =========================== +coverage/ +htmlcov/ + +# =========================== +# Alembic +# =========================== +alembic/__pycache__/ + +# =========================== +# Python build +# =========================== +build/ +dist/ +*.egg-info/ + +# =========================== +# Temporary +# =========================== +*.tmp +*.swp diff --git a/backend/hermes/alembic/versions/3f4c7b8d1e2a_extend_users_enterprise_fields.py b/backend/hermes/alembic/versions/3f4c7b8d1e2a_extend_users_enterprise_fields.py new file mode 100644 index 0000000..3d414ea --- /dev/null +++ b/backend/hermes/alembic/versions/3f4c7b8d1e2a_extend_users_enterprise_fields.py @@ -0,0 +1,65 @@ +"""extend users enterprise fields + +Revision ID: 3f4c7b8d1e2a +Revises: 0a247aadcfda +Create Date: 2026-07-02 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "3f4c7b8d1e2a" +down_revision: Union[str, Sequence[str], None] = "0a247aadcfda" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("first_name", sa.String(length=100), server_default="", nullable=False), + ) + op.add_column( + "users", + sa.Column("last_name", sa.String(length=100), server_default="", nullable=False), + ) + op.add_column( + "users", + sa.Column("role", sa.String(length=50), server_default="user", nullable=False), + ) + op.add_column( + "users", + sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False), + ) + op.add_column( + "users", + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_column("users", "updated_at") + op.drop_column("users", "created_at") + op.drop_column("users", "is_active") + op.drop_column("users", "role") + op.drop_column("users", "last_name") + op.drop_column("users", "first_name") diff --git a/backend/hermes/app/api/auth.py b/backend/hermes/app/api/auth.py index b209265..c3609ee 100644 --- a/backend/hermes/app/api/auth.py +++ b/backend/hermes/app/api/auth.py @@ -1,10 +1,14 @@ -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from app.core.security import create_access_token, verify_password +from app.core.security import ( + create_access_token, + get_access_token_expire_seconds, + verify_password, +) from app.db.database import get_db from app.models.user import User -from app.schemas.user import LoginRequest +from app.schemas.user import LoginRequest, LoginResponse router = APIRouter( prefix="/auth", @@ -12,32 +16,30 @@ router = APIRouter( ) -@router.post("/login") +@router.post("/login", response_model=LoginResponse) def login( login: LoginRequest, - response: Response, db: Session = Depends(get_db), ): user = db.query(User).filter(User.username == login.username).first() if not user: - raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") + raise HTTPException( + status_code=401, + detail="Ungültige Anmeldedaten", + ) if not verify_password(login.password, user.password_hash): - raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten") + raise HTTPException( + status_code=401, + detail="Ungültige Anmeldedaten", + ) token = create_access_token(user.username) - response.set_cookie( - key="access_token", - value=token, - httponly=True, - secure=False, - samesite="lax", - max_age=3600, - ) - return { - "message": "Login erfolgreich", - "user": user.username, - } \ No newline at end of file + "access_token": token, + "token_type": "bearer", + "expires_in": get_access_token_expire_seconds(), + "user": user, + } diff --git a/backend/hermes/app/api/users.py b/backend/hermes/app/api/users.py index e1cb0d3..5b834bc 100644 --- a/backend/hermes/app/api/users.py +++ b/backend/hermes/app/api/users.py @@ -1,24 +1,191 @@ -from fastapi import APIRouter, Depends +import logging + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session +from app.core.security import verify_access_token from app.db.database import get_db +from app.models.user import User from app.repositories.user_repository import UserRepository -from app.schemas.user import UserCreate, UserResponse +from app.schemas.user import UserCreate, UserPasswordUpdate, UserResponse, UserUpdate + +logger = logging.getLogger(__name__) router = APIRouter( prefix="/users", - tags=["Users"] + tags=["Users"], ) +bearer_scheme = HTTPBearer(auto_error=False) -@router.get("/", response_model=list[UserResponse]) -def get_users(db: Session = Depends(get_db)): + +def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + db: Session = Depends(get_db), +) -> User: + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Nicht authentifiziert", + headers={"WWW-Authenticate": "Bearer"}, + ) + + username = verify_access_token(credentials.credentials) + user = UserRepository.get_by_username(db, username) + + if user is None or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Nicht authentifiziert", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + + +def require_role(*allowed_roles: str): + def dependency(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Keine Berechtigung", + ) + return current_user + + return dependency + + +def get_user_or_404(db: Session, user_id: int) -> User: + db_user = UserRepository.get_by_id(db, user_id) + + if db_user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Benutzer nicht gefunden", + ) + + return db_user + + +def raise_conflict(field: str) -> None: + if field == "username": + detail = "Benutzername ist bereits vergeben" + else: + detail = "E-Mail ist bereits vergeben" + + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=detail, + ) + + +@router.get("", response_model=list[UserResponse]) +@router.get("/", response_model=list[UserResponse], include_in_schema=False) +def get_users( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + logger.info("users.list", extra={"actor_user_id": current_user.id}) return UserRepository.get_all(db) -@router.post("/", response_model=UserResponse) +@router.get("/{user_id}", response_model=UserResponse) +def get_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + logger.info( + "users.detail", + extra={"actor_user_id": current_user.id, "target_user_id": user_id}, + ) + return get_user_or_404(db, user_id) + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +@router.post( + "/", + response_model=UserResponse, + status_code=status.HTTP_201_CREATED, + include_in_schema=False, +) def create_user( user: UserCreate, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), ): - return UserRepository.create(db, user) \ No newline at end of file + conflict = UserRepository.find_conflict( + db, + username=user.username, + email=str(user.email), + ) + + if conflict is not None: + raise_conflict(conflict[0]) + + logger.info("users.create", extra={"actor_user_id": current_user.id}) + return UserRepository.create(db, user) + + +@router.put("/{user_id}", response_model=UserResponse) +def update_user( + user_id: int, + user: UserUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + db_user = get_user_or_404(db, user_id) + conflict = UserRepository.find_conflict( + db, + username=user.username, + email=str(user.email), + exclude_user_id=user_id, + ) + + if conflict is not None: + raise_conflict(conflict[0]) + + logger.info( + "users.update", + extra={"actor_user_id": current_user.id, "target_user_id": user_id}, + ) + return UserRepository.update(db, db_user, user) + + +@router.put("/{user_id}/password", response_model=UserResponse) +def update_user_password( + user_id: int, + password_update: UserPasswordUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + db_user = get_user_or_404(db, user_id) + + logger.info( + "users.password_update", + extra={"actor_user_id": current_user.id, "target_user_id": user_id}, + ) + return UserRepository.update_password(db, db_user, password_update) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if current_user.id == user_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Der aktuell angemeldete Benutzer darf sich nicht selbst löschen", + ) + + db_user = get_user_or_404(db, user_id) + + logger.info( + "users.delete", + extra={"actor_user_id": current_user.id, "target_user_id": user_id}, + ) + UserRepository.delete(db, db_user) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/hermes/app/core/config.py b/backend/hermes/app/core/config.py index 95ebea4..8a19737 100644 --- a/backend/hermes/app/core/config.py +++ b/backend/hermes/app/core/config.py @@ -7,6 +7,8 @@ class Settings(BaseSettings): app_name: str = "Hermes API" app_version: str = "0.1.0" + access_token_expire_minutes: int = 60 + jwt_issuer: str = "hermes" model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/hermes/app/core/security.py b/backend/hermes/app/core/security.py index 915ca85..c53f5de 100644 --- a/backend/hermes/app/core/security.py +++ b/backend/hermes/app/core/security.py @@ -1,6 +1,7 @@ from datetime import UTC, datetime, timedelta -from jose import jwt +from fastapi import HTTPException, status +from jose import JWTError, jwt from pwdlib import PasswordHash from app.core.config import settings @@ -8,7 +9,11 @@ from app.core.config import settings password_hash = PasswordHash.recommended() ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 60 +ACCESS_TOKEN_TYPE = "access" + + +def get_access_token_expire_seconds() -> int: + return settings.access_token_expire_minutes * 60 def hash_password(password: str) -> str: @@ -20,11 +25,15 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def create_access_token(subject: str) -> str: - expire = datetime.now(UTC) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + issued_at = datetime.now(UTC) + expire = issued_at + timedelta(minutes=settings.access_token_expire_minutes) payload = { "sub": subject, + "iat": issued_at, "exp": expire, + "iss": settings.jwt_issuer, + "type": ACCESS_TOKEN_TYPE, } return jwt.encode( @@ -32,3 +41,31 @@ def create_access_token(subject: str) -> str: settings.secret_key, algorithm=ALGORITHM, ) + + +def verify_access_token(token: str) -> str: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Nicht authentifiziert", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, + settings.secret_key, + algorithms=[ALGORITHM], + issuer=settings.jwt_issuer, + ) + subject = payload.get("sub") + token_type = payload.get("type") + except JWTError as exc: + raise credentials_exception from exc + + if not isinstance(subject, str) or not subject: + raise credentials_exception + + if token_type != ACCESS_TOKEN_TYPE: + raise credentials_exception + + return subject diff --git a/backend/hermes/app/main.py b/backend/hermes/app/main.py index 33a25d2..bc7ee85 100644 --- a/backend/hermes/app/main.py +++ b/backend/hermes/app/main.py @@ -1,5 +1,12 @@ +import logging + from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware +from fastapi import HTTPException +from fastapi.exceptions import RequestValidationError +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from starlette import status +from starlette.requests import Request from app.api.auth import router as auth_router from app.api.users import router as users_router @@ -11,21 +18,52 @@ app = FastAPI( description="Backend von Olympus", ) -app.add_middleware( - CORSMiddleware, - allow_origins=[ - "http://localhost:3000", - "http://127.0.0.1:3000", - "https://crm.funktechnik-schubert.de", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - app.include_router(auth_router) app.include_router(users_router) +logger = logging.getLogger(__name__) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException): + logger.warning( + "http_exception", + extra={ + "path": request.url.path, + "method": request.method, + "status_code": exc.status_code, + }, + ) + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=exc.headers, + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + logger.warning( + "validation_exception", + extra={"path": request.url.path, "method": request.method}, + ) + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": jsonable_encoder(exc.errors())}, + ) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception( + "unhandled_exception", + extra={"path": request.url.path, "method": request.method}, + ) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Interner Serverfehler"}, + ) + @app.get("/") def root(): @@ -40,4 +78,4 @@ def health(): return { "status": "healthy", "database": check_database(), - } \ No newline at end of file + } diff --git a/backend/hermes/app/models/user.py b/backend/hermes/app/models/user.py index fc393e9..eb0868f 100644 --- a/backend/hermes/app/models/user.py +++ b/backend/hermes/app/models/user.py @@ -1,4 +1,9 @@ +from datetime import datetime + +from sqlalchemy import Boolean +from sqlalchemy import DateTime from sqlalchemy import String +from sqlalchemy import func from sqlalchemy.orm import Mapped from sqlalchemy.orm import mapped_column @@ -10,6 +15,18 @@ class User(Base): id: Mapped[int] = mapped_column(primary_key=True) + first_name: Mapped[str] = mapped_column( + String(100), + default="", + server_default="" + ) + + last_name: Mapped[str] = mapped_column( + String(100), + default="", + server_default="" + ) + username: Mapped[str] = mapped_column( String(50), unique=True, @@ -23,4 +40,27 @@ class User(Base): password_hash: Mapped[str] = mapped_column( String(255) - ) \ No newline at end of file + ) + + role: Mapped[str] = mapped_column( + String(50), + default="user", + server_default="user" + ) + + is_active: Mapped[bool] = mapped_column( + Boolean, + default=True, + server_default="true" + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now() + ) + + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now() + ) diff --git a/backend/hermes/app/repositories/user_repository.py b/backend/hermes/app/repositories/user_repository.py index 81aa4e2..4877b78 100644 --- a/backend/hermes/app/repositories/user_repository.py +++ b/backend/hermes/app/repositories/user_repository.py @@ -1,27 +1,98 @@ +from sqlalchemy import select from sqlalchemy.orm import Session -from app.models.user import User -from app.schemas.user import UserCreate from app.core.security import hash_password +from app.models.user import User +from app.schemas.user import UserCreate, UserPasswordUpdate, UserUpdate class UserRepository: + @staticmethod + def get_all(db: Session) -> list[User]: + return list(db.scalars(select(User).order_by(User.created_at.desc()))) @staticmethod - def get_all(db: Session): - return db.query(User).all() + def get_by_id(db: Session, user_id: int) -> User | None: + return db.get(User, user_id) @staticmethod - def create(db: Session, user: UserCreate): + def get_by_username(db: Session, username: str) -> User | None: + return db.scalar(select(User).where(User.username == username)) + @staticmethod + def find_conflict( + db: Session, + *, + username: str, + email: str, + exclude_user_id: int | None = None, + ) -> tuple[str, User] | None: + username_query = select(User).where(User.username == username) + email_query = select(User).where(User.email == email) + + if exclude_user_id is not None: + username_query = username_query.where(User.id != exclude_user_id) + email_query = email_query.where(User.id != exclude_user_id) + + username_user = db.scalar(username_query) + if username_user is not None: + return ("username", username_user) + + email_user = db.scalar(email_query) + if email_user is not None: + return ("email", email_user) + + return None + + @staticmethod + def create(db: Session, user: UserCreate) -> User: db_user = User( - username=user.username, - email=user.email, - password_hash=hash_password(user.password), -) + first_name=user.first_name, + last_name=user.last_name, + username=user.username, + email=str(user.email), + role=user.role, + is_active=user.is_active, + password_hash=hash_password(user.password), + ) db.add(db_user) db.commit() db.refresh(db_user) - return db_user \ No newline at end of file + return db_user + + @staticmethod + def update(db: Session, db_user: User, user: UserUpdate) -> User: + db_user.first_name = user.first_name + db_user.last_name = user.last_name + db_user.username = user.username + db_user.email = str(user.email) + db_user.role = user.role + db_user.is_active = user.is_active + + if user.password: + db_user.password_hash = hash_password(user.password) + + db.commit() + db.refresh(db_user) + + return db_user + + @staticmethod + def update_password( + db: Session, + db_user: User, + password_update: UserPasswordUpdate, + ) -> User: + db_user.password_hash = hash_password(password_update.password) + + db.commit() + db.refresh(db_user) + + return db_user + + @staticmethod + def delete(db: Session, db_user: User) -> None: + db.delete(db_user) + db.commit() diff --git a/backend/hermes/app/schemas/user.py b/backend/hermes/app/schemas/user.py index fcf596c..d622eaa 100644 --- a/backend/hermes/app/schemas/user.py +++ b/backend/hermes/app/schemas/user.py @@ -1,21 +1,79 @@ -from pydantic import BaseModel, EmailStr +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + +UserRole = Literal["admin", "manager", "user"] -class UserCreate(BaseModel): - username: str +class UserBase(BaseModel): + first_name: str = Field(default="", max_length=100) + last_name: str = Field(default="", max_length=100) + username: str = Field(min_length=3, max_length=50) email: EmailStr - password: str + role: UserRole = "user" + is_active: bool = True + + @field_validator("first_name", "last_name", "username", mode="before") + @classmethod + def normalize_text(cls, value: object) -> str: + if value is None: + return "" + return str(value).strip() + + @field_validator("username") + @classmethod + def validate_username(cls, value: str) -> str: + allowed = value.replace(".", "").replace("_", "").replace("-", "") + if not allowed.isalnum(): + raise ValueError( + "Benutzername darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten" + ) + return value + + +class UserCreate(UserBase): + password: str = Field(min_length=8, max_length=128) + + +class UserUpdate(UserBase): + password: str | None = Field(default=None, max_length=128) + + @field_validator("password") + @classmethod + def validate_optional_password(cls, value: str | None) -> str | None: + if value is None or value == "": + return None + if len(value) < 8: + raise ValueError("Passwort muss mindestens 8 Zeichen lang sein") + return value + + +class UserPasswordUpdate(BaseModel): + password: str = Field(min_length=8, max_length=128) + class LoginRequest(BaseModel): username: str - password: str + password: str class UserResponse(BaseModel): id: int + first_name: str + last_name: str username: str email: EmailStr + role: UserRole + is_active: bool + created_at: datetime + updated_at: datetime - model_config = { - "from_attributes": True - } \ No newline at end of file + model_config = ConfigDict(from_attributes=True) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user: UserResponse diff --git a/backend/hermes/docker-compose.yml b/backend/hermes/docker-compose.yml index 9a42c66..1f0635e 100644 --- a/backend/hermes/docker-compose.yml +++ b/backend/hermes/docker-compose.yml @@ -8,6 +8,9 @@ services: DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus APP_NAME: Hermes API APP_VERSION: 0.1.0 + SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + JWT_ISSUER: ${JWT_ISSUER:-hermes} ports: - "8000:8000" diff --git a/docker-compose.yml b/docker-compose.yml index 91093de..f8ce432 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: ./backend/hermes dockerfile: dockerfile + container_name: hermes-api restart: unless-stopped @@ -11,6 +12,8 @@ services: APP_NAME: Hermes API APP_VERSION: 0.1.0 SECRET_KEY: ${SECRET_KEY} + ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60} + JWT_ISSUER: ${JWT_ISSUER:-hermes} ports: - "8000:8000" @@ -22,12 +25,15 @@ services: build: context: ./frontend/athena dockerfile: Dockerfile + container_name: athena-web restart: unless-stopped environment: NODE_ENV: production - NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + HERMES_INTERNAL_URL: http://hermes:8000 + AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-false} + ATHENA_PUBLIC_ORIGIN: ${ATHENA_PUBLIC_ORIGIN:-http://localhost:3001} ports: - "3001:3000" diff --git a/frontend/athena/app/api/login/route.ts b/frontend/athena/app/api/login/route.ts new file mode 100644 index 0000000..86b12b5 --- /dev/null +++ b/frontend/athena/app/api/login/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { + getHermesUrl, + readJson, + setAuthCookie, + upstreamConfigurationErrorResponse, + upstreamUnavailableResponse, +} from "@/lib/server/hermes"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type HermesLoginResponse = { + access_token: string; + expires_in: number; + user: { + id: number; + username: string; + email: string; + }; +}; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const credentials = await request.json(); + const hermesUrl = getHermesUrl(); + + if (!hermesUrl) { + return upstreamConfigurationErrorResponse(); + } + + let hermesResponse: Response; + + try { + hermesResponse = await fetch(`${hermesUrl}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(credentials), + cache: "no-store", + }); + } catch { + return upstreamUnavailableResponse(); + } + + const data = await readJson(hermesResponse); + + if (!hermesResponse.ok) { + return NextResponse.json(data, { + status: hermesResponse.status, + }); + } + + const loginData = data as HermesLoginResponse; + const response = NextResponse.json({ + user: loginData.user, + }); + + setAuthCookie(response, loginData.access_token, loginData.expires_in); + + return response; +} diff --git a/frontend/athena/app/api/logout/route.ts b/frontend/athena/app/api/logout/route.ts new file mode 100644 index 0000000..dd2740d --- /dev/null +++ b/frontend/athena/app/api/logout/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { clearAuthCookie } from "@/lib/server/hermes"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + const response = NextResponse.json({ ok: true }); + + clearAuthCookie(response); + + return response; +} diff --git a/frontend/athena/app/api/users/[id]/route.ts b/frontend/athena/app/api/users/[id]/route.ts new file mode 100644 index 0000000..4a26b7b --- /dev/null +++ b/frontend/athena/app/api/users/[id]/route.ts @@ -0,0 +1,87 @@ +import { NextRequest } from "next/server"; + +import { + clearAuthCookie, + getAccessToken, + getHermesUrl, + hermesJsonResponse, + unauthorizedResponse, + upstreamConfigurationErrorResponse, + upstreamUnavailableResponse, +} from "@/lib/server/hermes"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +type Params = { + params: Promise<{ + id: string; + }>; +}; + +async function proxyUserRequest(request: NextRequest, { params }: Params) { + const token = await getAccessToken(); + + if (!token) { + return unauthorizedResponse(); + } + + const hermesUrl = getHermesUrl(); + + if (!hermesUrl) { + return upstreamConfigurationErrorResponse(); + } + + const { id } = await params; + const action = request.nextUrl.searchParams.get("action"); + const path = action === "password" ? `/users/${id}/password` : `/users/${id}`; + + let hermesResponse: Response; + + try { + hermesResponse = await fetch(`${hermesUrl}${path}`, { + method: request.method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + body: request.method === "GET" || request.method === "DELETE" + ? undefined + : await request.text(), + cache: "no-store", + }); + } catch { + return upstreamUnavailableResponse(); + } + + if (hermesResponse.status === 401) { + const response = await hermesJsonResponse(hermesResponse); + clearAuthCookie(response); + return response; + } + + return hermesJsonResponse(hermesResponse); +} + +export async function GET(request: NextRequest, context: Params) { + return proxyUserRequest(request, context); +} + +export async function PUT(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyUserRequest(request, context); +} + +export async function DELETE(request: NextRequest, context: Params) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyUserRequest(request, context); +} diff --git a/frontend/athena/app/api/users/route.ts b/frontend/athena/app/api/users/route.ts new file mode 100644 index 0000000..ab8fcac --- /dev/null +++ b/frontend/athena/app/api/users/route.ts @@ -0,0 +1,65 @@ +import { NextRequest } from "next/server"; + +import { + clearAuthCookie, + getAccessToken, + getHermesUrl, + hermesJsonResponse, + unauthorizedResponse, + upstreamConfigurationErrorResponse, + upstreamUnavailableResponse, +} from "@/lib/server/hermes"; +import { assertSameOrigin } from "@/lib/server/request-guards"; + +async function proxyUsersRequest(request: NextRequest) { + const token = await getAccessToken(); + + if (!token) { + return unauthorizedResponse(); + } + + const hermesUrl = getHermesUrl(); + + if (!hermesUrl) { + return upstreamConfigurationErrorResponse(); + } + + let hermesResponse: Response; + + try { + hermesResponse = await fetch(`${hermesUrl}/users${request.nextUrl.search}`, { + method: request.method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + body: request.method === "GET" ? undefined : await request.text(), + cache: "no-store", + }); + } catch { + return upstreamUnavailableResponse(); + } + + if (hermesResponse.status === 401) { + const response = await hermesJsonResponse(hermesResponse); + clearAuthCookie(response); + return response; + } + + return hermesJsonResponse(hermesResponse); +} + +export async function GET(request: NextRequest) { + return proxyUsersRequest(request); +} + +export async function POST(request: NextRequest) { + const originError = assertSameOrigin(request); + + if (originError) { + return originError; + } + + return proxyUsersRequest(request); +} diff --git a/frontend/athena/app/login/page.tsx b/frontend/athena/app/login/page.tsx index 9bc693c..f9cbbcc 100644 --- a/frontend/athena/app/login/page.tsx +++ b/frontend/athena/app/login/page.tsx @@ -1,24 +1,20 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; export default function LoginPage() { - const router = useRouter(); - const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); async function handleLogin(e: React.FormEvent) { e.preventDefault(); + setError(""); - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/auth/login`, - { + try { + const response = await fetch("/api/login", { method: "POST", - credentials: "include", headers: { "Content-Type": "application/json", }, @@ -26,15 +22,20 @@ export default function LoginPage() { username, password, }), + }); + + const data = await response.json(); + + if (response.ok) { + window.location.href = "/dashboard"; + return; } - ); - if (response.ok) { - router.push("/dashboard"); - return; + setError(data.detail ?? "Login fehlgeschlagen"); + } catch (err) { + console.error(err); + setError("Technischer Fehler"); } - - setError("Benutzername oder Passwort ist falsch."); } return ( @@ -63,12 +64,11 @@ export default function LoginPage() { /> {error && ( -
- {error} -
+{error}
)}