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:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user