server-side remember-me

This commit is contained in:
Jokob @NetAlertX
2026-02-22 03:47:29 +00:00
parent 0e94dcb091
commit 70645e7ef3
5 changed files with 901 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import os
from flask import Flask, redirect, request, jsonify, url_for, Response
from models.device_instance import DeviceInstance # noqa: E402
from models.parameters_instance import ParametersInstance # noqa: E402
from flask_cors import CORS
from werkzeug.exceptions import HTTPException
@@ -94,7 +95,9 @@ from .openapi.schemas import ( # noqa: E402 [flake8 lint suppression]
DbQueryRequest, DbQueryResponse,
DbQueryUpdateRequest, DbQueryDeleteRequest,
AddToQueueRequest, GetSettingResponse,
RecentEventsRequest, SetDeviceAliasRequest
RecentEventsRequest, SetDeviceAliasRequest,
ValidateRememberRequest, ValidateRememberResponse,
SaveRememberRequest, SaveRememberResponse
)
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
@@ -1933,6 +1936,146 @@ def check_auth(payload=None):
return jsonify({"success": True, "message": "Authentication check successful"}), 200
# --------------------------
# Remember Me Validation endpoint
# --------------------------
@app.route("/auth/validate-remember", methods=["POST"])
@validate_request(
operation_id="validate_remember",
summary="Validate Remember Me Token",
description="Validate a persistent Remember Me token against stored hash. Called from login page (no auth required).",
request_model=ValidateRememberRequest,
response_model=ValidateRememberResponse,
tags=["auth"],
auth_callable=None # No auth required - used on login page
)
def validate_remember(payload=None):
"""
Validate a Remember Me token from persistent cookie.
Security: Uses timing-safe hash comparison to prevent timing attacks.
Token format: hex-encoded 32 random bytes (64 chars) from bin2hex(random_bytes(32))
"""
try:
# Extract token from request
data = request.get_json() or {}
token = data.get("token")
if not token:
mylog("verbose", ["[auth/validate-remember] Missing token in request"])
return jsonify({
"success": True,
"valid": False,
"message": "Token validation failed: missing token"
}), 200
# Validate token against stored hash
params_instance = ParametersInstance()
result = params_instance.validate_token(token)
if result['valid']:
mylog("verbose", ["[auth/validate-remember] Token validation successful"])
return jsonify({
"success": True,
"valid": True,
"message": "Token validation successful"
}), 200
else:
mylog("verbose", ["[auth/validate-remember] Token validation failed"])
return jsonify({
"success": True,
"valid": False,
"message": "Token validation failed"
}), 200
except Exception as e:
mylog("verbose", [f"[auth/validate-remember] Unexpected error: {e}"])
return jsonify({
"success": False,
"valid": False,
"error": "Internal server error",
"message": "An unexpected error occurred during token validation"
}), 500
# --------------------------
# Remember Me Save endpoint
# --------------------------
@app.route("/auth/remember-me/save", methods=["POST"])
@validate_request(
operation_id="save_remember",
summary="Save Remember Me Token",
description="Save a Remember Me token to the database. Called after successful login to enable persistent authentication.",
request_model=SaveRememberRequest,
response_model=SaveRememberResponse,
tags=["auth"],
auth_callable=None # No auth required - used on login page
)
def save_remember(payload=None):
"""
Save a Remember Me token.
Flow:
1. User logs in with "Remember Me" checkbox
2. Password validated successfully
3. Token generated: bin2hex(random_bytes(32))
4. This endpoint called: saves hash(token) to Parameters table
5. Token (unhashed) set in persistent cookie
6. Session created and user redirected
Security: Only the HASH is stored in the database, not the token itself.
If database is compromised, attacker cannot use stolen hashes without the original token.
"""
try:
import uuid
import hashlib
# Extract token from request
data = request.get_json() or {}
token = data.get("token")
if not token or len(token) < 64:
mylog("verbose", ["[auth/remember-me/save] Invalid or missing token"])
return jsonify({
"success": False,
"error": "Invalid token",
"message": "Token must be 64+ hex characters"
}), 400
# Hash the token
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()
# Generate UUID-based parameter ID
token_id = f"remember_me_token_{uuid.uuid4()}"
# Store hash in Parameters table
params_instance = ParametersInstance()
success = params_instance.set_parameter(token_id, token_hash)
if success:
mylog("verbose", [f"[auth/remember-me/save] Token saved successfully: {token_id}"])
return jsonify({
"success": True,
"message": "Remember Me token saved successfully",
"token_id": token_id
}), 200
else:
mylog("verbose", ["[auth/remember-me/save] Failed to save token to database"])
return jsonify({
"success": False,
"error": "Database error",
"message": "Failed to save Remember Me token"
}), 500
except Exception as e:
mylog("verbose", [f"[auth/remember-me/save] Unexpected error: {e}"])
return jsonify({
"success": False,
"error": "Internal server error",
"message": "An unexpected error occurred while saving Remember Me token"
}), 500
# --------------------------
# Health endpoint
# --------------------------

View File

@@ -1031,6 +1031,89 @@ class GetSettingResponse(BaseResponse):
value: Any = Field(None, description="The setting value")
# =============================================================================
# AUTH SCHEMAS (Remember Me)
# =============================================================================
class ValidateRememberRequest(BaseModel):
"""Request to validate a Remember Me token."""
model_config = ConfigDict(
json_schema_extra={
"examples": [{
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2"
}]
}
)
token: str = Field(
...,
min_length=64,
max_length=128,
description="The unhashed Remember Me token from persistent cookie (hex-encoded binary)"
)
class ValidateRememberResponse(BaseResponse):
"""Response from Remember Me token validation."""
model_config = ConfigDict(
extra="allow",
json_schema_extra={
"examples": [{
"success": True,
"valid": True,
"message": "Token validation successful"
}, {
"success": True,
"valid": False,
"message": "Token validation failed"
}]
}
)
valid: bool = Field(
...,
description="Whether the token is valid and matches stored hash"
)
class SaveRememberRequest(BaseModel):
"""Request to save a Remember Me token."""
model_config = ConfigDict(
json_schema_extra={
"examples": [{
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2"
}]
}
)
token: str = Field(
...,
min_length=64,
max_length=128,
description="The unhashed Remember Me token to save (hex-encoded binary from bin2hex(random_bytes(32)))"
)
class SaveRememberResponse(BaseResponse):
"""Response from Remember Me token save operation."""
model_config = ConfigDict(
extra="allow",
json_schema_extra={
"examples": [{
"success": True,
"message": "Token saved successfully",
"token_id": "remember_me_token_550e8400-e29b-41d4-a716-446655440000"
}]
}
)
token_id: Optional[str] = Field(
None,
description="The parameter ID where token hash was stored (UUID-based)"
)
# =============================================================================
# GRAPHQL SCHEMAS
# =============================================================================

View File

@@ -0,0 +1,204 @@
"""
Parameters Instance - Handles Parameters table operations for Remember Me tokens and other system parameters.
The Parameters table is used for temporary, ephemeral settings like Remember Me tokens.
Structure:
parID: TEXT PRIMARY KEY (e.g., "remember_me_token_{uuid}")
parValue: TEXT (e.g., hashed token value)
"""
import hashlib
import sqlite3
from database import get_temp_db_connection
from logger import mylog
class ParametersInstance:
"""Handler for Parameters table operations."""
# --- helper methods (DRY pattern from DeviceInstance) ----------------------
def _fetchall(self, query, params=()):
"""Fetch all rows and return as list of dicts."""
conn = get_temp_db_connection()
rows = conn.execute(query, params).fetchall()
conn.close()
return [dict(r) for r in rows]
def _fetchone(self, query, params=()):
"""Fetch single row and return as dict or None."""
conn = get_temp_db_connection()
row = conn.execute(query, params).fetchone()
conn.close()
return dict(row) if row else None
def _execute(self, query, params=()):
"""Execute write query (INSERT/UPDATE/DELETE)."""
conn = get_temp_db_connection()
cur = conn.cursor()
cur.execute(query, params)
conn.commit()
conn.close()
# --- public API -----------------------------------------------------------
def get_parameter(self, par_id):
"""
Retrieve a parameter value by ID.
Args:
par_id (str): The parameter ID to retrieve
Returns:
str: The parameter value, or None if not found
"""
try:
# Try with quoted column names in case they're reserved or have special chars
row = self._fetchone(
'SELECT "parValue" FROM "Parameters" WHERE "parID" = ?',
(par_id,)
)
return row['parValue'] if row else None
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error retrieving parameter {par_id}: {e}"])
return None
def set_parameter(self, par_id, par_value):
"""
Store or update a parameter (INSERT OR REPLACE).
Args:
par_id (str): The parameter ID
par_value (str): The parameter value
Returns:
bool: True if successful, False otherwise
"""
try:
# Try with quoted column names in case they're reserved or have special chars
self._execute(
'INSERT OR REPLACE INTO "Parameters" ("parID", "parValue") VALUES (?, ?)',
(par_id, par_value)
)
mylog("verbose", [f"[ParametersInstance] Parameter {par_id} stored successfully"])
return True
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error storing parameter {par_id}: {e}"])
return False
def delete_parameter(self, par_id):
"""
Delete a parameter by ID.
Args:
par_id (str): The parameter ID to delete
Returns:
bool: True if successful, False otherwise
"""
try:
# Try with quoted column names in case they're reserved or have special chars
self._execute(
'DELETE FROM "Parameters" WHERE "parID" = ?',
(par_id,)
)
mylog("verbose", [f"[ParametersInstance] Parameter {par_id} deleted successfully"])
return True
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error deleting parameter {par_id}: {e}"])
return False
def delete_parameters_by_prefix(self, prefix):
"""
Delete all parameters matching a prefix pattern (for cleanup).
Args:
prefix (str): The prefix pattern (e.g., "remember_me_token_")
Returns:
int: Number of parameters deleted
"""
try:
conn = get_temp_db_connection()
cur = conn.cursor()
cur.execute('DELETE FROM "Parameters" WHERE "parID" LIKE ?', (f"{prefix}%",))
deleted_count = cur.rowcount
conn.commit()
conn.close()
mylog("verbose", [f"[ParametersInstance] Deleted {deleted_count} parameters with prefix '{prefix}'"])
return deleted_count
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error deleting parameters with prefix '{prefix}': {e}"])
return 0
def validate_token(self, token):
"""
Validate a Remember Me token against stored hash.
Security: Compares hash(token) against stored hashes using hash_equals (timing-safe).
Args:
token (str): The unhashed token (from cookie)
Returns:
dict: {
'valid': bool,
'par_id': str or None # The matching parameter ID if valid
}
Note:
Returns immediately on first match. Use hash_equals() to prevent timing attacks.
"""
if not token:
return {'valid': False, 'par_id': None}
try:
# Compute hash of provided token
computed_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()
# Retrieve all remember_me tokens from Parameters table
remember_tokens = self._fetchall(
'SELECT "parID", "parValue" FROM "Parameters" WHERE "parID" LIKE ?',
("remember_me_token_%",)
)
# Check each stored token using timing-safe comparison
for token_record in remember_tokens:
stored_hash = token_record['parValue']
stored_id = token_record['parID']
# Use hash_equals() to prevent timing attacks
if self._hash_equals(stored_hash, computed_hash):
mylog("verbose", [f"[ParametersInstance] Token validation successful for {stored_id}"])
return {'valid': True, 'par_id': stored_id}
mylog("verbose", ["[ParametersInstance] Token validation failed: no matching token found"])
return {'valid': False, 'par_id': None}
except Exception as e:
mylog("verbose", [f"[ParametersInstance] Error validating token: {e}"])
return {'valid': False, 'par_id': None}
@staticmethod
def _hash_equals(known_string, user_string):
"""
Timing-safe string comparison to prevent timing attacks.
Args:
known_string (str): The known value (stored hash)
user_string (str): The user-supplied value (computed hash)
Returns:
bool: True if strings match, False otherwise
"""
if not isinstance(known_string, str) or not isinstance(user_string, str):
return False
if len(known_string) != len(user_string):
return False
# Compare all characters regardless of match (timing-safe)
result = 0
for x, y in zip(known_string, user_string):
result |= ord(x) ^ ord(y)
return result == 0