a bunch of stuff happened. I'll do better next commit...

This commit is contained in:
2026-10-09 08:24:28 -04:00
parent bf8ad6a858
commit 4bceed7cb9
27 changed files with 540 additions and 112 deletions
+147
View File
@@ -0,0 +1,147 @@
<?php
class Applications extends Base {
public function authorizeAdministration(): void {
$this->requireAdministrator();
}
private function requireAdministrator(): void {
$statement = $this->connect()->prepare('SELECT user_name, user_role, user_status FROM users WHERE user_serial = :user');
$statement->execute([':user' => (int) ($_SESSION[self::USER_SERIAL] ?? 0)]);
$user = $statement->fetch(PDO::FETCH_ASSOC);
$allowed = $user && $user['user_status'] === 'active' && in_array($user['user_role'], ['Administrator', 'System Administrator'], true);
if ($user && $user['user_status'] === 'active') {
foreach ($this->getSettings()->xpath('//SystemAdministrator') as $name) {
$allowed = $allowed || (string) $name === $user['user_name'];
}
}
if (!$allowed) {
http_response_code(403);
exit('Administrator access is required.');
}
}
public function dashboard(): void {
try {
$statement = $this->connect()->prepare('SELECT a.* FROM applications a JOIN user_applications ua ON ua.application_serial = a.application_serial WHERE ua.user_serial = :user AND a.application_active = 1 ORDER BY a.application_name');
$statement->execute([':user' => (int) $_SESSION[self::USER_SERIAL]]);
$xml = $this->getTableXML('applications', $statement);
} catch (PDOException $e) {
if ((int) ($e->errorInfo[1] ?? 0) !== 1146) { throw $e; }
$xml = new SimpleXMLElement('<applications/>');
$xml->addAttribute('setup_required', 'true');
}
$this->displayContent($this->applyXSL($xml, $this->getXSL('home')));
}
public function manageAccess(): void {
$this->requireAdministrator();
$connection = $this->connect();
$userSerial = (int) (filter_input(INPUT_GET, 'user_serial', FILTER_VALIDATE_INT) ?: 0);
$xml = new SimpleXMLElement('<access/>');
$xml->addAttribute('selected_user', (string) $userSerial);
$xml->addAttribute('saved', filter_input(INPUT_GET, 'saved') === '1' ? 'true' : 'false');
$xml = $this->mergeXML($this->getTableXML('users', $connection->query('SELECT user_serial, user_name, user_status FROM users ORDER BY user_name')), $xml);
try {
$statement = $connection->prepare('SELECT a.*, CASE WHEN ua.user_serial IS NULL THEN 0 ELSE 1 END AS assigned FROM applications a LEFT JOIN user_applications ua ON ua.application_serial = a.application_serial AND ua.user_serial = :user ORDER BY a.application_name');
$statement->execute([':user' => $userSerial]);
} catch (PDOException $e) {
if ((int) ($e->errorInfo[1] ?? 0) !== 1146) { throw $e; }
$this->displayContent($this->applyXSL('<access setup_required="true"/>', $this->getXSL('applicationAccess')));
return;
}
$xml = $this->mergeXML($this->getTableXML('applications', $statement), $xml);
try {
$links = $connection->prepare('SELECT application_serial, local_user_serial FROM user_application_accounts WHERE user_serial = :user');
$links->execute([':user' => $userSerial]);
$xml = $this->mergeXML($this->getTableXML('account_links', $links), $xml);
} catch (PDOException $e) {
if ((int) ($e->errorInfo[1] ?? 0) !== 1146) { throw $e; }
}
$this->displayContent($this->applyXSL($xml, $this->getXSL('applicationAccess')));
}
public function saveAccess(): void {
$this->requireAdministrator();
if (!$this->validAuthRequest()) { http_response_code(403); exit('Invalid request. Reload the application access page.'); }
$userSerial = filter_input(INPUT_POST, 'user_serial', FILTER_VALIDATE_INT) ?: 0;
$selected = filter_input(INPUT_POST, 'applications', FILTER_VALIDATE_INT, FILTER_REQUIRE_ARRAY);
$selected = $selected === null ? [] : $selected;
if (!is_array($selected) || in_array(false, $selected, true) || in_array(null, $selected, true)) {
http_response_code(422); exit('Invalid application selection.');
}
$selected = array_unique($selected);
$links = filter_input(INPUT_POST, 'local_users', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
if ($links !== null && !is_array($links)) { http_response_code(422); exit('Invalid account links.'); }
$linkedAccounts = [];
foreach ($links ?? [] as $application => $localUser) {
if (!ctype_digit((string) $application) || !is_string($localUser)) { http_response_code(422); exit('Invalid account link.'); }
if (trim($localUser) === '') { continue; }
$localUser = filter_var(trim($localUser), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($localUser === false) { http_response_code(422); exit('Account IDs must be positive whole numbers.'); }
$linkedAccounts[(int) $application] = $localUser;
}
$connection = $this->connect();
$connection->beginTransaction();
try {
$statement = $connection->prepare('SELECT user_serial FROM users WHERE user_serial = :user FOR UPDATE');
$statement->execute([':user' => $userSerial]);
if (!$statement->fetchColumn()) { $connection->rollBack(); http_response_code(422); exit('Select an existing user.'); }
$valid = array_map('intval', $connection->query('SELECT application_serial FROM applications')->fetchAll(PDO::FETCH_COLUMN));
if (array_diff($selected, $valid)) { $connection->rollBack(); http_response_code(422); exit('Invalid application selection.'); }
if (array_diff(array_keys($links ?? []), $valid)) { $connection->rollBack(); http_response_code(422); exit('Invalid application account link.'); }
if ($links !== null) {
$delete = $connection->prepare('DELETE FROM user_application_accounts WHERE user_serial = :user');
$delete->execute([':user' => $userSerial]);
$insert = $connection->prepare('INSERT INTO user_application_accounts (user_serial, application_serial, local_user_serial, linked_by) VALUES (:user, :application, :local, :actor)');
foreach ($linkedAccounts as $application => $localUser) {
$insert->execute([':user' => $userSerial, ':application' => $application, ':local' => $localUser, ':actor' => $_SESSION[self::USER_NAME]]);
}
}
$statement = $connection->prepare('DELETE FROM user_applications WHERE user_serial = :user');
$statement->execute([':user' => $userSerial]);
$statement = $connection->prepare('INSERT INTO user_applications (user_serial, application_serial, assigned_by) VALUES (:user, :application, :actor)');
foreach ($selected as $application) {
$statement->execute([':user' => $userSerial, ':application' => $application, ':actor' => $_SESSION[self::USER_NAME]]);
}
$journal = $connection->prepare('INSERT INTO journal (journal_origin, journal_ip, journal_entry) VALUES (:actor, :ip, :entry)');
$journal->execute([':actor' => $_SESSION[self::USER_NAME], ':ip' => $_SERVER['REMOTE_ADDR'] ?? '', ':entry' => 'Updated application access for user ' . $userSerial . ': ' . implode(', ', $selected) . ($links !== null ? '; account links: ' . json_encode($linkedAccounts) : '')]);
$connection->commit();
} catch (Throwable $e) {
if ($connection->inTransaction()) { $connection->rollBack(); }
if ($e instanceof PDOException && (int) ($e->errorInfo[1] ?? 0) === 1062) {
http_response_code(409);
exit('That application account is already linked to another DEC user. No changes were saved.');
}
throw $e;
}
header('Location: main.php?Xaction=Applications.manageAccess&user_serial=' . $userSerial . '&saved=1', true, 303);
exit();
}
public function setup(): void {
$this->requireAdministrator();
if (!$this->validAuthRequest()) { http_response_code(403); exit('Invalid setup request. Reload the page.'); }
$connection = $this->connect();
foreach (['001_application_access.sql', '002_application_account_links.sql'] as $migration) {
$sql = file_get_contents(__DIR__ . '/../migrations/' . $migration);
foreach (explode(';', $sql) as $statement) {
if (trim($statement) !== '') { $connection->exec($statement); }
}
}
header('Location: main.php?Xaction=Applications.manageAccess', true, 303);
exit();
}
public function launch(): void {
$application = filter_input(INPUT_GET, 'application_serial', FILTER_VALIDATE_INT) ?: 0;
$statement = $this->connect()->prepare('SELECT a.application_url FROM applications a JOIN user_applications ua ON ua.application_serial = a.application_serial WHERE a.application_serial = :application AND ua.user_serial = :user AND a.application_active = 1');
$statement->execute([':application' => $application, ':user' => (int) $_SESSION[self::USER_SERIAL]]);
$url = $statement->fetchColumn();
if (!$url) { http_response_code(403); exit('This application is not assigned to your account.'); }
if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(strtolower(parse_url($url, PHP_URL_SCHEME) ?: ''), ['http', 'https'], true) || preg_match('/[\r\n]/', $url)) {
http_response_code(500); exit('The application URL is invalid. Contact your administrator.');
}
header('Location: ' . $url, true, 303);
exit();
}
}
+3 -4
View File
@@ -47,7 +47,6 @@ class Authentication {
$host = 'localhost';
} else {
$host = '192.168.1.190';
$host = '100.67.215.67';
}
try {
@@ -78,7 +77,7 @@ class Authentication {
$password = '';
for ($i = 0; $i < $length; $i++) {
$index = rand(0, strlen($characters) - 1);
$index = random_int(0, strlen($characters) - 1);
$password .= $characters[$index];
}
@@ -186,14 +185,14 @@ class Authentication {
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
$SQL = "update users
set user_password = :user_password,
set user_password_hash = :user_password_hash,
user_change_password = :user_change_password
where user_serial = :user_serial";
$statement = $this->ghrconnection->prepare($SQL);
$statement->bindValue(":user_serial", $user_serial);
$statement->bindValue(":user_password", $user_temp_password);
$statement->bindValue(":user_password_hash", $user_temp_password);
$statement->bindValue(":user_change_password", 1);
$statement->execute();
+60 -29
View File
@@ -13,8 +13,8 @@ class Base {
// Constants
// =========
const APPLICATION_NAME = "BaseProject";
const APPLICATION_MNEMONIC = "BASEPROJECT";
const APPLICATION_NAME = "DEC Authentication Service";
const APPLICATION_MNEMONIC = "DECAUTHY";
const VERSION = "1.00";
const TIMEZONE = "America/New_York";
@@ -22,22 +22,22 @@ class Base {
// Session Variables
// =================
const ENVIRONMENT = "BASEPROJECT_ENVIRONMENT";
const ACTIVE_SESSION = "BASEPROJECT_ACTIVE_SESSION";
const USER_SERIAL = "BASEPROJECT_USER_SERIAL";
const USER_NAME = "BASEPROJECT_USER_NAME";
const USER_FULL_NAME = "BASEPROJECT_USER_FULL_NAME";
const USER_EMAIL = "BASEPROJECT_USER_EMAIL";
const USER_ROLE = "BASEPROJECT_USER_ROLE";
const USER_AUTHENTICATED = "BASEPROJECT_USER_AUTHENTICATED";
const REPORTS_GET = "BASEPROJECT_REPORTS_GET";
const REPORTS_POST = "BASEPROJECT_REPORTS_POST";
const THEME = "BASEPROJECT_THEME";
const AJAX = "BASEPROJECT_AJAX";
const TODAY = "BASEPROJECT_TODAY";
const COPYRIGHT = "BASEPROJECT_COPYRIGHT";
const PAGE = "BASEPROJECT_PAGE";
const PAGINATION_PAGE = "BASEPROJECT_PAGINATION_PAGE";
const ENVIRONMENT = "DECAUTHY_ENVIRONMENT";
const ACTIVE_SESSION = "DECAUTHY_ACTIVE_SESSION";
const USER_SERIAL = "DECAUTHY_USER_SERIAL";
const USER_NAME = "DECAUTHY_USER_NAME";
const USER_FULL_NAME = "DECAUTHY_USER_FULL_NAME";
const USER_EMAIL = "DECAUTHY_USER_EMAIL";
const USER_ROLE = "DECAUTHY_USER_ROLE";
const USER_AUTHENTICATED = "DECAUTHY_USER_AUTHENTICATED";
const REPORTS_GET = "DECAUTHY_REPORTS_GET";
const REPORTS_POST = "DECAUTHY_REPORTS_POST";
const THEME = "DECAUTHY_THEME";
const AJAX = "DECAUTHY_AJAX";
const TODAY = "DECAUTHY_TODAY";
const COPYRIGHT = "DECAUTHY_COPYRIGHT";
const PAGE = "DECAUTHY_PAGE";
const PAGINATION_PAGE = "DECAUTHY_PAGINATION_PAGE";
// =================
// Class Constructor
@@ -93,6 +93,8 @@ class Base {
$XSLParms["TODAY_MMDDYYYY"] = date("m/d/Y");
$XSLParms["TODAY_HHMM"] = date("Hi");
$XSLParms["COPYRIGHT"] = date("Y");
$_SESSION['DECAUTHY_CSRF'] ??= bin2hex(random_bytes(32));
$XSLParms['CSRF_TOKEN'] = $_SESSION['DECAUTHY_CSRF'];
$XML = new DOMDocument();
$XSL = new DOMDocument();
@@ -257,8 +259,7 @@ class Base {
public function displayHome() {
$content = $this->applyXSL("", $this->getXSL("home"));
$this->displayContent($content);
(new Applications())->dashboard();
}
// =======================
@@ -276,6 +277,14 @@ class Base {
$action = (empty($Xaction) ? $action : $Xaction);
if (strtolower(trim($action)) === 'signout') {
$_SESSION = [];
session_regenerate_id(true);
$this->initializeSession();
header('Location: main.php');
exit();
}
if ($Xaction == 'resetpassword') {
$this->resetpassword();
}
@@ -289,7 +298,7 @@ class Base {
// Determine if user needs a password change
if ($this->userChangePassword() === true) {
(new Users())->changePassword();
(new Users())->changePassword($action !== 'Users.changePassword');
exit();
}
@@ -304,16 +313,19 @@ class Base {
$action = (empty($Xaction) ? $action : $Xaction);
// Sign Out if requested.
if (strtolower(trim($action)) === "signout") {
$this->initializeSession();
}
// Process the requested Action.
list($class, $method) = array_pad(explode('.', $action), 2, "");
if (strcasecmp($class, 'Applications') === 0 && !in_array(strtolower($method), ['dashboard', 'manageaccess', 'saveaccess', 'launch', 'setup'], true)) {
http_response_code(404);
exit('Unknown application action.');
}
// Account management can change roles, so enforce its administrator boundary here as well.
if (strcasecmp($class, 'Users') === 0 && strcasecmp($method, 'changePassword') !== 0) {
(new Applications())->authorizeAdministration();
}
if (empty($method)) {
$method = $class;
@@ -745,7 +757,7 @@ class Base {
$password = '';
for ($i = 0; $i < $length; $i++) {
$index = rand(0, strlen($characters) - 1);
$index = random_int(0, strlen($characters) - 1);
$password .= $characters[$index];
}
@@ -760,6 +772,13 @@ class Base {
$user = (new Users())->getUserByUserName($_SESSION[self::USER_NAME]);
if ($user->count() === 0 || (string) $user->record->user_status !== 'active') {
$_SESSION = [];
session_regenerate_id(true);
header('Location: main.php');
exit();
}
if ($user->record->user_change_password == 1) {
return true;
@@ -802,16 +821,28 @@ class Base {
public function resetPassword() {
if (!$this->validAuthRequest()) {
http_response_code(403);
exit('Invalid authentication request. Reload the sign-in page.');
}
require_once 'classes/Authentication.php';
$XSLParms = [
"BTN_SUBMIT" => "main.php?action=Users.signIn&step=authenticate",
"BTN_SUBMIT" => "main.php",
"RESET_PASSWORD_SUCCESSFUL" => (new Authentication())->resetPassword() ? "TRUE" : "FALSE"
];
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
exit;
}
public function validAuthRequest(): bool {
$token = filter_input(INPUT_POST, 'csrf_token') ?: '';
return ($_SERVER['REQUEST_METHOD'] ?? '') === 'POST'
&& isset($_SESSION['DECAUTHY_CSRF'])
&& hash_equals($_SESSION['DECAUTHY_CSRF'], $token);
}
}
?>
+9 -4
View File
@@ -40,7 +40,7 @@ class SignIn extends Base {
case "prompt":
$XSLParms["BTN_SUBMIT"] = "main.php?action=SignIn.login&step=authenticate";
$XSLParms["BTN_SUBMIT"] = "main.php";
$XSLParms["INVALID_SIGNIN"] = "FALSE";
echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
@@ -50,10 +50,15 @@ class SignIn extends Base {
case "authenticate":
if (!$this->validAuthRequest()) {
http_response_code(403);
exit('Invalid authentication request. Reload the sign-in page.');
}
$user_name = trim(filter_input(INPUT_POST, "user_name") ?: "");
$user_password = filter_input(INPUT_POST, "user_password") ?: "";
$XSLParms["BTN_SUBMIT"] = "main.php?action=SignIn.login&step=authenticate";
$XSLParms["BTN_SUBMIT"] = "main.php";
$XSLParms["INVALID_SIGNIN"] = "TRUE";
$user = (new Users())->getUserByUserName($user_name);
@@ -102,14 +107,14 @@ class SignIn extends Base {
(new Users())->updateLogin();
header("Location: ./");
header("Location: main.php");
exit();
break;
default:
// User NOT Validated.
header("Location: ./");
header("Location: main.php");
exit();
}
}
+33 -26
View File
@@ -8,7 +8,6 @@ class Users extends Base {
// Variables
private $current_user_serial = "";
private $current_user_name = "";
private $current_user_email = "";
@@ -25,6 +24,8 @@ class Users extends Base {
date_default_timezone_set(self::TIMEZONE);
$this->current_user_serial = (int) ($_SESSION[self::USER_SERIAL] ?? 0);
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
@@ -120,18 +121,18 @@ class Users extends Base {
( user_name,
user_email,
user_client_name,
user_password,
user_first_name,
user_password_hash,
user_role,
user_change_password,
user_active,
user_status,
user_creator,
user_created )
values ( :user_name,
:user_email,
:user_client_name,
:user_password,
:user_password_hash,
:user_role,
:user_change_password,
:user_active,
@@ -143,10 +144,10 @@ class Users extends Base {
$statement->bindValue(":user_name", $user_name);
$statement->bindValue(":user_email", $user_email);
$statement->bindValue(":user_client_name", $user_client_name);
$statement->bindValue(":user_password", $user_temp_password);
$statement->bindValue(":user_password_hash", $user_temp_password);
$statement->bindValue(":user_role", $user_role);
$statement->bindValue(":user_change_password", $user_change_password, PDO::PARAM_BOOL);
$statement->bindValue(":user_active", $user_active, PDO::PARAM_BOOL);
$statement->bindValue(":user_active", $user_active ? 'active' : 'inactive');
$statement->bindValue(":user_creator", $this->current_user_name);
$statement->bindValue(":user_created", $this->getTimestamp());
@@ -241,9 +242,9 @@ class Users extends Base {
$SQL = "update users
set user_name = :user_name,
user_email = :user_email,
user_client_name = :user_client_name,
user_first_name = :user_client_name,
user_role = :user_role,
user_active = :user_active,
user_status = :user_active,
user_changer = :user_changer,
user_changed = :user_changed
where user_serial = :user_serial";
@@ -255,7 +256,7 @@ class Users extends Base {
$statement->bindValue(":user_email", $user_email);
$statement->bindValue(":user_client_name", $user_client_name);
$statement->bindValue(":user_role", $user_role);
$statement->bindValue(":user_active", $user_active, PDO::PARAM_BOOL);
$statement->bindValue(":user_active", $user_active ? 'active' : 'inactive');
$statement->bindValue(":user_changer", $this->current_user_name);
$statement->bindValue(":user_changed", $this->getTimestamp());
@@ -288,11 +289,9 @@ class Users extends Base {
public function getUserByUserName($user_name) {
$SQL = "select view_users.*
from view_users
where user_name = '{$user_name}'";
return $this->getTable("user", $SQL);
$statement = $this->connect()->prepare('select * from view_users where user_name = :user_name');
$statement->execute([':user_name' => $user_name]);
return $this->getTableXML('user', $statement);
}
// =====================
@@ -484,9 +483,9 @@ class Users extends Base {
// Change Password
// ===============
public function changePassword() {
public function changePassword($forcePrompt = false) {
$step = filter_input(INPUT_POST, "step") ?: "prompt";
$step = $forcePrompt ? 'prompt' : (filter_input(INPUT_POST, "step") ?: "prompt");
switch ($step) {
@@ -502,12 +501,19 @@ class Users extends Base {
case "change":
if (!$this->validAuthRequest()) {
http_response_code(403);
return;
}
$user_serial = $_SESSION[self::USER_SERIAL];
$user_new_password = filter_input(INPUT_POST, "user_new_password") ?: "";
$user_verify_password = filter_input(INPUT_POST, "user_verify_password") ?: "";
if (!$user_new_password == $user_verify_password) {
$this->initializeSession();
if ($user_serial <= 0 || $user_new_password === '' || $user_new_password !== $user_verify_password) {
http_response_code(422);
echo 'Enter a nonempty password and matching confirmation.';
return;
}
$user_new_hashed_password = password_hash($user_new_password, PASSWORD_DEFAULT);
@@ -517,7 +523,7 @@ class Users extends Base {
$connection = $this->connect();
$SQL = "update users
set user_password = :user_password,
set user_password_hash = :user_password_hash,
user_change_password = :user_change_password,
user_changer = :user_changer,
user_changed = :user_changed
@@ -526,7 +532,7 @@ class Users extends Base {
$statement = $connection->prepare($SQL);
$statement->bindValue(":user_serial", $user_serial);
$statement->bindValue(":user_password", $user_new_hashed_password);
$statement->bindValue(":user_password_hash", $user_new_hashed_password);
$statement->bindValue(":user_change_password", 0);
$statement->bindValue(":user_changer", $this->current_user_name);
$statement->bindValue(":user_changed", $this->getTimestamp());
@@ -536,6 +542,7 @@ class Users extends Base {
}
(new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} changed their password.");
session_regenerate_id(true);
break;
@@ -567,14 +574,14 @@ class Users extends Base {
$connection = $this->connect();
$SQL = "update users
set user_password = :user_password,
set user_password_hash = :user_password_hash,
user_change_password = :user_change_password
where user_serial = :user_serial";
$statement = $connection->prepare($SQL);
$statement->bindValue(":user_serial", $user_serial);
$statement->bindValue(":user_password", $user_temp_password);
$statement->bindValue(":user_password_hash", $user_temp_password);
$statement->bindValue(":user_change_password", 1);
if (!$statement->execute()) {
@@ -599,7 +606,7 @@ class Users extends Base {
public function updateLogin() {
$SQL = "update users
set user_login = :user_login
set user_last_login = :user_login
where user_serial = :user_serial";
$statement = $this->connect()->prepare($SQL);
@@ -620,7 +627,7 @@ class Users extends Base {
$user_active = filter_input(INPUT_POST, "user_active", FILTER_SANITIZE_NUMBER_INT) ?: 0;
$SQL = "update users
set user_active = :user_active,
set user_status = :user_active,
user_changed = :user_changed,
user_changer = :user_changer
where user_serial = :user_serial";
@@ -628,7 +635,7 @@ class Users extends Base {
$statement = $this->connect()->prepare($SQL);
$statement->bindValue(":user_serial", $user_serial);
$statement->bindValue(":user_active", $user_active);
$statement->bindValue(":user_active", $user_active ? 'active' : 'inactive');
$statement->bindValue(":user_changed", $this->getTimestamp());
$statement->bindValue(":user_changer", $this->current_user_name);
@@ -0,0 +1,10 @@
-- Run only against the production decauthy database after directory setup.
-- This changes dashboard destinations only; it does not enable shared sign-in.
START TRANSACTION;
UPDATE applications
SET application_url = 'https://apps.realestatedatainc.com/realestatedatainc/'
WHERE application_code = 'realestatedatainc';
UPDATE applications
SET application_url = 'https://apps.realestatedatainc.com/atlhousingreport/'
WHERE application_code = 'atlhousingreport';
COMMIT;
+10
View File
@@ -0,0 +1,10 @@
-- Run only against the staging decauthy database after directory setup.
-- This changes dashboard destinations only; it does not enable shared sign-in.
START TRANSACTION;
UPDATE applications
SET application_url = 'https://staging.realestatedatainc.com/realestatedatainc/'
WHERE application_code = 'realestatedatainc';
UPDATE applications
SET application_url = 'https://staging.realestatedatainc.com/atlhousingreport/'
WHERE application_code = 'atlhousingreport';
COMMIT;
+25
View File
@@ -0,0 +1,25 @@
# Application dashboard and assignments
Sign in as an Administrator or configured System Administrator. Open **Application Access** in the user menu.
On the first visit, use **Set up application directory**. This runs `migrations/001_application_access.sql` against the configured decauthy database, creating `applications` and `user_applications` and registering the two development URLs. It does not assign any user access. The migration is also available to run directly with your database tools. Repeating it preserves existing URLs and assignments.
Choose a user and press **Load**. Select their applications and press **Save access**. Clearing all selections removes all of that user's assignments. Saving replaces the selection in one transaction and records the change in the journal. Disabled applications retain assignments but do not appear on the dashboard.
After sign-in, customers see only their assigned active applications. The launch endpoint checks the assignment again before redirecting, so a removed assignment stops launches immediately.
These links currently open the applications' existing sign-in flows. They do not convey authenticated identity or replace either application's access checks. The secure authentication handoff and legacy-user mappings are a subsequent integration step.
For production, update `applications.application_url` to each product's HTTPS address. Each product keeps its own authentication and subscription permissions until integration is complete.
Verification: test a user with zero, one, and two assignments; clear an assignment and confirm the corresponding launch is denied; confirm a regular user cannot open or post to the administration routes. Administrator account-management routes are also protected server-side because changing an account's role would otherwise bypass assignment administration.
## Existing application accounts
Use **Enable account links** on the Application Access page to create the account-link table without changing existing assignments. Fresh directory setup now creates this table too.
After loading a DEC user, enter the existing `user_serial` from each destination application's `users` table. Matching usernames can help an administrator locate the record, but do not constitute automatic identity verification. Confirm the record belongs to the same customer before saving. Different usernames and different numeric IDs between applications are supported.
A destination account can be linked to only one DEC user. Duplicate links return a conflict and roll back the entire save, including assignment changes. Blank fields remove mappings. Unchecking application access preserves its account mapping, so restoring access can retain the same customer history. Account existence and active status must also be checked by the destination during the eventual authentication handoff.
This step prepares the identity mapping only. There is no cross-application authentication token or handoff enabled yet, and destination passwords continue to work as before.
+53
View File
@@ -0,0 +1,53 @@
# Shared sign-in deployment
## Confirmed production addresses
| Service | Address |
| --- | --- |
| DEC sign-in portal | https://login.realestatedatainc.com/ |
| Real Estate Data Inc | https://apps.realestatedatainc.com/realestatedatainc/ |
| Georgia Housing Report | https://apps.realestatedatainc.com/atlhousingreport/ |
## Confirmed staging addresses
| Service | Address |
| --- | --- |
| DEC sign-in portal | https://staging.realestatedatainc.com/decauthy/ |
| Real Estate Data Inc | https://staging.realestatedatainc.com/realestatedatainc/ |
| Georgia Housing Report | https://staging.realestatedatainc.com/atlhousingreport/ |
The current development environment uses HTTP. Shared sign-in will be verified on these separate HTTPS staging copies. Staging clients, signing keys, sessions, and databases must be separate from production. Use test users and direct staging mail to a test inbox.
After initializing the staging application directory, run `deployment/staging-application-urls.sql` against the staging decauthy database to set its dashboard destinations. This script is intentionally excluded from automatic directory setup.
## Staging preparation
1. Create HTTPS staging sites for all three applications with certificates trusted by the test browser and server. Keep each application's current login available during integration.
2. Create separate staging databases. Configure each copy to use its staging database and test mail destination before opening it to testers.
3. Deploy decauthy's dashboard and account-link changes. Initialize its application directory, replace the development application URLs with the actual staging URLs, and assign one test user to each product.
4. Link that test user to its existing local user_serial in each staging product. Confirm the destination user is active and has the intended test subscriptions.
5. Install and configure the identity provider on staging, then register one client for each destination application. Exact callback URLs will be chosen when the callback handlers are implemented. Do not register guessed or wildcard callbacks.
## Proposed PHP identity provider
SimpleSAMLphp with its OpenID Connect provider module is a self-hosted PHP option. The currently documented SimpleSAMLphp baseline is PHP 8.3 or newer; Composer must resolve the selected release and extensions on the actual PHP 8.5 server before compatibility is considered verified.
Install SimpleSAMLphp separately from the decauthy application, following the official installation guide. Publish only its public directory; configuration and private signing keys remain outside the web root. From that installation's directory, install the OIDC module with:
```sh
composer require simplesamlphp/simplesamlphp-module-oidc
composer check-platform-reqs
```
The provider needs an authentication integration with decauthy's central accounts, its own protocol storage, signing keys, and exact client registrations. Installing the module alone does not connect decauthy's current login session to the provider. Neither that integration nor the destination callbacks have been implemented yet.
Sources:
- https://simplesamlphp.org/docs/stable/simplesamlphp-install.html
- https://simplesamlphp.org/docs/contrib_modules/oidc/2-oidc-installation.html
- https://simplesamlphp.org/docs/contrib_modules/oidc/3-oidc-configuration.html
## Acceptance checks before production
Verify central login opens each assigned product as the mapped existing user, preserving its local permissions and history. Test missing mappings, inactive destination users, denied assignments, expired or reused authorization codes, incorrect state/nonce, and incorrect client or callback. Decide and verify how product sign-out, DEC sign-out, and removal of access affect existing sessions.
After staging passes, register production clients with separate secrets and signing keys and configure the confirmed production URLs. `deployment/production-application-urls.sql` updates dashboard destinations only and is intentionally excluded from the automatic directory setup. No production database changes or deployments have been performed from this chat.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

-1
View File
@@ -1 +0,0 @@
<?php header("location: main.php"); ?>
+7 -2
View File
@@ -52,9 +52,14 @@ $(document).ready(function () {
e.preventDefault();
$.post("./", $(this).serialize())
$.post("main.php", $(this).serialize())
.done(function () {
formSubmit({action: "./"});
window.location.assign('main.php');
})
.fail(function (response) {
createInputError(user_verify_password, response.status === 422
? 'Enter a nonempty password and matching confirmation.'
: 'Unable to change your password. Reload the page and try again.');
});
});
+1
View File
@@ -56,6 +56,7 @@ $(document).ready(function () {
$(".btnForgot").on("click", function (e) {
e.preventDefault();
$("#reset_user_name").val($("#user_name").val().trim());
$("#reset-password").submit();
});
+9 -1
View File
@@ -2,7 +2,15 @@
require_once "classes/Base.php";
session_start();
session_name('DECAUTHY_SESSION');
session_start([
'use_strict_mode' => true,
'use_only_cookies' => true,
'cookie_httponly' => true,
'cookie_secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'cookie_samesite' => 'Lax',
]);
header('Cache-Control: no-store');
(new Base())->do();
?>
+22
View File
@@ -0,0 +1,22 @@
-- Run once against the decauthy database. No users receive access automatically.
CREATE TABLE IF NOT EXISTS applications (
application_serial BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
application_code VARCHAR(64) NOT NULL UNIQUE,
application_name VARCHAR(128) NOT NULL,
application_description VARCHAR(255) NOT NULL DEFAULT '',
application_url VARCHAR(2048) NOT NULL,
application_active BOOLEAN NOT NULL DEFAULT TRUE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS user_applications (
user_serial BIGINT UNSIGNED NOT NULL,
application_serial BIGINT UNSIGNED NOT NULL,
assigned_by VARCHAR(128) NOT NULL,
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_serial, application_serial),
FOREIGN KEY (user_serial) REFERENCES users(user_serial) ON DELETE CASCADE,
FOREIGN KEY (application_serial) REFERENCES applications(application_serial) ON DELETE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
INSERT INTO applications (application_code, application_name, application_description, application_url) VALUES
('realestatedatainc', 'Real Estate Data Inc', 'Open your Real Estate Data Inc account.', 'http://ositsdevels/clients/dec-international/realestatedatainc/main.php'),
('atlhousingreport', 'Georgia Housing Report', 'Open your Georgia Housing Report account.', 'http://ositsdevels/clients/dec-international/atlhousingreport/')
ON DUPLICATE KEY UPDATE application_code = VALUES(application_code);
@@ -0,0 +1,12 @@
-- Application-local IDs are stable user_serial values, not usernames.
CREATE TABLE IF NOT EXISTS user_application_accounts (
user_serial BIGINT UNSIGNED NOT NULL,
application_serial BIGINT UNSIGNED NOT NULL,
local_user_serial BIGINT UNSIGNED NOT NULL,
linked_by VARCHAR(128) NOT NULL,
linked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_serial, application_serial),
UNIQUE KEY application_account (application_serial, local_user_serial),
FOREIGN KEY (user_serial) REFERENCES users(user_serial) ON DELETE CASCADE,
FOREIGN KEY (application_serial) REFERENCES applications(application_serial) ON DELETE CASCADE
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+3 -3
View File
@@ -1,6 +1,6 @@
copy.src.files=true
copy.src.on.open=false
copy.src.target=W:\\clients\\onesourceits\\baseProject
index.file=index.php
copy.src.target=W:\\clients\\onesourceits\\decauthy
index.file=main.php
run.as=LOCAL
url=http://ositsdevels/clients/onesourceits/baseProject/
url=http://ositsdevels/clients/onesourceits/decauthy/
+1 -1
View File
@@ -3,7 +3,7 @@
<type>org.netbeans.modules.php.project</type>
<configuration>
<data xmlns="http://www.netbeans.org/ns/php-project/1">
<name>baseProject</name>
<name>decauthy</name>
</data>
</configuration>
</project>
+2
View File
@@ -3,6 +3,8 @@
$SQL = "create view view_users as
select users.*,
user_first_name as user_client_name,
(user_status = 'active') as user_active,
date_format(user_last_login, '%c/%e/%Y %l:%i %p') as user_login_verbose,
date_format(user_created, '%c/%e/%Y %l:%i %p') as user_created_verbose,
date_format(user_changed, '%c/%e/%Y %l:%i %p') as user_changed_verbose,
+4 -4
View File
@@ -2,12 +2,12 @@
<Settings>
<ApplicationName>BaseProject</ApplicationName>
<ApplicationName>DEC Authentication Service</ApplicationName>
<DatabaseServer>192.168.1.190</DatabaseServer>
<DatabaseName>baseproject</DatabaseName>
<DatabaseUser>bpuser</DatabaseUser>
<DatabasePassword>bppass123</DatabasePassword>
<DatabaseName>decauthy</DatabaseName>
<DatabaseUser>aUthUs3r</DatabaseUser>
<DatabasePassword>HMV1N39YM4a4qPDB</DatabasePassword>
<SmtpServer>smtp.gmail.com</SmtpServer>
<SmtpUserName>info@onesourceits.com</SmtpUserName>
+76
View File
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:output method="html" encoding="utf-8" indent="yes"/>
<xsl:param name="CSRF_TOKEN"/>
<xsl:template match="/">
<div class="py-4">
<h1 class="h3">Application access</h1>
<p class="text-muted">Choose a user, then select the applications they may open.</p>
<xsl:choose>
<xsl:when test="access/@setup_required = 'true'">
<div class="alert alert-info">Set up the application directory to start assigning access.</div>
<form action="main.php" method="post">
<input type="hidden" name="action" value="Applications.setup"/>
<input type="hidden" name="csrf_token" value="{$CSRF_TOKEN}"/>
<button type="submit" class="btn btn-primary">Set up application directory</button>
</form>
</xsl:when>
<xsl:otherwise>
<xsl:if test="not(access/account_links)">
<div class="alert alert-info">Enable existing account links to prepare users for shared sign-in.</div>
<form action="main.php" method="post" class="mb-3">
<input type="hidden" name="action" value="Applications.setup"/>
<input type="hidden" name="csrf_token" value="{$CSRF_TOKEN}"/>
<button type="submit" class="btn btn-outline-primary">Enable account links</button>
</form>
</xsl:if>
<xsl:if test="access/@saved = 'true'"><div class="alert alert-success" role="status">Application access saved.</div></xsl:if>
<form action="main.php" method="get" class="mb-4">
<input type="hidden" name="Xaction" value="Applications.manageAccess"/>
<label for="access_user" class="form-label">User</label>
<div class="d-flex gap-2">
<select class="form-select" id="access_user" name="user_serial" required="required">
<option value="">Choose a user</option>
<xsl:for-each select="access/users/record">
<option value="{user_serial}">
<xsl:if test="user_serial = /access/@selected_user"><xsl:attribute name="selected">selected</xsl:attribute></xsl:if>
<xsl:value-of select="user_name"/><xsl:if test="user_status != 'active'"> (inactive)</xsl:if>
</option>
</xsl:for-each>
</select>
<button type="submit" class="btn btn-outline-primary">Load</button>
</div>
</form>
<xsl:if test="access/users/record[user_serial = /access/@selected_user]">
<form action="main.php" method="post">
<input type="hidden" name="action" value="Applications.saveAccess"/>
<input type="hidden" name="csrf_token" value="{$CSRF_TOKEN}"/>
<input type="hidden" name="user_serial" value="{access/@selected_user}"/>
<fieldset class="mb-3"><legend class="h5">Assigned applications</legend>
<xsl:for-each select="access/applications/record">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="applications[]" id="application_{application_serial}" value="{application_serial}">
<xsl:if test="assigned = '1'"><xsl:attribute name="checked">checked</xsl:attribute></xsl:if>
</input>
<label class="form-check-label" for="application_{application_serial}">
<xsl:value-of select="application_name"/><xsl:if test="application_active != '1'"> (application disabled)</xsl:if>
</label>
<xsl:if test="/access/account_links">
<xsl:variable name="application" select="application_serial"/>
<div class="mt-2 mb-3">
<label class="form-label" for="local_user_{application_serial}">Existing <xsl:value-of select="application_name"/> user ID</label>
<input class="form-control" type="number" min="1" step="1" id="local_user_{application_serial}" name="local_users[{application_serial}]" value="{/access/account_links/record[application_serial = $application]/local_user_serial}"/>
<div class="form-text">Use the existing account's user_serial from this application's Users table. Clearing this field removes the account link when you save.</div>
</div>
</xsl:if>
</div>
</xsl:for-each>
</fieldset>
<button type="submit" class="btn btn-primary">Save access</button>
</form>
</xsl:if>
</xsl:otherwise>
</xsl:choose>
</div>
</xsl:template>
</xsl:stylesheet>
+2
View File
@@ -6,6 +6,7 @@
<!-- Parameters -->
<xsl:param name="VERSION">1.0</xsl:param>
<xsl:param name="CSRF_TOKEN"/>
<xsl:param name="PAGE-TITLE">Change Password</xsl:param>
<xsl:param name="PAGE-NAME">changePassword</xsl:param>
@@ -28,6 +29,7 @@
<input type="hidden" name="action" value="Users.changePassword"/>
<input type="hidden" name="step" value="change"/>
<input type="hidden" name="csrf_token" value="{$CSRF_TOKEN}"/>
<div class="row">
+16 -8
View File
@@ -7,6 +7,11 @@
<xsl:param name="VERSION">1.0</xsl:param>
<xsl:param name="APPLICATION_NAME"></xsl:param>
<xsl:param name="APPLICATION_MNEMONIC"/>
<xsl:param name="COPYRIGHT"/>
<xsl:param name="THEME">light</xsl:param>
<xsl:param name="USER_FULL_NAME"/>
<xsl:param name="USER_ROLE"/>
<xsl:param name="NAVIGATION_AREA_WIDTH">xxl</xsl:param>
<xsl:param name="CONTENT_AREA_WIDTH">xxl</xsl:param>
@@ -28,7 +33,7 @@
<xsl:value-of select="$APPLICATION_NAME"/>
</title>
<link rel="icon" type="image/x-icon" href="images/baseproject-logo.png"/>
<link rel="icon" type="image/x-icon" href="images/dec-international-logo.png"/>
<link href="css/themes/Sandstone/bootstrap.min.css?version={$VERSION}" rel="stylesheet" media="screen"/>
<link href="css/bootstrap-icons.min.css?version={$VERSION}" rel="stylesheet" media="screen"/>
@@ -80,12 +85,12 @@
<ul class="navbar-nav mx-auto">
<li class="nav-item">
<!-- <li class="nav-item">
<a class="nav-link user-subscriptions" href="javascript:void(0);">
<i class="bi bi-credit-card me-1" aria-hidden="true"/>
PAYMENTS
</a>
</li>
</li>-->
<!-- MANAGE/REPORTS/SETUP is only available to Administrators -->
@@ -95,7 +100,7 @@
<xsl:text> | </xsl:text>
</li>-->
<li class="nav-item dropdown">
<!-- <li class="nav-item dropdown">
<a href="javascript:void(0);" class="nav-link dropdown-toggle" data-bs-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<i class="bi bi-kanban me-1" aria-hidden="true"/>
@@ -126,7 +131,7 @@
<i class="bi bi-printer me-1" aria-hidden="true"/>
REPORTS
</a>
</li>
</li> -->
<!-- <li class="nav-link disabled">
<xsl:text> | </xsl:text>
@@ -141,9 +146,9 @@
</xsl:if>
<li class="nav-link disabled">
<!-- <li class="nav-link disabled">
<xsl:text> | </xsl:text>
</li>
</li>-->
<li class="nav-item">
<a class="nav-link start-support-ticket" href="javascript:void(0);">
@@ -213,6 +218,9 @@
<i class="bi bi-person-circle me-2"/>Users
</a>
<a class="dropdown-item ps-2" href="main.php?Xaction=Applications.manageAccess">
<i class="bi bi-grid me-2"/>Application Access
</a>
<a class="dropdown-item ps-2 view-journal" href="javascript:void(0);">
<i class="bi bi-journal me-2"/>Journal
</a>
@@ -343,7 +351,7 @@
<!-- Common Submit Form -->
<form action="./" method="post" id="formSubmit"/>
<form action="main.php" method="post" id="formSubmit"/>
</body>
+1 -1
View File
@@ -25,7 +25,7 @@
<p class="text">Username: <xsl:value-of select="//user/record/user_name"/></p>
<p class="text">Password: <xsl:value-of select="$USER-TEMP-PASSWORD"/></p>
<p class="text">You login in at the following web address: <a href="https://ahr.realestatedatainc.com" target="_blank">Atlanta Housing Report</a></p>
<p class="text">Sign in to the DEC Authentication Service with these credentials. You will then be asked to choose a new password.</p>
<p class="text">Thank you,</p>
+1 -1
View File
@@ -37,7 +37,7 @@
<p class="text">Password: <xsl:value-of select="$USER-TEMP-PASSWORD"/></p>
<p class="text">Click <a href="https://apps.realestatedatainc.com/atlhousingreport/main.php" target="_blank">here</a> to Login.</p>
<p class="text">Return to the DEC Authentication Service sign-in page and use this temporary password. You will then be asked to choose a new password.</p>
<p class="text">Thank you,</p>
+21 -18
View File
@@ -1,21 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:output method="html" indent="yes"/>
<xsl:template match="/">
<div class="home pt-5">
<div class="col text-center">
<span class="text-primary fw-bold" style="font-size: 2rem;">
<!--<i class="bi bi-cloud me-2"></i>-->
Base Project Template
</span>
</div>
</div>
</xsl:template>
<xsl:output method="html" encoding="utf-8" indent="yes"/>
<xsl:param name="USER_FULL_NAME"/>
<xsl:template match="/">
<div class="home py-4">
<h1 class="h3">Your applications</h1>
<p class="text-muted">Welcome, <xsl:value-of select="$USER_FULL_NAME"/>. Choose an application to continue.</p>
<xsl:choose>
<xsl:when test="applications/@setup_required = 'true'"><div class="alert alert-info">Your application dashboard is being configured. Please contact your administrator.</div></xsl:when>
<xsl:when test="applications/record">
<div class="row g-3"><xsl:for-each select="applications/record">
<div class="col-md-6"><div class="card h-100 shadow-sm"><div class="card-body d-flex flex-column">
<h2 class="h5"><xsl:value-of select="application_name"/></h2>
<p class="text-muted"><xsl:value-of select="application_description"/></p>
<a class="btn btn-primary mt-auto align-self-start" href="main.php?Xaction=Applications.launch&amp;application_serial={application_serial}">Open <xsl:value-of select="application_name"/></a>
</div></div></div>
</xsl:for-each></div>
</xsl:when>
<xsl:otherwise><div class="alert alert-info">No applications are assigned to your account yet. Please contact your administrator to request access.</div></xsl:otherwise>
</xsl:choose>
</div>
</xsl:template>
</xsl:stylesheet>
+7 -4
View File
@@ -12,6 +12,7 @@
<xsl:param name="INVALID_SIGNIN"/>
<xsl:param name="RESET_PASSWORD_SUCCESSFUL"/>
<xsl:param name="VERSION"/>
<xsl:param name="CSRF_TOKEN"/>
<xsl:template match="/">
<xsl:text disable-output-escaping="yes">&lt;!DOCTYPE html&gt;</xsl:text>
@@ -46,8 +47,8 @@
<div class="card-body p-4">
<div class="text-center mb-2">
<img src="images/baseproject-logo.png"
alt="Base Project Logo"
<img src="images/dec-international-logo.png"
alt="DEC International Logo"
class="mb-1"
style="max-width: 200px;"/>
@@ -71,15 +72,16 @@
<div class="alert alert-info d-flex align-items-start py-2" role="alert">
<i class="bi bi-info-circle-fill me-2 mt-1"></i>
<div>
If an account exists for the email address provided, you will receive an email containing a temporary password and a link to reset your password.
If an account exists for the username provided, you will receive an email containing a temporary password. Return here to sign in and choose a new password.
</div>
</div>
</xsl:if>
<form action="{$BTN_SUBMIT}" name="{$FORM_NAME}" method="post" id="{$FORM_NAME}">
<input type="hidden" name="action" value="Users.login"/>
<input type="hidden" name="action" value="SignIn.login"/>
<input type="hidden" name="step" value="authenticate"/>
<input type="hidden" name="csrf_token" value="{$CSRF_TOKEN}"/>
<div class="mb-3">
<label for="user_name" class="form-label small fw-semibold">Username</label>
@@ -129,6 +131,7 @@
<form action="{$BTN_RESET_PASSWORD}" name="reset-password" method="post" id="reset-password">
<input type="hidden" id="reset_user_name" name="user_name"/>
<input type="hidden" name="csrf_token" value="{$CSRF_TOKEN}"/>
</form>
</div>