From ad5b94af5e48297631629c2a118e7f163a044a07 Mon Sep 17 00:00:00 2001
From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com>
Date: Tue, 24 Feb 2026 10:44:33 +0100
Subject: [PATCH] [Web] Add forced 2FA setup and password update enforcement
---
data/web/admin/dashboard.php | 13 +-
data/web/admin/index.php | 7 +-
data/web/admin/mailbox.php | 13 +-
data/web/admin/queue.php | 14 +-
data/web/admin/system.php | 13 +-
data/web/domainadmin/index.php | 7 +-
data/web/domainadmin/mailbox.php | 13 +-
data/web/domainadmin/user.php | 51 ++---
data/web/edit.php | 8 +-
data/web/inc/footer.inc.php | 2 +
data/web/inc/functions.admin.inc.php | 74 +++++---
data/web/inc/functions.auth.inc.php | 38 +++-
data/web/inc/functions.domain_admin.inc.php | 55 ++++--
data/web/inc/functions.inc.php | 196 +++++++++++++++-----
data/web/inc/functions.mailbox.inc.php | 8 +
data/web/inc/init_db.inc.php | 11 +-
data/web/inc/triggers.admin.inc.php | 19 ++
data/web/inc/triggers.domainadmin.inc.php | 19 ++
data/web/inc/triggers.global.inc.php | 19 ++
data/web/inc/triggers.user.inc.php | 19 ++
data/web/inc/vars.inc.php | 3 +
data/web/index.php | 28 +--
data/web/json_api.php | 2 +
data/web/lang/lang.de-de.json | 12 +-
data/web/lang/lang.en-gb.json | 7 +
data/web/sogo-auth.php | 8 +-
data/web/templates/base.twig | 106 +++++++++++
data/web/templates/edit/admin.twig | 18 ++
data/web/templates/edit/domainadmin.twig | 18 ++
data/web/templates/edit/mailbox.twig | 9 +
data/web/templates/modals/footer.twig | 129 +++++++++++++
data/web/templates/modals/mailbox.twig | 18 ++
data/web/user.php | 138 +++++++-------
33 files changed, 810 insertions(+), 285 deletions(-)
diff --git a/data/web/admin/dashboard.php b/data/web/admin/dashboard.php
index 443c2ea2a..2867770bc 100644
--- a/data/web/admin/dashboard.php
+++ b/data/web/admin/dashboard.php
@@ -2,18 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
-if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
- header('Location: /domainadmin/mailbox');
- exit();
-}
-elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
- header('Location: /user');
- exit();
-}
-elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
- header('Location: /admin');
- exit();
-}
+protect_route(['admin']);
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
diff --git a/data/web/admin/index.php b/data/web/admin/index.php
index 9ae4a0380..9e0bf1bd7 100644
--- a/data/web/admin/index.php
+++ b/data/web/admin/index.php
@@ -3,8 +3,11 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
- header('Location: /admin/dashboard');
- exit();
+ // Only redirect to dashboard if NO pending actions
+ if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
+ header('Location: /admin/dashboard');
+ exit();
+ }
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /domainadmin/mailbox');
diff --git a/data/web/admin/mailbox.php b/data/web/admin/mailbox.php
index d0073bbd6..7b07d6fa9 100644
--- a/data/web/admin/mailbox.php
+++ b/data/web/admin/mailbox.php
@@ -2,18 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
-if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
- header('Location: /domainadmin/mailbox');
- exit();
-}
-elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
- header('Location: /user');
- exit();
-}
-elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
- header('Location: /admin');
- exit();
-}
+protect_route(['admin']);
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
diff --git a/data/web/admin/queue.php b/data/web/admin/queue.php
index 85ec59401..66682c7ae 100644
--- a/data/web/admin/queue.php
+++ b/data/web/admin/queue.php
@@ -2,19 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
-if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
- header('Location: /domainadmin/mailbox');
- exit();
-}
-elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
- header('Location: /user');
- exit();
-}
-elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
- header('Location: /admin');
- exit();
-}
-
+protect_route(['admin']);
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$js_minifier->add('/web/js/site/queue.js');
diff --git a/data/web/admin/system.php b/data/web/admin/system.php
index 9fd44e0d8..4db40c753 100644
--- a/data/web/admin/system.php
+++ b/data/web/admin/system.php
@@ -2,18 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
-if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
- header('Location: /domainadmin/mailbox');
- exit();
-}
-elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
- header('Location: /user');
- exit();
-}
-elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
- header('Location: /admin');
- exit();
-}
+protect_route(['admin']);
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
diff --git a/data/web/domainadmin/index.php b/data/web/domainadmin/index.php
index 0d70ec3ae..07c62a7d2 100644
--- a/data/web/domainadmin/index.php
+++ b/data/web/domainadmin/index.php
@@ -3,8 +3,11 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
- header('Location: /domainadmin/mailbox');
- exit();
+ // Only redirect to mailbox if NO pending actions
+ if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
+ header('Location: /domainadmin/mailbox');
+ exit();
+ }
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
header('Location: /admin/dashboard');
diff --git a/data/web/domainadmin/mailbox.php b/data/web/domainadmin/mailbox.php
index bb2ef16f3..8beb5abb9 100644
--- a/data/web/domainadmin/mailbox.php
+++ b/data/web/domainadmin/mailbox.php
@@ -2,18 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
-if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
- header('Location: /admin/dashboard');
- exit();
-}
-elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
- header('Location: /user');
- exit();
-}
-elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "domainadmin") {
- header('Location: /domainadmin');
- exit();
-}
+protect_route(['domainadmin']);
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
diff --git a/data/web/domainadmin/user.php b/data/web/domainadmin/user.php
index 7f1b392e0..a45e00dfc 100644
--- a/data/web/domainadmin/user.php
+++ b/data/web/domainadmin/user.php
@@ -2,41 +2,28 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
-if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+/*
+/ DOMAIN ADMIN
+*/
- /*
- / DOMAIN ADMIN
- */
+protect_route(['domainadmin']);
- require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
- $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
- $tfa_data = get_tfa();
- $fido2_data = fido2(array("action" => "get_friendly_names"));
- $username = $_SESSION['mailcow_cc_username'];
+require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
+$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+$tfa_data = get_tfa();
+$fido2_data = fido2(array("action" => "get_friendly_names"));
+$username = $_SESSION['mailcow_cc_username'];
- $template = 'domainadmin.twig';
- $template_data = [
- 'acl' => $_SESSION['acl'],
- 'acl_json' => json_encode($_SESSION['acl']),
- 'user_spam_score' => mailbox('get', 'spam_score', $username),
- 'tfa_data' => $tfa_data,
- 'fido2_data' => $fido2_data,
- 'lang_user' => json_encode($lang['user']),
- 'lang_datatables' => json_encode($lang['datatables']),
- ];
-}
-elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
- header('Location: /admin/dashboard');
- exit();
-}
-elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
- header('Location: /user');
- exit();
-}
-else {
- header('Location: /domainadmin');
- exit();
-}
+$template = 'domainadmin.twig';
+$template_data = [
+ 'acl' => $_SESSION['acl'],
+ 'acl_json' => json_encode($_SESSION['acl']),
+ 'user_spam_score' => mailbox('get', 'spam_score', $username),
+ 'tfa_data' => $tfa_data,
+ 'fido2_data' => $fido2_data,
+ 'lang_user' => json_encode($lang['user']),
+ 'lang_datatables' => json_encode($lang['datatables']),
+];
$js_minifier->add('/web/js/site/user.js');
$js_minifier->add('/web/js/site/pwgen.js');
diff --git a/data/web/edit.php b/data/web/edit.php
index 57cf24bd2..48f2309c1 100644
--- a/data/web/edit.php
+++ b/data/web/edit.php
@@ -1,10 +1,8 @@
@$_SESSION['pending_tfa_methods'],
'pending_tfa_authmechs' => $pending_tfa_authmechs,
'pending_mailcow_cc_username' => @$_SESSION['pending_mailcow_cc_username'],
+ 'pending_tfa_setup' => !empty($_SESSION['pending_tfa_setup']),
+ 'pending_pw_update_modal' => !empty($_SESSION['pending_pw_update']),
'lang_footer' => json_encode($lang['footer']),
'lang_acl' => json_encode($lang['acl']),
'lang_tfa' => json_encode($lang['tfa']),
diff --git a/data/web/inc/functions.admin.inc.php b/data/web/inc/functions.admin.inc.php
index 9f42fd721..7bd0af42f 100644
--- a/data/web/inc/functions.admin.inc.php
+++ b/data/web/inc/functions.admin.inc.php
@@ -121,34 +121,56 @@ function admin($_action, $_data = null) {
continue;
}
}
- if (!empty($password)) {
- if (password_check($password, $password2) !== true) {
- return false;
+ // Check if this is a self password change via forced update
+ if ($username == $_SESSION['mailcow_cc_username'] && !empty($_SESSION['pending_pw_update'])) {
+ // Forced password update: only change password and clear force_pw_update flag
+ if (!empty($password)) {
+ if (password_check($password, $_data['password2']) !== true) {
+ return false;
+ }
+ $password_hashed = hash_password($password);
+ $stmt = $pdo->prepare("UPDATE `admin` SET `password` = :password_hashed,
+ `attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_pw_update', '0')
+ WHERE `username` = :username");
+ $stmt->execute(array(
+ ':password_hashed' => $password_hashed,
+ ':username' => $username
+ ));
+ unset($_SESSION['pending_pw_update']);
}
- $password_hashed = hash_password($password);
- $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed WHERE `username` = :username");
- $stmt->execute(array(
- ':password_hashed' => $password_hashed,
- ':username_new' => $username_new,
- ':username' => $username,
- ':active' => $active
- ));
- if (isset($_data['disable_tfa'])) {
- $stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
- $stmt->execute(array(':username' => $username));
+ } else {
+ // Normal admin edit: update all attributes
+ $force_tfa = intval($_data['force_tfa'] ?? 0) ? 1 : 0;
+ $force_pw_update = intval($_data['force_pw_update'] ?? 0) ? 1 : 0;
+ if (!empty($password)) {
+ if (password_check($password, $password2) !== true) {
+ return false;
+ }
+ $password_hashed = hash_password($password);
+ $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed,
+ `attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
+ WHERE `username` = :username");
+ $stmt->execute(array(
+ ':password_hashed' => $password_hashed,
+ ':username_new' => $username_new,
+ ':username' => $username,
+ ':active' => $active,
+ ':force_tfa' => strval($force_tfa),
+ ':force_pw_update' => strval($force_pw_update)
+ ));
}
else {
- $stmt = $pdo->prepare("UPDATE `tfa` SET `username` = :username_new WHERE `username` = :username");
- $stmt->execute(array(':username_new' => $username_new, ':username' => $username));
+ $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active,
+ `attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
+ WHERE `username` = :username");
+ $stmt->execute(array(
+ ':username_new' => $username_new,
+ ':username' => $username,
+ ':active' => $active,
+ ':force_tfa' => strval($force_tfa),
+ ':force_pw_update' => strval($force_pw_update)
+ ));
}
- }
- else {
- $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active WHERE `username` = :username");
- $stmt->execute(array(
- ':username_new' => $username_new,
- ':username' => $username,
- ':active' => $active
- ));
if (isset($_data['disable_tfa'])) {
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
$stmt->execute(array(':username' => $username));
@@ -223,7 +245,8 @@ function admin($_action, $_data = null) {
`tfa`.`active` AS `tfa_active`,
`admin`.`username`,
`admin`.`created`,
- `admin`.`active` AS `active`
+ `admin`.`active` AS `active`,
+ `admin`.`attributes` AS `attributes`
FROM `admin`
LEFT OUTER JOIN `tfa` ON `tfa`.`username`=`admin`.`username`
WHERE `admin`.`username`= :admin AND `superadmin` = '1'");
@@ -240,6 +263,7 @@ function admin($_action, $_data = null) {
$admindata['active'] = $row['active'];
$admindata['active_int'] = $row['active'];
$admindata['created'] = $row['created'];
+ $admindata['attributes'] = json_decode($row['attributes'], true) ?? array('force_tfa' => '0', 'force_pw_update' => '0');
return $admindata;
break;
}
diff --git a/data/web/inc/functions.auth.inc.php b/data/web/inc/functions.auth.inc.php
index 3903ba642..91a8c55fa 100644
--- a/data/web/inc/functions.auth.inc.php
+++ b/data/web/inc/functions.auth.inc.php
@@ -82,7 +82,7 @@ function admin_login($user, $pass){
}
$user = strtolower(trim($user));
- $stmt = $pdo->prepare("SELECT `password` FROM `admin`
+ $stmt = $pdo->prepare("SELECT `password`, `attributes` FROM `admin`
WHERE `superadmin` = '1'
AND `active` = '1'
AND `username` = :user");
@@ -91,6 +91,13 @@ function admin_login($user, $pass){
// verify password
if (verify_hash($row['password'], $pass)) {
+ $admin_attrs = json_decode($row['attributes'], true) ?? [];
+
+ // Check force_pw_update
+ if (intval($admin_attrs['force_pw_update'] ?? 0) == 1) {
+ $_SESSION['pending_pw_update'] = true;
+ }
+
// check for tfa authenticators
$authenticators = get_tfa($user);
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
@@ -110,6 +117,10 @@ function admin_login($user, $pass){
// Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user));
+ // Check force_tfa: only force setup if NO TFA exists at all
+ if (intval($admin_attrs['force_tfa'] ?? 0) == 1 && !tfa_exists($user)) {
+ $_SESSION['pending_tfa_setup'] = true;
+ }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $user, '*'),
@@ -135,7 +146,7 @@ function domainadmin_login($user, $pass){
return false;
}
- $stmt = $pdo->prepare("SELECT `password` FROM `admin`
+ $stmt = $pdo->prepare("SELECT `password`, `attributes` FROM `admin`
WHERE `superadmin` = '0'
AND `active`='1'
AND `username` = :user");
@@ -144,6 +155,13 @@ function domainadmin_login($user, $pass){
// verify password
if (verify_hash($row['password'], $pass) !== false) {
+ $admin_attrs = json_decode($row['attributes'], true) ?? [];
+
+ // Check force_pw_update
+ if (intval($admin_attrs['force_pw_update'] ?? 0) == 1) {
+ $_SESSION['pending_pw_update'] = true;
+ }
+
// check for tfa authenticators
$authenticators = get_tfa($user);
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
@@ -163,6 +181,10 @@ function domainadmin_login($user, $pass){
// Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user));
+ // Check force_tfa: only force setup if NO TFA exists at all
+ if (intval($admin_attrs['force_tfa'] ?? 0) == 1 && !tfa_exists($user)) {
+ $_SESSION['pending_tfa_setup'] = true;
+ }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $user, '*'),
@@ -286,6 +308,10 @@ function user_login($user, $pass, $extra = null){
// Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user));
+ // Check force_tfa: only force setup if NO TFA exists at all
+ if (intval($row['attributes']['force_tfa']) == 1 && !tfa_exists($user)) {
+ $_SESSION['pending_tfa_setup'] = true;
+ }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $user, '*', 'Provider: Keycloak'),
@@ -338,6 +364,10 @@ function user_login($user, $pass, $extra = null){
// Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user));
+ // Check force_tfa: only force setup if NO TFA exists at all
+ if (intval($row['attributes']['force_tfa']) == 1 && !tfa_exists($user)) {
+ $_SESSION['pending_tfa_setup'] = true;
+ }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP'),
@@ -381,6 +411,10 @@ function user_login($user, $pass, $extra = null){
// Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user));
+ // Check force_tfa: only force setup if NO TFA exists at all
+ if (intval($row['attributes']['force_tfa']) == 1 && !tfa_exists($user)) {
+ $_SESSION['pending_tfa_setup'] = true;
+ }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $user, '*', 'Provider: mailcow'),
diff --git a/data/web/inc/functions.domain_admin.inc.php b/data/web/inc/functions.domain_admin.inc.php
index bb88ea34c..46b651b85 100644
--- a/data/web/inc/functions.domain_admin.inc.php
+++ b/data/web/inc/functions.domain_admin.inc.php
@@ -195,17 +195,23 @@ function domain_admin($_action, $_data = null) {
));
}
}
+ $force_tfa = intval($_data['force_tfa'] ?? 0) ? 1 : 0;
+ $force_pw_update = intval($_data['force_pw_update'] ?? 0) ? 1 : 0;
if (!empty($password)) {
if (password_check($password, $password2) !== true) {
return false;
}
$password_hashed = hash_password($password);
- $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed WHERE `username` = :username");
+ $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed,
+ `attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
+ WHERE `username` = :username");
$stmt->execute(array(
':password_hashed' => $password_hashed,
':username_new' => $username_new,
':username' => $username,
- ':active' => $active
+ ':active' => $active,
+ ':force_tfa' => strval($force_tfa),
+ ':force_pw_update' => strval($force_pw_update)
));
if (isset($_data['disable_tfa'])) {
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
@@ -217,11 +223,15 @@ function domain_admin($_action, $_data = null) {
}
}
else {
- $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active WHERE `username` = :username");
+ $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active,
+ `attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
+ WHERE `username` = :username");
$stmt->execute(array(
':username_new' => $username_new,
':username' => $username,
- ':active' => $active
+ ':active' => $active,
+ ':force_tfa' => strval($force_tfa),
+ ':force_pw_update' => strval($force_pw_update)
));
if (isset($_data['disable_tfa'])) {
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
@@ -244,31 +254,37 @@ function domain_admin($_action, $_data = null) {
// Can only edit itself
elseif ($_SESSION['mailcow_cc_role'] == "domainadmin") {
$username = $_SESSION['mailcow_cc_username'];
- $password_old = $_data['user_old_pass'];
+ $password_old = $_data['user_old_pass'] ?? '';
$password_new = $_data['user_new_pass'];
$password_new2 = $_data['user_new_pass2'];
- $stmt = $pdo->prepare("SELECT `password` FROM `admin`
- WHERE `username` = :user");
- $stmt->execute(array(':user' => $username));
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
- if (!verify_hash($row['password'], $password_old)) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_action, $_data_log),
- 'msg' => 'access_denied'
- );
- return false;
+ // Only verify old password if this is NOT a forced password update
+ if (empty($_SESSION['pending_pw_update'])) {
+ $stmt = $pdo->prepare("SELECT `password` FROM `admin`
+ WHERE `username` = :user");
+ $stmt->execute(array(':user' => $username));
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!verify_hash($row['password'], $password_old)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_action, $_data_log),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
}
if (password_check($password_new, $password_new2) !== true) {
return false;
}
$password_hashed = hash_password($password_new);
- $stmt = $pdo->prepare("UPDATE `admin` SET `password` = :password_hashed WHERE `username` = :username");
+ $stmt = $pdo->prepare("UPDATE `admin` SET `password` = :password_hashed,
+ `attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_pw_update', '0')
+ WHERE `username` = :username");
$stmt->execute(array(
':password_hashed' => $password_hashed,
':username' => $username
));
+ unset($_SESSION['pending_pw_update']);
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_data_log),
@@ -360,9 +376,11 @@ function domain_admin($_action, $_data = null) {
`tfa`.`active` AS `tfa_active`,
`domain_admins`.`username`,
`domain_admins`.`created`,
- `domain_admins`.`active` AS `active`
+ `domain_admins`.`active` AS `active`,
+ `admin`.`attributes` AS `attributes`
FROM `domain_admins`
LEFT OUTER JOIN `tfa` ON `tfa`.`username`=`domain_admins`.`username`
+ LEFT OUTER JOIN `admin` ON `admin`.`username`=`domain_admins`.`username`
WHERE `domain_admins`.`username`= :domain_admin");
$stmt->execute(array(
':domain_admin' => $_data
@@ -377,6 +395,7 @@ function domain_admin($_action, $_data = null) {
$domainadmindata['active'] = $row['active'];
$domainadmindata['active_int'] = $row['active'];
$domainadmindata['created'] = $row['created'];
+ $domainadmindata['attributes'] = json_decode($row['attributes'], true) ?? array('force_tfa' => '0', 'force_pw_update' => '0');
// GET SELECTED
$stmt = $pdo->prepare("SELECT `domain` FROM `domain`
WHERE `domain` IN (
diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php
index 23b8d701d..89f14b574 100644
--- a/data/web/inc/functions.inc.php
+++ b/data/web/inc/functions.inc.php
@@ -1033,20 +1033,24 @@ function edit_user_account($_data) {
}
// edit password
- if (!empty($password_old) && !empty($_data['user_new_pass']) && !empty($_data['user_new_pass2'])) {
- $stmt = $pdo->prepare("SELECT `password` FROM `mailbox`
- WHERE `kind` NOT REGEXP 'location|thing|group'
- AND `username` = :user AND authsource = 'mailcow'");
- $stmt->execute(array(':user' => $username));
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ $is_forced_pw_update = !empty($_SESSION['pending_pw_update']);
+ if (((!empty($password_old) || $is_forced_pw_update) && !empty($_data['user_new_pass']) && !empty($_data['user_new_pass2']))) {
+ // Only verify old password if this is NOT a forced password update
+ if (!$is_forced_pw_update) {
+ $stmt = $pdo->prepare("SELECT `password` FROM `mailbox`
+ WHERE `kind` NOT REGEXP 'location|thing|group'
+ AND `username` = :user AND authsource = 'mailcow'");
+ $stmt->execute(array(':user' => $username));
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
- if (!verify_hash($row['password'], $password_old)) {
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_data_log),
- 'msg' => 'access_denied'
- );
- return false;
+ if (!verify_hash($row['password'], $password_old)) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_data_log),
+ 'msg' => 'access_denied'
+ );
+ return false;
+ }
}
$password_new = $_data['user_new_pass'];
@@ -1210,50 +1214,52 @@ function set_tfa($_data) {
global $iam_settings;
$_data_log = $_data;
- $access_denied = null;
!isset($_data_log['confirm_password']) ?: $_data_log['confirm_password'] = '*';
- $username = $_SESSION['mailcow_cc_username'];
- // check for empty user and role
- if (!isset($_SESSION['mailcow_cc_role']) || empty($username)) $access_denied = true;
-
- // check admin confirm password
- if ($access_denied === null) {
- $stmt = $pdo->prepare("SELECT `password` FROM `admin`
- WHERE `username` = :username");
- $stmt->execute(array(':username' => $username));
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
- if ($row) {
- if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
- else $access_denied = false;
+ // skip password check if this is a forced TFA enrollment after login
+ if (!empty($_SESSION['pending_tfa_setup'])) {
+ $username = $_SESSION['mailcow_cc_username'];
+ if (empty($username) || !isset($_SESSION['mailcow_cc_role'])) {
+ $_SESSION['return'][] = array('type' => 'danger', 'log' => array(__FUNCTION__, $_data_log), 'msg' => 'access_denied');
+ return false;
}
- }
+ } else {
+ $username = $_SESSION['mailcow_cc_username'];
+ $access_denied = null;
- // check mailbox confirm password
- if ($access_denied === null) {
- $stmt = $pdo->prepare("SELECT `password`, `authsource` FROM `mailbox`
- WHERE `username` = :username");
- $stmt->execute(array(':username' => $username));
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
- if ($row) {
- if ($row['authsource'] == 'ldap'){
- if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
- else $access_denied = false;
- } else {
+ if (!isset($_SESSION['mailcow_cc_role']) || empty($username)) $access_denied = true;
+
+ // check admin password
+ if ($access_denied === null) {
+ $stmt = $pdo->prepare("SELECT `password` FROM `admin` WHERE `username` = :username");
+ $stmt->execute(array(':username' => $username));
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if ($row) {
if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
else $access_denied = false;
}
}
- }
- // set access_denied error
- if ($access_denied){
- $_SESSION['return'][] = array(
- 'type' => 'danger',
- 'log' => array(__FUNCTION__, $_data_log),
- 'msg' => 'access_denied'
- );
- return false;
+ // check mailbox password
+ if ($access_denied === null) {
+ $stmt = $pdo->prepare("SELECT `password`, `authsource` FROM `mailbox` WHERE `username` = :username");
+ $stmt->execute(array(':username' => $username));
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if ($row) {
+ if ($row['authsource'] == 'ldap'){
+ if (!ldap_mbox_login($username, $_data["confirm_password"], $iam_settings)) $access_denied = true;
+ else $access_denied = false;
+ } else {
+ if (!verify_hash($row['password'], $_data["confirm_password"])) $access_denied = true;
+ else $access_denied = false;
+ }
+ }
+ }
+
+ if ($access_denied) {
+ $_SESSION['return'][] = array('type' => 'danger', 'log' => array(__FUNCTION__, $_data_log), 'msg' => 'access_denied');
+ return false;
+ }
}
switch ($_data["tfa_method"]) {
@@ -1306,6 +1312,7 @@ function set_tfa($_data) {
);
return false;
}
+ unset($_SESSION['pending_tfa_setup']);
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_data_log),
@@ -1319,6 +1326,7 @@ function set_tfa($_data) {
//$stmt->execute(array(':username' => $username));
$stmt = $pdo->prepare("INSERT INTO `tfa` (`username`, `key_id`, `authmech`, `secret`, `active`) VALUES (?, ?, 'totp', ?, '1')");
$stmt->execute(array($username, $key_id, $_POST['totp_secret']));
+ unset($_SESSION['pending_tfa_setup']);
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_data_log),
@@ -1347,6 +1355,7 @@ function set_tfa($_data) {
0
));
+ unset($_SESSION['pending_tfa_setup']);
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_data_log),
@@ -1354,6 +1363,25 @@ function set_tfa($_data) {
);
break;
case "none":
+ // Block TFA removal if force_tfa policy is active
+ $is_forced_tfa = false;
+ if ($_SESSION['mailcow_cc_role'] === 'user') {
+ $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
+ $stmt_check->execute(array($username));
+ $is_forced_tfa = ($stmt_check->fetchColumn() == '1');
+ } else {
+ $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
+ $stmt_check->execute(array($username));
+ $is_forced_tfa = ($stmt_check->fetchColumn() == '1');
+ }
+ if ($is_forced_tfa) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_data_log),
+ 'msg' => 'tfa_removal_blocked'
+ );
+ return false;
+ }
$stmt = $pdo->prepare("DELETE FROM `tfa` WHERE `username` = :username");
$stmt->execute(array(':username' => $username));
$_SESSION['return'][] = array(
@@ -1606,6 +1634,26 @@ function unset_tfa_key($_data) {
return false;
}
+ // Block key removal if force_tfa policy is active
+ $is_forced_tfa = false;
+ if ($_SESSION['mailcow_cc_role'] === 'user') {
+ $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
+ $stmt_check->execute(array($username));
+ $is_forced_tfa = ($stmt_check->fetchColumn() == '1');
+ } else {
+ $stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
+ $stmt_check->execute(array($username));
+ $is_forced_tfa = ($stmt_check->fetchColumn() == '1');
+ }
+ if ($is_forced_tfa) {
+ $_SESSION['return'][] = array(
+ 'type' => 'danger',
+ 'log' => array(__FUNCTION__, $_data_log),
+ 'msg' => 'tfa_removal_blocked'
+ );
+ return false;
+ }
+
// check if it's last key
$stmt = $pdo->prepare("SELECT COUNT(*) AS `keys` FROM `tfa`
WHERE `username` = :username AND `active` = '1'");
@@ -1638,6 +1686,15 @@ function unset_tfa_key($_data) {
return false;
}
}
+function tfa_exists($username) {
+ global $pdo;
+ if (empty($username)) {
+ return false;
+ }
+ $stmt = $pdo->prepare("SELECT COUNT(*) as count FROM `tfa` WHERE `username` = :username");
+ $stmt->execute(array(':username' => $username));
+ return $stmt->fetch(PDO::FETCH_ASSOC)['count'] > 0;
+}
function get_tfa($username = null, $id = null) {
global $pdo;
if (empty($username) && isset($_SESSION['mailcow_cc_username'])) {
@@ -3440,6 +3497,49 @@ function set_user_loggedin_session($user) {
unset($_SESSION['pending_mailcow_cc_role']);
unset($_SESSION['pending_tfa_methods']);
}
+function protect_route($allowed_roles = ['admin', 'domainadmin', 'user'], $redirects = []) {
+ // Check if user is authenticated
+ if (!isset($_SESSION['mailcow_cc_role'])) {
+ if (isset($redirects['unauthenticated'])) {
+ header('Location: ' . $redirects['unauthenticated']);
+ } else {
+ header('Location: /');
+ }
+ exit();
+ }
+
+ // Check for pending actions (2FA setup, password update)
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ $pending_redirect = '/';
+ if ($_SESSION['mailcow_cc_role'] === 'admin') {
+ $pending_redirect = '/admin';
+ } elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {
+ $pending_redirect = '/domainadmin';
+ }
+ header('Location: ' . $pending_redirect);
+ exit();
+ }
+
+ // Check if user's role is in the allowed roles for the route
+ if (!in_array($_SESSION['mailcow_cc_role'], $allowed_roles)) {
+ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
+ header('Location: /admin/dashboard');
+ exit();
+ }
+ elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
+ header('Location: /domainadmin/mailbox');
+ exit();
+ }
+ elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
+ header('Location: /user');
+ exit();
+ }
+ else {
+ header('Location: /');
+ exit();
+ }
+ }
+}
function get_logs($application, $lines = false) {
if ($lines === false) {
$lines = $GLOBALS['LOG_LINES'] - 1;
diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php
index 8d2efea3d..e8f3eb3d5 100644
--- a/data/web/inc/functions.mailbox.inc.php
+++ b/data/web/inc/functions.mailbox.inc.php
@@ -1083,6 +1083,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
}
$active = (isset($_data['active'])) ? intval($_data['active']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['active']);
$force_pw_update = (isset($_data['force_pw_update'])) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
+ $force_tfa = (isset($_data['force_tfa'])) ? intval($_data['force_tfa']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_tfa']);
$tls_enforce_in = (isset($_data['tls_enforce_in'])) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
$tls_enforce_out = (isset($_data['tls_enforce_out'])) ? intval($_data['tls_enforce_out']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_out']);
$sogo_access = (isset($_data['sogo_access'])) ? intval($_data['sogo_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['sogo_access']);
@@ -1099,10 +1100,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attribute_hash = (!empty($_data['attribute_hash'])) ? $_data['attribute_hash'] : '';
if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap'))){
$force_pw_update = 0;
+ $force_tfa = 0;
}
$mailbox_attrs = json_encode(
array(
'force_pw_update' => strval($force_pw_update),
+ 'force_tfa' => strval($force_tfa),
'tls_enforce_in' => strval($tls_enforce_in),
'tls_enforce_out' => strval($tls_enforce_out),
'sogo_access' => strval($sogo_access),
@@ -1720,6 +1723,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr["rl_frame"] = (!empty($_data['rl_frame'])) ? $_data['rl_frame'] : "s";
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
+ $attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_tfa']);
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['sogo_access']);
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : 1;
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
@@ -3065,6 +3069,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
if (!empty($is_now)) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
(int)$force_pw_update = (isset($_data['force_pw_update'])) ? intval($_data['force_pw_update']) : intval($is_now['attributes']['force_pw_update']);
+ (int)$force_tfa = (isset($_data['force_tfa'])) ? intval($_data['force_tfa']) : intval($is_now['attributes']['force_tfa']);
(int)$sogo_access = (isset($_data['sogo_access']) && hasACLAccess("sogo_access")) ? intval($_data['sogo_access']) : intval($is_now['attributes']['sogo_access']);
(int)$imap_access = (isset($_data['imap_access']) && hasACLAccess("protocol_access")) ? intval($_data['imap_access']) : intval($is_now['attributes']['imap_access']);
(int)$pop3_access = (isset($_data['pop3_access']) && hasACLAccess("protocol_access")) ? intval($_data['pop3_access']) : intval($is_now['attributes']['pop3_access']);
@@ -3088,6 +3093,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
}
if (in_array($authsource, array('keycloak', 'generic-oidc', 'ldap'))){
$force_pw_update = 0;
+ $force_tfa = 0;
}
$pw_recovery_email = (isset($_data['pw_recovery_email']) && $authsource == 'mailcow') ? $_data['pw_recovery_email'] : $is_now['attributes']['recovery_email'];
}
@@ -3359,6 +3365,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`quota` = :quota_b,
`authsource` = :authsource,
`attributes` = JSON_SET(`attributes`, '$.force_pw_update', :force_pw_update),
+ `attributes` = JSON_SET(`attributes`, '$.force_tfa', :force_tfa),
`attributes` = JSON_SET(`attributes`, '$.sogo_access', :sogo_access),
`attributes` = JSON_SET(`attributes`, '$.imap_access', :imap_access),
`attributes` = JSON_SET(`attributes`, '$.sieve_access', :sieve_access),
@@ -3376,6 +3383,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':quota_b' => $quota_b,
':attribute_hash' => $attribute_hash,
':force_pw_update' => $force_pw_update,
+ ':force_tfa' => $force_tfa,
':sogo_access' => $sogo_access,
':imap_access' => $imap_access,
':pop3_access' => $pop3_access,
diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php
index c64419800..72018a6bc 100644
--- a/data/web/inc/init_db.inc.php
+++ b/data/web/inc/init_db.inc.php
@@ -4,7 +4,7 @@ function init_db_schema()
try {
global $pdo;
- $db_version = "28012026_1000";
+ $db_version = "19022026_1220";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -76,7 +76,8 @@ function init_db_schema()
"superadmin" => "TINYINT(1) NOT NULL DEFAULT '0'",
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
"modified" => "DATETIME ON UPDATE NOW(0)",
- "active" => "TINYINT(1) NOT NULL DEFAULT '1'"
+ "active" => "TINYINT(1) NOT NULL DEFAULT '1'",
+ "attributes" => "JSON"
),
"keys" => array(
"primary" => array(
@@ -1390,6 +1391,11 @@ function init_db_schema()
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.passwd_update', \"0\") WHERE JSON_VALUE(`attributes`, '$.passwd_update') IS NULL;");
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.relayhost', \"0\") WHERE JSON_VALUE(`attributes`, '$.relayhost') IS NULL;");
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_pw_update', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_pw_update') IS NULL;");
+ $pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
+ // admin attributes
+ $pdo->query("UPDATE `admin` SET `attributes` = '{}' WHERE `attributes` = '' OR `attributes` IS NULL;");
+ $pdo->query("UPDATE `admin` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
+ $pdo->query("UPDATE `admin` SET `attributes` = JSON_SET(`attributes`, '$.force_pw_update', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_pw_update') IS NULL;");
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.sieve_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.sieve_access') IS NULL;");
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.sogo_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.sogo_access') IS NULL;");
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.imap_access', \"1\") WHERE JSON_VALUE(`attributes`, '$.imap_access') IS NULL;");
@@ -1449,6 +1455,7 @@ function init_db_schema()
"rl_frame" => "s",
"rl_value" => "",
"force_pw_update" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_pw_update']),
+ "force_tfa" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_tfa']),
"sogo_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sogo_access']),
"active" => 1,
"tls_enforce_in" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['tls_enforce_in']),
diff --git a/data/web/inc/triggers.admin.inc.php b/data/web/inc/triggers.admin.inc.php
index 2a02ba511..92043190e 100644
--- a/data/web/inc/triggers.admin.inc.php
+++ b/data/web/inc/triggers.admin.inc.php
@@ -9,6 +9,11 @@ if (isset($_POST["verify_tfa_login"])) {
unset($_SESSION['pending_mailcow_cc_role']);
unset($_SESSION['pending_tfa_methods']);
+ // If pending actions exist, redirect to /admin to show modal
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ header("Location: /admin");
+ die();
+ }
header("Location: /admin/dashboard");
die();
}
@@ -42,6 +47,15 @@ if (isset($_GET["cancel_tfa_login"])) {
header("Location: /admin");
}
+if (isset($_GET["cancel_tfa_setup"])) {
+ session_regenerate_id(true);
+ session_unset();
+ session_destroy();
+ session_write_close();
+ header("Location: /admin");
+ exit();
+}
+
if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
$login_user = strtolower(trim($_POST["login_user"]));
$as = check_login($login_user, $_POST["pass_user"], array("role" => "admin", "service" => "MAILCOWUI"));
@@ -50,6 +64,11 @@ if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
session_regenerate_id(true);
$_SESSION['mailcow_cc_username'] = $login_user;
$_SESSION['mailcow_cc_role'] = "admin";
+ // If pending actions exist, redirect to /admin to show modal
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ header("Location: /admin");
+ die();
+ }
header("Location: /admin/dashboard");
die();
}
diff --git a/data/web/inc/triggers.domainadmin.inc.php b/data/web/inc/triggers.domainadmin.inc.php
index 764d9009b..2eee8b993 100644
--- a/data/web/inc/triggers.domainadmin.inc.php
+++ b/data/web/inc/triggers.domainadmin.inc.php
@@ -20,6 +20,11 @@ if (isset($_POST["verify_tfa_login"])) {
unset($_SESSION['pending_mailcow_cc_role']);
unset($_SESSION['pending_tfa_methods']);
+ // If pending actions exist, redirect to /domainadmin to show modal
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ header("Location: /domainadmin");
+ die();
+ }
header("Location: /domainadmin/mailbox");
die();
}
@@ -53,6 +58,15 @@ if (isset($_GET["cancel_tfa_login"])) {
header("Location: /domainadmin");
}
+if (isset($_GET["cancel_tfa_setup"])) {
+ session_regenerate_id(true);
+ session_unset();
+ session_destroy();
+ session_write_close();
+ header("Location: /domainadmin");
+ exit();
+}
+
if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
$login_user = strtolower(trim($_POST["login_user"]));
$as = check_login($login_user, $_POST["pass_user"], array("role" => "domain_admin", "service" => "MAILCOWUI"));
@@ -61,6 +75,11 @@ if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
session_regenerate_id(true);
$_SESSION['mailcow_cc_username'] = $login_user;
$_SESSION['mailcow_cc_role'] = "domainadmin";
+ // If pending actions exist, redirect to /domainadmin to show modal
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ header("Location: /domainadmin");
+ die();
+ }
header("Location: /domainadmin/mailbox");
die();
}
diff --git a/data/web/inc/triggers.global.inc.php b/data/web/inc/triggers.global.inc.php
index dd88fad56..1f8f92004 100644
--- a/data/web/inc/triggers.global.inc.php
+++ b/data/web/inc/triggers.global.inc.php
@@ -36,7 +36,26 @@ if (isset($_SESSION['mailcow_cc_role']) && (isset($_SESSION['acl']['login_as'])
if (isset($_SESSION['mailcow_cc_role'])) {
if (isset($_POST["set_tfa"])) {
+ $had_pending_tfa_setup = !empty($_SESSION['pending_tfa_setup']);
set_tfa($_POST);
+ // After TFA setup during forced enrollment
+ if ($had_pending_tfa_setup && empty($_SESSION['pending_tfa_setup'])) {
+ if ($_SESSION['mailcow_cc_role'] === 'admin') {
+ header("Location: /admin/dashboard");
+ } elseif ($_SESSION['mailcow_cc_role'] === 'domainadmin') {
+ header("Location: /domainadmin/mailbox");
+ } elseif ($_SESSION['mailcow_cc_role'] === 'user') {
+ // Check if user should go to SOGo or /user
+ $user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
+ $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
+ if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual && getenv('SKIP_SOGO') != "y") {
+ header("Location: /SOGo/so/");
+ } else {
+ header("Location: /user");
+ }
+ }
+ exit();
+ }
}
if (isset($_POST["unset_tfa_key"])) {
unset_tfa_key($_POST);
diff --git a/data/web/inc/triggers.user.inc.php b/data/web/inc/triggers.user.inc.php
index cc33596f9..5dcde0b18 100644
--- a/data/web/inc/triggers.user.inc.php
+++ b/data/web/inc/triggers.user.inc.php
@@ -76,6 +76,11 @@ if (isset($_POST["verify_tfa_login"])) {
$user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
$is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
+ // If pending actions exist, redirect to / to show modal
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ header("Location: /");
+ die();
+ }
if (intval($user_details['attributes']['sogo_access']) == 1 &&
intval($user_details['attributes']['force_pw_update']) != 1 &&
getenv('SKIP_SOGO') != "y" &&
@@ -117,6 +122,15 @@ if (isset($_GET["cancel_tfa_login"])) {
header("Location: /");
}
+if (isset($_GET["cancel_tfa_setup"])) {
+ session_regenerate_id(true);
+ session_unset();
+ session_destroy();
+ session_write_close();
+ header("Location: /");
+ exit();
+}
+
if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
$login_user = strtolower(trim($_POST["login_user"]));
$as = check_login($login_user, $_POST["pass_user"], array("role" => "user", "service" => "MAILCOWUI"));
@@ -142,6 +156,11 @@ if (isset($_POST["login_user"]) && isset($_POST["pass_user"])) {
$user_details = mailbox("get", "mailbox_details", $login_user);
$is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
+ // If pending actions exist, redirect to / to show modal
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ header("Location: /");
+ die();
+ }
if (intval($user_details['attributes']['sogo_access']) == 1 &&
intval($user_details['attributes']['force_pw_update']) != 1 &&
getenv('SKIP_SOGO') != "y" &&
diff --git a/data/web/inc/vars.inc.php b/data/web/inc/vars.inc.php
index 6d1965542..2f4a4b076 100644
--- a/data/web/inc/vars.inc.php
+++ b/data/web/inc/vars.inc.php
@@ -193,6 +193,9 @@ $MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_out'] = false;
// Force password change on next login (only allows login to mailcow UI)
$MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update'] = false;
+// Force 2FA enrollment at next login
+$MAILBOX_DEFAULT_ATTRIBUTES['force_tfa'] = false;
+
// Enable SOGo access - Users will be redirected to SOGo after login (set to false to disable redirect by default)
$MAILBOX_DEFAULT_ATTRIBUTES['sogo_access'] = true;
diff --git a/data/web/index.php b/data/web/index.php
index a1ff9310f..b037c14f1 100644
--- a/data/web/index.php
+++ b/data/web/index.php
@@ -9,22 +9,28 @@ if (isset($_SESSION['mailcow_cc_role']) && isset($_SESSION['oauth2_request'])) {
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
- $user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
- $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
- if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual && getenv('SKIP_SOGO') != "y") {
- header("Location: /SOGo/so/");
- } else {
- header("Location: /user");
+ if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
+ $user_details = mailbox("get", "mailbox_details", $_SESSION['mailcow_cc_username']);
+ $is_dual = (!empty($_SESSION["dual-login"]["username"])) ? true : false;
+ if (intval($user_details['attributes']['sogo_access']) == 1 && !$is_dual && getenv('SKIP_SOGO') != "y") {
+ header("Location: /SOGo/so/");
+ } else {
+ header("Location: /user");
+ }
+ exit();
}
- exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
- header('Location: /admin/dashboard');
- exit();
+ if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
+ header('Location: /admin/dashboard');
+ exit();
+ }
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
- header('Location: /domainadmin/mailbox');
- exit();
+ if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
+ header('Location: /domainadmin/mailbox');
+ exit();
+ }
}
$host = strtolower($_SERVER['HTTP_HOST'] ?? '');
diff --git a/data/web/json_api.php b/data/web/json_api.php
index 5409e65d3..ebfec4353 100644
--- a/data/web/json_api.php
+++ b/data/web/json_api.php
@@ -169,6 +169,8 @@ if (isset($_GET['query'])) {
exit;
}
fido2(array("action" => "register", "registration" => $data));
+ // Release pending_tfa_setup session hold
+ unset($_SESSION['pending_tfa_setup']);
$return = new stdClass();
$return->success = true;
echo json_encode($return);
diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json
index b86879ded..06137344d 100644
--- a/data/web/lang/lang.de-de.json
+++ b/data/web/lang/lang.de-de.json
@@ -536,6 +536,7 @@
"temp_error": "Temporärer Fehler",
"text_empty": "Text darf nicht leer sein",
"tfa_token_invalid": "TFA-Token ungültig",
+ "tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.",
"tls_policy_map_dest_invalid": "Ziel ist ungültig",
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
"tls_policy_map_parameter_invalid": "Parameter ist ungültig",
@@ -685,6 +686,8 @@
"extended_sender_acl_info": "Der DKIM-Domainkey der externen Absenderdomain sollte in diesen Server importiert werden, falls vorhanden.
\r\n Wird SPF verwendet, muss diesem Server der Versand gestattet werden.
\r\n Wird eine Domain oder Alias-Domain zu diesem Server hinzugefügt, die sich mit der externen Absenderadresse überschneidet, wird der externe Absender hier entfernt.
\r\n Ein Eintrag @domain.tld erlaubt den Versand als *@domain.tld",
"force_pw_update": "Erzwinge Passwortänderung bei nächstem Login",
"force_pw_update_info": "Dem Benutzer wird lediglich der Zugang zur %s ermöglicht, App Passwörter funktionieren weiterhin.",
+ "force_tfa": "Zwei-Faktor-Authentifizierung beim Login erzwingen",
+ "force_tfa_info": "Der Benutzer muss Zwei-Faktor-Authentifizierung einrichten, bevor er auf den Bereich zugreifen kann.",
"footer_exclude": "von Fußzeile ausschließen",
"full_name": "Voller Name",
"gal": "Globales Adressbuch",
@@ -1233,7 +1236,13 @@
"webauthn": "WebAuthn-Authentifizierung",
"waiting_usb_auth": "Warte auf USB-Gerät...
Bitte jetzt den vorgesehenen Taster des USB-Gerätes berühren.",
"waiting_usb_register": "Warte auf USB-Gerät...
Bitte zuerst das obere Passwortfeld ausfüllen und erst dann den vorgesehenen Taster des USB-Gerätes berühren.",
- "yubi_otp": "Yubico OTP-Authentifizierung"
+ "yubi_otp": "Yubico OTP-Authentifizierung",
+ "force_tfa": "Zwei-Faktor-Authentifizierung beim Login erzwingen",
+ "force_tfa_info": "Der Benutzer muss Zwei-Faktor-Authentifizierung einrichten, bevor er auf den Bereich zugreifen kann.",
+ "setup_title": "Zwei-Faktor-Authentifizierung erforderlich",
+ "setup_required": "Ihr Konto erfordert Zwei-Faktor-Authentifizierung. Bitte richten Sie eine 2FA-Methode ein, um fortzufahren.",
+ "cancel_setup": "Abbrechen und abmelden",
+ "disable_tfa_blocked": "Die Zwei-Faktor-Authentifizierung kann nicht deaktiviert werden, da sie für dieses Konto erforderlich ist."
},
"user": {
"action": "Aktion",
@@ -1312,6 +1321,7 @@
"never": "Niemals",
"new_password": "Neues Passwort",
"new_password_repeat": "Neues Passwort (Wiederholung)",
+ "pw_update_required": "Ihr Konto erfordert eine Passwortänderung. Bitte setzen Sie ein neues Passwort, um fortzufahren.",
"no_active_filter": "Kein aktiver Filter vorhanden",
"no_last_login": "Keine letzte UI-Anmeldung gespeichert",
"no_record": "Kein Eintrag",
diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json
index 79175e578..eb26e3fcf 100644
--- a/data/web/lang/lang.en-gb.json
+++ b/data/web/lang/lang.en-gb.json
@@ -416,6 +416,7 @@
},
"danger": {
"access_denied": "Access denied or invalid form data",
+ "tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
"alias_domain_invalid": "Alias domain %s is invalid",
"alias_empty": "Alias address must not be empty",
"alias_goto_identical": "Alias and goto address must not be identical",
@@ -1221,6 +1222,11 @@
"confirm_totp_token": "Please confirm your changes by entering the generated token",
"delete_tfa": "Disable TFA",
"disable_tfa": "Disable TFA until next successful login",
+ "force_tfa": "Force 2FA enrollment at login",
+ "force_tfa_info": "The user will be required to set up two-factor authentication before accessing the panel.",
+ "setup_title": "Two-Factor Authentication Required",
+ "setup_required": "Your account requires two-factor authentication. Please set up a 2FA method to continue.",
+ "cancel_setup": "Cancel and log out",
"enter_qr_code": "Your TOTP code if your device cannot scan QR codes",
"error_code": "Error code",
"init_webauthn": "Initializing, please wait...",
@@ -1327,6 +1333,7 @@
"overview": "Overview",
"password": "Password",
"password_now": "Current password (confirm changes)",
+ "pw_update_required": "Your account requires a password change. Please set a new password to continue.",
"password_repeat": "Password (repeat)",
"password_reset_info": "If no email for password recovery is provided, this function cannot be used.",
"protocols": "Protocols",
diff --git a/data/web/sogo-auth.php b/data/web/sogo-auth.php
index 2da28d4d4..07456a7d1 100644
--- a/data/web/sogo-auth.php
+++ b/data/web/sogo-auth.php
@@ -50,6 +50,11 @@ elseif (isset($_GET['login'])) {
(($_SESSION['acl']['login_as'] == "1" && $ALLOW_ADMIN_EMAIL_LOGIN !== 0) || ($is_dual === false && $login == $_SESSION['mailcow_cc_username']))) {
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
if (user_get_alias_details($login) !== false) {
+ // Block SOGo access if pending actions (2FA setup, password update)
+ if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
+ header("Location: /");
+ exit;
+ }
// register username in session
$_SESSION[$session_var_user_allowed][] = $login;
// set dual login
@@ -94,7 +99,8 @@ elseif (isset($_SERVER['HTTP_X_ORIGINAL_URI']) && strcasecmp(substr($_SERVER['HT
filter_var($email, FILTER_VALIDATE_EMAIL) &&
is_array($_SESSION[$session_var_user_allowed]) &&
in_array($email, $_SESSION[$session_var_user_allowed]) &&
- !$_SESSION['pending_pw_update']
+ !$_SESSION['pending_pw_update'] &&
+ !$_SESSION['pending_tfa_setup']
) {
$username = $email;
$password = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
diff --git a/data/web/templates/base.twig b/data/web/templates/base.twig
index 98fdd86e4..4290edaef 100644
--- a/data/web/templates/base.twig
+++ b/data/web/templates/base.twig
@@ -377,6 +377,112 @@ function recursiveBase64StrToArrayBuffer(obj) {
});
{% endif %}
+ {% if pending_tfa_setup %}
+ var setupTFAModal = new bootstrap.Modal(document.getElementById("SetupTFAModal"), {
+ backdrop: 'static',
+ keyboard: false
+ });
+ setupTFAModal.show();
+
+ // Load QR code for TOTP setup in SetupTFAModal
+ var setupTotpSecret = $('#setup-tfa-qr-img').data('totp-secret');
+ if (setupTotpSecret) {
+ $.ajax({
+ type: "GET",
+ url: "/inc/ajax/qr_gen.php?token=" + encodeURIComponent(setupTotpSecret),
+ success: function(data) {
+ $('#setup-tfa-qr-img').attr('src', data);
+ }
+ });
+ }
+
+ // WebAuthn registration for SetupTFAModal
+ $('#start_setup_webauthn_register').click(function() {
+ if (!window.fetch || !navigator.credentials || !navigator.credentials.create) {
+ window.alert('Browser not supported.');
+ return;
+ }
+ var keyId = $('#setup_webauthn_reg_form input[name=key_id]').val();
+ if (!keyId) {
+ $('#setup_webauthn_return_code').show().text('Please fill in the key ID first.');
+ return;
+ }
+ window.fetch('/api/v1/get/webauthn-tfa-registration', {method: 'GET', cache: 'no-cache'}).then(function(response) {
+ return response.json();
+ }).then(function(json) {
+ if (json.success === false) throw new Error(json.error || 'Registration failed');
+ recursiveBase64StrToArrayBuffer(json);
+ return navigator.credentials.create(json);
+ }).then(function(cred) {
+ return {
+ id: cred.id,
+ rawId: arrayBufferToBase64(cred.rawId),
+ response: {
+ attestationObject: arrayBufferToBase64(cred.response.attestationObject),
+ clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON)
+ },
+ type: cred.type
+ };
+ }).then(function(credData) {
+ $('#setup_webauthn_register_data').val(JSON.stringify(credData));
+ $('#setup_webauthn_reg_form input[name=set_tfa]').val('1');
+ $('#setup_webauthn_reg_form').submit();
+ }).catch(function(err) {
+ $('#setup_webauthn_return_code').show().text(err.message || 'Registration failed');
+ });
+ });
+ {% endif %}
+
+ {% if pending_pw_update_modal and not pending_tfa_setup and not pending_tfa_methods %}
+ var changePWModal = new bootstrap.Modal(document.getElementById("ChangePWModal"), {
+ backdrop: 'static',
+ keyboard: false
+ });
+ changePWModal.show();
+
+ $('#changePWModalForm').on('submit', function(e) {
+ e.preventDefault();
+ var newPw = $('#changePWNew').val();
+ var newPw2 = $('#changePWNew2').val();
+ var role = '{{ mailcow_cc_role }}';
+ var username = '{{ mailcow_cc_username }}';
+
+ var url, attrPayload, itemsPayload;
+ if (role === 'admin') {
+ url = '/api/v1/edit/admin';
+ attrPayload = {password: newPw, password2: newPw2};
+ itemsPayload = [username];
+ } else {
+ url = '/api/v1/edit/self';
+ attrPayload = {user_new_pass: newPw, user_new_pass2: newPw2};
+ itemsPayload = null;
+ }
+
+ $('#changePWAlert').hide();
+ $.ajax({
+ type: 'POST',
+ url: url,
+ data: {
+ attr: JSON.stringify(attrPayload),
+ items: JSON.stringify(itemsPayload),
+ csrf_token: '{{ csrf_token }}'
+ },
+ dataType: 'json',
+ success: function(data) {
+ if (data && data[0] && data[0].type === 'success') {
+ window.location.reload();
+ } else {
+ var msg = (data && data[0] && data[0].msg) ? data[0].msg : 'Password change failed.';
+ $('#changePWAlert').show().text(msg);
+ }
+ },
+ error: function() {
+ $('#changePWAlert').show().text('Request failed. Please try again.');
+ }
+ });
+ });
+ {% endif %}
+
// Validate FIDO2
$("#fido2-login").click(function(){
diff --git a/data/web/templates/edit/admin.twig b/data/web/templates/edit/admin.twig
index e2c6f66ed..d9cf4aecb 100644
--- a/data/web/templates/edit/admin.twig
+++ b/data/web/templates/edit/admin.twig
@@ -39,6 +39,24 @@
+