M2-T02: add session/auth JSON API for the SPA

- GET /api/session (user + csrf_token, 401 when unauthenticated)
- POST /api/auth/login (sets HttpOnly session cookie; 401 on bad creds; no CSRF)
- POST /api/auth/logout (session+CSRF; revokes session, clears cookie; 204)
- POST /api/auth/password (session+CSRF; reuses change_password; 400 on failure; 204)
- reuses app/services/auth.py and shared require_session/require_csrf deps
- register router in app/main.py; regenerate openapi/
- tests/test_api_session.py
This commit is contained in:
2026-06-12 23:15:56 +02:00
parent de77019ce3
commit 8da1f13e60
6 changed files with 943 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app.api.routes.api.deps import require_csrf, require_session
from app.config import Settings
from app.dependencies import get_app_settings, get_db
from app.schemas.session import (
LoginRequest,
PasswordChangeRequest,
SessionResponse,
SessionUser,
)
from app.services.auth import (
AuthPasswordChangeError,
AuthenticatedSession,
authenticate_user,
change_password,
create_session,
revoke_session,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["api-session"])
def _build_session_response(auth: AuthenticatedSession) -> SessionResponse:
return SessionResponse(
user=SessionUser(
username=auth.user.username,
force_password_change=auth.user.force_password_change,
),
csrf_token=auth.session.csrf_token,
)
@router.get("/session", response_model=SessionResponse)
def get_session(
auth: AuthenticatedSession = Depends(require_session),
) -> SessionResponse:
"""Return the current session user and CSRF token. Returns 401 if not authenticated."""
return _build_session_response(auth)
@router.post("/auth/login", response_model=SessionResponse)
def post_login(
body: LoginRequest,
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
) -> SessionResponse:
"""
Authenticate with username and password.
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
On failure, returns 401 with no cookie set.
No X-CSRF-Token required (unauthenticated endpoint).
"""
user = authenticate_user(db, username=body.username, password=body.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
auth_session, raw_token = create_session(db, user=user, settings=settings)
logger.info("Created API authenticated session for user '%s'", user.username)
response.set_cookie(
key=settings.auth_session_cookie_name,
value=raw_token,
max_age=settings.auth_session_ttl_hours * 3600,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
path="/",
)
auth = AuthenticatedSession(user=user, session=auth_session)
return _build_session_response(auth)
@router.post("/auth/logout")
def post_logout(
response: Response,
db: Session = Depends(get_db),
settings: Settings = Depends(get_app_settings),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Revoke the current session and clear the session cookie.
Requires authentication and X-CSRF-Token header.
Returns 204 No Content.
"""
revoke_session(db, auth_session=auth.session)
logger.info("Revoked API authenticated session for user '%s'", auth.user.username)
no_content = Response(status_code=status.HTTP_204_NO_CONTENT)
no_content.delete_cookie(settings.auth_session_cookie_name, path="/")
return no_content
@router.post("/auth/password")
def post_change_password(
body: PasswordChangeRequest,
db: Session = Depends(get_db),
auth: AuthenticatedSession = Depends(require_session),
_csrf: None = Depends(require_csrf),
) -> Response:
"""
Change the current user's password.
Requires authentication and X-CSRF-Token header.
On AuthPasswordChangeError returns 400 with a generic message.
On success, force_password_change becomes False (handled by the service).
Returns 204 No Content.
"""
try:
change_password(
db,
user=auth.user,
current_password=body.current_password,
new_password=body.new_password,
confirm_password=body.confirm_password,
)
except AuthPasswordChangeError as exc:
logger.info(
"Rejected password change for user '%s': %s",
auth.user.username,
exc,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="password change failed",
) from exc
logger.info("Password updated for user '%s'", auth.user.username)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+2
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app import models # noqa: F401
from app.api.routes.api.config import router as api_config_router
from app.api.routes.api.session import router as api_session_router
from app.api.routes.auth import router as auth_router
from app.api.routes import pages, status
from app.db import get_session_local
@@ -93,6 +94,7 @@ def create_app() -> FastAPI:
app.include_router(auth_router)
app.include_router(pages.router)
app.include_router(api_config_router)
app.include_router(api_session_router)
app.include_router(homeassistant_router)
app.include_router(location_router)
app.include_router(poo_router)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from pydantic import BaseModel
class SessionUser(BaseModel):
username: str
force_password_change: bool
class SessionResponse(BaseModel):
user: SessionUser
csrf_token: str
class LoginRequest(BaseModel):
username: str
password: str
class PasswordChangeRequest(BaseModel):
current_password: str
new_password: str
confirm_password: str
+246
View File
@@ -350,6 +350,176 @@
}
}
},
"/api/session": {
"get": {
"tags": [
"api-session"
],
"summary": "Get Session",
"description": "Return the current session user and CSRF token. Returns 401 if not authenticated.",
"operationId": "get_session_api_session_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionResponse"
}
}
}
}
}
}
},
"/api/auth/login": {
"post": {
"tags": [
"api-session"
],
"summary": "Post Login",
"description": "Authenticate with username and password.\n\nOn success, sets an HttpOnly session cookie and returns the session user + CSRF token.\nOn failure, returns 401 with no cookie set.\nNo X-CSRF-Token required (unauthenticated endpoint).",
"operationId": "post_login_api_auth_login_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/logout": {
"post": {
"tags": [
"api-session"
],
"summary": "Post Logout",
"description": "Revoke the current session and clear the session cookie.\nRequires authentication and X-CSRF-Token header.\nReturns 204 No Content.",
"operationId": "post_logout_api_auth_logout_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/password": {
"post": {
"tags": [
"api-session"
],
"summary": "Post Change Password",
"description": "Change the current user's password.\nRequires authentication and X-CSRF-Token header.\nOn AuthPasswordChangeError returns 400 with a generic message.\nOn success, force_password_change becomes False (handled by the service).\nReturns 204 No Content.",
"operationId": "post_change_password_api_auth_password_post",
"parameters": [
{
"name": "X-CSRF-Token",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "X-Csrf-Token"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordChangeRequest"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/homeassistant/publish": {
"post": {
"tags": [
@@ -673,6 +843,47 @@
"type": "object",
"title": "HTTPValidationError"
},
"LoginRequest": {
"properties": {
"username": {
"type": "string",
"title": "Username"
},
"password": {
"type": "string",
"title": "Password"
}
},
"type": "object",
"required": [
"username",
"password"
],
"title": "LoginRequest"
},
"PasswordChangeRequest": {
"properties": {
"current_password": {
"type": "string",
"title": "Current Password"
},
"new_password": {
"type": "string",
"title": "New Password"
},
"confirm_password": {
"type": "string",
"title": "Confirm Password"
}
},
"type": "object",
"required": [
"current_password",
"new_password",
"confirm_password"
],
"title": "PasswordChangeRequest"
},
"PublicIPCheckResponse": {
"properties": {
"status": {
@@ -703,6 +914,41 @@
],
"title": "PublicIPCheckResponse"
},
"SessionResponse": {
"properties": {
"user": {
"$ref": "#/components/schemas/SessionUser"
},
"csrf_token": {
"type": "string",
"title": "Csrf Token"
}
},
"type": "object",
"required": [
"user",
"csrf_token"
],
"title": "SessionResponse"
},
"SessionUser": {
"properties": {
"username": {
"type": "string",
"title": "Username"
},
"force_password_change": {
"type": "boolean",
"title": "Force Password Change"
}
},
"type": "object",
"required": [
"username",
"force_password_change"
],
"title": "SessionUser"
},
"StatusResponse": {
"properties": {
"status": {
+178
View File
@@ -222,6 +222,129 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/session:
get:
tags:
- api-session
summary: Get Session
description: Return the current session user and CSRF token. Returns 401 if
not authenticated.
operationId: get_session_api_session_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
/api/auth/login:
post:
tags:
- api-session
summary: Post Login
description: 'Authenticate with username and password.
On success, sets an HttpOnly session cookie and returns the session user +
CSRF token.
On failure, returns 401 with no cookie set.
No X-CSRF-Token required (unauthenticated endpoint).'
operationId: post_login_api_auth_login_post
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/SessionResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/logout:
post:
tags:
- api-session
summary: Post Logout
description: 'Revoke the current session and clear the session cookie.
Requires authentication and X-CSRF-Token header.
Returns 204 No Content.'
operationId: post_logout_api_auth_logout_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/api/auth/password:
post:
tags:
- api-session
summary: Post Change Password
description: 'Change the current user''s password.
Requires authentication and X-CSRF-Token header.
On AuthPasswordChangeError returns 400 with a generic message.
On success, force_password_change becomes False (handled by the service).
Returns 204 No Content.'
operationId: post_change_password_api_auth_password_post
parameters:
- name: X-CSRF-Token
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Csrf-Token
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasswordChangeRequest'
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/homeassistant/publish:
post:
tags:
@@ -443,6 +566,36 @@ components:
title: Detail
type: object
title: HTTPValidationError
LoginRequest:
properties:
username:
type: string
title: Username
password:
type: string
title: Password
type: object
required:
- username
- password
title: LoginRequest
PasswordChangeRequest:
properties:
current_password:
type: string
title: Current Password
new_password:
type: string
title: New Password
confirm_password:
type: string
title: Confirm Password
type: object
required:
- current_password
- new_password
- confirm_password
title: PasswordChangeRequest
PublicIPCheckResponse:
properties:
status:
@@ -466,6 +619,31 @@ components:
- checked_at
- changed
title: PublicIPCheckResponse
SessionResponse:
properties:
user:
$ref: '#/components/schemas/SessionUser'
csrf_token:
type: string
title: Csrf Token
type: object
required:
- user
- csrf_token
title: SessionResponse
SessionUser:
properties:
username:
type: string
title: Username
force_password_change:
type: boolean
title: Force Password Change
type: object
required:
- username
- force_password_change
title: SessionUser
StatusResponse:
properties:
status:
+352
View File
@@ -0,0 +1,352 @@
"""Tests for M2-T02: GET /api/session, POST /api/auth/login, /logout, /password."""
from __future__ import annotations
import re
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _extract_csrf_token(html: str) -> str:
match = re.search(r'name="csrf_token" value="([^"]+)"', html)
assert match is not None, "csrf_token not found in HTML"
return match.group(1)
def _jinja_login(client: TestClient) -> None:
"""Log in via the existing Jinja form so the client has a session cookie."""
login_page = client.get("/login")
csrf_token = _extract_csrf_token(login_page.text)
resp = client.post(
"/login",
data={"username": "admin", "password": "test-password", "csrf_token": csrf_token},
follow_redirects=False,
)
assert resp.status_code == 303, f"Jinja login failed: {resp.status_code}"
def _api_login(client: TestClient, *, username: str = "admin", password: str = "test-password"):
"""Log in via POST /api/auth/login and return the response."""
return client.post(
"/api/auth/login",
json={"username": username, "password": password},
)
# ---------------------------------------------------------------------------
# GET /api/session — unauthenticated
# ---------------------------------------------------------------------------
def test_get_session_unauthenticated_returns_401(client: TestClient) -> None:
response = client.get("/api/session")
assert response.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/session — authenticated (via Jinja login)
# ---------------------------------------------------------------------------
def test_get_session_authenticated_returns_user_and_csrf(client: TestClient) -> None:
_jinja_login(client)
response = client.get("/api/session")
assert response.status_code == 200
body = response.json()
assert "user" in body
assert "csrf_token" in body
assert body["user"]["username"] == "admin"
assert isinstance(body["user"]["force_password_change"], bool)
assert isinstance(body["csrf_token"], str)
assert body["csrf_token"] # non-empty
def test_get_session_does_not_leak_password(client: TestClient) -> None:
_jinja_login(client)
response = client.get("/api/session")
body_str = str(response.json())
assert "test-password" not in body_str
assert "password_hash" not in body_str
# ---------------------------------------------------------------------------
# POST /api/auth/login
# ---------------------------------------------------------------------------
def test_post_login_valid_credentials_returns_200_with_session(client: TestClient) -> None:
response = _api_login(client)
assert response.status_code == 200
body = response.json()
assert "user" in body
assert "csrf_token" in body
assert body["user"]["username"] == "admin"
assert isinstance(body["csrf_token"], str)
assert body["csrf_token"]
def test_post_login_sets_httponly_session_cookie(client: TestClient) -> None:
response = _api_login(client)
assert response.status_code == 200
set_cookie = response.headers.get("set-cookie", "").lower()
assert "home_automation_session=" in set_cookie
assert "httponly" in set_cookie
assert "samesite=lax" in set_cookie
assert "path=/" in set_cookie
def test_post_login_cookie_secure_flag_follows_settings(client: TestClient) -> None:
"""In test mode AUTH_COOKIE_SECURE_OVERRIDE=false so secure should be absent."""
response = _api_login(client)
assert response.status_code == 200
set_cookie = response.headers.get("set-cookie", "").lower()
# secure is absent because AUTH_COOKIE_SECURE_OVERRIDE=false in conftest
assert "secure" not in set_cookie
def test_post_login_invalid_credentials_returns_401(client: TestClient) -> None:
response = _api_login(client, password="wrong-password")
assert response.status_code == 401
# No session cookie should be set
assert "set-cookie" not in response.headers or (
"home_automation_session=" not in response.headers.get("set-cookie", "").lower()
)
def test_post_login_unknown_user_returns_401(client: TestClient) -> None:
response = _api_login(client, username="nobody", password="irrelevant")
assert response.status_code == 401
def test_post_login_does_not_require_csrf_header(client: TestClient) -> None:
"""Login is unauthenticated; no X-CSRF-Token should be required."""
response = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
# Deliberately omit X-CSRF-Token
)
assert response.status_code == 200
def test_post_login_allows_subsequent_authenticated_request(client: TestClient) -> None:
login_resp = _api_login(client)
assert login_resp.status_code == 200
# GET /api/session should now succeed (cookie was set on the client)
session_resp = client.get("/api/session")
assert session_resp.status_code == 200
# ---------------------------------------------------------------------------
# POST /api/auth/logout
# ---------------------------------------------------------------------------
def test_post_logout_unauthenticated_returns_401(client: TestClient) -> None:
response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"})
assert response.status_code == 401
def test_post_logout_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
_api_login(client)
response = client.post("/api/auth/logout")
assert response.status_code == 403
def test_post_logout_authenticated_empty_csrf_returns_403(client: TestClient) -> None:
_api_login(client)
response = client.post("/api/auth/logout", headers={"X-CSRF-Token": ""})
assert response.status_code == 403
def test_post_logout_authenticated_with_csrf_returns_204(client: TestClient) -> None:
_api_login(client)
response = client.post("/api/auth/logout", headers={"X-CSRF-Token": "any-non-empty-value"})
assert response.status_code == 204
def test_post_logout_invalidates_session(client: TestClient) -> None:
_api_login(client)
# Verify session is active
assert client.get("/api/session").status_code == 200
# Logout
client.post("/api/auth/logout", headers={"X-CSRF-Token": "token"})
# Session should now be gone
assert client.get("/api/session").status_code == 401
# ---------------------------------------------------------------------------
# POST /api/auth/password
# ---------------------------------------------------------------------------
def test_post_password_unauthenticated_returns_401(client: TestClient) -> None:
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 401
def test_post_password_authenticated_missing_csrf_returns_403(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
)
assert response.status_code == 403
def test_post_password_success_returns_204(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 204
def test_post_password_wrong_current_password_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "wrong-current",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
# Error message must be generic — no leaking which check failed
detail = response.json().get("detail", "")
assert "current password is invalid" not in detail
assert detail == "password change failed"
def test_post_password_mismatched_new_passwords_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "different-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
assert response.json()["detail"] == "password change failed"
def test_post_password_too_short_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "short",
"confirm_password": "short",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
assert response.json()["detail"] == "password change failed"
def test_post_password_same_as_current_returns_400(client: TestClient) -> None:
_api_login(client)
response = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "test-password",
"confirm_password": "test-password",
},
headers={"X-CSRF-Token": "token"},
)
assert response.status_code == 400
assert response.json()["detail"] == "password change failed"
def test_post_password_success_sets_force_password_change_false(client: TestClient) -> None:
"""After successful password change, force_password_change should be False."""
_api_login(client)
# The bootstrap user always has force_password_change=True; change it
resp = client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
assert resp.status_code == 204
# Session still active; force_password_change should now be False
session_resp = client.get("/api/session")
assert session_resp.status_code == 200
assert session_resp.json()["user"]["force_password_change"] is False
def test_post_password_does_not_revoke_session(client: TestClient) -> None:
"""After password change, the session remains valid (not revoked)."""
_api_login(client)
client.post(
"/api/auth/password",
json={
"current_password": "test-password",
"new_password": "new-password-123",
"confirm_password": "new-password-123",
},
headers={"X-CSRF-Token": "token"},
)
# Session must still be active
assert client.get("/api/session").status_code == 200
# ---------------------------------------------------------------------------
# Response schema correctness — no secrets in session response
# ---------------------------------------------------------------------------
def test_session_response_has_no_secret_fields(client: TestClient) -> None:
login_resp = _api_login(client)
assert login_resp.status_code == 200
body = login_resp.json()
# Must have exactly these top-level keys
assert set(body.keys()) == {"user", "csrf_token"}
# user must have exactly these keys
assert set(body["user"].keys()) == {"username", "force_password_change"}