From 9afcf072b0370fb04d36e6e9a167a78b84310520 Mon Sep 17 00:00:00 2001 From: Thanakorn Date: Mon, 14 Sep 2026 15:03:16 +0700 Subject: [PATCH] Add OTP_REQUIRED switch for email OTP login --- .env.example | 5 ++++ app/assets/utils/otp_policy.php | 35 ++++++++++++++++++++++++++ app/config.example.php | 10 ++++++++ app/include_topbar.php | 13 ++++++++++ app/login/api/engine/login_confirm.php | 11 ++++++-- app/login/api/engine/login_otp.php | 13 +++++++--- app/login/index.php | 20 +++++++++++++-- docker-compose.yml | 1 + docker/init-env.sh | 1 + docker/php/config.php.template | 7 ++++++ docker/php/entrypoint.sh | 26 ++++++++++++++++++- 11 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 app/assets/utils/otp_policy.php diff --git a/.env.example b/.env.example index bcc84a0..4d21c9b 100644 --- a/.env.example +++ b/.env.example @@ -13,5 +13,10 @@ EMIT_SECRET= SMTP_USERNAME= SMTP_PASSWORD= +# Email OTP on sign-in. Leave true; only the exact value "false" makes sign-in +# 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=true + # Port to expose the web app on (default 80) HTTP_PORT=80 diff --git a/app/assets/utils/otp_policy.php b/app/assets/utils/otp_policy.php new file mode 100644 index 0000000..69303b2 --- /dev/null +++ b/app/assets/utils/otp_policy.php @@ -0,0 +1,35 @@ += 100; + + +
  • + + + OTP off + +
  • + +
  • diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index c4daaf7..e9c07fa 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -58,6 +58,7 @@ require_once '../../../config.php'; require_once '../../../preset.php'; define('UNAUTHENTICATED_ROUTE', true); require_once '../../../assets/utils/db_auth.php'; +require_once '../../../assets/utils/otp_policy.php'; // ── Step 1: Load session state written by login_otp.php ─────────────────────── $data["username"] = $_SESSION["login_data"]['username']; @@ -100,9 +101,15 @@ $_SESSION["diff"] = $otp_diff_minutes; // ── Step 4: Validate OTP value and expiry ───────────────────────────────────── // 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 ($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)"; exit(json_encode($answer)); } diff --git a/app/login/api/engine/login_otp.php b/app/login/api/engine/login_otp.php index 46c4994..dbee542 100644 --- a/app/login/api/engine/login_otp.php +++ b/app/login/api/engine/login_otp.php @@ -62,6 +62,7 @@ require_once '../../../config.php'; require_once '../../../preset.php'; define('UNAUTHENTICATED_ROUTE', true); 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) ──────── $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)); } - // ── Step 5g: Role check — staff/viewer skip OTP entirely ───────────────── - // Owners always require 2FA. Invited users (license='user') require 2FA only + // ── Step 5g: OTP policy, then role check — staff/viewer skip OTP entirely ─ + // 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. - $requires_otp = true; - if (($r['license'] ?? 'owner') !== 'owner') { + $requires_otp = otp_required(); + if (!$requires_otp) { + otp_log_bypass($user_id, 'login_otp'); + } elseif (($r['license'] ?? 'owner') !== 'owner') { $sth_role = $pdo1->prepare( "SELECT role FROM company_map_user WHERE company_id = :cid AND user_id = :uid LIMIT 1" ); diff --git a/app/login/index.php b/app/login/index.php index ba06d41..f040332 100644 --- a/app/login/index.php +++ b/app/login/index.php @@ -1,6 +1,7 @@
    + + +
    + + Email OTP is temporarily disabled — sign-in is password only. +
    +
    @@ -51,7 +59,7 @@
    @@ -62,6 +70,7 @@

    +
    OTP is sent via your company's SMTP setting. @@ -73,13 +82,20 @@ One Time Password " required minlength="6"> + placeholder="your otp for reference number " required minlength="6">
    Please provide a otp (min 6 characters).
    + + + + diff --git a/docker-compose.yml b/docker-compose.yml index 6292605..85ed542 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: EMIT_SECRET: ${EMIT_SECRET} SMTP_USERNAME: ${SMTP_USERNAME} SMTP_PASSWORD: ${SMTP_PASSWORD} + OTP_REQUIRED: ${OTP_REQUIRED:-true} volumes: - .:/var/www/html/wms-app ports: diff --git a/docker/init-env.sh b/docker/init-env.sh index f7954ca..de6b950 100755 --- a/docker/init-env.sh +++ b/docker/init-env.sh @@ -55,6 +55,7 @@ PUBLIC_HOST=$public_host EMIT_SECRET=$emit_secret SMTP_USERNAME=$smtp_user SMTP_PASSWORD=$smtp_pass +OTP_REQUIRED=true HTTP_PORT=$http_port EOF chmod 600 "$ENV_FILE" diff --git a/docker/php/config.php.template b/docker/php/config.php.template index 735e0ab..ca80301 100644 --- a/docker/php/config.php.template +++ b/docker/php/config.php.template @@ -33,6 +33,13 @@ if (!defined('NODE_EMIT_SECRET')) { define('NODE_EMIT_SECRET', '${EMIT_SECRET}'); } +// ── Login OTP ──────────────────────────────────────────────────────────────── +// Set from OTP_REQUIRED in .env and reconciled by the entrypoint on every start. +// Fails safe: anything other than the boolean false keeps the OTP step on. +if (!defined('OTP_REQUIRED')) { + define('OTP_REQUIRED', ${OTP_REQUIRED}); +} + // ── Usage packages ─────────────────────────────────────────────────────────── $packages = [ 'starter' => [ diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index 15ec422..8aa3e9f 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -4,15 +4,39 @@ set -e APP_DIR=/var/www/html/wms-app CONFIG=$APP_DIR/app/config.php +# Email OTP on sign-in. Anything but the exact string "false" means required, +# so a typo or a missing variable can never switch it off. +: "${OTP_REQUIRED:=true}" +[ "$OTP_REQUIRED" = "false" ] || OTP_REQUIRED=true +export OTP_REQUIRED + # Generate app/config.php from template on first run only. # Restrict envsubst to known placeholders so it never touches the app's own # $variable syntax (envsubst blanks out any $NAME it doesn't recognize). if [ ! -f "$CONFIG" ]; then 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" 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] WARNING -- email OTP is OFF; sign-in is password only." +fi + mkdir -p "$APP_DIR/app/uploads" chown -R www-data:www-data "$APP_DIR/app/uploads"