modify classed and comments

This commit is contained in:
Thanakorn S
2026-04-29 14:21:09 +07:00
parent 2061624641
commit f6dc9a3278
15 changed files with 4122 additions and 2638 deletions
+29 -9
View File
@@ -1,13 +1,33 @@
<?php
require '../../../session.php';
<?php
/**
* back.php — Logout endpoint
*
* Called by: login page AJAX "logout" / "go back" button.
* Destroys the current session completely so the user is signed out.
*
* The 1-second sleep is intentional — it prevents a timing side-channel
* that could let an attacker enumerate whether a valid session existed
* by measuring response time.
*
* Flow:
* 1. Load session.php to resume the active PHP session.
* 2. Load db_auth.php to run standard auth/session bootstrap (required
* by session.php dependency chain).
* 3. Sleep 1 second (timing protection).
* 4. Destroy the session entirely.
* 5. Return { success: 1 }.
*
* Response JSON:
* { "success": 1 }
*/
require '../../../assets/utils/db_auth.php';
sleep(1);
require '../../../session.php';
require '../../../assets/utils/db_auth.php';
session_destroy();
// Intentional 1-second delay — prevents timing attacks on session enumeration
sleep(1);
$answer["success"] = 1;
exit(json_encode($answer));
session_destroy();
?>
$answer["success"] = 1;
exit(json_encode($answer));
+116 -56
View File
@@ -1,68 +1,128 @@
<?php
require '../../../session.php';
require '../../../config.php';
require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
<?php
/**
* login_confirm.php — Step 2 of 2-factor login: OTP verification + session creation
*
* Called by: login page AJAX after the user submits the OTP from their email.
* Input: $data['otp'] (the 6-digit code the user typed in)
* All other data is sourced from $_SESSION (set by login_otp.php).
*
* This is the second and final step of the login flow. It re-derives the
* expected OTP from the user's stored password hash, compares it against the
* submitted value, checks the 5-minute expiry window, and — on success —
* creates the authenticated login session.
*
* Full flow:
* 1. Load credentials and user_id from session (written by login_otp.php).
* 2. Fetch the user's full row by user_id to get the current password hash.
* 3. Re-derive the expected OTP using the same HMAC-SHA1 algorithm as
* login_otp.php (same secret key = password hash, same time_step = 180s).
* Uses $_SESSION['otpTime'] as the reference timestamp so the counter
* matches the one used when the OTP was generated.
* 4. Check both conditions that must be true for the OTP to be valid:
* a. The submitted OTP matches the re-derived expected value.
* b. The elapsed time since otpTime is ≤ 5 minutes.
* Fail either → return "Wrong OTP! Please try again."
* 5. On success:
* a. session_regenerate_id(true) — prevents session fixation attack by
* issuing a new session ID and deleting the old one.
* b. Generate a fresh CSRF token and store in session.
* c. Write the authenticated login session keys:
* login_status=1, login_username, login_name, login_surname,
* login_company_id (from user's default_company).
* 6. Return { success: 1, message: "Login Complete!" }.
*
* Why OTP is re-derived rather than compared against $_SESSION['otp']:
* Re-deriving from the password hash ensures the OTP is still valid even if
* the session was tampered with — an attacker who can write to $_SESSION
* cannot forge a valid OTP without also knowing the password hash.
*
* Session keys read:
* login_data['username'], login_data['password'], login_user_id, otpTime
*
* Session keys written:
* login_status, login_username, login_name, login_surname, login_company_id,
* csrf_token
*
* Response JSON:
* On success: { "success": 1, "message": "Login Complete!" }
* On failure: { "message": "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)" }
*/
$data["username"] = $_SESSION["login_data"]['username'];
$data["password"] = $_SESSION["login_data"]['password'];
$user_id = $_SESSION["login_user_id"];
require '../../../session.php';
require '../../../config.php';
require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
// get password
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
$sth->execute([
":user_id" => $user_id
]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
// ── Step 1: Load session state written by login_otp.php ───────────────────────
$data["username"] = $_SESSION["login_data"]['username'];
$data["password"] = $_SESSION["login_data"]['password'];
$user_id = $_SESSION["login_user_id"];
/**
* Validate OTP
*/
function generateOTP($sercet_key, $time_step = 180, $length = 6){
$counter = floor($_SESSION["otpTime"] / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
// ── Step 2: Fetch user record — need password hash to re-derive the OTP ───────
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
$sth->execute([":user_id" => $user_id]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
// ── Step 3: Re-derive expected OTP ────────────────────────────────────────────
// Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP
// counter base. This is the same algorithm used in login_otp.php and
// request_new_otp.php — any change to one must be reflected in all three.
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
$counter = floor($_SESSION["otpTime"] / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
$otp = generateOTP($temp["password"]);
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
// time diff between $_SESSION["otpTime"] and now() in minutes
$otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0;
$now = time();
$otp_diff_seconds = max(0, $now - $otp_time);
$otp_diff_minutes = $otp_diff_seconds / 60.0;
$otp = generateOTP($temp["password"]);
// print time
$_SESSION["now"] = $now;
$_SESSION["diff"] = $otp_diff_minutes;
// ── Step 3b: Calculate elapsed time since OTP was issued ──────────────────────
// otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent.
// The diff is computed in minutes for the 5-minute validity window check.
$otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0;
$now = time();
$otp_diff_seconds = max(0, $now - $otp_time);
$otp_diff_minutes = $otp_diff_seconds / 60.0;
/**
* Validate OTP
*/
if( $data["otp"]!=$otp || $otp_diff_minutes > 5 ){
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
// Store for debug convenience — visible in $_SESSION on the session inspect page
$_SESSION["now"] = $now;
$_SESSION["diff"] = $otp_diff_minutes;
session_regenerate_id(true); // ← fixes session fixation
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // ← CSRF token
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
// Fails if either the code doesn't match OR more than 5 minutes have elapsed
// since the OTP was issued. The two conditions are intentionally combined in one
// error message to avoid leaking whether the code was correct but expired.
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
/**
* Create login session
*/
$_SESSION["login_status"] = 1;
$_SESSION["login_username"] = $temp["username"];
$_SESSION["login_name"] = $temp["name"];
$_SESSION["login_surname"] = $temp["surname"];
$_SESSION["login_company_id"] = $temp["default_company"];
// ── Step 5a: Regenerate session ID ────────────────────────────────────────────
// session_regenerate_id(true) issues a brand-new session ID and deletes the old
// session file, preventing session fixation attacks where an attacker pre-sets
// a session ID before the user logs in.
session_regenerate_id(true);
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
// ── Step 5b: Issue CSRF token ─────────────────────────────────────────────────
// A fresh 256-bit token is generated here and stored in session. All subsequent
// POST requests from the authenticated app must include this token in the
// X-CSRF-Token header (validated by individual engine endpoints).
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
?>
// ── Step 5c: Write authenticated login session ────────────────────────────────
// These keys are read by db_auth.php on every subsequent request to gate access.
// login_company_id is the user's default_company — used to scope all DB queries.
$_SESSION["login_status"] = 1;
$_SESSION["login_username"] = $temp["username"];
$_SESSION["login_name"] = $temp["name"];
$_SESSION["login_surname"] = $temp["surname"];
$_SESSION["login_company_id"] = $temp["default_company"];
// ── Step 6: Respond ───────────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
+298 -213
View File
@@ -1,253 +1,338 @@
<?php
require '../../../session.php';
require '../../../config.php';
require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
<?php
/**
* login_otp.php — Step 1 of 2-factor login: credential validation + OTP dispatch
*
* Called by: login page AJAX on first form submission (username + password).
* Input: $data['username'], $data['password'], $data['cookie'] (from preset.php)
*
* This is the first of two login steps. It validates the user's credentials,
* runs all pre-login checks, generates a TOTP, emails it to the user, and
* stores the OTP state in session so login_confirm.php can verify it.
*
* Full flow:
* 1. Resolve user_id by username or email (case-insensitive).
* 2. Fetch hashed password and full user record.
* 3. Verify submitted password via password_verify().
* 4. On failure → clear cookies, return "Incorrect Password".
* 5. On success → run the following pre-login checks in order:
* a. Email format guard (malformed email → block with message).
* b. Unverified account (status = 'pending'):
* - Generate a fresh 30-day verification token.
* - Resend verification email (silently ignore mailer errors).
* - Return a message instructing the user to check their inbox.
* c. Deactivated account (status = 'not activated') → block with message.
* d. Secure-login / device whitelist check (if enabled in $pinform):
* - Unknown device → register cookie in whitelist (status=1),
* destroy session, return "wait" (device pending approval).
* - Blocked device (status=0) → destroy session, return "block".
* - Pending device (status=1) → destroy session, return "wait",
* trigger new_device_login_alert.php notification.
* - Approved device (status=2) → proceed.
* - Note: 'support' user and 'lord' licence bypass this check.
* e. Licence expiry check: if now > $expire + 1 day → return "expire".
* 6. Generate 6-digit TOTP from the user's password hash (HMAC-SHA1, 3-min window).
* 7. Generate a 6-letter human-readable reference number from the TOTP.
* 8. If the user's default_company has a company_smtp row → send OTP email.
* If no SMTP configured → skip email, set skip_otp flag in response.
* 9. Clear session and repopulate with OTP state:
* login_data, otp, otpTime, reference, user_email, login_user_id, no_smtp.
* 10. Return { success: 1, skip_otp: bool, message: "Login Complete!" }.
* When skip_otp=true the login page skips the OTP step and calls
* login_confirm.php directly.
*
* Session keys written:
* login_data — original { username, password } for request_new_otp.php
* otp — the generated TOTP value
* otpTime — Unix timestamp the OTP was generated (used for expiry check)
* reference — 6-letter reference code shown on the OTP screen
* user_email — masked in UI; full value stored for display
* login_user_id — resolved user_id (used by login_confirm.php)
* no_smtp — true if no company SMTP exists (OTP step is skipped)
*
* Response JSON:
* On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" }
* On failure: { "message": "<reason>" }
* Special: { "message": "wait" } — device pending whitelist approval
* { "message": "block" } — device is blacklisted
* { "expire": "expire" } — licence has expired
*/
// get user_id by username or password
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
$sth->execute(array(strtolower($data["username"])));
$user_id = $sth->fetchColumn();
require '../../../session.php';
require '../../../config.php';
require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
$username = strtolower($data["username"]);
// get password
$sth = $pdo1->prepare("select password from user where username = ? or email = ? limit 1;");
$sth->execute(array($username,$username));
$temp = $sth->fetch(PDO::FETCH_ASSOC);
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
$sth->execute(array(strtolower($data["username"])));
$user_id = $sth->fetchColumn();
/**
* validate password
*/
if(password_verify(trim($data["password"]), $temp["password"])) {
$username = strtolower($data["username"]);
// create user session
if( strtolower($data["username"]) == "support" ){
$s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;");
$r = $s->fetch(PDO::FETCH_ASSOC);
}else{
$s = $pdo1->prepare("select * from user where (username=? or email=?) and user_id = ? limit 1;");
$s->execute(array($username,$username,$user_id));
$r = $s->fetch(PDO::FETCH_ASSOC);
}
// ── Step 2: Fetch the user's hashed password ──────────────────────────────────
$sth = $pdo1->prepare("select password from user where username = ? or email = ? limit 1;");
$sth->execute(array($username, $username));
$temp = $sth->fetch(PDO::FETCH_ASSOC);
// user email
$user_email = $r["email"];
// ── Step 3–4: Verify password — exit with error on mismatch ──────────────────
if (password_verify(trim($data["password"]), $temp["password"])) {
if(strpos($user_email,"@")===false){
$answer["message"] = "<b>".$user_email."</b> is not eligible email, please contact your administrator to change your email.";
exit(json_encode($answer));
}
// ── Step 5a: Fetch full user record ──────────────────────────────────────
// 'support' user gets a hardcoded email so it can always log in even without
// a registered email address in the DB.
if (strtolower($data["username"]) == "support") {
$s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;");
$r = $s->fetch(PDO::FETCH_ASSOC);
} else {
$s = $pdo1->prepare("select * from user where (username=? or email=?) and user_id = ? limit 1;");
$s->execute(array($username, $username, $user_id));
$r = $s->fetch(PDO::FETCH_ASSOC);
}
// ── Block unverified accounts — resend verification email ───
if ($r["status"] === "pending") {
$user_email = $r["email"];
// generate fresh token
$token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
// ── Step 5b: Email format guard ───────────────────────────────────────────
// Blocks accounts with a malformed email (e.g. set by admin without @) so
// the OTP email delivery step further down doesn't silently fail.
if (strpos($user_email, "@") === false) {
$answer["message"] = "<b>" . $user_email . "</b> is not eligible email, please contact your administrator to change your email.";
exit(json_encode($answer));
}
$sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id");
$sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]);
// ── Step 5c: Unverified account (status = 'pending') ─────────────────────
// Generate a fresh verification token and resend the email.
// Errors from the mailer are caught silently so the user still gets the
// "check your inbox" message without exposing internal error details.
if ($r["status"] === "pending") {
// build verify URL
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
$token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
// send email — silently ignore if it fails, don't expose error to user
try {
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $r['email'],
'subject' => 'Verify your email — WMS',
'message' => implode("
", [
"Hi {$r['name']},",
"",
"You attempted to login but your email is not yet verified.",
"Please verify your email address by clicking the button below:",
"",
"<a href='{$verify_url}' style='display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;'>Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href='{$verify_url}'>{$verify_url}</a>",
"",
"This link will expire in 30 days.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
} catch (Exception $e) {
error_log('[resend_verify] ' . $e->getMessage());
}
$sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id");
$sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]);
$answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email.";
exit(json_encode($answer));
}
// Build absolute verify URL from current server context
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
if ($r["status"] === "not activated") {
$answer["message"] = "Your account has been deactivated. Please contact your administrator.";
exit(json_encode($answer));
}
try {
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $r['email'],
'subject' => 'Verify your email — WMS',
'message' => implode("\n", [
"Hi {$r['name']},",
"",
"You attempted to login but your email is not yet verified.",
"Please verify your email address by clicking the button below:",
"",
"<a href='{$verify_url}' style='display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;'>Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href='{$verify_url}'>{$verify_url}</a>",
"",
"This link will expire in 30 days.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
} catch (Exception $e) {
// Log silently — do not expose mailer errors to the end user
error_log('[resend_verify] ' . $e->getMessage());
}
//~ access control
if( isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord"){
$sth = $pdo1->prepare("select * from whitelist where cookie = :cookie");
$sth->execute(array(":cookie"=>$data["cookie"]));
if($sth->rowCount()==0){
$s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);");
$s->execute(array(":cookie"=>$data["cookie"],":ip"=>$_SERVER["REMOTE_ADDR"]));
session_destroy();
$answer["message"] = "wait";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
echo json_encode($answer);
}else{
$coo = $sth->fetch(PDO::FETCH_ASSOC);
if( $coo["status"] == "0" ){
session_destroy();
$answer["message"] = "block";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
echo json_encode($answer);
$answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email.";
exit(json_encode($answer));
}
$deviceDecision = [
'type' => 'BLOCKED',
'status' => 0
];
// ── Step 5d: Deactivated account ─────────────────────────────────────────
if ($r["status"] === "not activated") {
$answer["message"] = "Your account has been deactivated. Please contact your administrator.";
exit(json_encode($answer));
}
}else if( $coo["status"] == "1" ){
session_destroy();
$answer["message"] = "wait";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
echo json_encode($answer);
// ── Step 5e: Secure-login device whitelist check ──────────────────────────
// Only enforced when secure_login is "on" in $pinform and the licence
// is not "lord". The user's browser sends a device cookie ($data["cookie"]).
// - Unknown cookie → INSERT into whitelist with status=1 (pending approval),
// destroy session, return "wait".
// - status=0 (blocked) → destroy session, return "block".
// - status=1 (pending) → destroy session, return "wait",
// fire new_device_login_alert notification.
// - status=2 (approved) → fall through and continue login.
if (isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord") {
$deviceDecision = [
'type' => 'WAIT_APPROVAL',
'status' => 1
];
include __DIR__ . "/api/engine-notification/new_device_login_alert.php";
exit;
}else if( $coo["status"] == "2" ){
//~ you can go
}
}
}
//~ end access control
if( strtotime("now") > strtotime($expire." + 1 day") ){
session_destroy();
$answer["expire"] = "expire";
exit(json_encode($answer));
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
}
$sth = $pdo1->prepare("select * from whitelist where cookie = :cookie");
$sth->execute(array(":cookie" => $data["cookie"]));
if ($sth->rowCount() == 0) {
// Register unknown device as pending approval
$s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);");
$s->execute(array(":cookie" => $data["cookie"], ":ip" => $_SERVER["REMOTE_ADDR"]));
/**
* Generate OTP
*/
function generateOTP($sercet_key, $time_step = 180, $length = 6){
session_destroy();
$answer["message"] = "wait";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
echo json_encode($answer);
global $otpTime;
} else {
$otpTime = time();
$coo = $sth->fetch(PDO::FETCH_ASSOC);
$counter = floor($otpTime / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
if ($coo["status"] == "0") {
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
// Device explicitly blocked by admin
session_destroy();
$answer["message"] = "block";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
echo json_encode($answer);
$deviceDecision = ['type' => 'BLOCKED', 'status' => 0];
function numberToLetters($num) {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
} else if ($coo["status"] == "1") {
$otp = generateOTP($temp["password"]);
// Device registered but not yet approved — notify admin
session_destroy();
$answer["message"] = "wait";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
echo json_encode($answer);
$reference_number = numberToLetters(generateOTP($otp));
$deviceDecision = ['type' => 'WAIT_APPROVAL', 'status' => 1];
include __DIR__ . "/api/engine-notification/new_device_login_alert.php";
exit;
// ── Look up company SMTP using user's default_company ───────
$smtp_config = null;
$default_company = (int)($r["default_company"] ?? 0);
} else if ($coo["status"] == "2") {
// Device approved — continue to OTP step
}
}
}
// ── End secure-login device whitelist check ───────────────────────────────
if($default_company > 0) {
$sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1");
$sth->execute([":cid" => $default_company]);
$smtp_row = $sth->fetch(PDO::FETCH_ASSOC);
if(!empty($smtp_row)) {
$smtp_config = $smtp_row;
}
}
// ── Step 5f: Licence expiry check ────────────────────────────────────────
// $expire is loaded from db_auth.php via session/preset bootstrap.
// If the licence expired more than 1 day ago, reject the login.
// Note: the cookie-clearing lines after exit() are unreachable — left as-is
// to preserve original logic without business-logic changes.
if (strtotime("now") > strtotime($expire . " + 1 day")) {
session_destroy();
$answer["expire"] = "expire";
exit(json_encode($answer));
setcookie("u", "", time() - 1, "/"); // unreachable — preserved from original
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
}
// ── SMTP found → send OTP email ──────────────────────────────
if(!empty($smtp_config)) {
// ── Step 6: Generate 6-digit TOTP ────────────────────────────────────────
// The secret key is the user's current password hash, so the OTP is unique
// per user and automatically invalidated if the password changes.
// time_step=180 means the OTP window is 3 minutes (same counter for 3 min).
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
require "../../../assets/utils/module/mailer.php";
global $otpTime;
$mailer = new mailer(["pdo1"=>$pdo1,"pdo2"=>$pdo2]);
$otpTime = time(); // captured globally so it can be stored in session
$mailer->send_email([
"company_id" => $default_company,
"smtp" => $smtp_config,
"subject" => "One Time Password (OTP) For reference number ".$reference_number,
"message" => "Your OTP is ".$otp." for reference number ".$reference_number,
"channel_name" => "WMS LOGIN OTP",
"to" => $user_email,
"key" => $pinkey,
]);
$counter = floor($otpTime / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
}
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
$_SESSION = [];
// ── Step 7: Generate 6-letter reference number ───────────────────────────
// Converts a second TOTP (derived from the first OTP as key) to a base-26
// uppercase letter string. Shown on the OTP screen so the user can confirm
// they received the correct email.
function numberToLetters($num) {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
$_SESSION["login_data"] = $data;
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime;
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
$_SESSION["no_smtp"] = empty($smtp_config); // flag for login_confirm
$otp = generateOTP($temp["password"]);
$reference_number = numberToLetters(generateOTP($otp));
$answer["success"] = 1;
$answer["skip_otp"] = empty($smtp_config);
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
}
else
{
$answer["message"] = "Incorrect Password";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
exit(json_encode($answer));
}
// ── Step 8: Look up company SMTP and send OTP email ──────────────────────
// Uses the SMTP settings saved for the user's default_company.
// If no SMTP row exists, the email step is skipped and skip_otp=true is
// returned so the login page can proceed directly to login_confirm.php
// without waiting for an OTP the user will never receive.
$smtp_config = null;
$default_company = (int)($r["default_company"] ?? 0);
$answer["success"] = 1;
exit(json_encode($answer));
if ($default_company > 0) {
$sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1");
$sth->execute([":cid" => $default_company]);
$smtp_row = $sth->fetch(PDO::FETCH_ASSOC);
if (!empty($smtp_row)) {
$smtp_config = $smtp_row;
}
}
?>
if (!empty($smtp_config)) {
require "../../../assets/utils/module/mailer.php";
$mailer = new mailer(["pdo1" => $pdo1, "pdo2" => $pdo2]);
$mailer->send_email([
"company_id" => $default_company,
"smtp" => $smtp_config,
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
"message" => "Your OTP is " . $otp . " for reference number " . $reference_number,
"channel_name" => "WMS LOGIN OTP",
"to" => $user_email,
"key" => $pinkey,
]);
}
// ── Step 9: Reset session and write OTP state ─────────────────────────────
// The full session is cleared first to prevent session fixation — any data
// from a previous partial login attempt is discarded before writing new state.
$_SESSION = [];
$_SESSION["login_data"] = $data; // preserved for request_new_otp.php resend flow
$_SESSION["otp"] = $otp; // expected value for login_confirm.php to verify
$_SESSION["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window
$_SESSION["reference"] = $reference_number; // shown on OTP input screen
$_SESSION["user_email"] = $user_email; // shown masked on OTP screen
$_SESSION["login_user_id"] = $user_id; // used by login_confirm.php to build the login session
$_SESSION["no_smtp"] = empty($smtp_config); // true = skip OTP step on login page
// ── Step 10: Respond ──────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["skip_otp"] = empty($smtp_config); // login page skips OTP screen when true
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
} else {
// ── Password mismatch ─────────────────────────────────────────────────────
// Clear identifying cookies on failure to prevent cookie-based session reuse.
$answer["message"] = "Incorrect Password";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
exit(json_encode($answer));
}
$answer["success"] = 1;
exit(json_encode($answer));
+232 -155
View File
@@ -1,174 +1,251 @@
<?php
require '../../../session.php';
require '../../../config.php';
require '../../../dbconn.php';
require '../../../assets/utils/db_helpers.php';
/**
* onboarding.php — Company setup for newly verified users
*
* Called by: onboarding page AJAX after a user has verified their email
* and is setting up their first company.
* Input: JSON body decoded from $_POST['json']:
* company_name, company_name2, channel_name, branch, branch_no,
* email, phone, smtp_host, smtp_username, smtp_password,
* smtp_port, smtp_encryption
*
* This endpoint runs once per user — it creates the company record, links
* the user as owner, sets their default_company, saves SMTP settings, and
* activates the account. The session must contain 'onboarding_user_id'
* (written by verify.php after successful email verification).
*
* Full flow:
* 1. Session guard — rejects if 'onboarding_user_id' is missing (403).
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
* 3. Decode and sanitise input fields.
* 4. Required field validation — company_name and channel_name must be non-empty.
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
* must all be provided (company SMTP is mandatory for WMS email delivery).
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
* 8. Silent SMTP test — attempt to send a test email BEFORE touching the DB.
* If the mailer fails, it exits internally with its own error JSON, so the
* DB is never written with bad SMTP credentials. This is the "test first"
* guard that prevents the user getting locked out by an undeliverable OTP.
* 9. Duplicate channel_name check — 409 if already taken.
* 10. INSERT company_list row.
* 11. INSERT company_map_user row (user_id → company_id, role='owner').
* 12. UPDATE user: set default_company = new company_id, status = 'active'.
* 13. INSERT company_smtp row with the encrypted password.
* 14. Clear onboarding session keys (onboarding_user_id, _name, _email).
* 15. Return { success: 1, message: "Setup complete." }
*
* HTTP status codes used:
* 200 — success
* 403 — session guard failure or CSRF failure
* 409 — duplicate channel_name
* 422 — validation failure (missing required fields)
* 500 — unexpected exception (logged server-side, generic message to client)
*
* Response JSON:
* On success: { "success": 1, "message": "Setup complete." }
* On failure: { "success": 0, "message": "<reason>" }
*/
header('Content-Type: application/json; charset=utf-8');
require '../../../session.php';
require '../../../config.php';
require '../../../dbconn.php';
require '../../../assets/utils/db_helpers.php';
$answer = ['success' => 0, 'message' => ''];
header('Content-Type: application/json; charset=utf-8');
// ─── Must come from onboarding session ───────────────────────
if (empty($_SESSION['onboarding_user_id'])) {
$answer['message'] = 'Invalid session. Please verify your email first.';
$answer = ['success' => 0, 'message' => ''];
// ── Step 1: Session guard ─────────────────────────────────────────────────────
// 'onboarding_user_id' is only written by verify.php after successful email
// verification. If it's missing, this request is out-of-sequence — reject.
if (empty($_SESSION['onboarding_user_id'])) {
$answer['message'] = 'Invalid session. Please verify your email first.';
http_response_code(403);
exit(json_encode($answer));
}
$user_id = (int)$_SESSION['onboarding_user_id'];
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
exit(json_encode(['message' => 'Invalid request.']));
}
}
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
try {
// ── Step 3: Sanitise input ────────────────────────────────────────────────
$company_name = trim($data['company_name'] ?? '');
$company_name2 = trim($data['company_name2'] ?? '');
// channel_name is the URL slug / identifier — strip everything except
// lowercase letters, digits, hyphens, and underscores.
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
$branch_no = trim($data['branch_no'] ?? '00000');
$email = trim($data['email'] ?? '');
$phone = trim($data['phone'] ?? '');
// ── Step 4: Required field validation ────────────────────────────────────
if (!$company_name || !$channel_name) {
$answer['message'] = 'Company name and channel name are required.';
http_response_code(422);
exit(json_encode($answer));
}
$user_id = (int)$_SESSION['onboarding_user_id'];
// ── Step 5: SMTP field validation ────────────────────────────────────────
// SMTP is mandatory because the company needs to send OTP emails to users.
// An account without working SMTP would be unable to complete 2FA login.
$smtp_host = trim($data['smtp_host'] ?? '');
$smtp_username = trim($data['smtp_username'] ?? '');
$smtp_password = $data['smtp_password'] ?? '';
// ─── CSRF ─────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
exit(json_encode(['message' => 'Invalid request.']));
}
if (!$smtp_host || !$smtp_username || !$smtp_password) {
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
http_response_code(422);
exit(json_encode($answer));
}
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
try {
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
$company_name = trim($data['company_name'] ?? '');
$company_name2 = trim($data['company_name2'] ?? '');
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
$branch_no = trim($data['branch_no'] ?? '00000');
$email = trim($data['email'] ?? '');
$phone = trim($data['phone'] ?? '');
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
// so the stored password can be decrypted by the mailer module.
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
if (!$company_name || !$channel_name) {
$answer['message'] = 'Company name and channel name are required.';
http_response_code(422);
exit(json_encode($answer));
}
// Assemble a temporary SMTP config for the test send (step 8)
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// ── SMTP fields required ──────────────────────────────────
$smtp_host = trim($data['smtp_host'] ?? '');
$smtp_username = trim($data['smtp_username'] ?? '');
$smtp_password = $data['smtp_password'] ?? '';
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
// Sends a test email to the onboarding user's registered address.
// If the mailer throws or exits, no DB records have been created yet,
// so the user can correct their SMTP settings and retry cleanly.
require_once $include_url . 'assets/utils/module/mailer.php';
if (!$smtp_host || !$smtp_username || !$smtp_password) {
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
http_response_code(422);
exit(json_encode($answer));
}
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
// ── Silent SMTP test — before touching the DB ─────────────
// Build a temporary config using the encrypted password
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// if mailer fails it exits with its own error JSON — nothing below runs
// ── Duplicate channel name ────────────────────────────────
$sth = $pdo1->prepare('SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1');
$sth->execute([':c' => $channel_name]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'Channel name is already taken. Please choose another.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Insert company ────────────────────────────────────────
$sth = $pdo1->prepare("
INSERT INTO company_list
(channel_name, company_name, company_name2, branch, branch_no, email, phone, fx)
VALUES
(:channel_name, :company_name, :company_name2, :branch, :branch_no, :email, :phone, 'thb')
");
$sth->execute([
':channel_name' => $channel_name,
':company_name' => $company_name,
':company_name2' => $company_name2,
':branch' => $branch,
':branch_no' => $branch_no,
':email' => $email,
':phone' => $phone,
]);
db_check($sth, $answer);
$company_id = (int)$pdo1->lastInsertId();
// ── Map user as owner ─────────────────────────────────────
$sth = $pdo1->prepare("
INSERT INTO company_map_user (company_id, user_id, role, created_at)
VALUES (:company_id, :user_id, 'owner', NOW())
");
$sth->execute([':company_id' => $company_id, ':user_id' => $user_id]);
db_check($sth, $answer);
// ── Set as default company for this user ──────────────────
$sth = $pdo1->prepare("UPDATE user SET default_company = :c, `status` = 'active' WHERE user_id = :u");
$sth->execute([':c' => $company_id, ':u' => $user_id]);
db_check($sth, $answer);
// ── Save SMTP ─────────────────────────────────────────────
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// ── Clear onboarding session ──────────────────────────────
unset(
$_SESSION['onboarding_user_id'],
$_SESSION['onboarding_name'],
$_SESSION['onboarding_email']
);
$answer['success'] = 1;
$answer['message'] = 'Setup complete.';
} catch (Exception $e) {
error_log('[onboarding] ' . $e->getMessage());
$answer['message'] = 'Setup failed. Please try again.';
http_response_code(500);
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
$sth = $pdo1->prepare('SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1');
$sth->execute([':c' => $channel_name]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'Channel name is already taken. Please choose another.';
http_response_code(409);
exit(json_encode($answer));
}
exit(json_encode($answer));
?>
// ── Step 10: Create company record ───────────────────────────────────────
// fx (currency) defaults to 'thb' — can be changed later in company settings.
$sth = $pdo1->prepare("
INSERT INTO company_list
(channel_name, company_name, company_name2, branch, branch_no, email, phone, fx)
VALUES
(:channel_name, :company_name, :company_name2, :branch, :branch_no, :email, :phone, 'thb')
");
$sth->execute([
':channel_name' => $channel_name,
':company_name' => $company_name,
':company_name2' => $company_name2,
':branch' => $branch,
':branch_no' => $branch_no,
':email' => $email,
':phone' => $phone,
]);
db_check($sth, $answer);
$company_id = (int)$pdo1->lastInsertId();
// ── Step 11: Map user as company owner ───────────────────────────────────
// company_map_user is the many-to-many table between users and companies.
// 'owner' role grants full admin access within the company.
$sth = $pdo1->prepare("
INSERT INTO company_map_user (company_id, user_id, role, created_at)
VALUES (:company_id, :user_id, 'owner', NOW())
");
$sth->execute([':company_id' => $company_id, ':user_id' => $user_id]);
db_check($sth, $answer);
// ── Step 12: Activate user account and set default company ───────────────
// Changing status from 'pending' to 'active' lets login_otp.php proceed
// past the unverified-account check. default_company scopes all DB queries
// after login to this company.
$sth = $pdo1->prepare("UPDATE user SET default_company = :c, `status` = 'active' WHERE user_id = :u");
$sth->execute([':c' => $company_id, ':u' => $user_id]);
db_check($sth, $answer);
// ── Step 13: Save company SMTP settings ──────────────────────────────────
// Stored with the encrypted password so the mailer module can decrypt and
// use it for all outgoing email from this company (OTP, notifications, etc.).
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// ── Step 14: Clear onboarding session keys ───────────────────────────────
// These keys are no longer needed and should not persist into the
// authenticated session. The user will be redirected to the login page.
unset(
$_SESSION['onboarding_user_id'],
$_SESSION['onboarding_name'],
$_SESSION['onboarding_email']
);
// ── Step 15: Respond ──────────────────────────────────────────────────────
$answer['success'] = 1;
$answer['message'] = 'Setup complete.';
} catch (Exception $e) {
// Unexpected error — log details server-side, return generic message to client
error_log('[onboarding] ' . $e->getMessage());
$answer['message'] = 'Setup failed. Please try again.';
http_response_code(500);
}
exit(json_encode($answer));
+209 -148
View File
@@ -1,156 +1,217 @@
<?php
require '../../../session.php';
require '../../../config.php';
require '../../../dbconn.php';
require '../../../assets/utils/db_helpers.php';
require '../../../assets/utils/classes/PasswordManager.php';
/**
* register.php — New user registration
*
* Called by: registration page AJAX on form submission.
* Input: JSON body decoded from $_POST['json']:
* name, surname, username, email, password, confirm_password
*
* Creates a new user account in status='pending' (email not yet verified)
* and sends a 30-day email verification link. The user cannot log in until
* they click the verification link and their status changes to 'active'.
*
* Full flow:
* 1. CSRF check — rejects requests missing a valid X-CSRF-Token header.
* 2. Decode and sanitise input fields (trim, lowercase username/email).
* 3. Required field validation — all 6 fields must be non-empty.
* 4. Username format validation — lowercase letters, numbers, underscores only.
* 5. Email format validation — PHP's FILTER_VALIDATE_EMAIL.
* 6. Password match check — $password must equal $confirm_password.
* 7. Duplicate username check — 409 if already taken.
* 8. Duplicate email check — 409 if already registered.
* 9. Password strength check via PasswordManager::checkStrength():
* - zxcvbn score must be ≥ PasswordManager::MIN_SCORE (3).
* - User's own name, surname, username, email passed as penalty inputs.
* - 422 if too weak, with the first actionable zxcvbn suggestion.
* 10. Hash password with PASSWORD_BCRYPT.
* 11. Generate a 64-hex-char verification token (32 random bytes).
* 12. INSERT user row with status='pending' and the verification token.
* 13. Build absolute verify URL: <base_url>/login/verify.php?token=<token>
* 14. Send verification email via system SMTP ($SMTP from config.php).
* If mailer fails, it exits internally with its own error JSON.
* 15. Return { success: 1, message: "Account created! Please check your email..." }
*
* HTTP status codes used:
* 200 — success
* 403 — CSRF failure
* 409 — duplicate username or email
* 422 — validation failure (missing fields, bad format, weak password)
* 500 — unexpected exception (logged server-side, generic message to client)
*
* Response JSON:
* On success: { "success": 1, "message": "Account created! Please check your email to verify your account." }
* On failure: { "success": 0, "message": "<reason>" }
*/
header('Content-Type: application/json; charset=utf-8');
require '../../../session.php';
require '../../../config.php';
require '../../../dbconn.php';
require '../../../assets/utils/db_helpers.php';
require '../../../assets/utils/classes/PasswordManager.php';
$answer = ['success' => 0, 'message' => ''];
header('Content-Type: application/json; charset=utf-8');
// ─── CSRF ─────────────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
exit(json_encode(['message' => 'Invalid request.']));
}
$answer = ['success' => 0, 'message' => ''];
// ── Step 1: CSRF check ────────────────────────────────────────────────────────
// All POST requests must include a valid X-CSRF-Token header matching the token
// stored in session. This prevents cross-site request forgery on the register form.
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
exit(json_encode(['message' => 'Invalid request.']));
}
}
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
try {
// ── Step 2: Sanitise input ────────────────────────────────────────────────
$name = trim($data['name'] ?? '');
$surname = trim($data['surname'] ?? '');
$username = strtolower(trim($data['username'] ?? ''));
$email = strtolower(trim($data['email'] ?? ''));
$password = $data['password'] ?? '';
$confirm = $data['confirm_password'] ?? '';
// ── Step 3: Required field validation ────────────────────────────────────
if (!$name || !$surname || !$username || !$email || !$password || !$confirm) {
$answer['message'] = 'All fields are required.';
http_response_code(422);
exit(json_encode($answer));
}
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
try {
$name = trim($data['name'] ?? '');
$surname = trim($data['surname'] ?? '');
$username = strtolower(trim($data['username'] ?? ''));
$email = strtolower(trim($data['email'] ?? ''));
$password = $data['password'] ?? '';
$confirm = $data['confirm_password'] ?? '';
// ── Required fields ───────────────────────────────────────
if (!$name || !$surname || !$username || !$email || !$password || !$confirm) {
$answer['message'] = 'All fields are required.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Username format ───────────────────────────────────────
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
$answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Email format ──────────────────────────────────────────
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$answer['message'] = 'Invalid email address.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Password match ────────────────────────────────────────
if ($password !== $confirm) {
$answer['message'] = 'Passwords do not match.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Duplicate username ────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1');
$sth->execute([':u' => $username]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'Username is already taken.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Duplicate email ───────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1');
$sth->execute([':e' => $email]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'An account with that email already exists.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Password strength ─────────────────────────────────────
$pm = new PasswordManager($pdo1, $include_url);
$result = $pm->checkStrength($password, [$name, $surname, $username, $email]);
if ($result['score'] < PasswordManager::MIN_SCORE) {
$msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.');
$answer['message'] = 'Password is too weak. ' . $msg;
http_response_code(422);
exit(json_encode($answer));
}
// ── Insert user with status=pending ───────────────────────
$hashed = password_hash($password, PASSWORD_BCRYPT);
$token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
$sth = $pdo1->prepare("
INSERT INTO user
(username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at)
VALUES
(:username, :name, :surname, :email, :password, 'pending', '', :token, :expires)
");
$sth->execute([
':username' => $username,
':name' => $name,
':surname' => $surname,
':email' => $email,
':password' => $hashed,
':token' => $token,
':expires' => $expires_at,
]);
db_check($sth, $answer);
// ── Send verification email via default SMTP ──────────────
// Build absolute URL
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $email,
'subject' => 'Verify your email — WMS',
'message' => implode("\n", [
"Hi {$name},",
"",
"Thanks for registering. Please verify your email address by clicking the button below:",
"",
"<a href=\"{$verify_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href=\"{$verify_url}\">{$verify_url}</a>",
"",
"This link will expire in 30 days.",
"",
"If you did not create an account, you can ignore this email.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
$answer['success'] = 1;
$answer['message'] = 'Account created! Please check your email to verify your account.';
} catch (Exception $e) {
error_log('[register] ' . $e->getMessage());
$answer['message'] = 'Registration failed. Please try again.';
http_response_code(500);
// ── Step 4: Username format validation ───────────────────────────────────
// Restricts usernames to URL-safe characters — prevents injection via
// username in any context where it appears in a URL or query.
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
$answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.';
http_response_code(422);
exit(json_encode($answer));
}
exit(json_encode($answer));
?>
// ── Step 5: Email format validation ──────────────────────────────────────
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$answer['message'] = 'Invalid email address.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 6: Password match check ─────────────────────────────────────────
if ($password !== $confirm) {
$answer['message'] = 'Passwords do not match.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 7: Duplicate username check ─────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1');
$sth->execute([':u' => $username]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'Username is already taken.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Step 8: Duplicate email check ────────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1');
$sth->execute([':e' => $email]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'An account with that email already exists.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Step 9: Password strength check via PasswordManager ──────────────────
// Passes user's own personal data as penalty inputs so zxcvbn penalises
// passwords that contain the user's name, username, or email.
$pm = new PasswordManager($pdo1, $include_url);
$result = $pm->checkStrength($password, [$name, $surname, $username, $email]);
if ($result['score'] < PasswordManager::MIN_SCORE) {
$msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.');
$answer['message'] = 'Password is too weak. ' . $msg;
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 10–11: Hash password and generate verification token ─────────────
$hashed = password_hash($password, PASSWORD_BCRYPT);
$token = bin2hex(random_bytes(32)); // 64-char hex token
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
// ── Step 12: Insert user with status='pending' ────────────────────────────
// status='pending' means the account exists but cannot log in until the
// email is verified. login_otp.php checks this and re-sends the verify email
// if the user tries to log in before verifying.
$sth = $pdo1->prepare("
INSERT INTO user
(username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at)
VALUES
(:username, :name, :surname, :email, :password, 'pending', '', :token, :expires)
");
$sth->execute([
':username' => $username,
':name' => $name,
':surname' => $surname,
':email' => $email,
':password' => $hashed,
':token' => $token,
':expires' => $expires_at,
]);
db_check($sth, $answer);
// ── Step 13: Build absolute verify URL ───────────────────────────────────
// $server_url is the app's root path from config.php (e.g. '/wms').
// The full URL is constructed from the current request's server context
// so it works correctly across dev / staging / production environments.
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
// ── Step 14: Send verification email via system SMTP ─────────────────────
// Uses $SMTP from config.php (system-level, not company SMTP) because the
// user does not have a company yet at registration time.
// If the mailer fails it exits internally with its own error JSON response.
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $email,
'subject' => 'Verify your email — WMS',
'message' => implode("\n", [
"Hi {$name},",
"",
"Thanks for registering. Please verify your email address by clicking the button below:",
"",
"<a href=\"{$verify_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href=\"{$verify_url}\">{$verify_url}</a>",
"",
"This link will expire in 30 days.",
"",
"If you did not create an account, you can ignore this email.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
// ── Step 15: Respond ──────────────────────────────────────────────────────
$answer['success'] = 1;
$answer['message'] = 'Account created! Please check your email to verify your account.';
} catch (Exception $e) {
// Unexpected error — log details server-side, return generic message to client
error_log('[register] ' . $e->getMessage());
$answer['message'] = 'Registration failed. Please try again.';
http_response_code(500);
}
exit(json_encode($answer));
+130 -92
View File
@@ -1,114 +1,152 @@
<?php
require '../../../session.php';
require '../../../config.php';
require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
<?php
/**
* request_new_otp.php — Resend OTP during the 2-factor login flow
*
* Called by: login page AJAX "Resend OTP" button on the OTP input screen.
* Input: All data sourced from $_SESSION (written by login_otp.php).
* No new user input is accepted — credentials are re-read from session
* to avoid re-exposing the password in a second HTTP request.
*
* This endpoint regenerates a fresh TOTP and resends the OTP email without
* requiring the user to re-enter their username and password. It is only
* reachable after login_otp.php has successfully validated credentials and
* written the login session state.
*
* Full flow:
* 1. Reload username, password, and user_id from session.
* 2. Fetch the full user row (need the password hash to regenerate OTP
* and the email address to resend to).
* 3. Re-verify the stored password against the session-stored hash.
* This is a safety re-check — the session could theoretically have been
* tampered with between login_otp.php and this call.
* 4. On password mismatch → clear cookies, return "Incorrect Password".
* 5. On success:
* a. Generate a fresh 6-digit TOTP (new timestamp → new OTP).
* b. Generate a new 6-letter reference number.
* c. Send the OTP email via system SMTP ($SMTP from config.php).
* Note: uses system-level SMTP unconditionally (unlike login_otp.php
* which tries the company SMTP first). The if(true) wrapper is a
* placeholder left from the original — email always sends.
* d. Clear session and repopulate with new OTP state.
* 6. Return { success: 1, message: "Login Complete!" }.
*
* Session keys read:
* login_data['username'], login_data['password'], login_user_id
*
* Session keys overwritten:
* login_data, otp, otpTime, reference, user_email, login_user_id
* (same keys as login_otp.php — login_confirm.php reads the same structure)
*
* Response JSON:
* On success: { "success": 1, "message": "Login Complete!" }
* On failure: { "message": "Incorrect Password" }
*/
$data["username"] = $_SESSION["login_data"]['username'];
$data["password"] = $_SESSION["login_data"]['password'];
$user_id = (int)$_SESSION["login_user_id"];
require '../../../session.php';
require '../../../config.php';
require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
// get password
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
$sth->execute([
":user_id" => $user_id
]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
// ── Step 1: Reload credentials from session ───────────────────────────────────
// These were stored by login_otp.php so the user doesn't have to retype them.
$data["username"] = $_SESSION["login_data"]['username'];
$data["password"] = $_SESSION["login_data"]['password'];
$user_id = (int)$_SESSION["login_user_id"];
// user email
$user_email = $temp["email"];
// ── Step 2: Fetch user record ─────────────────────────────────────────────────
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
$sth->execute([":user_id" => $user_id]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
/**
* validate password
*/
if(password_verify(trim($data["password"]), $temp["password"])) {
$user_email = $temp["email"];
/**
* Generate OTP
*/
function generateOTP($sercet_key, $time_step = 180, $length = 6){
// ── Step 3–4: Re-verify password ─────────────────────────────────────────────
// Safety check — ensures the session hasn't been tampered with between
// login_otp.php and this resend call.
if (password_verify(trim($data["password"]), $temp["password"])) {
global $otpTime;
// ── Step 5a: Generate fresh 6-digit TOTP ──────────────────────────────────
// Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php.
// A new $otpTime is captured so the OTP window resets from this moment.
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
$otpTime = time();
global $otpTime;
$counter = floor($otpTime / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
$otpTime = time(); // new timestamp — extends the 5-minute validity window
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
$counter = floor($otpTime / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
function numberToLetters($num) {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
// ── Step 5b: Generate 6-letter reference number ───────────────────────────
// Converts a second TOTP (derived from the first OTP as the key) to a
// base-26 uppercase letter string shown on the OTP input screen.
function numberToLetters($num) {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
$otp = generateOTP($temp["password"]);
$otp = generateOTP($temp["password"]);
$reference_number = numberToLetters(generateOTP($otp));
$reference_number = numberToLetters(generateOTP($otp));
// ── Step 5c: Send OTP email ───────────────────────────────────────────────
// Uses the system-level $SMTP config from config.php.
// The if(true) wrapper is a no-op placeholder from the original code —
// the email block always executes.
require "../../../assets/utils/module/mailer.php";
/**
* Sent Email With OTP
*/
require "../../../assets/utils/module/mailer.php";
if (true) {
// send email
if(true){
$mailer = new mailer(["pdo1" => $pdo1]);
$mailer = new mailer(["pdo1"=>$pdo1]);
$mailer->send_email([
"company_id" => 0,
"smtp" => $SMTP,
"subject" => "One Time Password (OTP) For reference number " . $reference_number,
"message" => "Your OTP is " . $otp . " for reference number " . $reference_number,
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
}
$mailer->send_email([
"company_id" => 0,
"smtp" => $SMTP,
"subject" => "One Time Password (OTP) For reference number ".$reference_number,
"message" => "Your OTP is ".$otp." for reference number ".$reference_number,
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
// ── Step 5d: Reset session with new OTP state ─────────────────────────────
// Full session is cleared before repopulating to avoid stale state
// from the previous OTP attempt leaking into this one.
$_SESSION = [];
}
$_SESSION["login_data"] = $data;
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
// ── Step 6: Respond ───────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
$_SESSION = [];
} else {
$_SESSION["login_data"] = $data; // store variables
// ── Password mismatch — clear cookies and reject ──────────────────────────
$answer["message"] = "Incorrect Password";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
exit(json_encode($answer));
}
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime;
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
}
else
{
$answer["message"] = "Incorrect Password";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
exit(json_encode($answer));
}
$answer["success"] = 1;
exit(json_encode($answer));
?>
$answer["success"] = 1;
exit(json_encode($answer));