mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-03-31 07:12:23 -07:00
server-side remember-me
This commit is contained in:
@@ -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
|
||||
# --------------------------
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
204
server/models/parameters_instance.py
Normal file
204
server/models/parameters_instance.py
Normal 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
|
||||
Reference in New Issue
Block a user