Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion project/backend/Auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
import bcrypt
from pydantic import BaseModel

import hashlib

PASSWORD_RESET_EXPIRE_MINUTES = 30

from Models import Token, User

Expand Down Expand Up @@ -132,3 +135,31 @@ async def getCurrentUser(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
raise credentials_exception

return User(email=konto["email"], name=konto["name"])

# --- Reset Password --- #

def hashResetToken(token: str) -> str:
"""SHA-256 Hash – für Reset-Tokens reicht das, sie sind ohnehin hochentropisch."""
return hashlib.sha256(token.encode()).hexdigest()


def createPasswordResetToken(kontoId: int) -> str:
"""Generiert Klartext-Token (geht per Mail) und speichert nur den Hash in der DB."""
from Database import savePasswordResetToken
token = secrets.token_urlsafe(48)
expiresAt = datetime.now(timezone.utc) + timedelta(minutes=PASSWORD_RESET_EXPIRE_MINUTES)
savePasswordResetToken(kontoId, hashResetToken(token), expiresAt.isoformat())
return token


def validatePasswordResetToken(token: str) -> dict | None:
from Database import getPasswordResetToken
entry = getPasswordResetToken(hashResetToken(token))
if entry is None or entry["usedAt"] is not None:
return None
expiresAt = datetime.fromisoformat(entry["expiresAt"])
if expiresAt.tzinfo is None:
expiresAt = expiresAt.replace(tzinfo=timezone.utc)
if expiresAt < datetime.now(timezone.utc):
return None
return entry
Comment on lines +146 to +165
98 changes: 85 additions & 13 deletions project/backend/Database.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
import sqlite3
from contextlib import contextmanager
from pathlib import Path
import os

# Verwende die Datenbank aus dem data-Ordner
DB_PATH = Path(__file__).parent.parent / "data" / "LazyCookDB.sqlite3"

# Stelle sicher, dass das Verzeichnis existiert
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
DB_PATH = Path("/data/LazyCookDB.sqlite3")


def getConnection() -> sqlite3.Connection:
"""Erstellt eine neue SQLite-Connection mit Row-Factory."""
con = sqlite3.connect(str(DB_PATH), check_same_thread=False)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
# con.execute("PRAGMA journal_mode = WAL")
con.execute("PRAGMA journal_mode = WAL")
return con


Expand Down Expand Up @@ -104,13 +99,23 @@ def initDB():
UNIQUE (AccountID, rid)
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS PasswordResetToken (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kontoID INTEGER NOT NULL,
tokenHash TEXT NOT NULL UNIQUE,
expiresAt TIMESTAMP NOT NULL,
usedAt TIMESTAMP,
createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kontoID) REFERENCES Account (id) ON DELETE CASCADE
)
""")

print("✅ Datenbank-Tabellen erfolgreich initialisiert")


# ── Account-Operationen ──────────────────────────────────────────


def createAccount(email: str, name: str, hashedPassword: str) -> dict | None:
"""Legt ein neues Account an. Gibt die Account-Daten zurück oder None bei Duplikat."""
with getDB() as con:
Expand Down Expand Up @@ -142,7 +147,6 @@ def getAccountByEmail(email: str) -> dict | None:

# ── Refresh-Token-Operationen ──────────────────────────────────


def saveRefreshToken(AccountID: int, token: str, expiresAt: str) -> None:
"""Speichert einen neuen Refresh Token in der Datenbank."""
with getDB() as con:
Expand Down Expand Up @@ -191,22 +195,90 @@ def deleteAccount(email: str) -> bool:
cur.execute("DELETE FROM Account WHERE email = ?", (email,))
return cur.rowcount > 0


def updateAccount(konto_id: int, email: str = None, password_hash: str = None) -> None:
"""Aktualisiert E-Mail und/oder Passwort eines Accounts."""
with getDB() as con:
cur = con.cursor()
if email:
cur.execute("UPDATE Account SET email = ? WHERE id = ?", (email, konto_id))
cur.execute(
"UPDATE Account SET email = ? WHERE id = ?",
(email, konto_id)
)
if password_hash:
cur.execute(
"UPDATE Account SET hashedPassword = ? WHERE id = ?",
(password_hash, konto_id),
(password_hash, konto_id)
)


def cleanupExpiredTokens() -> None:
"""Löscht alle abgelaufenen Refresh Tokens."""
with getDB() as con:
cur = con.cursor()
cur.execute("DELETE FROM RefreshToken WHERE expiresAt < datetime('now')")



# ── Password-Reset-Token-Operationen ───────────────────────────

def savePasswordResetToken(kontoID: int, tokenHash: str, expiresAt: str) -> None:
"""Invalidiert alte Tokens des Kontos und speichert einen neuen."""
with getDB() as con:
cur = con.cursor()
# Alte, ungenutzte Tokens für dieses Konto invalidieren
cur.execute(
"UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP "
"WHERE kontoID = ? AND usedAt IS NULL",
(kontoID,),
)
cur.execute(
"INSERT INTO PasswordResetToken (kontoID, tokenHash, expiresAt) VALUES (?, ?, ?)",
(kontoID, tokenHash, expiresAt),
)


def getPasswordResetToken(tokenHash: str) -> dict | None:
con = getConnection()
try:
cur = con.cursor()
cur.execute(
"SELECT id, kontoID, tokenHash, expiresAt, usedAt "
"FROM PasswordResetToken WHERE tokenHash = ?",
(tokenHash,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
con.close()


def markResetTokenUsed(tokenID: int) -> None:
with getDB() as con:
cur = con.cursor()
cur.execute(
"UPDATE PasswordResetToken SET usedAt = CURRENT_TIMESTAMP WHERE id = ?",
(tokenID,),
)


def updateKontoPassword(konto_id: int, hashed_password: str) -> None:
with getDB() as con:
cur = con.cursor()
cur.execute(
"UPDATE Account SET hashedPassword = ? WHERE id = ?",
(hashed_password, konto_id),
)


def getAccountById(konto_id: int) -> dict | None:
"""Gibt Account-Daten anhand der ID zurück, oder None."""
con = getConnection()
try:
cur = con.cursor()
cur.execute(
"SELECT id, email, name, hashedPassword FROM Account WHERE id = ?",
(konto_id,),
)
row = cur.fetchone()
return dict(row) if row else None
finally:
con.close()
41 changes: 40 additions & 1 deletion project/backend/EmailService.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
gmailUser = os.environ.get("GMAIL_USER")
gmailPassword = os.environ.get("GMAIL_PASSWORD")


def sendPasswordChangedEmail(to_email: str, name: str) -> None:
try:
msg = MIMEMultipart("alternative")
Expand All @@ -32,3 +31,43 @@ def sendPasswordChangedEmail(to_email: str, name: str) -> None:
except Exception as e:
print(f"E-Mail Fehler: {e}")
raise


def sendPasswordResetEmail(to_email: str, name: str, resetLink: str) -> None:
try:
msg = MIMEMultipart("alternative")
msg["Subject"] = "Passwort zurücksetzen – Lazy Cook"
msg["From"] = gmailUser
msg["To"] = to_email

html = f"""
<div style="font-family: sans-serif; max-width: 500px; margin: auto;">
<h2>Hallo {name},</h2>
<p>du hast angefordert, dein Passwort bei <strong>Lazy Cook</strong> zurückzusetzen.</p>
<p>Klicke auf den Button, um ein neues Passwort festzulegen:</p>
<p style="text-align:center; margin: 24px 0;">
<a href="{resetLink}"
style="background:#030213; color:#fff; padding:12px 24px;
text-decoration:none; border-radius:6px; display:inline-block;">
Passwort zurücksetzen
</a>
</p>
<p style="font-size:12px; color:#666;">
Oder kopiere diesen Link in deinen Browser:<br>
<a href="{resetLink}">{resetLink}</a>
</p>
Comment on lines +43 to +58
<p>Der Link ist <strong>30 Minuten</strong> gültig.</p>
<p>Falls du das nicht angefordert hast, ignoriere diese E-Mail einfach – dein Passwort bleibt unverändert.</p>
<br>
<p>– Das Lazy Cook Team</p>
</div>
"""
msg.attach(MIMEText(html, "html"))

with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(gmailUser, gmailPassword)
server.sendmail(gmailUser, to_email, msg.as_string())

except Exception as e:
print(f"E-Mail Fehler: {e}")
raise
6 changes: 5 additions & 1 deletion project/backend/LazyCookAdministration.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
Expand All @@ -6,6 +7,9 @@
from Database import initDB
from Routes import router

# Frontend-URL aus Env, mit Dev-Fallback (Frontend läuft per compose.yaml auf Port 8000)
FRONTEND_URL = os.environ.get("FRONTEND_URL", "http://localhost:8000")


@asynccontextmanager
async def lifespan(app: FastAPI):
Expand All @@ -17,7 +21,7 @@ async def lifespan(app: FastAPI):

app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8000"],
allow_origins=[FRONTEND_URL],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
13 changes: 13 additions & 0 deletions project/backend/Models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str


Expand All @@ -24,3 +25,15 @@ class RefreshRequest(BaseModel):

class LogoutRequest(BaseModel):
refresh_token: str

class ForgotPasswordRequest(BaseModel):
email: str

class ResetPasswordRequest(BaseModel):
token: str
new_password: str

class UpdateUser(BaseModel):
email: str | None = None
currentPassword: str | None = None
newPassword: str | None = None
Loading
Loading