Compare commits
13
Commits
sdlc
...
2ef2f32107
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef2f32107 | ||
|
|
1f72633cb6 | ||
|
|
4efab9f7c9 | ||
|
|
44c66c49a5 | ||
|
|
6b3a590aa9 | ||
|
|
21148bf50c | ||
|
|
cf8106771b | ||
|
|
d7203583b7 | ||
|
|
9afcf072b0 | ||
|
|
2f290ddb26 | ||
|
|
5d021d7683 | ||
|
|
5ee0c8d41b | ||
|
|
c3113bc70d |
@@ -13,5 +13,11 @@ EMIT_SECRET=
|
|||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
|
|
||||||
|
# Email OTP on sign-in. Off by default; only the exact value "true" turns it on,
|
||||||
|
# and that needs working SMTP. While off, sign-in is password only (logged as
|
||||||
|
# OTP_BYPASSED, shown on the login page and top bar).
|
||||||
|
# Applied to app/config.php by the php container on every start.
|
||||||
|
OTP_REQUIRED=false
|
||||||
|
|
||||||
# Port to expose the web app on (default 80)
|
# Port to expose the web app on (default 80)
|
||||||
HTTP_PORT=80
|
HTTP_PORT=80
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class CompanyProfileManager
|
|||||||
|
|
||||||
public function saveProfile(array $data, string $company_logo, string $company_seal): void
|
public function saveProfile(array $data, string $company_logo, string $company_seal): void
|
||||||
{
|
{
|
||||||
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
$channel = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"UPDATE company_list SET
|
"UPDATE company_list SET
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
// app/assets/utils/otp_policy.php
|
||||||
|
//
|
||||||
|
// Email OTP login policy, set by OTP_REQUIRED in config.php.
|
||||||
|
//
|
||||||
|
// OFF BY DEFAULT: the OTP step runs only when the constant is defined and is
|
||||||
|
// exactly the boolean true. A missing constant (any config.php written before
|
||||||
|
// this switch existed), 1, 'true' or a typo all leave it off, so sign-in is
|
||||||
|
// password only and no SMTP is needed to log in.
|
||||||
|
//
|
||||||
|
// While it is off, every sign-in that skips the OTP because of it is logged as
|
||||||
|
// OTP_BYPASSED, and the login page and top bar both say so on screen — a
|
||||||
|
// password-only sign-in must never be invisible to whoever is using it.
|
||||||
|
//
|
||||||
|
// Only the login OTP is affected. When it is on, the staff/viewer and no-SMTP
|
||||||
|
// skips in login_otp.php still apply; password-reset OTPs (PasswordResetManager)
|
||||||
|
// are a separate flow that stays on regardless.
|
||||||
|
|
||||||
|
if (!function_exists('otp_required')) {
|
||||||
|
function otp_required(): bool {
|
||||||
|
return defined('OTP_REQUIRED') && OTP_REQUIRED === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!function_exists('otp_log_bypass')) {
|
||||||
|
// There is no auth log table in this app, so bypasses go to the PHP error
|
||||||
|
// log (the container's Apache log) under a fixed, greppable tag.
|
||||||
|
function otp_log_bypass($user_id, string $where): void {
|
||||||
|
error_log(sprintf(
|
||||||
|
'[auth] OTP_BYPASSED user_id=%d ip=%s where=%s -- OTP_REQUIRED is not true in config.php',
|
||||||
|
(int)$user_id,
|
||||||
|
$_SERVER['REMOTE_ADDR'] ?? '-',
|
||||||
|
$where
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,15 @@ if (!defined('NODE_EMIT_SECRET')) {
|
|||||||
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
|
define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Login OTP ────────────────────────────────────────────────────────────────
|
||||||
|
// Email OTP on sign-in. OFF BY DEFAULT: only the boolean true turns it on —
|
||||||
|
// anything else, the constant being absent included, leaves sign-in password
|
||||||
|
// only (logged as OTP_BYPASSED, shown on the login page and top bar). Turn it
|
||||||
|
// on only with working SMTP. Password-reset OTPs are not affected.
|
||||||
|
if (!defined('OTP_REQUIRED')) {
|
||||||
|
define('OTP_REQUIRED', false);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Usage packages ───────────────────────────────────────────────────────────
|
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||||
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
|
// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to
|
||||||
// enforce daily/weekly action limits and which features lock once exceeded.
|
// enforce daily/weekly action limits and which features lock once exceeded.
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class db_statement extends PDOStatement {
|
|||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PDOStatement::execute() is declared ?array $params = null : bool. This
|
||||||
|
// override deliberately accepts a looser signature so callers may pass
|
||||||
|
// positional arguments (see func_get_args() below), so the tightened return
|
||||||
|
// type is opted out of rather than the call sites being changed.
|
||||||
|
#[\ReturnTypeWillChange]
|
||||||
public function execute($args = null) {
|
public function execute($args = null) {
|
||||||
// Perform logging here. PDO object is accessible
|
// Perform logging here. PDO object is accessible
|
||||||
// from $this->pdo.
|
// from $this->pdo.
|
||||||
|
|||||||
@@ -1,4 +1,23 @@
|
|||||||
<?php
|
<?php
|
||||||
|
// Output buffering must be active before the first byte of HTML below, so that
|
||||||
|
// header() calls made later in the page still work — notably the
|
||||||
|
// not-logged-in redirect in include_topbar.php, which runs *after* this file
|
||||||
|
// has already emitted <!DOCTYPE html>. Without a buffer that redirect depends
|
||||||
|
// entirely on php.ini's output_buffering: it is on for the dev stack but off
|
||||||
|
// in production, where every protected page answered 200 with a half-rendered
|
||||||
|
// body instead of sending the browser to the login form. session.php starts a
|
||||||
|
// buffer for the same reason.
|
||||||
|
if (ob_get_level() === 0) {
|
||||||
|
ob_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never render PHP notices/warnings into the page: they leak absolute server
|
||||||
|
// paths to anonymous visitors and corrupt the markup. Errors still reach the
|
||||||
|
// server log. This mirrors the policy db_auth.php already applies to the JSON
|
||||||
|
// API routes, and keeps the app safe even where php.ini has display_errors on.
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('log_errors', '1');
|
||||||
|
|
||||||
// Security headers — emitted before any HTML output.
|
// Security headers — emitted before any HTML output.
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
header('X-Frame-Options: SAMEORIGIN');
|
header('X-Frame-Options: SAMEORIGIN');
|
||||||
|
|||||||
@@ -3,11 +3,17 @@
|
|||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/dbconn.php';
|
require_once __DIR__ . '/dbconn.php';
|
||||||
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
|
require_once __DIR__ . '/assets/utils/classes/UsageGuard.php';
|
||||||
|
require_once __DIR__ . '/assets/utils/otp_policy.php';
|
||||||
|
|
||||||
// Redirect to login if the user has not completed full authentication.
|
// Redirect to login if the user has not completed full authentication.
|
||||||
// login_company_id is only written by login_confirm.php after OTP is verified —
|
// login_company_id is only written by login_confirm.php after OTP is verified —
|
||||||
// using it (not "otp") ensures half-logged-in sessions are also redirected.
|
// using it (not "otp") ensures half-logged-in sessions are also redirected.
|
||||||
if(empty($_SESSION["login_company_id"])){
|
if(empty($_SESSION["login_company_id"])){
|
||||||
|
// Discard the markup include_header.php has already buffered so the browser
|
||||||
|
// receives a clean redirect rather than a partially rendered page body.
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
header('Location: '.$server_url.'login/index.php');
|
header('Location: '.$server_url.'login/index.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -193,6 +199,18 @@ $_usage_full = $_usage_max_pct >= 100;
|
|||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Email OTP off (the default): a password-only sign-in must never be invisible to whoever is using it -->
|
||||||
|
<?php if (!otp_required()): ?>
|
||||||
|
<li class="d-none d-md-block">
|
||||||
|
<span class="badge bg-warning text-dark d-flex align-items-center gap-1 px-2 py-1"
|
||||||
|
style="font-size:11px; cursor:default;"
|
||||||
|
title="OTP_REQUIRED is not true in config.php">
|
||||||
|
<i class="ti ti-shield-off"></i>
|
||||||
|
OTP off
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Usage limit warning -->
|
<!-- Usage limit warning -->
|
||||||
<?php if ($_usage_full || $_usage_warn): ?>
|
<?php if ($_usage_full || $_usage_warn): ?>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ require_once '../../../config.php';
|
|||||||
require_once '../../../preset.php';
|
require_once '../../../preset.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
require_once '../../../assets/utils/otp_policy.php';
|
||||||
|
|
||||||
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
// ── Step 1: Load session state written by login_otp.php ───────────────────────
|
||||||
$data["username"] = $_SESSION["login_data"]['username'];
|
$data["username"] = $_SESSION["login_data"]['username'];
|
||||||
@@ -100,9 +101,15 @@ $_SESSION["diff"] = $otp_diff_minutes;
|
|||||||
|
|
||||||
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
// ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
|
||||||
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
|
// Skipped for staff/viewer roles — login_otp.php sets skip_otp=true in session
|
||||||
// so they never receive or enter an OTP. Admin/owner always go through this check.
|
// so they never receive or enter an OTP. Admin/owner always go through this check,
|
||||||
|
// unless OTP_REQUIRED=false in config.php: that also covers a user who was already
|
||||||
|
// on the OTP screen when the switch was turned off.
|
||||||
if (empty($_SESSION['skip_otp'])) {
|
if (empty($_SESSION['skip_otp'])) {
|
||||||
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
if (!otp_required()) {
|
||||||
|
if (!empty($user_id)) {
|
||||||
|
otp_log_bypass($user_id, 'login_confirm');
|
||||||
|
}
|
||||||
|
} elseif ($data["otp"] != $otp || $otp_diff_minutes > 5) {
|
||||||
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ require_once '../../../config.php';
|
|||||||
require_once '../../../preset.php';
|
require_once '../../../preset.php';
|
||||||
define('UNAUTHENTICATED_ROUTE', true);
|
define('UNAUTHENTICATED_ROUTE', true);
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
require_once '../../../assets/utils/db_auth.php';
|
||||||
|
require_once '../../../assets/utils/otp_policy.php';
|
||||||
|
|
||||||
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
|
// ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
|
||||||
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
|
||||||
@@ -253,11 +254,15 @@ if (password_verify(trim($data["password"]), $temp["password"])) {
|
|||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5g: Role check — staff/viewer skip OTP entirely ─────────────────
|
// ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─
|
||||||
// Owners always require 2FA. Invited users (license='user') require 2FA only
|
// OTP_REQUIRED=false in config.php turns the email OTP off for everyone and
|
||||||
|
// logs the sign-in as a bypass (see assets/utils/otp_policy.php).
|
||||||
|
// Otherwise owners always require 2FA. Invited users (license='user') require 2FA only
|
||||||
// if their role in this company is admin or owner; staff/viewer go straight in.
|
// if their role in this company is admin or owner; staff/viewer go straight in.
|
||||||
$requires_otp = true;
|
$requires_otp = otp_required();
|
||||||
if (($r['license'] ?? 'owner') !== 'owner') {
|
if (!$requires_otp) {
|
||||||
|
otp_log_bypass($user_id, 'login_otp');
|
||||||
|
} elseif (($r['license'] ?? 'owner') !== 'owner') {
|
||||||
$sth_role = $pdo1->prepare(
|
$sth_role = $pdo1->prepare(
|
||||||
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
|
"SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
|
||||||
* 3. Decode and sanitise input fields.
|
* 3. Decode and sanitise input fields.
|
||||||
* 4. Required field validation — company_name and channel_name must be non-empty.
|
* 4. Required field validation — company_name and channel_name must be non-empty.
|
||||||
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
|
* 5. SMTP validation — smtp_host, smtp_username, smtp_password must all be
|
||||||
* must all be provided (company SMTP is mandatory for WMS email delivery).
|
* provided while email OTP is on (company SMTP delivers the OTP). With
|
||||||
|
* OTP_REQUIRED=false they are optional but all-or-nothing: left blank,
|
||||||
|
* steps 6-8 and 13 are skipped and the company is created without SMTP.
|
||||||
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
|
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
|
||||||
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
|
* 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).
|
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
|
||||||
@@ -52,6 +54,7 @@ require_once '../../../session.php';
|
|||||||
require_once '../../../config.php';
|
require_once '../../../config.php';
|
||||||
require_once '../../../dbconn.php';
|
require_once '../../../dbconn.php';
|
||||||
require_once '../../../assets/utils/db_helpers.php';
|
require_once '../../../assets/utils/db_helpers.php';
|
||||||
|
require_once '../../../assets/utils/otp_policy.php';
|
||||||
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
@@ -97,9 +100,9 @@ try {
|
|||||||
$company_name = trim($data['company_name'] ?? '');
|
$company_name = trim($data['company_name'] ?? '');
|
||||||
$company_name2 = trim($data['company_name2'] ?? '');
|
$company_name2 = trim($data['company_name2'] ?? '');
|
||||||
|
|
||||||
// channel_name is the URL slug / identifier — strip everything except
|
// channel_name is the URL slug / identifier — lowercase first, then strip
|
||||||
// lowercase letters, digits, hyphens, and underscores.
|
// everything except lowercase letters, digits, hyphens, and underscores.
|
||||||
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
$channel_name = preg_replace('/[^a-z0-9\-_]/', '', strtolower(trim($data['channel_name'] ?? '')));
|
||||||
|
|
||||||
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
|
||||||
$branch_no = trim($data['branch_no'] ?? '00000');
|
$branch_no = trim($data['branch_no'] ?? '00000');
|
||||||
@@ -114,19 +117,26 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
// ── Step 5: SMTP field validation ────────────────────────────────────────
|
||||||
// SMTP is mandatory because the company needs to send OTP emails to users.
|
// While email OTP is on, SMTP is mandatory: the company needs it to send OTP
|
||||||
// An account without working SMTP would be unable to complete 2FA login.
|
// emails, and an account without working SMTP could not complete 2FA login.
|
||||||
|
// With OTP_REQUIRED=false in config.php it is optional — all three fields
|
||||||
|
// left blank means "no SMTP", and the test send (step 8) and the company_smtp
|
||||||
|
// row (step 13) are skipped. Partly filled is an error either way.
|
||||||
$smtp_host = trim($data['smtp_host'] ?? '');
|
$smtp_host = trim($data['smtp_host'] ?? '');
|
||||||
$smtp_username = trim($data['smtp_username'] ?? '');
|
$smtp_username = trim($data['smtp_username'] ?? '');
|
||||||
$smtp_password = $data['smtp_password'] ?? '';
|
$smtp_password = $data['smtp_password'] ?? '';
|
||||||
|
$smtp_given = ($smtp_host !== '' || $smtp_username !== '' || $smtp_password !== '');
|
||||||
|
|
||||||
if (!$smtp_host || !$smtp_username || !$smtp_password) {
|
if ((otp_required() || $smtp_given) && (!$smtp_host || !$smtp_username || !$smtp_password)) {
|
||||||
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
|
$answer['message'] = otp_required()
|
||||||
|
? 'SMTP configuration is required. Please fill in all SMTP fields.'
|
||||||
|
: 'Fill in SMTP host, username and password, or leave all three blank.';
|
||||||
http_response_code(422);
|
http_response_code(422);
|
||||||
exit(json_encode($answer));
|
exit(json_encode($answer));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 6: Normalise SMTP port and encryption ────────────────────────────
|
if ($smtp_given) {
|
||||||
|
// ── Step 6: Normalise SMTP port and encryption ────────────────────────
|
||||||
// Clamp to known-good values to prevent storing unsupported configuration.
|
// Clamp to known-good values to prevent storing unsupported configuration.
|
||||||
$smtp_port = trim($data['smtp_port'] ?? '587');
|
$smtp_port = trim($data['smtp_port'] ?? '587');
|
||||||
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
|
||||||
@@ -134,7 +144,7 @@ try {
|
|||||||
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
|
||||||
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
|
||||||
|
|
||||||
// ── Step 7: Encrypt SMTP password ────────────────────────────────────────
|
// ── Step 7: Encrypt SMTP password ────────────────────────────────────
|
||||||
// Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
|
// 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.
|
// so the stored password can be decrypted by the mailer module.
|
||||||
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
|
||||||
@@ -150,7 +160,7 @@ try {
|
|||||||
'encryption' => $smtp_encryption,
|
'encryption' => $smtp_encryption,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
|
// ── Step 8: Silent SMTP test — before any DB writes ──────────────────
|
||||||
// Sends a test email to the onboarding user's registered address.
|
// Sends a test email to the onboarding user's registered address.
|
||||||
// If the mailer throws or exits, no DB records have been created yet,
|
// If the mailer throws or exits, no DB records have been created yet,
|
||||||
// so the user can correct their SMTP settings and retry cleanly.
|
// so the user can correct their SMTP settings and retry cleanly.
|
||||||
@@ -167,6 +177,7 @@ try {
|
|||||||
'key' => $pinkey,
|
'key' => $pinkey,
|
||||||
]);
|
]);
|
||||||
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
// If mailer fails, it calls exit() internally — nothing below this line runs.
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
|
// ── Step 9: Duplicate channel_name check ─────────────────────────────────
|
||||||
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
|
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
|
||||||
@@ -226,6 +237,9 @@ try {
|
|||||||
// ── Step 13: Save company SMTP settings ──────────────────────────────────
|
// ── Step 13: Save company SMTP settings ──────────────────────────────────
|
||||||
// Stored with the encrypted password so the mailer module can decrypt and
|
// Stored with the encrypted password so the mailer module can decrypt and
|
||||||
// use it for all outgoing email from this company (OTP, notifications, etc.).
|
// use it for all outgoing email from this company (OTP, notifications, etc.).
|
||||||
|
// Skipped when no SMTP was given (only allowed with OTP_REQUIRED=false); it
|
||||||
|
// can be added later under Settings → SMTP.
|
||||||
|
if ($smtp_given) {
|
||||||
$sth = $pdo1->prepare("
|
$sth = $pdo1->prepare("
|
||||||
INSERT INTO company_smtp
|
INSERT INTO company_smtp
|
||||||
(company_id, server, port, username, password,
|
(company_id, server, port, username, password,
|
||||||
@@ -245,6 +259,7 @@ try {
|
|||||||
':encryption' => $smtp_encryption,
|
':encryption' => $smtp_encryption,
|
||||||
]);
|
]);
|
||||||
db_check($sth, $answer);
|
db_check($sth, $answer);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
// ── Step 14: Clear onboarding session keys ───────────────────────────────
|
||||||
// These keys are no longer needed and should not persist into the
|
// These keys are no longer needed and should not persist into the
|
||||||
|
|||||||
+18
-2
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require '../session.php';
|
require '../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
|
require_once '../assets/utils/otp_policy.php';
|
||||||
require '../include_header.php';
|
require '../include_header.php';
|
||||||
// successful login — redirect based on app_access
|
// successful login — redirect based on app_access
|
||||||
if(!empty($_SESSION["login_status"])){
|
if(!empty($_SESSION["login_status"])){
|
||||||
@@ -32,6 +33,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="needs-validation mt-3" novalidate id="login-form">
|
<form class="needs-validation mt-3" novalidate id="login-form">
|
||||||
|
<?php if (!otp_required()): ?>
|
||||||
|
<!-- OTP_REQUIRED is not true in config.php (the default): a password-only sign-in must never be invisible -->
|
||||||
|
<div class="alert alert-warning small py-2 mb-3" title="OTP_REQUIRED is not true in config.php">
|
||||||
|
<i class="ti ti-alert-triangle me-1"></i>
|
||||||
|
Email OTP is off — sign-in is password only.
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<!-- first step login [OTP] -->
|
<!-- first step login [OTP] -->
|
||||||
<?php if(!isset($_SESSION['login_data'])){?>
|
<?php if(!isset($_SESSION['login_data'])){?>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -51,7 +59,7 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<!-- "Remember me" is intentionally excluded.
|
<!-- "Remember me" is intentionally excluded.
|
||||||
This login uses 2FA (OTP via email) on every session.
|
This login uses 2FA (OTP via email) on every session when OTP_REQUIRED=true in config.php (off by default).
|
||||||
A persistent login would bypass the OTP step and undermine the security model.
|
A persistent login would bypass the OTP step and undermine the security model.
|
||||||
Do not add this back. -->
|
Do not add this back. -->
|
||||||
</div>
|
</div>
|
||||||
@@ -62,6 +70,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<?php }else{ ?>
|
<?php }else{ ?>
|
||||||
<!-- second step login -->
|
<!-- second step login -->
|
||||||
|
<?php if (otp_required()): ?>
|
||||||
<div class="alert alert-warning small py-2 mb-3">
|
<div class="alert alert-warning small py-2 mb-3">
|
||||||
<i class="ti ti-mail me-1"></i>
|
<i class="ti ti-mail me-1"></i>
|
||||||
OTP is sent via your company's SMTP setting.
|
OTP is sent via your company's SMTP setting.
|
||||||
@@ -73,13 +82,20 @@
|
|||||||
<span>One Time Password</span>
|
<span>One Time Password</span>
|
||||||
</label>
|
</label>
|
||||||
<input id="otp" type="otp" class="form-control"
|
<input id="otp" type="otp" class="form-control"
|
||||||
placeholder="your otp for reference number <?php echo $_SESSION["reference"]?>" required minlength="6">
|
placeholder="your otp for reference number <?php echo $_SESSION["reference"] ?? ''?>" required minlength="6">
|
||||||
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
|
<div class="invalid-feedback">Please provide a otp (min 6 characters).</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<!-- OTP_REQUIRED was switched off while this session sat on the OTP step:
|
||||||
|
login_confirm.php no longer checks the code, so there is nothing to type. -->
|
||||||
|
<input id="otp" type="hidden" value="">
|
||||||
|
<?php endif; ?>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label d-flex justify-content-between">
|
<label for="password" class="form-label d-flex justify-content-between">
|
||||||
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
|
<a href="javascript:;" class="small link-primary" onclick="back()">Back</a>
|
||||||
|
<?php if (otp_required()): ?>
|
||||||
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
|
<a href="javascript:;" class="small link-primary" onclick="request_new_otp();">Request New OTP</a>
|
||||||
|
<?php endif; ?>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
|
<button class="btn btn-primary w-100" onclick="login_confirm();">Sign in</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
require '../session.php';
|
require '../session.php';
|
||||||
require '../config.php';
|
require '../config.php';
|
||||||
|
require_once '../assets/utils/otp_policy.php';
|
||||||
|
|
||||||
// Must come from email verification
|
// Must come from email verification
|
||||||
if (empty($_SESSION['onboarding_user_id'])) {
|
if (empty($_SESSION['onboarding_user_id'])) {
|
||||||
@@ -48,7 +49,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
|
<label class="form-label">Channel Name <span class="text-danger">*</span></label>
|
||||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
|
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||||
|
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -77,11 +79,20 @@
|
|||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||||
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
|
<h2 class="fs-5 mb-0"><i class="ti ti-mail-cog me-2"></i>SMTP / Email Setting</h2>
|
||||||
|
<?php if (otp_required()): ?>
|
||||||
<span class="badge bg-label-danger">Required</span>
|
<span class="badge bg-label-danger">Required</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge bg-label-secondary">Optional</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted small mb-3">
|
<p class="text-muted small mb-3">
|
||||||
|
<?php if (otp_required()): ?>
|
||||||
SMTP is required to send OTP during login.
|
SMTP is required to send OTP during login.
|
||||||
A verification email will be sent when you finish setup.
|
A verification email will be sent when you finish setup.
|
||||||
|
<?php else: ?>
|
||||||
|
Email OTP is turned off, so SMTP is optional. Leave it blank to skip;
|
||||||
|
you can add it later under Settings → SMTP.
|
||||||
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- SMTP User Guide (collapsible) -->
|
<!-- SMTP User Guide (collapsible) -->
|
||||||
@@ -174,11 +185,11 @@
|
|||||||
<!-- SMTP Form -->
|
<!-- SMTP Form -->
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<label class="form-label">SMTP Host <span class="text-danger">*</span></label>
|
<label class="form-label">SMTP Host <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||||
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
|
<input type="text" class="form-control" id="smtp_host" placeholder="e.g. smtp.gmail.com">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Port <span class="text-danger">*</span></label>
|
<label class="form-label">Port <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||||
<select class="form-select" id="smtp_port">
|
<select class="form-select" id="smtp_port">
|
||||||
<option value="587">587 — TLS</option>
|
<option value="587">587 — TLS</option>
|
||||||
<option value="465">465 — SSL</option>
|
<option value="465">465 — SSL</option>
|
||||||
@@ -186,11 +197,11 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Username / Email <span class="text-danger">*</span></label>
|
<label class="form-label">Username / Email <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||||
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
|
<input type="text" class="form-control" id="smtp_username" placeholder="your@email.com">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Password <span class="text-danger">*</span></label>
|
<label class="form-label">Password <?php if (otp_required()): ?><span class="text-danger">*</span><?php endif; ?></label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
|
<input type="password" class="form-control" id="smtp_password" placeholder="SMTP password">
|
||||||
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
|
<button class="btn btn-outline-secondary toggle-pw" type="button" data-target="smtp_password">
|
||||||
@@ -251,13 +262,23 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$('#smtp_host').val().trim() || !$('#smtp_username').val().trim() || !$('#smtp_password').val()) {
|
// Mirrors api/engine/onboarding.php: SMTP is required while email OTP is on;
|
||||||
bootbox.alert('SMTP host, username and password are required.');
|
// with OTP_REQUIRED=false it is optional, but the three fields go together.
|
||||||
|
const smtp_required = <?php echo otp_required() ? 'true' : 'false'; ?>;
|
||||||
|
const smtp_host = $('#smtp_host').val().trim();
|
||||||
|
const smtp_user = $('#smtp_username').val().trim();
|
||||||
|
const smtp_pass = $('#smtp_password').val();
|
||||||
|
const smtp_given = !!(smtp_host || smtp_user || smtp_pass);
|
||||||
|
|
||||||
|
if ((smtp_required || smtp_given) && (!smtp_host || !smtp_user || !smtp_pass)) {
|
||||||
|
bootbox.alert(smtp_required
|
||||||
|
? 'SMTP host, username and password are required.'
|
||||||
|
: 'Fill in SMTP host, username and password, or leave all three blank.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const $btn = $('#btn_finish');
|
const $btn = $('#btn_finish');
|
||||||
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>Verifying SMTP…');
|
$btn.prop('disabled', true).html('<i class="ti ti-loader-2 me-1"></i>' + (smtp_given ? 'Verifying SMTP…' : 'Setting up…'));
|
||||||
|
|
||||||
const encryption = $('input[name="smtp_encryption"]:checked').val();
|
const encryption = $('input[name="smtp_encryption"]:checked').val();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
// app/session.php
|
// app/session.php
|
||||||
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
|
ob_start(); // ensure output buffering is on regardless of php.ini — prevents stray output from corrupting JSON API responses
|
||||||
|
|
||||||
|
// The buffer is still flushed, so notices would still land in front of the JSON
|
||||||
|
// body and break the client's parse ("Server error occurred."). The login API
|
||||||
|
// engines load this file instead of db_auth.php, so apply the same policy here.
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('log_errors', '1');
|
||||||
|
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
|
||||||
// Derive cookie path dynamically from the current script location.
|
// Derive cookie path dynamically from the current script location.
|
||||||
|
|||||||
@@ -121,7 +121,8 @@
|
|||||||
|
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label">Nickname / Channel Name</label>
|
<label class="form-label">Nickname / Channel Name</label>
|
||||||
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop">
|
<input type="text" class="form-control" id="channel_name" placeholder="e.g. my-shop"
|
||||||
|
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9_-]/g,'')">
|
||||||
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
<div class="form-text">Unique identifier. Lowercase, no spaces.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ services:
|
|||||||
EMIT_SECRET: ${EMIT_SECRET}
|
EMIT_SECRET: ${EMIT_SECRET}
|
||||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||||
|
OTP_REQUIRED: ${OTP_REQUIRED:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/html/wms-app
|
- .:/var/www/html/wms-app
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ PUBLIC_HOST=$public_host
|
|||||||
EMIT_SECRET=$emit_secret
|
EMIT_SECRET=$emit_secret
|
||||||
SMTP_USERNAME=$smtp_user
|
SMTP_USERNAME=$smtp_user
|
||||||
SMTP_PASSWORD=$smtp_pass
|
SMTP_PASSWORD=$smtp_pass
|
||||||
|
OTP_REQUIRED=false
|
||||||
HTTP_PORT=$http_port
|
HTTP_PORT=$http_port
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ FROM php:8.3-apache
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
|
libzip-dev libicu-dev libonig-dev default-mysql-client gettext-base \
|
||||||
|
libpng-dev libjpeg-dev libfreetype6-dev \
|
||||||
|
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||||
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
|
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
|
||||||
&& a2enmod rewrite \
|
&& a2enmod rewrite \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ if (!defined('NODE_EMIT_SECRET')) {
|
|||||||
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
|
define('NODE_EMIT_SECRET', '${EMIT_SECRET}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Login OTP ────────────────────────────────────────────────────────────────
|
||||||
|
// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start.
|
||||||
|
// Off by default: anything other than the boolean true leaves the OTP step off.
|
||||||
|
if (!defined('OTP_REQUIRED')) {
|
||||||
|
define('OTP_REQUIRED', ${OTP_REQUIRED});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Usage packages ───────────────────────────────────────────────────────────
|
// ── Usage packages ───────────────────────────────────────────────────────────
|
||||||
$packages = [
|
$packages = [
|
||||||
'starter' => [
|
'starter' => [
|
||||||
|
|||||||
@@ -4,15 +4,39 @@ set -e
|
|||||||
APP_DIR=/var/www/html/wms-app
|
APP_DIR=/var/www/html/wms-app
|
||||||
CONFIG=$APP_DIR/app/config.php
|
CONFIG=$APP_DIR/app/config.php
|
||||||
|
|
||||||
|
# Email OTP on sign-in, OFF BY DEFAULT. Only the exact string "true" turns it
|
||||||
|
# on; a missing variable or anything else means false.
|
||||||
|
: "${OTP_REQUIRED:=false}"
|
||||||
|
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
|
||||||
|
export OTP_REQUIRED
|
||||||
|
|
||||||
# Generate app/config.php from template on first run only.
|
# Generate app/config.php from template on first run only.
|
||||||
# Restrict envsubst to known placeholders so it never touches the app's own
|
# Restrict envsubst to known placeholders so it never touches the app's own
|
||||||
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
||||||
if [ ! -f "$CONFIG" ]; then
|
if [ ! -f "$CONFIG" ]; then
|
||||||
echo "[entrypoint] generating app/config.php"
|
echo "[entrypoint] generating app/config.php"
|
||||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD}' \
|
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
|
||||||
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# config.php is never regenerated once it exists, so OTP_REQUIRED is the one
|
||||||
|
# line reconciled on every start: the .env value always wins, and a config.php
|
||||||
|
# written before this switch existed gets the line added.
|
||||||
|
if grep -q "define('OTP_REQUIRED'" "$CONFIG"; then
|
||||||
|
if ! grep -q "define('OTP_REQUIRED', ${OTP_REQUIRED});" "$CONFIG"; then
|
||||||
|
sed -i "s/define('OTP_REQUIRED', [A-Za-z]*);/define('OTP_REQUIRED', ${OTP_REQUIRED});/" "$CONFIG"
|
||||||
|
echo "[entrypoint] OTP_REQUIRED is now ${OTP_REQUIRED}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Drop a closing ?> on the last line so the appended block stays inside PHP.
|
||||||
|
sed -i -e '${/^[[:space:]]*?>[[:space:]]*$/d}' "$CONFIG"
|
||||||
|
printf "\nif (!defined('OTP_REQUIRED')) {\n\tdefine('OTP_REQUIRED', %s);\n}\n" "$OTP_REQUIRED" >> "$CONFIG"
|
||||||
|
echo "[entrypoint] added OTP_REQUIRED = ${OTP_REQUIRED} to an existing config.php"
|
||||||
|
fi
|
||||||
|
if [ "$OTP_REQUIRED" = "false" ]; then
|
||||||
|
echo "[entrypoint] email OTP is off (OTP_REQUIRED=false); sign-in is password only."
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$APP_DIR/app/uploads"
|
mkdir -p "$APP_DIR/app/uploads"
|
||||||
chown -R www-data:www-data "$APP_DIR/app/uploads"
|
chown -R www-data:www-data "$APP_DIR/app/uploads"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user