From 28b8563ea09ad28055ba3bcaa24527c063cc67b8 Mon Sep 17 00:00:00 2001 From: Skylar Ittner Date: Wed, 2 Jan 2019 23:53:08 -0700 Subject: [PATCH] Finish merge/upgrade --- langs/en/actions.json | 5 + langs/en/strings.json | 127 +++++++++++++++++ lib/Log.lib.php | 72 ++++++++++ lib/Report.lib.php | 137 +++++++++++++++++++ lib/User.lib.php | 312 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 653 insertions(+) create mode 100644 langs/en/actions.json create mode 100644 langs/en/strings.json create mode 100644 lib/Log.lib.php create mode 100644 lib/Report.lib.php create mode 100644 lib/User.lib.php diff --git a/langs/en/actions.json b/langs/en/actions.json new file mode 100644 index 0000000..c03ccd4 --- /dev/null +++ b/langs/en/actions.json @@ -0,0 +1,5 @@ +{ + "Edit": "Edit", + "cancel": "Cancel", + "Choose a user": "Choose a user" +} diff --git a/langs/en/strings.json b/langs/en/strings.json new file mode 100644 index 0000000..f7a3f7f --- /dev/null +++ b/langs/en/strings.json @@ -0,0 +1,127 @@ +{ + "sign in": "Sign In", + "username": "Username", + "password": "Password", + "continue": "Continue", + "authcode": "Authentication code", + "2fa prompt": "Enter the six-digit code from your mobile authenticator app.", + "2fa incorrect": "Authentication code incorrect.", + "login incorrect": "Login incorrect.", + "no admin permission": "You do not have permission to access this system.", + "account locked": "This account has been disabled. Contact technical support.", + "password expired": "You must change your password before continuing.", + "account terminated": "Account terminated. Access denied.", + "account state error": "Your account state is not stable. Log out, restart your browser, and try again.", + "welcome user": "Welcome, {user}!", + "settings": "Settings", + "options": "Options", + "login server user data error": "The login server refused to provide account information. Try again or contact technical support.", + "captcha error": "There was a problem with the CAPTCHA (robot test). Try again.", + "home": "Home", + "users": "Users", + "more": "More", + "actions": "Actions", + "name": "Name", + "email": "Email", + "status": "Status", + "type": "Type", + "new user": "New User", + "total users": "Total Users", + "view users": "View Users", + "normal accounts": "Normal Accounts", + "locked accounts": "Locked Accounts", + "editing user": "Editing {user}", + "invalid userid": "Invalid user ID.", + "user saved": "User saved.", + "adding user": "Adding new user", + "placeholder name": "John Doe", + "placeholder username": "jdoe", + "placeholder email address": "jdoe@example.com", + "placeholder password": "swordfish", + "new password": "New Password", + "non-local account warning": "This account is not locally managed. Changes made here will not synchronize to the directory server and some attributes cannot be edited.", + "delete user": "Delete User", + "really delete user": "Are you sure you want to delete this user? This action cannot be reversed.", + "user deleted": "User account deleted.", + "user does not exist": "User does not exist.", + "logtime": "Date\/Time", + "logtype": "Event Type", + "ip address": "IP Address", + "other data": "Other", + "security log": "Security Log", + "event type reference": "Event Type Reference", + "clear log": "Clear Log", + "really clear log": "Are you sure you want to purge the security log? This action cannot be reversed.", + "log cleared": "Security log cleared.", + "removed n entries": "Removed {n} entries", + "security log entries": "Security Log Entries", + "view security log": "View Security Log", + "managers": "Managers", + "manager": "Manager", + "employee": "Employee", + "delete relationship": "Delete Relationship", + "really delete relationship": "Are you sure you want to remove this manager-employee relationship? This action cannot be reversed.", + "relationship deleted": "Relationship deleted.", + "edit relationship": "Edit Relationship", + "adding relationship": "Adding Relationship", + "relationship added": "Relationship added.", + "permissions": "Permissions", + "permission": "Permission", + "new permission": "New Permission", + "delete permission": "Delete Permission", + "adding permission": "Adding Permission", + "user": "User", + "permission does not exist": "Permission does not exist: {arg}", + "really delete permission": "Are you sure you want to revoke this permission?", + "permission added": "Permission assigned.", + "permission deleted": "Permission deleted.", + "remove 2fa": "Reset 2FA", + "action performed by": "Action performed by {user}", + "2fa removed": "2-factor authentication removed.", + "2fa": "2FA", + "show deleted": "Show deleted", + "editing deleted account": "You are editing an account marked as deleted. The account will be undeleted if you press Save.", + "manager assigned": "Manager relationships saved.", + "manager does not exist": "The selected manager username does not exist.", + "type to add a person": "Type to add a person", + "employees": "Employees", + "type to select a manager": "Type to select a manager", + "select a manager to view or edit employees": "Select a manager to view or edit the assigned employees.", + "report export": "Reports\/Export", + "report type": "Report type", + "format": "Format", + "generate report": "Generate report", + "choose an option": "Choose an option", + "csv file": "CSV text file", + "ods file": "ODS spreadsheet", + "html file": "HTML web page", + "uid": "User ID", + "manager name": "Manager", + "manager username": "Mgr. Username", + "employee name": "Employee", + "employee username": "Emp. Username", + "permission id": "Perm. ID", + "permissions assigned": "Permissions assigned.", + "type to select a user": "Type to select a user", + "type to add a permission": "Type to add a permission", + "Choose a permission": "Choose a permission", + "select a user to view or edit permissions": "Select a user to view or edit the assigned permissions.", + "group": "Group", + "groups": "Groups", + "group does not exist": "That group does not exist.", + "group members updated": "Group members updated.", + "group added": "Group added.", + "group deleted": "Group deleted.", + "group already exists": "A group with that name already exists.", + "save": "Save", + "next": "Next", + "add": "Add", + "delete": "Delete", + "new group": "New group", + "delete group": "Delete group", + "enter group name": "Group name", + "group management": "Group Management", + "group assignments": "Group Assignments", + "group id": "Group ID", + "group name": "Group Name" +} \ No newline at end of file diff --git a/lib/Log.lib.php b/lib/Log.lib.php new file mode 100644 index 0000000..15d8bb3 --- /dev/null +++ b/lib/Log.lib.php @@ -0,0 +1,72 @@ +getType(); + } + + if (is_a($user, "User")) { + $uid = $user->getUID(); + } else if (gettype($user) == "integer") { + $uid = $user; + } else { + $uid = null; + } + + $database->insert("authlog", ['logtime' => date("Y-m-d H:i:s"), 'logtype' => $type, 'uid' => $uid, 'ip' => $ip, 'otherdata' => $data]); + } + +} + +class LogType { + + const LOGIN_OK = 1; + const LOGIN_FAILED = 2; + const PASSWORD_CHANGED = 3; + const API_LOGIN_OK = 4; + const API_LOGIN_FAILED = 5; + const BAD_2FA = 6; + const API_BAD_2FA = 7; + const BAD_CAPTCHA = 8; + const ADDED_2FA = 9; + const REMOVED_2FA = 10; + const LOGOUT = 11; + const API_AUTH_OK = 12; + const API_AUTH_FAILED = 13; + const API_BAD_KEY = 14; + const LOG_CLEARED = 15; + const USER_REMOVED = 16; + const USER_ADDED = 17; + const USER_EDITED = 18; + const MOBILE_LOGIN_OK = 19; + const MOBILE_LOGIN_FAILED = 20; + const MOBILE_BAD_KEY = 21; + + private $type; + + function __construct(int $type) { + $this->type = $type; + } + + public function getType(): int { + return $type; + } +} diff --git a/lib/Report.lib.php b/lib/Report.lib.php new file mode 100644 index 0000000..73f1305 --- /dev/null +++ b/lib/Report.lib.php @@ -0,0 +1,137 @@ +title = $title; + $this->header = $header; + $this->data = $data; + } + + public function setHeader(array $header) { + $this->header = $header; + } + + public function addDataRow(array $columns) { + $this->data[] = $columns; + } + + public function getHeader(): array { + return $this->header; + } + + public function getData(): array { + return $this->data; + } + + public function output(string $format) { + switch ($format) { + case "ods": + $this->toODS(); + break; + case "html": + $this->toHTML(); + break; + case "csv": + default: + $this->toCSV(); + break; + } + } + + private function toODS() { + $ods = new ods(); + $styleColumn = new odsStyleTableColumn(); + $styleColumn->setUseOptimalColumnWidth(true); + $headerstyle = new odsStyleTableCell(); + $headerstyle->setFontWeight("bold"); + $table = new odsTable($this->title); + + for ($i = 0; $i < count($this->header); $i++) { + $table->addTableColumn(new odsTableColumn($styleColumn)); + } + + $row = new odsTableRow(); + foreach ($this->header as $cell) { + $row->addCell(new odsTableCellString($cell, $headerstyle)); + } + $table->addRow($row); + + foreach ($this->data as $cols) { + $row = new odsTableRow(); + foreach ($cols as $cell) { + $row->addCell(new odsTableCellString($cell)); + } + $table->addRow($row); + } + $ods->addTable($table); + // The @ is a workaround to silence the tempnam notice, + // which breaks the file. This is apparently the intended behavior: + // https://bugs.php.net/bug.php?id=69489 + @$ods->downloadOdsFile($this->title . "_" . date("Y-m-d_Hi") . ".ods"); + } + + private function toHTML() { + global $SECURE_NONCE; + $data = array_merge([$this->header], $this->data); + // HTML exporter doesn't like null values + for ($i = 0; $i < count($data); $i++) { + for ($j = 0; $j < count($data[$i]); $j++) { + if (is_null($data[$i][$j])) { + $data[$i][$j] = ''; + } + } + } + header('Content-type: text/html'); + $converter = new HTMLConverter(); + $out = "\n" + . "\n" + . "\n" + . "" . $this->title . "_" . date("Y-m-d_Hi") . "\n" + . << +STYLE + . $converter->convert($data); + echo $out; + } + + private function toCSV() { + $csv = Writer::createFromString(''); + $data = array_merge([$this->header], $this->data); + $csv->insertAll($data); + header('Content-type: text/csv'); + header('Content-Disposition: attachment; filename="' . $this->title . "_" . date("Y-m-d_Hi") . ".csv" . '"'); + echo $csv; + } + +} diff --git a/lib/User.lib.php b/lib/User.lib.php new file mode 100644 index 0000000..edc3127 --- /dev/null +++ b/lib/User.lib.php @@ -0,0 +1,312 @@ +has('accounts', ['AND' => ['uid' => $uid, 'deleted' => false]])) { + $this->uid = $uid; + $user = $database->get('accounts', ['username', 'password', 'email', 'realname', 'authsecret'], ['uid' => $uid]); + $this->username = $user['username']; + $this->passhash = $user['password']; + $this->email = $user['email']; + $this->realname = $user['realname']; + $this->authsecret = $user['authsecret']; + $this->has2fa = !empty($user['authsecret']); + $this->exists = true; + } else { + $this->uid = $uid; + $this->username = $username; + } + } + + public static function byUsername(string $username): User { + global $database; + $username = strtolower($username); + if ($database->has('accounts', ['AND' => ['username' => $username, 'deleted' => false]])) { + $uid = $database->get('accounts', 'uid', ['username' => $username]); + return new self($uid * 1); + } + return new self(-1, $username); + } + + /** + * Add a user to the system. /!\ Assumes input is OK /!\ + * @param string $username Username, saved in lowercase. + * @param string $password Password, will be hashed before saving. + * @param string $realname User's real legal name + * @param string $email User's email address. + * @param string $phone1 Phone number #1 + * @param string $phone2 Phone number #2 + * @param int $type Account type + * @return int The new user's ID number in the database. + */ + public static function add(string $username, string $password, string $realname, string $email = null, string $phone1 = "", string $phone2 = "", int $type = 1): int { + global $database; + $database->insert('accounts', [ + 'username' => strtolower($username), + 'password' => (is_null($password) ? null : password_hash($password, PASSWORD_BCRYPT)), + 'realname' => $realname, + 'email' => $email, + 'phone1' => $phone1, + 'phone2' => $phone2, + 'acctstatus' => 1, + 'accttype' => $type + ]); + return $database->id(); + } + + public function exists(): bool { + return $this->exists; + } + + public function has2fa(): bool { + return $this->has2fa; + } + + function getUsername() { + return $this->username; + } + + function getUID() { + return $this->uid; + } + + function getEmail() { + return $this->email; + } + + function getName() { + return $this->realname; + } + + /** + * Check the given plaintext password against the stored hash. + * @param string $password + * @return bool + */ + function checkPassword(string $password): bool { + return password_verify($password, $this->passhash); + } + + /** + * Change the user's password. + * @global $database $database + * @param string $old The current password + * @param string $new The new password + * @param string $new2 New password again + * @throws PasswordMatchException + * @throws PasswordMismatchException + * @throws IncorrectPasswordException + * @throws WeakPasswordException + */ + function changePassword(string $old, string $new, string $new2) { + global $database, $SETTINGS; + if ($old == $new) { + throw new PasswordMatchException(); + } + if ($new != $new2) { + throw new PasswordMismatchException(); + } + + if (!$this->checkPassword($old)) { + throw new IncorrectPasswordException(); + } + + require_once __DIR__ . "/worst_passwords.php"; + + $passrank = checkWorst500List($new); + if ($passrank !== FALSE) { + throw new WeakPasswordException(); + } + if (strlen($new) < $SETTINGS['min_password_length']) { + throw new WeakPasswordException(); + } + + $database->update('accounts', ['password' => password_hash($new, PASSWORD_DEFAULT), 'acctstatus' => 1], ['uid' => $this->uid]); + Log::insert(LogType::PASSWORD_CHANGED, $this); + return true; + } + + function check2fa(string $code): bool { + if (!$this->has2fa) { + return true; + } + + $totp = new TOTP(null, $this->authsecret); + $time = time(); + if ($totp->verify($code, $time)) { + return true; + } + if ($totp->verify($code, $time - 30)) { + return true; + } + if ($totp->verify($code, $time + 30)) { + return true; + } + + return false; + } + + /** + * Generate a TOTP secret for the given user. + * @return string OTP provisioning URI (for generating a QR code) + */ + function generate2fa(): string { + global $SETTINGS; + $secret = random_bytes(20); + $encoded_secret = Base32::encode($secret); + $totp = new TOTP((empty($this->email) ? $this->realname : $this->email), $encoded_secret); + $totp->setIssuer($SETTINGS['system_name']); + return $totp->getProvisioningUri(); + } + + /** + * Save a TOTP secret for the user. + * @global $database $database + * @param string $username + * @param string $secret + */ + function save2fa(string $secret) { + global $database; + $database->update('accounts', ['authsecret' => $secret], ['username' => $this->username]); + } + + /** + * Check if the given username has the given permission (or admin access) + * @global $database $database + * @param string $code + * @return boolean TRUE if the user has the permission (or admin access), else FALSE + */ + function hasPermission(string $code): bool { + global $database; + return $database->has('assigned_permissions', [ + '[>]permissions' => [ + 'permid' => 'permid' + ] + ], ['AND' => ['OR' => ['permcode #code' => $code, 'permcode #admin' => 'ADMIN'], 'uid' => $this->uid]]) === TRUE; + } + + /** + * Get the account status. + * @return \AccountStatus + */ + function getStatus(): AccountStatus { + global $database; + $statuscode = $database->get('accounts', 'acctstatus', ['uid' => $this->uid]); + return new AccountStatus($statuscode); + } + + function sendAlertEmail(string $appname = null) { + global $SETTINGS; + if (is_null($appname)) { + $appname = $SETTINGS['site_title']; + } + if (empty(ADMIN_EMAIL) || filter_var(ADMIN_EMAIL, FILTER_VALIDATE_EMAIL) === FALSE) { + return "invalid_to_email"; + } + if (empty(FROM_EMAIL) || filter_var(FROM_EMAIL, FILTER_VALIDATE_EMAIL) === FALSE) { + return "invalid_from_email"; + } + + $mail = new PHPMailer; + + if ($SETTINGS['debug']) { + $mail->SMTPDebug = 2; + } + + if ($SETTINGS['email']['use_smtp']) { + $mail->isSMTP(); + $mail->Host = $SETTINGS['email']['host']; + $mail->SMTPAuth = $SETTINGS['email']['auth']; + $mail->Username = $SETTINGS['email']['user']; + $mail->Password = $SETTINGS['email']['password']; + $mail->SMTPSecure = $SETTINGS['email']['secure']; + $mail->Port = $SETTINGS['email']['port']; + if ($SETTINGS['email']['allow_invalid_certificate']) { + $mail->SMTPOptions = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ) + ); + } + } + + $mail->setFrom(FROM_EMAIL, 'Account Alerts'); + $mail->addAddress(ADMIN_EMAIL, "System Admin"); + $mail->isHTML(false); + $mail->Subject = $Strings->get("admin alert email subject", false); + $mail->Body = $Strings->build("admin alert email message", ["username" => $this->username, "datetime" => date("Y-m-d H:i:s"), "ipaddr" => IPUtils::getClientIP(), "appname" => $appname], false); + + if (!$mail->send()) { + return $mail->ErrorInfo; + } + return true; + } + +} + +class AccountStatus { + + const NORMAL = 1; + const LOCKED_OR_DISABLED = 2; + const CHANGE_PASSWORD = 3; + const TERMINATED = 4; + const ALERT_ON_ACCESS = 5; + + private $status; + + public function __construct(int $status) { + $this->status = $status; + } + + /** + * Get the account status/state as an integer. + * @return int + */ + public function get(): int { + return $this->status; + } + + /** + * Get the account status/state as a string representation. + * @return string + */ + public function getString(): string { + switch ($this->status) { + case self::NORMAL: + return "NORMAL"; + case self::LOCKED_OR_DISABLED: + return "LOCKED_OR_DISABLED"; + case self::CHANGE_PASSWORD: + return "CHANGE_PASSWORD"; + case self::TERMINATED: + return "TERMINATED"; + case self::ALERT_ON_ACCESS: + return "ALERT_ON_ACCESS"; + default: + return "OTHER_" . $this->status; + } + } + +}