auth für user

This commit is contained in:
Bastian Wagner
2026-08-15 21:34:40 +02:00
parent adfe14dfa8
commit f7b04337ce
11 changed files with 773 additions and 0 deletions

36
app/auth/account.py Normal file
View File

@@ -0,0 +1,36 @@
from sqlalchemy.orm import Session
from app.auth.admin import password_matches
from app.db.models import SyncUser
from app.db.repositories import UserRepository
from app.security.credentials import CredentialCipher
def authenticate_self_service(
session: Session, cipher: CredentialCipher, email: str, password: str
) -> SyncUser | None:
"""Matches submitted email/password against any user's stored MyWhoosh OR
Garmin credentials -- decrypted and compared locally rather than
verified against the live MyWhoosh/Garmin APIs, so logging into this app
never opens a redundant upstream session (which, for MyWhoosh, would
itself trigger the "already logged in from another device" conflict)."""
submitted_email = email.strip()
if not submitted_email or not password:
return None
for user in UserRepository(session).list_all():
if _credential_matches(cipher, user.mywhoosh_email_enc, user.mywhoosh_password_enc, submitted_email, password):
return user
if _credential_matches(cipher, user.garmin_email_enc, user.garmin_password_enc, submitted_email, password):
return user
return None
def _credential_matches(
cipher: CredentialCipher, email_enc: str, password_enc: str, submitted_email: str, submitted_password: str
) -> bool:
try:
stored_email = cipher.decrypt(email_enc)
stored_password = cipher.decrypt(password_enc)
except ValueError:
return False
return stored_email == submitted_email and password_matches(submitted_password, stored_password)

View File

@@ -10,3 +10,10 @@ def password_matches(submitted: str, configured: str) -> bool:
def require_admin(request: Request) -> None:
if request.session.get("admin_authenticated") is not True:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"})
def require_self_service(request: Request) -> int:
user_id = request.session.get("self_service_user_id")
if not isinstance(user_id, int):
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
return user_id

View File

@@ -14,6 +14,7 @@ from app.notifications.emailer import EmailNotifier
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncManager
from app.sync.scheduler import SyncScheduler
from app.web.account import router as account_router
from app.web.operations import router as operations_router
from app.web.routes import router as web_router
@@ -78,6 +79,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
app.include_router(web_router)
app.include_router(operations_router)
app.include_router(account_router)
app.mount(
"/static",
StaticFiles(directory=str(Path(__file__).resolve().parent / "web" / "static")),

153
app/web/account.py Normal file
View File

@@ -0,0 +1,153 @@
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.account import authenticate_self_service
from app.auth.admin import require_self_service
from app.auth.csrf import ensure_csrf_token, validate_csrf
from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher
from app.sync.manager import SyncAlreadyRunning
from app.web.operations import _normalize_outcome
from app.web.routes import templates
router = APIRouter()
def _cipher(request: Request) -> CredentialCipher:
return CredentialCipher(request.app.state.settings.credential_encryption_key)
def _require_non_empty(value: str, field_name: str) -> None:
if not value.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{field_name} must not be empty",
)
@router.get("/account-login", response_class=HTMLResponse)
def account_login_page(request: Request):
return templates.TemplateResponse(request, "account_login.html", {"csrf_token": ensure_csrf_token(request)})
@router.post("/account-login")
def account_login(
request: Request,
csrf_token: str = Form(...),
email: str = Form(...),
password: str = Form(...),
):
validate_csrf(request, csrf_token)
cipher = _cipher(request)
with request.app.state.session_factory() as session:
user = authenticate_self_service(session, cipher, email, password)
if user is None:
return templates.TemplateResponse(
request,
"account_login.html",
{"csrf_token": ensure_csrf_token(request), "error": "Invalid email or password"},
status_code=401,
)
request.session["self_service_user_id"] = user.id
return RedirectResponse("/account", status_code=303)
@router.post("/account-logout")
def account_logout(request: Request, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
request.session.pop("self_service_user_id", None)
return RedirectResponse("/account-login", status_code=303)
def _get_own_user_or_reauth(request: Request, repository: UserRepository, user_id: int):
user = repository.get(user_id)
if user is None:
request.session.pop("self_service_user_id", None)
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/account-login"})
return user
@router.get("/account", response_class=HTMLResponse)
def account_detail(request: Request):
user_id = require_self_service(request)
with request.app.state.session_factory() as session:
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
activities = ActivityRepository(session).list_pending_for_user(user_id)
recent_runs = SyncRunRepository(session).list_recent_for_user(user_id, limit=10)
return templates.TemplateResponse(
request,
"account/detail.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"activities": activities,
"recent_runs": recent_runs,
},
)
@router.post("/account/sync", response_class=HTMLResponse)
async def account_sync(request: Request, csrf_token: str = Form(...)):
user_id = require_self_service(request)
validate_csrf(request, csrf_token)
try:
outcome = await request.app.state.sync_manager.sync_user(user_id)
except SyncAlreadyRunning:
return HTMLResponse("Sync already running for this user", status_code=409)
return templates.TemplateResponse(
request, "fragments/sync_result.html", {"outcomes": [_normalize_outcome(outcome)]}
)
@router.get("/account/edit", response_class=HTMLResponse)
def account_edit_page(request: Request):
user_id = require_self_service(request)
cipher = _cipher(request)
with request.app.state.session_factory() as session:
user = _get_own_user_or_reauth(request, UserRepository(session), user_id)
return templates.TemplateResponse(
request,
"account/form.html",
{
"csrf_token": ensure_csrf_token(request),
"user": user,
"mywhoosh_email": cipher.decrypt(user.mywhoosh_email_enc),
"garmin_email": cipher.decrypt(user.garmin_email_enc),
},
)
@router.post("/account/edit")
def account_update(
request: Request,
csrf_token: str = Form(...),
mywhoosh_email: str = Form(""),
mywhoosh_password: str = Form(""),
garmin_email: str = Form(""),
garmin_password: str = Form(""),
notify_email_enabled: str | None = Form(None),
notification_email: str = Form(""),
):
user_id = require_self_service(request)
validate_csrf(request, csrf_token)
_require_non_empty(mywhoosh_email, "mywhoosh_email")
_require_non_empty(garmin_email, "garmin_email")
notify_enabled = notify_email_enabled is not None
if notify_enabled:
_require_non_empty(notification_email, "notification_email")
cipher = _cipher(request)
with request.app.state.session_factory() as session:
repository = UserRepository(session)
user = _get_own_user_or_reauth(request, repository, user_id)
values = {
"mywhoosh_email_enc": cipher.encrypt(mywhoosh_email.strip()),
"garmin_email_enc": cipher.encrypt(garmin_email.strip()),
"notify_email_enabled": notify_enabled,
"notification_email": notification_email.strip() or None,
}
if mywhoosh_password:
values["mywhoosh_password_enc"] = cipher.encrypt(mywhoosh_password)
if garmin_password:
values["garmin_password_enc"] = cipher.encrypt(garmin_password)
repository.update(user, **values)
return RedirectResponse("/account", status_code=303)

View File

@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}My Account - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>{{ user.name }}</h1>
<div class="page-actions">
<a class="btn secondary" href="/account/edit">Edit</a>
<form method="post" action="/account/sync" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Sync now</button>
</form>
<form method="post" action="/account-logout" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Log out</button>
</form>
</div>
<div class="card">
<dl class="info-grid">
<dt>Status</dt>
<dd><span class="badge badge-{{ user.health_state.value }}">{{ user.health_state.value.replace("_", " ") }}</span></dd>
<dt>MyWhoosh state</dt>
<dd>{{ user.mywhoosh_state }}</dd>
<dt>Garmin state</dt>
<dd>{{ user.garmin_state }}</dd>
<dt>Action reason</dt>
<dd>{{ user.action_reason or "-" }}</dd>
</dl>
</div>
{% if user.action_reason == "mywhoosh_device_conflict" %}
<h2>MyWhoosh device conflict</h2>
<div class="card">
<p>MyWhoosh reports this account is already logged in on another device. Log out of MyWhoosh there (app or website), then retry the sync.</p>
</div>
{% endif %}
<h2>Recent sync runs</h2>
<div class="card">
{% if recent_runs %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Finished</th>
<th>Status</th>
<th>Discovered</th>
<th>Imported</th>
<th>Skipped</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for run in recent_runs %}
<tr>
<td>{{ run.started_at }}</td>
<td>{{ run.finished_at or "-" }}</td>
<td><span class="badge badge-{{ run.status.value }}">{{ run.status.value }}</span></td>
<td>{{ run.discovered_count }}</td>
<td>{{ run.imported_count }}</td>
<td>{{ run.skipped_count }}</td>
<td>{{ run.failed_count }}</td>
</tr>
{% if run.summary_error %}
<tr>
<td colspan="7" class="summary-error">{{ run.summary_error }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty-state">No sync runs yet.</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Edit My Account - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Edit My Account</h1>
<div class="card">
<form method="post" action="/account/edit" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="mywhoosh_email">MyWhoosh Email</label>
<input type="email" id="mywhoosh_email" name="mywhoosh_email" value="{{ mywhoosh_email }}" required>
<label for="mywhoosh_password">MyWhoosh Password</label>
<input type="password" id="mywhoosh_password" name="mywhoosh_password" autocomplete="new-password">
<p class="hint">Leave blank to keep the existing password.</p>
<label for="garmin_email">Garmin Email</label>
<input type="email" id="garmin_email" name="garmin_email" value="{{ garmin_email }}" required>
<label for="garmin_password">Garmin Password</label>
<input type="password" id="garmin_password" name="garmin_password" autocomplete="new-password">
<p class="hint">Leave blank to keep the existing password.</p>
<label for="notify_email_enabled">
<input type="checkbox" id="notify_email_enabled" name="notify_email_enabled"
{% if user.notify_email_enabled %}checked{% endif %}>
Email me when this account needs attention
</label>
<label for="notification_email">Notification email</label>
<input type="email" id="notification_email" name="notification_email"
value="{{ user.notification_email or '' }}">
<button type="submit">Save</button>
</form>
</div>
<p><a href="/account">Back to my account</a></p>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Account Login - MyWhoosh Garmin Sync{% endblock %}
{% block content %}
<h1>Account Login</h1>
<div class="card">
{% if error %}
<p class="error">{{ error }}</p>
{% endif %}
<form method="post" action="/account-login" class="stacked-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="email">MyWhoosh or Garmin email</label>
<input type="email" id="email" name="email" required autofocus>
<label for="password">MyWhoosh or Garmin password</label>
<input type="password" id="password" name="password" required>
<button type="submit">Log in</button>
</form>
<p class="hint">Use the email and password for either your MyWhoosh or your Garmin account.</p>
</div>
<p><a href="/login">Admin login</a></p>
{% endblock %}

View File

@@ -10,8 +10,13 @@
<header class="topbar">
<span class="brand">MyWhoosh &rarr; Garmin Sync</span>
<nav>
{% if request.session.get('admin_authenticated') %}
<a href="/">Dashboard</a>
<a href="/system">System</a>
{% endif %}
{% if request.session.get('self_service_user_id') %}
<a href="/account">My Account</a>
{% endif %}
</nav>
</header>
<main class="container">

View File

@@ -15,4 +15,5 @@
<button type="submit">Log in</button>
</form>
</div>
<p><a href="/account-login">Log in with your MyWhoosh or Garmin account instead</a></p>
{% endblock %}