Skip to content

Commit 70645e7

Browse files
committed
server-side remember-me
1 parent 0e94dcb commit 70645e7

File tree

5 files changed

+901
-11
lines changed

5 files changed

+901
-11
lines changed

front/index.php

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,52 @@ function is_https_request(): bool {
8484
return false;
8585
}
8686

87+
function call_api(string $endpoint, array $data = []): ?array {
88+
/*
89+
Call NetAlertX API endpoint (for login page endpoints that don't require auth).
90+
91+
Returns: JSON response as array, or null on failure
92+
*/
93+
try {
94+
// Determine API host (assume localhost on same port as frontend)
95+
$api_host = $_SERVER['HTTP_HOST'] ?? 'localhost';
96+
$api_scheme = is_https_request() ? 'https' : 'http';
97+
$api_url = $api_scheme . '://' . $api_host;
98+
99+
$url = $api_url . $endpoint;
100+
101+
$ch = curl_init($url);
102+
if (!$ch) return null;
103+
104+
curl_setopt_array($ch, [
105+
CURLOPT_RETURNTRANSFER => true,
106+
CURLOPT_TIMEOUT => 5,
107+
CURLOPT_FOLLOWLOCATION => false,
108+
CURLOPT_HTTPHEADER => [
109+
'Content-Type: application/json',
110+
'Accept: application/json'
111+
]
112+
]);
113+
114+
if (!empty($data)) {
115+
curl_setopt($ch, CURLOPT_POST, true);
116+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
117+
}
118+
119+
$response = curl_exec($ch);
120+
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
121+
curl_close($ch);
122+
123+
if ($httpcode !== 200 || !$response) {
124+
return null;
125+
}
126+
127+
return json_decode($response, true);
128+
} catch (Exception $e) {
129+
return null;
130+
}
131+
}
132+
87133

88134
function logout_user(): void {
89135
$_SESSION = [];
@@ -127,18 +173,26 @@ function logout_user(): void {
127173

128174
login_user();
129175

176+
// Handle "Remember Me" if checked
130177
if (!empty($_POST['PWRemember'])) {
178+
// Generate random token (64-byte hex = 128 chars, use 64 chars)
131179
$token = bin2hex(random_bytes(32));
132180

133-
$_SESSION['remember_token'] = hash('sha256',$token);
134-
135-
setcookie(COOKIE_NAME,$token,[
136-
'expires'=>time()+604800,
137-
'path'=>'/',
138-
'secure'=>is_https_request(),
139-
'httponly'=>true,
140-
'samesite'=>'Strict'
181+
// Call API to save token hash to Parameters table
182+
$save_response = call_api('/auth/remember-me/save', [
183+
'token' => $token
141184
]);
185+
186+
// If API call successful, set persistent cookie
187+
if ($save_response && isset($save_response['success']) && $save_response['success']) {
188+
setcookie(COOKIE_NAME, $token, [
189+
'expires' => time() + 604800,
190+
'path' => '/',
191+
'secure' => is_https_request(),
192+
'httponly' => true,
193+
'samesite' => 'Strict'
194+
]);
195+
}
142196
}
143197

144198
safe_redirect(append_hash($redirectTo));
@@ -149,9 +203,15 @@ function logout_user(): void {
149203
Remember Me Validation
150204
===================================================== */
151205

152-
if (!is_authenticated() && !empty($_COOKIE[COOKIE_NAME]) && !empty($_SESSION['remember_token'])) {
206+
if (!is_authenticated() && !empty($_COOKIE[COOKIE_NAME])) {
207+
208+
// Call API to validate token against stored hash
209+
$validate_response = call_api('/auth/validate-remember', [
210+
'token' => $_COOKIE[COOKIE_NAME]
211+
]);
153212

154-
if (hash_equals($_SESSION['remember_token'], hash('sha256',$_COOKIE[COOKIE_NAME]))) {
213+
// If API returns valid token, authenticate and redirect
214+
if ($validate_response && isset($validate_response['valid']) && $validate_response['valid'] === true) {
155215
login_user();
156216
safe_redirect(append_hash($redirectTo));
157217
}

server/api_server/api_server_start.py

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from flask import Flask, redirect, request, jsonify, url_for, Response
88
from models.device_instance import DeviceInstance # noqa: E402
9+
from models.parameters_instance import ParametersInstance # noqa: E402
910
from flask_cors import CORS
1011
from werkzeug.exceptions import HTTPException
1112

@@ -94,7 +95,9 @@
9495
DbQueryRequest, DbQueryResponse,
9596
DbQueryUpdateRequest, DbQueryDeleteRequest,
9697
AddToQueueRequest, GetSettingResponse,
97-
RecentEventsRequest, SetDeviceAliasRequest
98+
RecentEventsRequest, SetDeviceAliasRequest,
99+
ValidateRememberRequest, ValidateRememberResponse,
100+
SaveRememberRequest, SaveRememberResponse
98101
)
99102

100103
from .sse_endpoint import ( # noqa: E402 [flake8 lint suppression]
@@ -1933,6 +1936,146 @@ def check_auth(payload=None):
19331936
return jsonify({"success": True, "message": "Authentication check successful"}), 200
19341937

19351938

1939+
# --------------------------
1940+
# Remember Me Validation endpoint
1941+
# --------------------------
1942+
@app.route("/auth/validate-remember", methods=["POST"])
1943+
@validate_request(
1944+
operation_id="validate_remember",
1945+
summary="Validate Remember Me Token",
1946+
description="Validate a persistent Remember Me token against stored hash. Called from login page (no auth required).",
1947+
request_model=ValidateRememberRequest,
1948+
response_model=ValidateRememberResponse,
1949+
tags=["auth"],
1950+
auth_callable=None # No auth required - used on login page
1951+
)
1952+
def validate_remember(payload=None):
1953+
"""
1954+
Validate a Remember Me token from persistent cookie.
1955+
1956+
Security: Uses timing-safe hash comparison to prevent timing attacks.
1957+
Token format: hex-encoded 32 random bytes (64 chars) from bin2hex(random_bytes(32))
1958+
"""
1959+
try:
1960+
# Extract token from request
1961+
data = request.get_json() or {}
1962+
token = data.get("token")
1963+
1964+
if not token:
1965+
mylog("verbose", ["[auth/validate-remember] Missing token in request"])
1966+
return jsonify({
1967+
"success": True,
1968+
"valid": False,
1969+
"message": "Token validation failed: missing token"
1970+
}), 200
1971+
1972+
# Validate token against stored hash
1973+
params_instance = ParametersInstance()
1974+
result = params_instance.validate_token(token)
1975+
1976+
if result['valid']:
1977+
mylog("verbose", ["[auth/validate-remember] Token validation successful"])
1978+
return jsonify({
1979+
"success": True,
1980+
"valid": True,
1981+
"message": "Token validation successful"
1982+
}), 200
1983+
else:
1984+
mylog("verbose", ["[auth/validate-remember] Token validation failed"])
1985+
return jsonify({
1986+
"success": True,
1987+
"valid": False,
1988+
"message": "Token validation failed"
1989+
}), 200
1990+
1991+
except Exception as e:
1992+
mylog("verbose", [f"[auth/validate-remember] Unexpected error: {e}"])
1993+
return jsonify({
1994+
"success": False,
1995+
"valid": False,
1996+
"error": "Internal server error",
1997+
"message": "An unexpected error occurred during token validation"
1998+
}), 500
1999+
2000+
2001+
# --------------------------
2002+
# Remember Me Save endpoint
2003+
# --------------------------
2004+
@app.route("/auth/remember-me/save", methods=["POST"])
2005+
@validate_request(
2006+
operation_id="save_remember",
2007+
summary="Save Remember Me Token",
2008+
description="Save a Remember Me token to the database. Called after successful login to enable persistent authentication.",
2009+
request_model=SaveRememberRequest,
2010+
response_model=SaveRememberResponse,
2011+
tags=["auth"],
2012+
auth_callable=None # No auth required - used on login page
2013+
)
2014+
def save_remember(payload=None):
2015+
"""
2016+
Save a Remember Me token.
2017+
2018+
Flow:
2019+
1. User logs in with "Remember Me" checkbox
2020+
2. Password validated successfully
2021+
3. Token generated: bin2hex(random_bytes(32))
2022+
4. This endpoint called: saves hash(token) to Parameters table
2023+
5. Token (unhashed) set in persistent cookie
2024+
6. Session created and user redirected
2025+
2026+
Security: Only the HASH is stored in the database, not the token itself.
2027+
If database is compromised, attacker cannot use stolen hashes without the original token.
2028+
"""
2029+
try:
2030+
import uuid
2031+
import hashlib
2032+
2033+
# Extract token from request
2034+
data = request.get_json() or {}
2035+
token = data.get("token")
2036+
2037+
if not token or len(token) < 64:
2038+
mylog("verbose", ["[auth/remember-me/save] Invalid or missing token"])
2039+
return jsonify({
2040+
"success": False,
2041+
"error": "Invalid token",
2042+
"message": "Token must be 64+ hex characters"
2043+
}), 400
2044+
2045+
# Hash the token
2046+
token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()
2047+
2048+
# Generate UUID-based parameter ID
2049+
token_id = f"remember_me_token_{uuid.uuid4()}"
2050+
2051+
# Store hash in Parameters table
2052+
params_instance = ParametersInstance()
2053+
success = params_instance.set_parameter(token_id, token_hash)
2054+
2055+
if success:
2056+
mylog("verbose", [f"[auth/remember-me/save] Token saved successfully: {token_id}"])
2057+
return jsonify({
2058+
"success": True,
2059+
"message": "Remember Me token saved successfully",
2060+
"token_id": token_id
2061+
}), 200
2062+
else:
2063+
mylog("verbose", ["[auth/remember-me/save] Failed to save token to database"])
2064+
return jsonify({
2065+
"success": False,
2066+
"error": "Database error",
2067+
"message": "Failed to save Remember Me token"
2068+
}), 500
2069+
2070+
except Exception as e:
2071+
mylog("verbose", [f"[auth/remember-me/save] Unexpected error: {e}"])
2072+
return jsonify({
2073+
"success": False,
2074+
"error": "Internal server error",
2075+
"message": "An unexpected error occurred while saving Remember Me token"
2076+
}), 500
2077+
2078+
19362079
# --------------------------
19372080
# Health endpoint
19382081
# --------------------------

server/api_server/openapi/schemas.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,89 @@ class GetSettingResponse(BaseResponse):
10311031
value: Any = Field(None, description="The setting value")
10321032

10331033

1034+
# =============================================================================
1035+
# AUTH SCHEMAS (Remember Me)
1036+
# =============================================================================
1037+
1038+
1039+
class ValidateRememberRequest(BaseModel):
1040+
"""Request to validate a Remember Me token."""
1041+
model_config = ConfigDict(
1042+
json_schema_extra={
1043+
"examples": [{
1044+
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2"
1045+
}]
1046+
}
1047+
)
1048+
1049+
token: str = Field(
1050+
...,
1051+
min_length=64,
1052+
max_length=128,
1053+
description="The unhashed Remember Me token from persistent cookie (hex-encoded binary)"
1054+
)
1055+
1056+
1057+
class ValidateRememberResponse(BaseResponse):
1058+
"""Response from Remember Me token validation."""
1059+
model_config = ConfigDict(
1060+
extra="allow",
1061+
json_schema_extra={
1062+
"examples": [{
1063+
"success": True,
1064+
"valid": True,
1065+
"message": "Token validation successful"
1066+
}, {
1067+
"success": True,
1068+
"valid": False,
1069+
"message": "Token validation failed"
1070+
}]
1071+
}
1072+
)
1073+
1074+
valid: bool = Field(
1075+
...,
1076+
description="Whether the token is valid and matches stored hash"
1077+
)
1078+
1079+
1080+
class SaveRememberRequest(BaseModel):
1081+
"""Request to save a Remember Me token."""
1082+
model_config = ConfigDict(
1083+
json_schema_extra={
1084+
"examples": [{
1085+
"token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2"
1086+
}]
1087+
}
1088+
)
1089+
1090+
token: str = Field(
1091+
...,
1092+
min_length=64,
1093+
max_length=128,
1094+
description="The unhashed Remember Me token to save (hex-encoded binary from bin2hex(random_bytes(32)))"
1095+
)
1096+
1097+
1098+
class SaveRememberResponse(BaseResponse):
1099+
"""Response from Remember Me token save operation."""
1100+
model_config = ConfigDict(
1101+
extra="allow",
1102+
json_schema_extra={
1103+
"examples": [{
1104+
"success": True,
1105+
"message": "Token saved successfully",
1106+
"token_id": "remember_me_token_550e8400-e29b-41d4-a716-446655440000"
1107+
}]
1108+
}
1109+
)
1110+
1111+
token_id: Optional[str] = Field(
1112+
None,
1113+
description="The parameter ID where token hash was stored (UUID-based)"
1114+
)
1115+
1116+
10341117
# =============================================================================
10351118
# GRAPHQL SCHEMAS
10361119
# =============================================================================

0 commit comments

Comments
 (0)