feat: webbasierte Azure-Authentifizierung

Device-Code-Flow direkt ueber das Web-Interface starten statt
ueber die Kommandozeile. Button "Mit Azure verbinden" in den
Einstellungen zeigt Code und Link an, pollt den Status und
laedt die Seite nach Erfolg neu.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Mueller 2026-05-03 10:03:04 +02:00
parent d5d5160257
commit bae07e78e7
2 changed files with 161 additions and 8 deletions

76
app.py
View File

@ -11,7 +11,7 @@ import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
from fastapi import FastAPI, Request, Form, UploadFile, File from fastapi import FastAPI, Request, Form, UploadFile, File, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@ -859,6 +859,80 @@ async def delete_firmware(filename: str):
return RedirectResponse("/devices", status_code=303) return RedirectResponse("/devices", status_code=303)
# ── Azure Auth (Web-basiert) ──────────────────────────────────────────────
# Global state for device code flow
_auth_state = {"status": "idle", "user_code": None, "verification_uri": None, "error": None}
def _run_device_code_auth():
"""Background task: run device code flow and save auth record."""
from azure.identity import DeviceCodeCredential, TokenCachePersistenceOptions
from database import get_setting, set_setting
client_id = get_setting("azure_client_id")
tenant_id = get_setting("azure_auth_tenant", "common")
scopes = get_setting("azure_scopes", "User.Read").split()
if not client_id:
_auth_state["status"] = "error"
_auth_state["error"] = "Anwendungs-ID (Client) nicht konfiguriert"
return
def prompt_callback(verification_uri, user_code, expires_on):
_auth_state["user_code"] = user_code
_auth_state["verification_uri"] = verification_uri
_auth_state["status"] = "waiting"
try:
cred = DeviceCodeCredential(
client_id=client_id,
tenant_id=tenant_id,
cache_persistence_options=TokenCachePersistenceOptions(
name="busylight", allow_unencrypted_storage=True
),
prompt_callback=prompt_callback,
)
record = cred.authenticate(tenant_id=tenant_id, scopes=scopes)
set_setting("azure_auth_record", record.serialize())
_auth_state["status"] = "success"
# Reinit graph client
try:
scheduler.graph = GraphAPI()
logger.info("Graph-Client nach Web-Auth neu initialisiert")
except Exception as e:
logger.error("Graph-Client Reinit fehlgeschlagen: %s", e)
except Exception as e:
_auth_state["status"] = "error"
_auth_state["error"] = str(e)
logger.error("Web-Auth fehlgeschlagen: %s", e)
@app.post("/api/azure/auth")
async def start_azure_auth():
"""Start device code flow in background."""
import threading
_auth_state["status"] = "starting"
_auth_state["user_code"] = None
_auth_state["verification_uri"] = None
_auth_state["error"] = None
# Clear old auth record
database.set_setting("azure_auth_record", "")
thread = threading.Thread(target=_run_device_code_auth, daemon=True)
thread.start()
return JSONResponse({"status": "starting"})
@app.get("/api/azure/auth/status")
async def azure_auth_status():
"""Poll auth state from frontend."""
return JSONResponse(_auth_state)
# ── CLI Auth Command ─────────────────────────────────────────────────────── # ── CLI Auth Command ───────────────────────────────────────────────────────
def cli_auth(): def cli_auth():

View File

@ -43,19 +43,53 @@
placeholder="User.Read User.Read.All Presence.Read Presence.Read.All"> placeholder="User.Read User.Read.All Presence.Read Presence.Read.All">
<small class="text-muted">Standard: User.Read User.Read.All Presence.Read Presence.Read.All</small> <small class="text-muted">Standard: User.Read User.Read.All Presence.Read Presence.Read.All</small>
</div> </div>
<div class="border rounded p-3 bg-light"> <div id="auth-status" class="border rounded p-3 bg-light">
{% if authenticated %} {% if authenticated %}
<i class="bi bi-check-circle text-success"></i> <i class="bi bi-check-circle text-success"></i>
<strong class="text-success">Authentifiziert</strong> <strong class="text-success">Verbunden</strong>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-warning" onclick="startAuth()">
<i class="bi bi-arrow-repeat"></i> Neu verbinden
</button>
</div>
{% else %} {% else %}
<i class="bi bi-x-circle text-danger"></i> <i class="bi bi-x-circle text-danger"></i>
<strong class="text-danger">Nicht authentifiziert</strong> <strong class="text-danger">Nicht verbunden</strong>
<p class="small text-muted mt-2 mb-0"> <div class="mt-2">
Fuehre auf dem Server aus:<br> <button type="button" class="btn btn-sm btn-primary" onclick="startAuth()">
<code>python app.py --auth</code> <i class="bi bi-microsoft"></i> Mit Azure verbinden
</p> </button>
</div>
{% endif %} {% endif %}
</div> </div>
<div id="auth-flow" class="border rounded p-3 bg-light mt-2" style="display:none;">
<div id="auth-waiting" style="display:none;">
<p class="mb-2"><strong>Schritt 1:</strong> Oeffne den folgenden Link:</p>
<p><a id="auth-url" href="#" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right"></i> Bei Microsoft anmelden
</a></p>
<p class="mb-2"><strong>Schritt 2:</strong> Gib diesen Code ein:</p>
<p class="text-center">
<span id="auth-code" class="badge bg-dark fs-4 font-monospace p-3"></span>
</p>
<p class="small text-muted mb-0">
<i class="bi bi-hourglass-split"></i> Warte auf Anmeldung...
</p>
</div>
<div id="auth-starting" style="display:none;">
<i class="bi bi-hourglass-split"></i> Verbindung wird hergestellt...
</div>
<div id="auth-success" style="display:none;">
<i class="bi bi-check-circle text-success"></i>
<strong class="text-success">Erfolgreich verbunden!</strong>
<p class="small text-muted mt-1 mb-0">Seite wird neu geladen...</p>
</div>
<div id="auth-error" style="display:none;">
<i class="bi bi-exclamation-triangle text-danger"></i>
<strong class="text-danger">Fehler:</strong>
<span id="auth-error-msg"></span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -132,3 +166,48 @@
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
let authPollInterval = null;
function startAuth() {
document.getElementById('auth-flow').style.display = 'block';
document.getElementById('auth-starting').style.display = 'block';
document.getElementById('auth-waiting').style.display = 'none';
document.getElementById('auth-success').style.display = 'none';
document.getElementById('auth-error').style.display = 'none';
fetch('/api/azure/auth', {method: 'POST'})
.then(() => {
authPollInterval = setInterval(pollAuthStatus, 1000);
});
}
function pollAuthStatus() {
fetch('/api/azure/auth/status')
.then(r => r.json())
.then(data => {
if (data.status === 'waiting') {
document.getElementById('auth-starting').style.display = 'none';
document.getElementById('auth-waiting').style.display = 'block';
document.getElementById('auth-code').textContent = data.user_code;
const url = document.getElementById('auth-url');
url.href = data.verification_uri;
} else if (data.status === 'success') {
clearInterval(authPollInterval);
document.getElementById('auth-waiting').style.display = 'none';
document.getElementById('auth-starting').style.display = 'none';
document.getElementById('auth-success').style.display = 'block';
setTimeout(() => location.reload(), 2000);
} else if (data.status === 'error') {
clearInterval(authPollInterval);
document.getElementById('auth-waiting').style.display = 'none';
document.getElementById('auth-starting').style.display = 'none';
document.getElementById('auth-error').style.display = 'block';
document.getElementById('auth-error-msg').textContent = data.error;
}
});
}
</script>
{% endblock %}