Feature/m2 frontend v2 #8
@@ -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 import models # noqa: F401
|
||||||
from app.api.routes.api.config import router as api_config_router
|
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.auth import router as auth_router
|
||||||
from app.api.routes import pages, status
|
from app.api.routes import pages, status
|
||||||
from app.db import get_session_local
|
from app.db import get_session_local
|
||||||
@@ -93,6 +94,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(pages.router)
|
app.include_router(pages.router)
|
||||||
app.include_router(api_config_router)
|
app.include_router(api_config_router)
|
||||||
|
app.include_router(api_session_router)
|
||||||
app.include_router(homeassistant_router)
|
app.include_router(homeassistant_router)
|
||||||
app.include_router(location_router)
|
app.include_router(location_router)
|
||||||
app.include_router(poo_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": {
|
"/homeassistant/publish": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -673,6 +843,47 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "HTTPValidationError"
|
"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": {
|
"PublicIPCheckResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
@@ -703,6 +914,41 @@
|
|||||||
],
|
],
|
||||||
"title": "PublicIPCheckResponse"
|
"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": {
|
"StatusResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": {
|
||||||
|
|||||||
@@ -222,6 +222,129 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/HTTPValidationError'
|
$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:
|
/homeassistant/publish:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -443,6 +566,36 @@ components:
|
|||||||
title: Detail
|
title: Detail
|
||||||
type: object
|
type: object
|
||||||
title: HTTPValidationError
|
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:
|
PublicIPCheckResponse:
|
||||||
properties:
|
properties:
|
||||||
status:
|
status:
|
||||||
@@ -466,6 +619,31 @@ components:
|
|||||||
- checked_at
|
- checked_at
|
||||||
- changed
|
- changed
|
||||||
title: PublicIPCheckResponse
|
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:
|
StatusResponse:
|
||||||
properties:
|
properties:
|
||||||
status:
|
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