From 56b2dc406f4abfc3b7f6d927efc4765b56832d29 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 2 Sep 2017 18:00:13 -0300 Subject: [PATCH 001/217] Split Auth class to support multiple providers --- src/Auth.php | 477 +++++--------------- src/Auth/AuthException.php | 7 + src/Auth/BasicAuth.php | 107 +++++ src/Auth/Controller/UserController.php | 55 +++ src/Auth/MysqlAuth.php | 534 +++++++++++++++++++++++ src/Auth/PasswordMismatchException.php | 7 - src/Auth/UserException.php | 2 +- test/{AuthTest.php => MysqlAuthTest.php} | 12 +- test/UserControllerTest.php | 105 +++++ 9 files changed, 913 insertions(+), 393 deletions(-) create mode 100644 src/Auth/AuthException.php create mode 100644 src/Auth/BasicAuth.php create mode 100644 src/Auth/Controller/UserController.php create mode 100644 src/Auth/MysqlAuth.php delete mode 100644 src/Auth/PasswordMismatchException.php rename test/{AuthTest.php => MysqlAuthTest.php} (92%) create mode 100644 test/UserControllerTest.php diff --git a/src/Auth.php b/src/Auth.php index c619389..ff4924c 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -2,54 +2,22 @@ namespace Objectiveweb; -use Objectiveweb\Auth\UserException; -use Objectiveweb\Auth\PasswordMismatchException; +abstract class Auth { -use PDO; -class Auth -{ - private $params; + public $params; - /** @var \PDO */ - private $pdo; - - const SELECT_ALL = "SELECT * FROM `%s` where `%s` = %s"; - const SELECT_SEARCH = "SELECT SQL_CALC_FOUND_ROWS * FROM `%s` WHERE %s"; - const REGISTER_QUERY = "INSERT INTO `%s` (%s) VALUES (%s)"; - const RESET_PASSWORD = 'UPDATE `%s` SET `%s` = %s, `%s` = NULL WHERE %s = %s'; - const UPDATE_QUERY = "UPDATE `%s` SET %s WHERE `%s` = %s"; - const UPDATE_VALUE = "UPDATE `%s` SET `%s` = %s WHERE `%s` = %s"; - const USER_BY_NAME = "SELECT `%s` FROM `%s` WHERE `%s` = %s"; - const DELETE_QUERY = "DELETE FROM `%s` WHERE `%s` = %s LIMIT 1"; - - public static function hash($password = null) - { - if (!$password) { - // return a random token - return md5(microtime(true)); - } - - return password_hash($password, PASSWORD_BCRYPT); - } - - public function __construct(\PDO $pdo, $params = array()) - { - $defaults = array( + function __construct($params) { + $defaults = [ 'session_key' => 'ow_auth', - 'table' => 'ow_auth', 'id' => 'id', 'username' => 'username', 'password' => 'password', - 'token' => NULL, - 'created' => NULL, - 'last_login' => NULL - ); - - $this->pdo = $pdo; + 'scopes' => 'scopes', + 'token' => NULL + ]; $this->params = array_merge($defaults, $params); - } /** @@ -60,146 +28,14 @@ public function check() return !empty($_SESSION[$this->params['session_key']]); } - /** - * Queries the Auth table - */ - public function query($params = array(), $operator = "OR") { - - $page = intval(@$params['page']); - $size = intval(@$params['size']); - $sort = @$params['sort']; - - unset($params['page']); - unset($params['size']); - unset($params['sort']); - - if(!$size) $size = 20; - - $cond = array(); - $bindings = array(); - - foreach($params as $key => $value) { - $cond[] = sprintf("`%s` %s :where_%s", - str_replace('`', '``', $key), - is_null($value) ? 'is' : (strpos($value, '%') !== FALSE ? 'LIKE' : '='), - $key); - $bindings[":where_$key"] = $value; - } - - $query = sprintf(self::SELECT_SEARCH, - $this->params['table'], - empty($cond) ? '1=1' : implode(" $operator ", $cond) - ); - - $query .= sprintf(" LIMIT %d, %d", $page * $size, $size); - - $stmt = $this->pdo->prepare($query); - - $stmt->execute($bindings); - - if(!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2]); - } - - $data = $stmt->fetchAll(PDO::FETCH_ASSOC); - - $stmt = $this->pdo->query("SELECT FOUND_ROWS() as count"); - - $count = $stmt->fetch(PDO::FETCH_ASSOC); - - if(!$count) { - throw new \Exception("Error fetching count"); - } - - $count = intval($count['count']); - - return array( - '_embedded' => array( - $this->params['table'] => $data - ), - 'page' => array( - 'size' => $size, - 'number' => $page, - 'totalElements' => $count, - 'totalPages' => ceil($count/$size) - ) - ); - } - - /** - * Retrieves a user from the database - * @param $username username - * @return array user data - * @throws UserException - * @throws Exception - */ - public function get($username) { - - - if(is_numeric($username)) { - $key = 'id'; - } - else { - $key = 'username'; - } - - $query = sprintf(self::SELECT_ALL, - $this->params['table'], - $this->params[$key], - $this->pdo->quote($username)); - - $stmt = $this->pdo->query($query); - - if(!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2]); - } - - if($user = $stmt->fetch(PDO::FETCH_ASSOC)) { - return $user; - } - else { - throw new UserException('User not found', 404); - } - } - - /** - * @param $username - * @param $password - * @throws UserException - * @throws PasswordMismatchException - */ - public function &login($username, $password) + public static function hash($password = null) { - - $user = $this->get($username); - - if(\password_verify($password, $user[$this->params['password']])) { - - unset($user[$this->params['password']]); - - $_SESSION[$this->params['session_key']] = $user; - - if($this->params['last_login']) { - $query = sprintf(self::UPDATE_VALUE, - $this->params['table'], - $this->params['last_login'], - 'NOW()', - $this->params['id'], - $user[$this->params['id']]); - - $this->pdo->query($query); - } - - return $_SESSION[$this->params['session_key']]; - } - else { - throw new PasswordMismatchException(); + if (!$password) { + // return a random token + return md5(microtime(true)); } + return password_hash($password, PASSWORD_BCRYPT); } /** @@ -210,83 +46,30 @@ public function logout() unset($_SESSION[$this->params['session_key']]); } - - /** - * @param string $username - * @param string $password - * @param array $data associative array of additional columns to store + * Reloads current user from the backend */ - public function register($username, $password = null, $data = array()) - { - if(is_array($username)) { - $data = $username; - - $username = @$data[$this->params['username']]; - unset($data[$this->params['username']]); - - $password = @$data['password']; - unset($data['password']); - } - - $fields = array(); - - // escape fields - foreach($data as $k => $v) { - $fields[str_replace(array('\\',"\0" ,'`'), '', $k)] = $this->pdo->quote($v); - } - - if(empty($username) || empty($password)) { - throw new \Exception("Por favor informe usuário e senha"); - } - - $fields[$this->params['username']] = $this->pdo->quote($username); - $fields[$this->params['password']] = $this->pdo->quote(self::hash($password)); - if($this->params['token']) { - $fields[$this->params['token']] = $this->pdo->quote(self::hash()); - } - - if($this->params['created']) { - $fields[$this->params['created']] = 'NOW()'; - } + public function reload() { + $user = $this->user(); + $user = $this->get($user[$this->params['id']], $this->params['id']); - $query = (sprintf(self::REGISTER_QUERY, - $this->params['table'], - implode(", ", array_keys($fields)), - implode(", ", array_values($fields)) - )); - - if ($this->pdo->query($query)) { - - $fields[$this->params['id']] = $this->pdo->lastInsertId(); - unset($fields[$this->params['created']]); - - return $fields; - } else { - $errorInfo = $this->pdo->errorInfo(); - - if($errorInfo[1] == 1062) { - throw new \Exception(sprintf("User %s already exists", $username), 409); - } - else { - throw new \Exception($errorInfo[2]); - } - } + return $this->user($user); } /** * Returns the current logged in user or sets the current login data * @param $user array - * @return array user data + * @return array user data, sets a new user on session if * @throws UserException if noone is logged in */ public function &user($user = null) { - if($user) { + if ($user) { + unset($user[$this->params['token']]); + unset($user[$this->params['password']]); $_SESSION[$this->params['session_key']] = $user; - } - else { - if(!$this->check()) { + } else { + if (!$this->check()) { throw new UserException('Not logged in', 403); } } @@ -295,176 +78,112 @@ public function &user($user = null) return $_SESSION[$this->params['session_key']]; } - /** - * - * Update a user's password - * - * @param $username - * @param $password - * @return bool TRUE on success - * @throws UserException if no rows were updated - */ - public function passwd($username, $password) - { + public function validate($user) { + $username = trim(@$user[$this->params['username']]); - if(is_numeric($username)) { - $key = 'id'; + if(empty($username)) { + throw new \Exception("Missing username", 400); } - else { - $key = 'username'; + + $user[$this->params['username']] = $username; + + if(isset($user[$this->params['password']])) { + if(empty($user[$this->params['password']])) { + throw new \Exception("Missing password", 400); + } } - $query = sprintf(self::UPDATE_VALUE, - $this->params['table'], - $this->params['password'], - $this->pdo->quote(self::hash($password)), - $this->params[$key], - $this->pdo->quote($username)); + return $user; + } + // - $stmt = $this->pdo->query($query); + abstract public function query($params = array(), $operator = "OR"); - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('User not found'); - } + /** + * Retrieves a user from the database + * @param $username String username + * @param $key String which key to lookup (username, id, token) + * @return array user data + * @throws UserException + * @throws \Exception + */ + abstract public function get($username, $key = 'username'); - return TRUE; + /** + * @param $username + * @param $password + * @throws UserException + * @throws PasswordMismatchException + */ + abstract public function &login($username, $password); - } + /** + * @param string $username + * @param string $password + * @param array $data associative array of additional columns to store + */ + abstract public function register($username, $password = null, $data = array()); /** * - * Reset a user's password + * Update a user's password * - * @param $token - * @param $password new password + * @param $username + * @param $password + * @param $key String username or id * @return bool TRUE on success * @throws UserException if no rows were updated */ - public function passwd_reset($token, $password) - { - - $query = sprintf(self::RESET_PASSWORD, - $this->params['table'], - $this->params['password'], - $this->pdo->quote(self::hash($password)), - $this->params['token'], - $this->params['token'], - $this->pdo->quote($token)); - - - $stmt = $this->pdo->query($query); - - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('Hash not found'); - } - - return TRUE; - - } + abstract public function passwd($username, $password, $key = 'username'); + /** + * + * Reset a user's password + * + * @param $token String + * @param $password String new password + * @return $user array user data on success + * @throws UserException if no rows were updated + */ + abstract public function passwd_reset($token, $password); /** * Update arbitrary user data * @param $username * @param array $data associative array of data + * @param key String username or id * @throws UserException if no rows were updated */ - public function update($username, array $data) { - $cond = array(); - unset($data[$this->params['id']]); - unset($data[$this->params['password']]); - - if($this->params['token']) { - unset($data[$this->params['token']]); - } - - if($this->params['created']) { - unset($data[$this->params['created']]); - } - - if($this->params['last_login']) { - unset($data[$this->params['last_login']]); - } - - - foreach($data as $k => $v) { - $cond[] = sprintf("`%s` = %s", str_replace(array('\\',"\0" ,'`'), '', $k), $this->pdo->quote($v)); - } - - - if(is_numeric($username)) { - $key = 'id'; - } - else { - $key = 'username'; - } - - $query = sprintf(self::UPDATE_QUERY, - $this->params['table'], - implode(', ', $cond), - $this->params[$key], - $this->pdo->quote($username)); - - $stmt = $this->pdo->query($query); - - if($stmt === FALSE) { - throw new UserException(json_encode($this->pdo->errorInfo())); - } - - } + abstract public function update($username, array $data, $key = 'username'); - public function delete($username) { + abstract public function delete($username, $key = 'username'); - if(is_numeric($username)) { - $key = 'id'; - } - else { - $key = 'username'; - } - - - if($this->check()) { - $user = $this->user(); - - if($user[$this->params[$key]] == $username) { - throw new \Exception("Cannot delete yourself!"); - } - } - - $query = sprintf(self::DELETE_QUERY, - $this->params['table'], - $this->params[$key], - $this->pdo->quote($username)); - - $stmt = $this->pdo->query($query); - - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new \Exception(json_encode($this->pdo->errorInfo())); - } - } - /** * Generates a new token for the user and update the database * @param $username * @return string new token */ - public function update_token($username) { - $token = self::hash(); - - $query = sprintf(self::UPDATE_VALUE, - $this->params['table'], - $this->params['token'], - $this->pdo->quote($token), - $this->params['username'], - $this->pdo->quote($username)); - - $stmt = $this->pdo->query($query); + abstract public function update_token($username); - if($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('Token not found'); - } + /** + * Retrieves an account from ext_accounts_table + * @param $userid String user_id + * @param $key String which key to lookup (username, id, token) + * @return mixed account data or null if account not found + * @throws \Exception + */ + abstract public function get_account($provider, $accountid); - return $token; - } + /** + * Inserts a new account on $userid, or update the existing one + * + * @param $userid + * @param $provider + * @param $uid + * @param null $profile + * @return bool + * @throws \Exception + */ + abstract public function update_account($userid, $provider, $uid, $profile = null); } \ No newline at end of file diff --git a/src/Auth/AuthException.php b/src/Auth/AuthException.php new file mode 100644 index 0000000..6ca2280 --- /dev/null +++ b/src/Auth/AuthException.php @@ -0,0 +1,7 @@ +passwd = $passwd; + } + + + public function query($params = array(), $operator = "OR") + { + $result = []; + foreach ($this->passwd as $user => $pass) { + if(strpos($user[$this->params['username']], $params['username']) !== FALSE) { + $result[] = $user; + } + } + } + + public function get($username, $key = 'username') + { + print_r($this->passwd); + if(!in_array($username, array_keys($this->passwd))) { + throw new UserException("User `$username` not found", 404); + } + + return [ + $this->params['username'] => $username, + $this->params['id'] => $username + ]; + } + + public function &login($username, $password) + { + $user = $this->get($username); + + if($user[$this->params['password']] == $user[$this->params['password']]) { + unset($user[$this->params['password']]); + $this->user($user); + + return $user; + } else { + throw new AuthException('Invalid username or password'); + } + // TODO: Implement login() method. + } + + public function register($username, $password = null, $data = array()) + { + $this->passwd[$username] = $password; + + return [ + $this->params['username'] => $username, + $this->params['id'] => $username + ]; + + } + + public function passwd($username, $password, $key = 'username') + { + // TODO: Implement passwd() method. + } + + public function passwd_reset($token, $password) + { + // TODO: Implement passwd_reset() method. + } + + public function update($username, array $data, $key = 'username') + { + // TODO: Implement update() method. + } + + public function delete($username, $key = 'username') + { + // TODO: Implement delete() method. + } + + public function update_token($username) + { + // TODO: Implement update_token() method. + } + + public function get_account($provider, $accountid) + { + // TODO: Implement get_account() method. + } + + public function update_account($userid, $provider, $uid, $profile = null) + { + // TODO: Implement update_account() method. + } +} \ No newline at end of file diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php new file mode 100644 index 0000000..91653c8 --- /dev/null +++ b/src/Auth/Controller/UserController.php @@ -0,0 +1,55 @@ +auth = $auth; + } + + public function index() { + return $this->get(); + } + + public function get($params = array()) { + if(is_array($params)) { + return $this->auth->query($params); + } + else { + return $this->auth->get($params); + } + } + + public function post($data) { + + return $this->auth->register($data); + + } + + public function put($username, $data) { + + if(!empty($data['password'])) { + $this->auth->passwd($username, $data['password']); + } + + unset($data['password']); + + $this->auth->update($username, $data); + + return true; + } + + public function delete($username) { + $this->auth->delete($username); + } +} diff --git a/src/Auth/MysqlAuth.php b/src/Auth/MysqlAuth.php new file mode 100644 index 0000000..8a51ce8 --- /dev/null +++ b/src/Auth/MysqlAuth.php @@ -0,0 +1,534 @@ +pdo = $pdo; + + $defaults = [ + 'table' => 'ow_auth', + 'created' => NULL, + 'last_login' => NULL, + 'ext_accounts_table' => NULL, + 'with' => [] + ]; + + parent::__construct(array_merge($defaults, $params)); + + if(!empty($this->params['ext_accounts_table'])) { + $this->params['with'][$this->params['ext_accounts_table']] = 'user_id'; + } + + } + + public function setup() { + + $fields = [ + "{$this->params['id']} INT UNSIGNED PRIMARY KEY AUTO_INCREMENT", + "{$this->params['username']} VARCHAR(255) NOT NULL", + "{$this->params['password']} VARCHAR(255)" + ]; + + if($this->params['token']) { + $fields[] = "{$this->params['token']} CHAR(32)"; + } + + if($this->params['created']) { + $fields[] = "{$this->params['created']} DATETIME"; + } + + if($this->params['last_login']) { + $fields[] = "{$this->params['last_login']} DATETIME"; + } + + + $queries[] = sprintf("CREATE TABLE `%s` (%s)", + $this->params['table'], + implode(",", $fields)); + + $queries[] = sprintf("CREATE UNIQUE INDEX %s_username_uindex ON %s (%s)", + $this->params['table'], + $this->params['table'], + $this->params['username']); + + if($this->params['ext_accounts_table']) { + $queries[] = sprintf("CREATE TABLE `%s` ( + user_id INT UNSIGNED NOT NULL, + provider VARCHAR(32) NOT NULL, + uid VARCHAR(255) NOT NULL, + profile text null, + CONSTRAINT auth_accounts_uid_provider_user_id_pk PRIMARY KEY (uid, provider, user_id), + CONSTRAINT auth_accounts_users_id_fk FOREIGN KEY (user_id) REFERENCES %s (%s))", + $this->params['ext_accounts_table'], + $this->params['table'], + $this->params['id']); + } + + return $queries; + } + + /** + * Queries the Auth table + */ + public function query($params = array(), $operator = "OR") + { + + $page = intval(@$params['page']); + $size = intval(@$params['size']); + $sort = @$params['sort']; + + unset($params['page']); + unset($params['size']); + unset($params['sort']); + + if (!$size) $size = 20; + + $cond = array(); + $bindings = array(); + + foreach ($params as $key => $value) { + $cond[] = sprintf("`%s` %s :where_%s", + str_replace('`', '``', $key), + is_null($value) ? 'is' : (strpos($value, '%') !== FALSE ? 'LIKE' : '='), + $key); + $bindings[":where_$key"] = $value; + } + + $query = sprintf("SELECT SQL_CALC_FOUND_ROWS * FROM `%s` WHERE %s", + $this->params['table'], + empty($cond) ? '1=1' : implode(" $operator ", $cond) + ); + + $query .= sprintf(" LIMIT %d, %d", $page * $size, $size); + + $stmt = $this->pdo->prepare($query); + + $stmt->execute($bindings); + + if (!$stmt) { + $error = $this->pdo->errorInfo(); + + throw new \Exception($error[2]); + } + + $data = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $stmt = $this->pdo->query("SELECT FOUND_ROWS() as count"); + + $count = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!$count) { + throw new \Exception("Error fetching count"); + } + + $count = intval($count['count']); + + return array( + '_embedded' => array( + $this->params['table'] => $data + ), + 'page' => array( + 'size' => $size, + 'number' => $page, + 'totalElements' => $count, + 'totalPages' => ceil($count / $size) + ) + ); + } + + /** + * Retrieves a user from the database + * @param $username String username + * @param $key String which key to lookup (username, id, token) + * @return array user data + * @throws UserException + * @throws \Exception + */ + public function get($username, $key = 'username') + { + + $query = sprintf("SELECT * FROM `%s` where `%s` = %s", + $this->params['table'], + $this->params[$key], + $this->pdo->quote($username)); + + $stmt = $this->pdo->query($query); + + if (!$stmt) { + $error = $this->pdo->errorInfo(); + + throw new \Exception($error[2]); + } + + if ($user = $stmt->fetch(PDO::FETCH_ASSOC)) { + + foreach ($this->params['with'] as $table => $fk) { + $query = sprintf("SELECT * FROM `%s` where `%s` = %s", + $table, + $fk, + $user[$this->params['id']] + ); + + $stmt = $this->pdo->query($query); + + if (!$stmt) { + $error = $this->pdo->errorInfo(); + + throw new \Exception($error[2]); + } + + $user[$table] = $stmt->fetchAll(PDO::FETCH_ASSOC); + } + + return $user; + } else { + throw new UserException('User not found', 404); + } + } + + /** + * @param $username + * @param $password + * @throws UserException + * @throws AuthException + */ + public function &login($username, $password) + { + $user = $this->get($username); + + if (\password_verify($password, $user[$this->params['password']])) { + + unset($user[$this->params['password']]); + unset($user[$this->params['token']]); + + $this->user($user); + + if ($this->params['last_login']) { + $query = sprintf("UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", + $this->params['table'], + $this->params['last_login'], + 'NOW()', + $this->params['id'], + $user[$this->params['id']]); + + $this->pdo->query($query); + } + + return $user; + } else { + throw new AuthException('Password invalid', 400); + } + + } + + /** + * @param string $username + * @param string $password + * @param array $data associative array of additional columns to store + */ + public function register($username, $password = null, $data = array()) + { + if (is_array($username)) { + $data = $username; + + $username = @$data[$this->params['username']]; + unset($data[$this->params['username']]); + + $password = @$data['password']; + unset($data['password']); + } + + $fields = array( + $this->params['username'] => $username + ); + + // escape fields + foreach ($data as $k => $v) { + $fields[str_replace(array('\\', "\0", '`'), '', $k)] = $v; + } + + if($password) { + $fields[$this->params['password']] = self::hash($password); + } + + if ($this->params['token']) { + $fields[$this->params['token']] = self::hash(); + } + + if ($this->params['created']) { + $fields[$this->params['created']] = date('Y-m-d H:i:s'); + } + + $fields = $this->validate($fields); + + $stmt = $this->pdo->prepare("INSERT INTO " . $this->params['table'] + . " (" . implode(array_keys($fields), ", ") + . ") VALUES (:" . implode(array_keys($fields), ", :") . ");"); + + foreach($fields as $k => $v) { + $stmt->bindValue(":".$k, $v); + } + + if ($stmt->execute()) { + $fields[$this->params['id']] = $this->pdo->lastInsertId(); + unset($fields[$this->params['created']]); + unset($fields[$this->params['password']]); + if(!empty($fields[$this->params['scopes']])) { + $fields['params']['scopes'] = explode(",", $fields[$this->params['scopes']]); + } + + return $fields; + } else { + $errorInfo = $this->pdo->errorInfo(); + + if ($errorInfo[1] == 1062) { + throw new \Exception(sprintf("User %s already exists", $username), 409); + } else { + throw new \Exception($errorInfo[2]); + } + } + } + + /** + * + * Update a user's password + * + * @param $username + * @param $password + * @param $key String username or id + * @return bool TRUE on success + * @throws UserException if no rows were updated + */ + public function passwd($username, $password, $key = 'username') + { + + $query = sprintf("UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", + $this->params['table'], + $this->params['password'], + $this->pdo->quote(self::hash($password)), + $this->params[$key], + $this->pdo->quote($username)); + + + $stmt = $this->pdo->query($query); + + if ($stmt === FALSE || $stmt->rowCount() !== 1) { + throw new UserException('User not found'); + } + + return TRUE; + + } + + /** + * + * Reset a user's password + * + * @param $token String + * @param $password String new password + * @return $user array user data on success + * @throws UserException if no rows were updated + */ + public function passwd_reset($token, $password) + { + + $user = $this->get($token, $this->params['token']); + + $query = sprintf('UPDATE `%s` SET `%s` = %s, `%s` = NULL WHERE %s = %s', + $this->params['table'], + $this->params['password'], + $this->pdo->quote(self::hash($password)), + $this->params['token'], + $this->params['token'], + $this->pdo->quote($token)); + + + $stmt = $this->pdo->query($query); + + if ($stmt === FALSE || $stmt->rowCount() !== 1) { + throw new UserException('Hash not found'); + } + + return $user; + + } + + + /** + * Update arbitrary user data + * @param $username + * @param array $data associative array of data + * @param key String username or id + * @throws UserException if no rows were updated + */ + public function update($username, array $data, $key = 'username') + { + $cond = array(); + unset($data[$this->params['id']]); + unset($data[$this->params['password']]); + + if ($this->params['token']) { + unset($data[$this->params['token']]); + } + + if ($this->params['created']) { + unset($data[$this->params['created']]); + } + + if ($this->params['last_login']) { + unset($data[$this->params['last_login']]); + } + + + foreach ($data as $k => $v) { + $cond[] = sprintf("`%s` = %s", str_replace(array('\\', "\0", '`'), '', $k), $this->pdo->quote($v)); + } + + $query = sprintf("UPDATE `%s` SET %s WHERE `%s` = %s", + $this->params['table'], + implode(', ', $cond), + $this->params[$key], + $this->pdo->quote($username)); + + $stmt = $this->pdo->query($query); + + if ($stmt === FALSE) { + throw new UserException(json_encode($this->pdo->errorInfo())); + } + + } + + public function delete($username, $key = 'username') + { + + if ($this->check()) { + $user = $this->user(); + + if ($user[$this->params[$key]] == $username) { + throw new \Exception("Cannot delete yourself!"); + } + } + + $query = sprintf("DELETE FROM `%s` WHERE `%s` = %s LIMIT 1", + $this->params['table'], + $this->params[$key], + $this->pdo->quote($username)); + + $stmt = $this->pdo->query($query); + + if ($stmt === FALSE || $stmt->rowCount() !== 1) { + throw new \Exception(json_encode($this->pdo->errorInfo())); + } + } + + /** + * Generates a new token for the user and update the database + * @param $username + * @return string new token + */ + public function update_token($username) + { + $token = self::hash(); + + $query = sprintf("UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", + $this->params['table'], + $this->params['token'], + $this->pdo->quote($token), + $this->params['username'], + $this->pdo->quote($username)); + + $stmt = $this->pdo->query($query); + + if ($stmt === FALSE || $stmt->rowCount() !== 1) { + throw new UserException('Token not found'); + } + + return $token; + } + + /** + * Retrieves an account from ext_accounts_table + * @param $userid String user_id + * @param $key String which key to lookup (username, id, token) + * @return mixed account data or null if account not found + * @throws \Exception + */ + public function get_account($provider, $accountid) + { + $query = sprintf("SELECT a.user_id, a.provider, a.uid, a.profile + FROM `%s` a WHERE a.provider = %s and a.uid = %s", + $this->params['ext_accounts_table'], + $this->pdo->quote($provider), + $this->pdo->quote($accountid)); + + $stmt = $this->pdo->query($query); + + if (!$stmt) { + $error = $this->pdo->errorInfo(); + + throw new \Exception($error[2]); + } + + if ($account = $stmt->fetch(PDO::FETCH_ASSOC)) { + + if(!empty($account['profile'])) { + $account['profile'] = json_decode($account['profile'], true); + } + + return $account; + } else { + return false; + } + } + + /** + * Inserts a new account on $userid, or update the existing one + * + * @param $userid + * @param $provider + * @param $uid + * @param null $profile + * @return bool + * @throws \Exception + */ + public function update_account($userid, $provider, $uid, $profile = null) + { + + if(is_array($profile)) { + $profile = json_encode($profile); + } + + $query = sprintf("INSERT INTO `%s` ( + user_id, provider, uid, profile + ) VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE profile = VALUES(profile)", + $this->params['ext_accounts_table'], + $this->pdo->quote($userid), + $this->pdo->quote($provider), + $this->pdo->quote($uid), + $this->pdo->quote($profile) + ); + + if (!$this->pdo->query($query)) { + $errorInfo = $this->pdo->errorInfo(); + + throw new \Exception($errorInfo[2]); + } + + return true; + + } + +} \ No newline at end of file diff --git a/src/Auth/PasswordMismatchException.php b/src/Auth/PasswordMismatchException.php deleted file mode 100644 index 2c14d55..0000000 --- a/src/Auth/PasswordMismatchException.php +++ /dev/null @@ -1,7 +0,0 @@ - 'ow_auth_test', 'created' => 'created', 'token' => 'token', @@ -55,7 +55,7 @@ public function tearDown() public function testRegistration() { - $user = AuthTest::$auth->register('user', 'test', array( + $user = self::$auth->register('user', 'test', array( 'email' => 'vagrant@localhost', 'displayName' => 'Test User' )); @@ -69,7 +69,7 @@ public function testRegistration() $this->assertRegExp('/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/', $user['created'], "Check creation date"); - $this->assertTrue(AuthTest::$auth->check()); + $this->assertTrue(self::$auth->check()); // test Session $user = self::$auth->user(); @@ -92,7 +92,7 @@ public function testInvalidLogin() } /** - * @expectedException Objectiveweb\Auth\PasswordMismatchException + * @expectedException Objectiveweb\Auth\AuthException */ public function testInvalidPassword() { diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php new file mode 100644 index 0000000..70b78b3 --- /dev/null +++ b/test/UserControllerTest.php @@ -0,0 +1,105 @@ +query('drop table if exists ow_auth_test'); + $pdo->query('create table ow_auth_test + (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, + `username` VARCHAR(255), + `displayName` VARCHAR(255), + `email` VARCHAR(255), + `created` DATETIME, + `last_login` DATETIME, + `password` CHAR(60), + `token` CHAR(32));'); + + $auth = new MysqlAuth($pdo, array( + 'table' => 'ow_auth_test', + 'created' => 'created', + 'token' => 'token', + 'last_login' => 'last_login' + )); + + self::$controller = new UserController($auth); + } + + public function testPost() + { + + $user = self::$controller->post(array( + 'username' => 'user', + 'password' => 'test', + 'email' => 'vagrant@localhost', + 'displayName' => 'Test User')); + + $this->assertEquals(1, $user['id']); + + } + + /** + * @depends testPost + */ + public function testGet() { + $user = self::$controller->get('user'); + + $this->assertEquals(1, $user['id']); + } + + /** + * @depends testGet + */ + public function testQuery() { + $all = self::$controller->get(); + + $this->assertEquals(1, count($all['_embedded']['ow_auth_test'])); + + $this->assertEquals('Test User', $all['_embedded']['ow_auth_test'][0]['displayName']); + + $this->assertEquals(1, $all['page']['totalElements']); + $this->assertEquals(1, $all['page']['totalPages']); + $this->assertEquals(0, $all['page']['number']); + } + + /** + * @depends testQuery + */ + public function testPut() { + + self::$controller->put('user', array( 'displayName' => 'Updated name' )); + + $user = self::$controller->get('user'); + + $this->assertEquals('Updated name', $user['displayName']); + } + + /** + * @depends testPut + * @expectedException Objectiveweb\Auth\UserException + */ + public function testDelete() { + self::$controller->delete('user'); + + $user = self::$controller->get('user'); + } +} \ No newline at end of file From eb623299490bf538112c2561eb225e25466908e3 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 5 May 2018 00:27:06 -0300 Subject: [PATCH 002/217] ACL Support --- src/Auth.php | 4 + src/Auth/AclTrait.php | 88 +++++++ src/Auth/AuthController.php | 49 ---- src/Auth/Controller/AuthController.php | 107 +++++++++ src/password.php | 314 ------------------------- test/AuthControllerTest.php | 105 --------- 6 files changed, 199 insertions(+), 468 deletions(-) create mode 100644 src/Auth/AclTrait.php delete mode 100644 src/Auth/AuthController.php create mode 100644 src/Auth/Controller/AuthController.php delete mode 100644 src/password.php delete mode 100644 test/AuthControllerTest.php diff --git a/src/Auth.php b/src/Auth.php index ff4924c..1dc7443 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -4,6 +4,10 @@ abstract class Auth { + // Base scopes + const ANONYMOUS = [ 'anon' ]; + const AUTHENTICATED = [ 'auth' ]; + const ALL = [ 'anon', 'auth' ]; public $params; diff --git a/src/Auth/AclTrait.php b/src/Auth/AclTrait.php new file mode 100644 index 0000000..f3e040c --- /dev/null +++ b/src/Auth/AclTrait.php @@ -0,0 +1,88 @@ + Auth::AUTHENTICATED, + * 'register' => Auth::ANONYMOUS, + * 'post' => [ 'admin' ] + * ] + * ``` + * + * So only users with the `admin` scope will have access to the `post` method + * + * Acls can also be defined as a callback that should return an array of scopes + * + * + * ``` + * $acl = [ + * 'post' => function() { + * return [ 'scope1', 'scope2' ] + * } + * ] + * ``` + * + * @var array + */ + private $acl = [ + '*' => Auth::AUTHENTICATED + ]; + + function aclSetup(Auth $auth, array $acl = []) { + $this->auth = $auth; + + $this->acl = array_merge($this->acl, $acl); + } + + function before($method, $fn) { + + if($this->auth->check()) { + $scopes = [ 'auth' ]; + + $this->user = $this->auth->user(); + + if(is_array($this->user['scopes'])) { + $scopes = array_merge($scopes, $this->user['scopes']); + } + } + else { + $scopes = [ 'anon' ]; + } + + $perms = isset($this->acl[$fn]) ? $this->acl[$fn] : $this->acl['*']; + + if(is_callable($perms)) { + $perms = call_user_func($perms); + } + + if(count(array_intersect($perms, $scopes)) == 0) { + throw new AuthException("Forbidden", 403); + } + + } +} \ No newline at end of file diff --git a/src/Auth/AuthController.php b/src/Auth/AuthController.php deleted file mode 100644 index 8357020..0000000 --- a/src/Auth/AuthController.php +++ /dev/null @@ -1,49 +0,0 @@ -auth = $auth; - } - - public function index() { - return $this->get(); - } - - public function get($params = array()) { - if(is_array($params)) { - return $this->auth->query($params); - } - else { - return $this->auth->get($params); - } - } - - public function post($data) { - - return $this->auth->register($data); - - } - - public function put($username, $data) { - - if(!empty($data['password'])) { - $this->auth->passwd($username, $data['password']); - } - - unset($data['password']); - - $this->auth->update($username, $data); - - return true; - } - - public function delete($username) { - $this->auth->delete($username); - } -} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php new file mode 100644 index 0000000..1fdfd69 --- /dev/null +++ b/src/Auth/Controller/AuthController.php @@ -0,0 +1,107 @@ +aclSetup($auth, [ + '*' => Auth::ANONYMOUS, + 'get' => Auth::ALL, + 'callback' => Auth::ALL, + 'index' => Auth::AUTHENTICATED, + 'getLogout' => Auth::AUTHENTICATED + ]); + + } + + function index() + { + return $this->user; + } + + function getLogout() + { + $this->auth->logout(); + } + + /** + * Login a local user + */ + function post(array $user) + { + + $username = $user[$this->auth->params['username']]; + unset($user[$this->auth->params['username']]); + $password = $user[$this->auth->params['password']]; + unset($user[$this->auth->params['password']]); + + return $this->auth->login($username, $password); + } + + /** + * Register a new user + * @param $user array + */ + function postRegister(array $user) + { + $username = @$user[$this->auth->params['username']]; + unset($user[$this->auth->params['username']]); + $password = @$user[$this->auth->params['password']]; + unset($user[$this->auth->params['password']]); + + $user = $this->auth->register($username, $password, $user); + + return $user; + } + + function postPassword(array $form) + { + if ($this->auth->check()) { + if (empty($form['password']) || $form['password'] != @$form['confirm']) { + throw new AuthException('Passwords dont match', 400); + } + + $user = $this->auth->user(); + + return $this->auth->passwd($user['username'], $form['password']); + } else { + if (!empty($form['token'])) { + if (empty($form['password']) || $form['password'] != @$form['confirm']) { + throw new AuthException('Passwords dont match', 400); + } + + $user = $this->auth->passwd_reset($form['token'], $form['password']); + + // set current session and return user + return $this->auth->user($user); + } else { + if (empty($form['username'])) { + throw new UserException('Invalid request', 400); + } + + // return new token + return $this->auth->update_token($form['username']); + } + } + + } + + +} diff --git a/src/password.php b/src/password.php deleted file mode 100644 index 805caa5..0000000 --- a/src/password.php +++ /dev/null @@ -1,314 +0,0 @@ - - * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @copyright 2012 The Authors - */ - -namespace { - - if (!defined('PASSWORD_BCRYPT')) { - /** - * PHPUnit Process isolation caches constants, but not function declarations. - * So we need to check if the constants are defined separately from - * the functions to enable supporting process isolation in userland - * code. - */ - define('PASSWORD_BCRYPT', 1); - define('PASSWORD_DEFAULT', PASSWORD_BCRYPT); - define('PASSWORD_BCRYPT_DEFAULT_COST', 10); - } - - if (!function_exists('password_hash')) { - - /** - * Hash the password using the specified algorithm - * - * @param string $password The password to hash - * @param int $algo The algorithm to use (Defined by PASSWORD_* constants) - * @param array $options The options for the algorithm to use - * - * @return string|false The hashed password, or false on error. - */ - function password_hash($password, $algo, array $options = array()) { - if (!function_exists('crypt')) { - trigger_error("Crypt must be loaded for password_hash to function", E_USER_WARNING); - return null; - } - if (is_null($password) || is_int($password)) { - $password = (string) $password; - } - if (!is_string($password)) { - trigger_error("password_hash(): Password must be a string", E_USER_WARNING); - return null; - } - if (!is_int($algo)) { - trigger_error("password_hash() expects parameter 2 to be long, " . gettype($algo) . " given", E_USER_WARNING); - return null; - } - $resultLength = 0; - switch ($algo) { - case PASSWORD_BCRYPT: - $cost = PASSWORD_BCRYPT_DEFAULT_COST; - if (isset($options['cost'])) { - $cost = $options['cost']; - if ($cost < 4 || $cost > 31) { - trigger_error(sprintf("password_hash(): Invalid bcrypt cost parameter specified: %d", $cost), E_USER_WARNING); - return null; - } - } - // The length of salt to generate - $raw_salt_len = 16; - // The length required in the final serialization - $required_salt_len = 22; - $hash_format = sprintf("$2y$%02d$", $cost); - // The expected length of the final crypt() output - $resultLength = 60; - break; - default: - trigger_error(sprintf("password_hash(): Unknown password hashing algorithm: %s", $algo), E_USER_WARNING); - return null; - } - $salt_req_encoding = false; - if (isset($options['salt'])) { - switch (gettype($options['salt'])) { - case 'NULL': - case 'boolean': - case 'integer': - case 'double': - case 'string': - $salt = (string) $options['salt']; - break; - case 'object': - if (method_exists($options['salt'], '__tostring')) { - $salt = (string) $options['salt']; - break; - } - case 'array': - case 'resource': - default: - trigger_error('password_hash(): Non-string salt parameter supplied', E_USER_WARNING); - return null; - } - if (PasswordCompat\binary\_strlen($salt) < $required_salt_len) { - trigger_error(sprintf("password_hash(): Provided salt is too short: %d expecting %d", PasswordCompat\binary\_strlen($salt), $required_salt_len), E_USER_WARNING); - return null; - } elseif (0 == preg_match('#^[a-zA-Z0-9./]+$#D', $salt)) { - $salt_req_encoding = true; - } - } else { - $buffer = ''; - $buffer_valid = false; - if (function_exists('mcrypt_create_iv') && !defined('PHALANGER')) { - $buffer = mcrypt_create_iv($raw_salt_len, MCRYPT_DEV_URANDOM); - if ($buffer) { - $buffer_valid = true; - } - } - if (!$buffer_valid && function_exists('openssl_random_pseudo_bytes')) { - $buffer = openssl_random_pseudo_bytes($raw_salt_len); - if ($buffer) { - $buffer_valid = true; - } - } - if (!$buffer_valid && @is_readable('/dev/urandom')) { - $file = fopen('/dev/urandom', 'r'); - $read = PasswordCompat\binary\_strlen($buffer); - while ($read < $raw_salt_len) { - $buffer .= fread($file, $raw_salt_len - $read); - $read = PasswordCompat\binary\_strlen($buffer); - } - fclose($file); - if ($read >= $raw_salt_len) { - $buffer_valid = true; - } - } - if (!$buffer_valid || PasswordCompat\binary\_strlen($buffer) < $raw_salt_len) { - $buffer_length = PasswordCompat\binary\_strlen($buffer); - for ($i = 0; $i < $raw_salt_len; $i++) { - if ($i < $buffer_length) { - $buffer[$i] = $buffer[$i] ^ chr(mt_rand(0, 255)); - } else { - $buffer .= chr(mt_rand(0, 255)); - } - } - } - $salt = $buffer; - $salt_req_encoding = true; - } - if ($salt_req_encoding) { - // encode string with the Base64 variant used by crypt - $base64_digits = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; - $bcrypt64_digits = - './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - - $base64_string = base64_encode($salt); - $salt = strtr(rtrim($base64_string, '='), $base64_digits, $bcrypt64_digits); - } - $salt = PasswordCompat\binary\_substr($salt, 0, $required_salt_len); - - $hash = $hash_format . $salt; - - $ret = crypt($password, $hash); - - if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != $resultLength) { - return false; - } - - return $ret; - } - - /** - * Get information about the password hash. Returns an array of the information - * that was used to generate the password hash. - * - * array( - * 'algo' => 1, - * 'algoName' => 'bcrypt', - * 'options' => array( - * 'cost' => PASSWORD_BCRYPT_DEFAULT_COST, - * ), - * ) - * - * @param string $hash The password hash to extract info from - * - * @return array The array of information about the hash. - */ - function password_get_info($hash) { - $return = array( - 'algo' => 0, - 'algoName' => 'unknown', - 'options' => array(), - ); - if (PasswordCompat\binary\_substr($hash, 0, 4) == '$2y$' && PasswordCompat\binary\_strlen($hash) == 60) { - $return['algo'] = PASSWORD_BCRYPT; - $return['algoName'] = 'bcrypt'; - list($cost) = sscanf($hash, "$2y$%d$"); - $return['options']['cost'] = $cost; - } - return $return; - } - - /** - * Determine if the password hash needs to be rehashed according to the options provided - * - * If the answer is true, after validating the password using password_verify, rehash it. - * - * @param string $hash The hash to test - * @param int $algo The algorithm used for new password hashes - * @param array $options The options array passed to password_hash - * - * @return boolean True if the password needs to be rehashed. - */ - function password_needs_rehash($hash, $algo, array $options = array()) { - $info = password_get_info($hash); - if ($info['algo'] != $algo) { - return true; - } - switch ($algo) { - case PASSWORD_BCRYPT: - $cost = isset($options['cost']) ? $options['cost'] : PASSWORD_BCRYPT_DEFAULT_COST; - if ($cost != $info['options']['cost']) { - return true; - } - break; - } - return false; - } - - /** - * Verify a password against a hash using a timing attack resistant approach - * - * @param string $password The password to verify - * @param string $hash The hash to verify against - * - * @return boolean If the password matches the hash - */ - function password_verify($password, $hash) { - if (!function_exists('crypt')) { - trigger_error("Crypt must be loaded for password_verify to function", E_USER_WARNING); - return false; - } - $ret = crypt($password, $hash); - if (!is_string($ret) || PasswordCompat\binary\_strlen($ret) != PasswordCompat\binary\_strlen($hash) || PasswordCompat\binary\_strlen($ret) <= 13) { - return false; - } - - $status = 0; - for ($i = 0; $i < PasswordCompat\binary\_strlen($ret); $i++) { - $status |= (ord($ret[$i]) ^ ord($hash[$i])); - } - - return $status === 0; - } - } - -} - -namespace PasswordCompat\binary { - - if (!function_exists('PasswordCompat\\binary\\_strlen')) { - - /** - * Count the number of bytes in a string - * - * We cannot simply use strlen() for this, because it might be overwritten by the mbstring extension. - * In this case, strlen() will count the number of *characters* based on the internal encoding. A - * sequence of bytes might be regarded as a single multibyte character. - * - * @param string $binary_string The input string - * - * @internal - * @return int The number of bytes - */ - function _strlen($binary_string) { - if (function_exists('mb_strlen')) { - return mb_strlen($binary_string, '8bit'); - } - return strlen($binary_string); - } - - /** - * Get a substring based on byte limits - * - * @see _strlen() - * - * @param string $binary_string The input string - * @param int $start - * @param int $length - * - * @internal - * @return string The substring - */ - function _substr($binary_string, $start, $length) { - if (function_exists('mb_substr')) { - return mb_substr($binary_string, $start, $length, '8bit'); - } - return substr($binary_string, $start, $length); - } - - /** - * Check if current PHP version is compatible with the library - * - * @return boolean the check result - */ - function check() { - static $pass = NULL; - - if (is_null($pass)) { - if (function_exists('crypt')) { - $hash = '$2y$04$usesomesillystringfore7hnbRJHxXVLeakoG8K30oukPsA.ztMG'; - $test = crypt("password", $hash); - $pass = $test == $hash; - } else { - $pass = false; - } - } - return $pass; - } - - } -} \ No newline at end of file diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php deleted file mode 100644 index fb6a456..0000000 --- a/test/AuthControllerTest.php +++ /dev/null @@ -1,105 +0,0 @@ -query('drop table if exists ow_auth_test'); - $pdo->query('create table ow_auth_test - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `username` VARCHAR(255), - `displayName` VARCHAR(255), - `email` VARCHAR(255), - `created` DATETIME, - `last_login` DATETIME, - `password` CHAR(60), - `token` CHAR(32));'); - - $auth = new Auth($pdo, array( - 'table' => 'ow_auth_test', - 'created' => 'created', - 'token' => 'token', - 'last_login' => 'last_login' - )); - - self::$controller = new AuthController($auth); - } - - public function testPost() - { - - $user = self::$controller->post(array( - 'username' => 'user', - 'password' => 'test', - 'email' => 'vagrant@localhost', - 'displayName' => 'Test User')); - - $this->assertEquals(1, $user['id']); - - } - - /** - * @depends testPost - */ - public function testGet() { - $user = self::$controller->get('user'); - - $this->assertEquals(1, $user['id']); - } - - /** - * @depends testGet - */ - public function testQuery() { - $all = self::$controller->get(); - - $this->assertEquals(1, count($all['_embedded']['ow_auth_test'])); - - $this->assertEquals('Test User', $all['_embedded']['ow_auth_test'][0]['displayName']); - - $this->assertEquals(1, $all['page']['totalElements']); - $this->assertEquals(1, $all['page']['totalPages']); - $this->assertEquals(0, $all['page']['number']); - } - - /** - * @depends testQuery - */ - public function testPut() { - - self::$controller->put('user', array( 'displayName' => 'Updated name' )); - - $user = self::$controller->get('user'); - - $this->assertEquals('Updated name', $user['displayName']); - } - - /** - * @depends testPut - * @expectedException Objectiveweb\Auth\UserException - */ - public function testDelete() { - self::$controller->delete('user'); - - $user = self::$controller->get('user'); - } -} \ No newline at end of file From 9b07b9789a49731accc2f2e5c9c1b77529276756 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 5 May 2018 00:19:05 -0300 Subject: [PATCH 003/217] Add OAuth2 client support --- composer.json | 13 +-- src/Auth/Controller/OAuthController.php | 127 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 src/Auth/Controller/OAuthController.php diff --git a/composer.json b/composer.json index 9b9df84..84c3bd4 100644 --- a/composer.json +++ b/composer.json @@ -8,20 +8,21 @@ } ], "support": { - "issues" : "https://github.com/bravado/objectiveweb/issues" + "issues" : "https://github.com/objetiveweb/auth/issues" }, "require": { - "php": ">=5.3.0" + "php": ">=5.6.0" }, "require-dev": { "phpunit/phpunit": "4.*" }, + "suggest": { + "league/oauth2-google": "Google Login", + "league/oauth2-facebook": "Facebook Login" + }, "autoload": { "psr-4": { "Objectiveweb\\": "src" - }, - "files": [ - "src/password.php" - ] + } } } diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php new file mode 100644 index 0000000..03703a1 --- /dev/null +++ b/src/Auth/Controller/OAuthController.php @@ -0,0 +1,127 @@ +providers = $providers; + + } + + /** + * Execute login on oauth provider + */ + function get($id, $query) + { + if (!isset($this->providers[$id])) { + throw new \Exception("Invalid provider $id", 406); + } + + $config = $this->providers[$id]; + $config['redirectUri'] = "{$_SERVER['REQUEST_SCHEME']}://{$_SERVER['HTTP_HOST']}{$_SERVER['PHP_SELF']}"; + + $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); + + /** @var \League\OAuth2\Client\Provider\GenericProvider $provider */ + $provider = new $classname($config); + + if (!empty($query['error'])) { + throw new \Exception("Got error {$query['error']}", 500); + } elseif (empty($query['code'])) { + // generate authUrl first to update state + $authUrl = $provider->getAuthorizationUrl(); + $_SESSION['oauth2state'] = $provider->getState(); + header('Location: ' . $authUrl); + exit; + } elseif (empty($query['state']) || $query['state'] !== $_SESSION['oauth2state']) { + unset($_SESSION['oauth2state']); + throw new \Exception('Invalid state', 406); + } else { + $token = $provider->getAccessToken('authorization_code', [ + 'code' => $query['code'] + ]); + $resourceOwner = $provider->getResourceOwner($token); + + $this->login($id, $resourceOwner); + + header("Location: /" ); + + } + } + + /** + * @param $provider + * @param \League\OAuth2\Client\Provider\ResourceOwnerInterface $resourceOwner + * @return mixed + */ + private function login($provider, $resourceOwner) { + + // Interface fields + $uid = $resourceOwner->getId(); + $data = $resourceOwner->toArray(); + + // Implementation-specific fields + $email = $resourceOwner->getEmail(); + $name = $resourceOwner->getName(); + $image = is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl(); + $account = $this->auth->get_account($provider, $uid); + + // if account does not exist, create + if (!$account) { + // if already logged in + if ($this->auth->check()) { + $user = $this->auth->user(); + } else { + // create new user + $username = empty($email) ? + "{$provider}:{$uid}" + : $email; + + // see if there's account with the same username + try { + $user = $this->auth->get($username); + } catch (UserException $ex) { + $user = $this->auth->register($username, null, [ + 'name' => $name, + 'image' => $image + ]); + } + } + + // Add account to the user + $this->auth->update_account($user[$this->auth->params['id']], + $provider, + $uid, + $data); + + // fetch updated user + $user = $this->auth->get($user[$this->auth->params['id']], $this->auth->params['id']); + } + else { + // account exists, fetch existing user + $user = $this->auth->get($account['user_id'], $this->auth->params['id']); + } + + // Set session + $this->auth->user($user); + + return $user; + + } + +} From fbe68df4a0c84d823a80d00eee7c4e414fc9ee49 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 5 May 2018 00:20:33 -0300 Subject: [PATCH 004/217] Release 0.2 --- .travis.yml | 6 ++---- README.md | 17 ++++++++++++----- composer.json | 7 +++++-- src/Auth.php | 2 +- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index 9a7fc08..1769d25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,12 +3,10 @@ language: php sudo: false php: - - 5.3 - - 5.4 - - 5.5 - 5.6 - 7.0 - - hhvm + - 7.1 + - 7.2 services: mysql diff --git a/README.md b/README.md index 967e978..a9cb005 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,22 @@ Setup the auth dependency on your project's composer.json, then run `composer up "require": { - "objectiveweb/auth": "~0.1" + "objectiveweb/auth": "~0.2" } +Or use the command line + +``` +composer require objectiveweb "^0.4" +``` ### Auth initialization Create a new $auth instance, passing the appropriate parameters - // Auth depends on PDO - $pdo = new PDO($dsn); + // MysqlAuth depends on PDO + $pdo = new \PDO($dsn); - $auth = new Auth($pdo, [ + $auth = new \Objectiveweb\Auth\MysqlAuth($pdo, [ 'session_key' => 'ow_auth', 'table' => 'ow_auth', 'id' => 'id', @@ -64,6 +69,8 @@ Create a new $auth instance, passing the appropriate parameters ## Usage + include "vendor/autoload.php"; + # register user try { $user = $auth->register('username', 'password'); @@ -76,7 +83,7 @@ Create a new $auth instance, passing the appropriate parameters try { $user = $auth->login('username', 'password'); } - catch(PasswordMismatchException $ex) { + catch(AuthException $ex) { printf("Password mismatch"); } catch(UserException $ex) { diff --git a/composer.json b/composer.json index 84c3bd4..ae8abea 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,10 @@ }, "autoload": { "psr-4": { - "Objectiveweb\\": "src" - } + "Objectiveweb\\Auth\\": "src/Auth/" + }, + "files": [ + "src/Auth.php" + ] } } diff --git a/src/Auth.php b/src/Auth.php index 1dc7443..4f8e1e0 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -74,7 +74,7 @@ public function &user($user = null) $_SESSION[$this->params['session_key']] = $user; } else { if (!$this->check()) { - throw new UserException('Not logged in', 403); + throw new Auth\UserException('Not logged in', 403); } } From fb4eeb8113be0b8ca46664585a42e1dab12de2da Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 17 Feb 2019 12:36:12 -0300 Subject: [PATCH 005/217] Add credentials table --- src/Auth.php | 120 ++++++---- src/Auth/BasicAuth.php | 10 +- src/Auth/Controller/AuthController.php | 28 ++- src/Auth/Controller/OAuthController.php | 64 ++--- src/Auth/Controller/UserController.php | 21 +- src/Auth/MysqlAuth.php | 296 +++++++++++------------- src/Auth/UserException.php | 16 ++ test/MysqlAuthTest.php | 82 ++++--- test/UserControllerTest.php | 66 +++--- 9 files changed, 380 insertions(+), 323 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 4f8e1e0..42c82ca 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -2,20 +2,21 @@ namespace Objectiveweb; -abstract class Auth { +abstract class Auth +{ // Base scopes - const ANONYMOUS = [ 'anon' ]; - const AUTHENTICATED = [ 'auth' ]; - const ALL = [ 'anon', 'auth' ]; + const ANONYMOUS = ['anon']; + const AUTHENTICATED = ['auth']; + const ALL = ['anon', 'auth']; public $params; - function __construct($params) { + function __construct($params) + { $defaults = [ 'session_key' => 'ow_auth', 'id' => 'id', - 'username' => 'username', 'password' => 'password', 'scopes' => 'scopes', 'token' => NULL @@ -24,6 +25,16 @@ function __construct($params) { $this->params = array_merge($defaults, $params); } + public static function hash($password = null) + { + if (!$password) { + // return a random token + return md5(microtime(true)); + } + + return \password_hash($password, PASSWORD_BCRYPT); + } + /** * Returns true if the user is logged on */ @@ -32,14 +43,37 @@ public function check() return !empty($_SESSION[$this->params['session_key']]); } - public static function hash($password = null) + /** + * @param $uid + * @param $password + * @throws Auth\UserException + * @throws Auth\AuthException + */ + public function &login($uid, $password) { - if (!$password) { - // return a random token - return md5(microtime(true)); + + $cred = $this->get_credential('local', $uid); + + if (!$cred || empty($cred['user'])) { + throw new Auth\UserException('User does not exist', 404); } - return password_hash($password, PASSWORD_BCRYPT); + $user = $cred['user']; + + if (\password_verify($password, $user[$this->params['password']])) { + + unset($user[$this->params['password']]); + unset($user[$this->params['token']]); + + $this->user($user); + + // TODO add login ip + $this->update_credential($user[$this->params['id']], 'local', $uid, []); + + return $user; + } else { + throw new Auth\AuthException('Password invalid', 400); + } } /** @@ -53,7 +87,8 @@ public function logout() /** * Reloads current user from the backend */ - public function reload() { + public function reload() + { $user = $this->user(); $user = $this->get($user[$this->params['id']], $this->params['id']); @@ -82,17 +117,18 @@ public function &user($user = null) return $_SESSION[$this->params['session_key']]; } - public function validate($user) { - $username = trim(@$user[$this->params['username']]); + public function validate($user) + { + $uid = trim(@$user['uid']); - if(empty($username)) { - throw new \Exception("Missing username", 400); + if (empty($uid)) { + throw new \Exception("Missing uid", 400); } - $user[$this->params['username']] = $username; + $user['uid'] = $uid; - if(isset($user[$this->params['password']])) { - if(empty($user[$this->params['password']])) { + if (isset($user[$this->params['password']])) { + if (empty($user[$this->params['password']])) { throw new \Exception("Missing password", 400); } } @@ -100,46 +136,39 @@ public function validate($user) { return $user; } + // abstract public function query($params = array(), $operator = "OR"); /** * Retrieves a user from the database - * @param $username String username - * @param $key String which key to lookup (username, id, token) + * @param $user_id String user_id + * @param $key String which key to lookup (id, token) * @return array user data * @throws UserException * @throws \Exception */ - abstract public function get($username, $key = 'username'); - - /** - * @param $username - * @param $password - * @throws UserException - * @throws PasswordMismatchException - */ - abstract public function &login($username, $password); + abstract public function get($user_id, $key = 'id'); /** - * @param string $username + * @param string $uid * @param string $password * @param array $data associative array of additional columns to store */ - abstract public function register($username, $password = null, $data = array()); + abstract public function register($uid, $password = null, $data = array()); /** * * Update a user's password * - * @param $username + * @param $user_id * @param $password - * @param $key String username or id + * @param $key String * @return bool TRUE on success * @throws UserException if no rows were updated */ - abstract public function passwd($username, $password, $key = 'username'); + abstract public function passwd($user_id, $password, $key = 'id'); /** * @@ -154,30 +183,31 @@ abstract public function passwd_reset($token, $password); /** * Update arbitrary user data - * @param $username + * @param $user_id * @param array $data associative array of data - * @param key String username or id + * @param key String * @throws UserException if no rows were updated */ - abstract public function update($username, array $data, $key = 'username'); + abstract public function update($user_id, array $data, $key = 'id'); - abstract public function delete($username, $key = 'username'); + abstract public function delete($user_id); /** * Generates a new token for the user and update the database - * @param $username + * @param $user_id * @return string new token */ - abstract public function update_token($username); + abstract public function update_token($user_id); /** - * Retrieves an account from ext_accounts_table + * Retrieves an account from credentials_table + * @param $provider String provider * @param $userid String user_id - * @param $key String which key to lookup (username, id, token) * @return mixed account data or null if account not found + * MUST include the 'user' key with the associated user (can be NULL) * @throws \Exception */ - abstract public function get_account($provider, $accountid); + abstract public function get_credential($provider, $accountid); /** * Inserts a new account on $userid, or update the existing one @@ -189,5 +219,5 @@ abstract public function get_account($provider, $accountid); * @return bool * @throws \Exception */ - abstract public function update_account($userid, $provider, $uid, $profile = null); + abstract public function update_credential($userid, $provider, $uid, $profile = null); } \ No newline at end of file diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 0d6f6c4..d0f053b 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -25,7 +25,7 @@ public function query($params = array(), $operator = "OR") { $result = []; foreach ($this->passwd as $user => $pass) { - if(strpos($user[$this->params['username']], $params['username']) !== FALSE) { + if(strpos($user['uid'], $params['uid']) !== FALSE) { $result[] = $user; } } @@ -95,13 +95,13 @@ public function update_token($username) // TODO: Implement update_token() method. } - public function get_account($provider, $accountid) + public function get_credential($provider, $accountid) { - // TODO: Implement get_account() method. + // TODO: Implement get_credential() method. } - public function update_account($userid, $provider, $uid, $profile = null) + public function update_credential($userid, $provider, $uid, $profile = null) { - // TODO: Implement update_account() method. + // TODO: Implement update_credential() method. } } \ No newline at end of file diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 1fdfd69..468986a 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -47,12 +47,12 @@ function getLogout() function post(array $user) { - $username = $user[$this->auth->params['username']]; - unset($user[$this->auth->params['username']]); + $uid = $user['uid']; + unset($user['uid']); $password = $user[$this->auth->params['password']]; unset($user[$this->auth->params['password']]); - return $this->auth->login($username, $password); + return $this->auth->login($uid, $password); } /** @@ -61,12 +61,12 @@ function post(array $user) */ function postRegister(array $user) { - $username = @$user[$this->auth->params['username']]; - unset($user[$this->auth->params['username']]); + $uid = @$user['uid']; + unset($user['uid']); $password = @$user[$this->auth->params['password']]; unset($user[$this->auth->params['password']]); - $user = $this->auth->register($username, $password, $user); + $user = $this->auth->register($uid, $password, $user); return $user; } @@ -74,13 +74,14 @@ function postRegister(array $user) function postPassword(array $form) { if ($this->auth->check()) { + // TODO validar senha anterior if (empty($form['password']) || $form['password'] != @$form['confirm']) { throw new AuthException('Passwords dont match', 400); } $user = $this->auth->user(); - return $this->auth->passwd($user['username'], $form['password']); + return $this->auth->passwd($user[$this->auth->params['id']], $form['password']); } else { if (!empty($form['token'])) { if (empty($form['password']) || $form['password'] != @$form['confirm']) { @@ -92,12 +93,19 @@ function postPassword(array $form) // set current session and return user return $this->auth->user($user); } else { - if (empty($form['username'])) { + if (empty($form['uid'])) { throw new UserException('Invalid request', 400); } - // return new token - return $this->auth->update_token($form['username']); + // find user + $account = $this->auth->get_credential('local', $form['uid']); + + if(!empty($account['user'])) { + // return new token + return $this->auth->update_token($account['user']['id']); + } else { + throw new UserException('Account not found', 404); + } } } diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 03703a1..a83849f 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -65,6 +65,7 @@ function get($id, $query) } /** + * Login with oauth2 credentials. Create a new user if it doesn't exist * @param $provider * @param \League\OAuth2\Client\Provider\ResourceOwnerInterface $resourceOwner * @return mixed @@ -72,49 +73,30 @@ function get($id, $query) private function login($provider, $resourceOwner) { // Interface fields - $uid = $resourceOwner->getId(); - $data = $resourceOwner->toArray(); - - // Implementation-specific fields - $email = $resourceOwner->getEmail(); - $name = $resourceOwner->getName(); - $image = is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl(); - $account = $this->auth->get_account($provider, $uid); - - // if account does not exist, create - if (!$account) { - // if already logged in - if ($this->auth->check()) { - $user = $this->auth->user(); + $data = [ + 'uid' => $resourceOwner->getId(), + 'provider' => $provider, + 'profile' => $resourceOwner->toArray(), + 'name' => $resourceOwner->getName(), + 'image' => is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl() + ]; + + $data['profile']['email'] = $resourceOwner->getEmail(); + + try { + $user = $this->auth->register($data); + } catch(UserException $ex) { + if ($ex->getCode() == 409) { + $user = $ex->getUser(); + + // Add account to the user + $this->auth->update_credential($user[$this->auth->params['id']], + $provider, + $uid, + $data['profile']); } else { - // create new user - $username = empty($email) ? - "{$provider}:{$uid}" - : $email; - - // see if there's account with the same username - try { - $user = $this->auth->get($username); - } catch (UserException $ex) { - $user = $this->auth->register($username, null, [ - 'name' => $name, - 'image' => $image - ]); - } + throw $ex; } - - // Add account to the user - $this->auth->update_account($user[$this->auth->params['id']], - $provider, - $uid, - $data); - - // fetch updated user - $user = $this->auth->get($user[$this->auth->params['id']], $this->auth->params['id']); - } - else { - // account exists, fetch existing user - $user = $this->auth->get($account['user_id'], $this->auth->params['id']); } // Set session diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 91653c8..05b73bd 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -2,6 +2,8 @@ namespace Objectiveweb\Auth\Controller; +use Objectiveweb\Auth\UserException; + /** * Class UserController * Simple controller to manage users using the Auth service @@ -35,21 +37,28 @@ public function post($data) { return $this->auth->register($data); } - - public function put($username, $data) { + + + public function put($user_id, $data) { + + $user = $this->auth->get($user_id); + + if(!$user) { + throw new UserException('User does not exist', 404); + } if(!empty($data['password'])) { - $this->auth->passwd($username, $data['password']); + $this->auth->passwd($user[$this->auth->params['id']], $data['password']); } unset($data['password']); - $this->auth->update($username, $data); + $this->auth->update($user[$this->auth->params['id']], $data); return true; } - public function delete($username) { - $this->auth->delete($username); + public function delete($uid) { + $this->auth->delete($uid); } } diff --git a/src/Auth/MysqlAuth.php b/src/Auth/MysqlAuth.php index 8a51ce8..9dade30 100644 --- a/src/Auth/MysqlAuth.php +++ b/src/Auth/MysqlAuth.php @@ -18,69 +18,23 @@ public function __construct(\PDO $pdo, $params = []) $this->pdo = $pdo; $defaults = [ - 'table' => 'ow_auth', + 'table' => 'user', + 'prefix' => 'ow_', 'created' => NULL, 'last_login' => NULL, - 'ext_accounts_table' => NULL, + 'credentials_table' => 'credentials', 'with' => [] ]; parent::__construct(array_merge($defaults, $params)); - if(!empty($this->params['ext_accounts_table'])) { - $this->params['with'][$this->params['ext_accounts_table']] = 'user_id'; - } - - } - - public function setup() { - - $fields = [ - "{$this->params['id']} INT UNSIGNED PRIMARY KEY AUTO_INCREMENT", - "{$this->params['username']} VARCHAR(255) NOT NULL", - "{$this->params['password']} VARCHAR(255)" - ]; - - if($this->params['token']) { - $fields[] = "{$this->params['token']} CHAR(32)"; - } - - if($this->params['created']) { - $fields[] = "{$this->params['created']} DATETIME"; - } - - if($this->params['last_login']) { - $fields[] = "{$this->params['last_login']} DATETIME"; - } - - - $queries[] = sprintf("CREATE TABLE `%s` (%s)", - $this->params['table'], - implode(",", $fields)); - - $queries[] = sprintf("CREATE UNIQUE INDEX %s_username_uindex ON %s (%s)", - $this->params['table'], - $this->params['table'], - $this->params['username']); - - if($this->params['ext_accounts_table']) { - $queries[] = sprintf("CREATE TABLE `%s` ( - user_id INT UNSIGNED NOT NULL, - provider VARCHAR(32) NOT NULL, - uid VARCHAR(255) NOT NULL, - profile text null, - CONSTRAINT auth_accounts_uid_provider_user_id_pk PRIMARY KEY (uid, provider, user_id), - CONSTRAINT auth_accounts_users_id_fk FOREIGN KEY (user_id) REFERENCES %s (%s))", - $this->params['ext_accounts_table'], - $this->params['table'], - $this->params['id']); - } - - return $queries; + $this->params['table'] = $this->params['prefix'] . $this->params['table']; + $this->params['credentials_table'] = $this->params['prefix'] . $this->params['credentials_table']; + $this->params['with'][$this->params['credentials_table']] = 'user_id'; } /** - * Queries the Auth table + * Queries the user table */ public function query($params = array(), $operator = "OR") { @@ -106,7 +60,8 @@ public function query($params = array(), $operator = "OR") $bindings[":where_$key"] = $value; } - $query = sprintf("SELECT SQL_CALC_FOUND_ROWS * FROM `%s` WHERE %s", + $query = sprintf(/** @lang text */ + "SELECT SQL_CALC_FOUND_ROWS * FROM `%s` WHERE %s", $this->params['table'], empty($cond) ? '1=1' : implode(" $operator ", $cond) ); @@ -150,19 +105,22 @@ public function query($params = array(), $operator = "OR") /** * Retrieves a user from the database - * @param $username String username - * @param $key String which key to lookup (username, id, token) + * @param $user_id String user_id + * @param $key String which key to lookup (id, token) * @return array user data * @throws UserException * @throws \Exception */ - public function get($username, $key = 'username') + public function get($user_id, $key = 'id') { - $query = sprintf("SELECT * FROM `%s` where `%s` = %s", + $key = $key == 'credential' ? + + $query = sprintf(/** @lang text */ + "SELECT * FROM `%s` where `%s` = %s", $this->params['table'], $this->params[$key], - $this->pdo->quote($username)); + $this->pdo->quote($user_id)); $stmt = $this->pdo->query($query); @@ -175,8 +133,9 @@ public function get($username, $key = 'username') if ($user = $stmt->fetch(PDO::FETCH_ASSOC)) { foreach ($this->params['with'] as $table => $fk) { - $query = sprintf("SELECT * FROM `%s` where `%s` = %s", - $table, + $query = sprintf(/** @lang text */ + "SELECT * FROM `%s` where `%s` = %s", + $table, $fk, $user[$this->params['id']] ); @@ -198,68 +157,50 @@ public function get($username, $key = 'username') } } + /** - * @param $username - * @param $password - * @throws UserException - * @throws AuthException + * + * TODO usar transaction, atualizar credentials + * + * @param string $uid + * @param string $password + * @param array $data associative array of additional columns to store */ - public function &login($username, $password) + public function register($uid, $password = null, $data = array()) { - $user = $this->get($username); - - if (\password_verify($password, $user[$this->params['password']])) { + $fields = []; - unset($user[$this->params['password']]); - unset($user[$this->params['token']]); + if (is_array($uid)) { + $data = array_merge($uid, $data); - $this->user($user); + $uid = @$data['uid']; + unset($data['uid']); - if ($this->params['last_login']) { - $query = sprintf("UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", - $this->params['table'], - $this->params['last_login'], - 'NOW()', - $this->params['id'], - $user[$this->params['id']]); - - $this->pdo->query($query); - } + $password = @$data['password']; + unset($data['password']); - return $user; - } else { - throw new AuthException('Password invalid', 400); } - } + $provider = empty($data['provider']) ? 'local' : $data['provider']; + unset($data['provider']); - /** - * @param string $username - * @param string $password - * @param array $data associative array of additional columns to store - */ - public function register($username, $password = null, $data = array()) - { - if (is_array($username)) { - $data = $username; + $user = $this->get_credential($provider, $uid); - $username = @$data[$this->params['username']]; - unset($data[$this->params['username']]); - - $password = @$data['password']; - unset($data['password']); + if (!empty($user)) { + $ex = new UserException('User already registered', 409); + $ex->setUser($user['user']); + throw $ex; } - $fields = array( - $this->params['username'] => $username - ); + $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; + unset($data['profile']); - // escape fields + // escape and encode fields foreach ($data as $k => $v) { - $fields[str_replace(array('\\', "\0", '`'), '', $k)] = $v; + $fields[str_replace(array('\\', "\0", '`'), '', $k)] = is_array($v) ? json_encode($v) : $v; } - if($password) { + if ($password) { $fields[$this->params['password']] = self::hash($password); } @@ -271,56 +212,71 @@ public function register($username, $password = null, $data = array()) $fields[$this->params['created']] = date('Y-m-d H:i:s'); } - $fields = $this->validate($fields); + $this->pdo->beginTransaction(); $stmt = $this->pdo->prepare("INSERT INTO " . $this->params['table'] . " (" . implode(array_keys($fields), ", ") . ") VALUES (:" . implode(array_keys($fields), ", :") . ");"); - foreach($fields as $k => $v) { - $stmt->bindValue(":".$k, $v); + foreach ($fields as $k => $v) { + $stmt->bindValue(":" . $k, $v); } if ($stmt->execute()) { $fields[$this->params['id']] = $this->pdo->lastInsertId(); unset($fields[$this->params['created']]); unset($fields[$this->params['password']]); - if(!empty($fields[$this->params['scopes']])) { + if (!empty($fields[$this->params['scopes']])) { $fields['params']['scopes'] = explode(",", $fields[$this->params['scopes']]); } - - return $fields; } else { + $this->pdo->rollBack(); $errorInfo = $this->pdo->errorInfo(); if ($errorInfo[1] == 1062) { - throw new \Exception(sprintf("User %s already exists", $username), 409); + throw new \Exception(sprintf("User %s already exists", $uid), 409); } else { throw new \Exception($errorInfo[2]); } } + + $stmt = $this->pdo->prepare("INSERT INTO `{$this->params['credentials_table']}` + (user_id, provider, uid, profile) VALUES (:user_id, :provider, :uid, :profile)"); + $stmt->bindValue(':user_id', $fields[$this->params['id']]); + $stmt->bindValue(':provider', $provider); + $stmt->bindValue(':uid', $uid); + $stmt->bindValue(':profile', $profile); + + if ($stmt->execute()) { + $this->pdo->commit(); + return $fields; + } else { + $this->pdo->rollBack(); + $errorInfo = $this->pdo->errorInfo(); + throw new \Exception($errorInfo[2]); + } } /** * * Update a user's password * - * @param $username + * @param user_id * @param $password - * @param $key String username or id + * @param $key String id * @return bool TRUE on success * @throws UserException if no rows were updated */ - public function passwd($username, $password, $key = 'username') + public function passwd($user_id, $password, $key = 'id') { - $query = sprintf("UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", + $query = sprintf(/** @lang text */ + "UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", $this->params['table'], $this->params['password'], $this->pdo->quote(self::hash($password)), $this->params[$key], - $this->pdo->quote($username)); - + $this->pdo->quote($user_id)); $stmt = $this->pdo->query($query); @@ -346,7 +302,8 @@ public function passwd_reset($token, $password) $user = $this->get($token, $this->params['token']); - $query = sprintf('UPDATE `%s` SET `%s` = %s, `%s` = NULL WHERE %s = %s', + $query = sprintf(/** @lang text */ + 'UPDATE `%s` SET `%s` = %s, `%s` = NULL WHERE %s = %s', $this->params['table'], $this->params['password'], $this->pdo->quote(self::hash($password)), @@ -368,12 +325,12 @@ public function passwd_reset($token, $password) /** * Update arbitrary user data - * @param $username + * @param $user_id * @param array $data associative array of data - * @param key String username or id + * @param key String * @throws UserException if no rows were updated */ - public function update($username, array $data, $key = 'username') + public function update($user_id, array $data, $key = 'id') { $cond = array(); unset($data[$this->params['id']]); @@ -396,11 +353,12 @@ public function update($username, array $data, $key = 'username') $cond[] = sprintf("`%s` = %s", str_replace(array('\\', "\0", '`'), '', $k), $this->pdo->quote($v)); } - $query = sprintf("UPDATE `%s` SET %s WHERE `%s` = %s", + $query = sprintf(/** @lang text */ + "UPDATE `%s` SET %s WHERE `%s` = %s", $this->params['table'], implode(', ', $cond), $this->params[$key], - $this->pdo->quote($username)); + $this->pdo->quote($user_id)); $stmt = $this->pdo->query($query); @@ -410,44 +368,60 @@ public function update($username, array $data, $key = 'username') } - public function delete($username, $key = 'username') + public function delete($user_id) { if ($this->check()) { $user = $this->user(); - if ($user[$this->params[$key]] == $username) { + if ($user[$this->params[$key]] == $user_id) { throw new \Exception("Cannot delete yourself!"); } } - $query = sprintf("DELETE FROM `%s` WHERE `%s` = %s LIMIT 1", - $this->params['table'], - $this->params[$key], - $this->pdo->quote($username)); + $this->pdo->beginTransaction(); - $stmt = $this->pdo->query($query); + $stmt = $this->pdo->prepare(/** @lang text */ + "DELETE FROM `{$this->params['credentials_table']}` WHERE `user_id` = :user_id"); - if ($stmt === FALSE || $stmt->rowCount() !== 1) { + $stmt->bindValue(':user_id', $user_id); + + if ($stmt->execute() === FALSE) { + $this->pdo->rollBack(); throw new \Exception(json_encode($this->pdo->errorInfo())); } + + $stmt = $this->pdo->prepare(/** @lang text */ + "DELETE FROM `{$this->params['table']}` WHERE `{$this->params['id']}` = :user_id LIMIT 1"); + + $stmt->bindValue(':user_id', $user_id); + + if ($stmt->execute() === FALSE) { + $this->pdo->rollBack(); + throw new \Exception(json_encode($this->pdo->errorInfo())); + } + + $this->pdo->commit(); + + return true; } /** * Generates a new token for the user and update the database - * @param $username + * @param $user_id * @return string new token */ - public function update_token($username) + public function update_token($user_id) { $token = self::hash(); - $query = sprintf("UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", + $query = sprintf(/** @lang text */ + "UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", $this->params['table'], $this->params['token'], $this->pdo->quote($token), - $this->params['username'], - $this->pdo->quote($username)); + $this->params['id'], + $this->pdo->quote($user_id)); $stmt = $this->pdo->query($query); @@ -459,17 +433,19 @@ public function update_token($username) } /** - * Retrieves an account from ext_accounts_table + * Retrieves an account from credentials_table + * @param $provider provider * @param $userid String user_id - * @param $key String which key to lookup (username, id, token) * @return mixed account data or null if account not found * @throws \Exception */ - public function get_account($provider, $accountid) + public function get_credential($provider, $accountid) { - $query = sprintf("SELECT a.user_id, a.provider, a.uid, a.profile - FROM `%s` a WHERE a.provider = %s and a.uid = %s", - $this->params['ext_accounts_table'], + $query = sprintf(/** @lang text */ + "SELECT a.user_id, a.provider, a.uid, a.profile + FROM `%s` a + WHERE a.provider = %s and a.uid = %s", + $this->params['credentials_table'], $this->pdo->quote($provider), $this->pdo->quote($accountid)); @@ -478,15 +454,20 @@ public function get_account($provider, $accountid) if (!$stmt) { $error = $this->pdo->errorInfo(); - throw new \Exception($error[2]); + throw new \Exception($error[2], 500); } if ($account = $stmt->fetch(PDO::FETCH_ASSOC)) { - - if(!empty($account['profile'])) { + if (!empty($account['profile'])) { $account['profile'] = json_decode($account['profile'], true); } + if (!empty($account['user_id'])) { + $account['user'] = $this->get($account['user_id']); + } else { + $account['user'] = null; + } + return $account; } else { return false; @@ -503,32 +484,31 @@ public function get_account($provider, $accountid) * @return bool * @throws \Exception */ - public function update_account($userid, $provider, $uid, $profile = null) + public function update_credential($userid, $provider, $uid, $profile = null) { - if(is_array($profile)) { + if (is_array($profile)) { $profile = json_encode($profile); } - $query = sprintf("INSERT INTO `%s` ( - user_id, provider, uid, profile - ) VALUES (%s, %s, %s, %s) - ON DUPLICATE KEY UPDATE profile = VALUES(profile)", - $this->params['ext_accounts_table'], + $query = sprintf(/** @lang text */ + "INSERT INTO `%s` (user_id, provider, uid, profile) VALUES (%s, %s, %s, %s) + ON DUPLICATE KEY UPDATE profile = VALUES(profile), modified = %s", + $this->params['credentials_table'], $this->pdo->quote($userid), $this->pdo->quote($provider), $this->pdo->quote($uid), - $this->pdo->quote($profile) + $this->pdo->quote($profile), + $this->pdo->quote(date('Y-m-d H:i:s')) ); if (!$this->pdo->query($query)) { $errorInfo = $this->pdo->errorInfo(); - throw new \Exception($errorInfo[2]); + throw new \Exception($errorInfo[2], 500); } return true; } - -} \ No newline at end of file +} diff --git a/src/Auth/UserException.php b/src/Auth/UserException.php index e4bcd2e..9b71863 100644 --- a/src/Auth/UserException.php +++ b/src/Auth/UserException.php @@ -2,6 +2,22 @@ namespace Objectiveweb\Auth; +use Throwable; + class UserException extends \Exception { + private $user; + + function __construct($message = "", $code = 0, Throwable $previous = null) + { + parent::__construct($message, $code, $previous); + } + + function setUser(array $user) { + $this->user = $user; + } + + function getUser() { + return $this->user; + } } diff --git a/test/MysqlAuthTest.php b/test/MysqlAuthTest.php index 9da9fcf..21672d6 100644 --- a/test/MysqlAuthTest.php +++ b/test/MysqlAuthTest.php @@ -20,25 +20,37 @@ class MysqlAuthTest extends PHPUnit_Framework_TestCase public static function setUpBeforeClass() { - - $pdo = new PDO('mysql:dbname=objectiveweb;host=localhost', 'root'); - $pdo->query('drop table if exists ow_auth_test'); - $pdo->query('create table ow_auth_test + $pdo = new PDO('mysql:dbname=objectiveweb;host=mysql', 'root', 'root'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('drop table if exists ow_credentials'); + $pdo->exec('drop table if exists ow_user'); + $pdo->query('create table ow_user (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `username` VARCHAR(255), - `displayName` VARCHAR(255), - `email` VARCHAR(255), + `name` VARCHAR(255), + `image` VARCHAR(255), + `scopes` VARCHAR(255), `created` DATETIME, - `last_login` DATETIME, `password` CHAR(60), - `token` CHAR(32));'); + `token` CHAR(32))'); + + $pdo->query("create table ow_credentials + ( + uid varchar(255) not null, + provider varchar(32) not null, + user_id int(11) unsigned not null, + profile text null, + modified datetime null, + primary key (uid, provider), + constraint credentials_ibfk_1 + foreign key (user_id) references ow_user (id) + + )"); self::$auth = new MysqlAuth($pdo, array( - 'table' => 'ow_auth_test', 'created' => 'created', - 'token' => 'token', - 'last_login' => 'last_login' + 'token' => 'token' )); + } public function setUp() @@ -55,15 +67,14 @@ public function tearDown() public function testRegistration() { - $user = self::$auth->register('user', 'test', array( - 'email' => 'vagrant@localhost', - 'displayName' => 'Test User' + $user = self::$auth->register('vagrant@localhost', 'test', array( + 'name' => 'Test User' )); $this->assertEquals(1, $user['id']); // test Login - $user = self::$auth->login('user', 'test'); + $user = self::$auth->login('vagrant@localhost', 'test'); $this->assertEquals(1, $user['id'], "Check user ID"); $this->assertRegExp('/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/', @@ -96,17 +107,22 @@ public function testInvalidLogin() */ public function testInvalidPassword() { - self::$auth->login('user', 'pass'); + self::$auth->login('vagrant@localhost', 'pass'); } public function testPasswd() { - $t = self::$auth->passwd('user', "1234"); + + $account = self::$auth->get_credential('local', 'vagrant@localhost'); + + $this->assertNotNull($account); + + $t = self::$auth->passwd($account['user_id'], "1234"); $this->assertTrue($t); - $user = self::$auth->login('user', '1234'); + $user = self::$auth->login('vagrant@localhost', '1234'); $this->assertEquals(1, $user['id']); @@ -117,15 +133,17 @@ public function testPasswd() */ public function testUpdate() { - $user = self::$auth->get('user'); + $account = self::$auth->get_credential('local', 'vagrant@localhost'); + + $user = self::$auth->get($account['user_id']); $this->assertEquals(1, $user['id']); - self::$auth->update('user', array( 'displayName' => 'Updated name' )); + self::$auth->update($account['user_id'], array( 'name' => 'Updated name' )); - $user = self::$auth->get('user'); + $user = self::$auth->get($account['user_id']); - $this->assertEquals('Updated name', $user['displayName']); + $this->assertEquals('Updated name', $user['name']); } /** @@ -134,9 +152,9 @@ public function testUpdate() { public function testQuery() { $all = self::$auth->query(); - $this->assertEquals(1, count($all['_embedded']['ow_auth_test'])); + $this->assertEquals(1, count($all['_embedded']['ow_user'])); - $this->assertEquals('Updated name', $all['_embedded']['ow_auth_test'][0]['displayName']); + $this->assertEquals('Updated name', $all['_embedded']['ow_user'][0]['name']); $this->assertEquals(1, $all['page']['totalElements']); $this->assertEquals(1, $all['page']['totalPages']); @@ -144,19 +162,23 @@ public function testQuery() { } public function testRequestToken() { - $token = self::$auth->update_token('user'); + $account = self::$auth->get_credential('local', 'vagrant@localhost'); + + $token = self::$auth->update_token($account['user_id']); self::$auth->passwd_reset($token, 'test'); - self::$auth->login('user', 'test'); + self::$auth->login('vagrant@localhost', 'test'); } /** * @expectedException Objectiveweb\Auth\UserException */ public function testDelete() { - self::$auth->logout(); - self::$auth->delete("user"); - self::$auth->login('user', 'pass'); + $account = self::$auth->get_credential('local', 'vagrant@localhost'); + + self::$auth->logout(); + self::$auth->delete($account['user_id']); + self::$auth->login('vagrant@localhost', 'pass'); } } \ No newline at end of file diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 70b78b3..dd2bafb 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -22,23 +22,34 @@ class UserControllerTest extends PHPUnit_Framework_TestCase public static function setUpBeforeClass() { - $pdo = new PDO('mysql:dbname=objectiveweb;host=localhost', 'root'); - $pdo->query('drop table if exists ow_auth_test'); - $pdo->query('create table ow_auth_test + $pdo = new PDO('mysql:dbname=objectiveweb;host=mysql', 'root', 'root'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('drop table if exists ow_credentials'); + $pdo->exec('drop table if exists ow_user'); + $pdo->query('create table ow_user (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `username` VARCHAR(255), - `displayName` VARCHAR(255), - `email` VARCHAR(255), + `name` VARCHAR(255), + `image` VARCHAR(255), + `scopes` VARCHAR(255), `created` DATETIME, - `last_login` DATETIME, `password` CHAR(60), - `token` CHAR(32));'); + `token` CHAR(32))'); + + $pdo->query("create table ow_credentials + ( + uid varchar(255) not null, + provider varchar(32) not null, + user_id int(11) unsigned not null, + profile text null, + modified datetime null, + primary key (uid, provider), + constraint credentials_ibfk_1 + foreign key (user_id) references ow_user (id) + )"); $auth = new MysqlAuth($pdo, array( - 'table' => 'ow_auth_test', 'created' => 'created', - 'token' => 'token', - 'last_login' => 'last_login' + 'token' => 'token' )); self::$controller = new UserController($auth); @@ -48,20 +59,19 @@ public function testPost() { $user = self::$controller->post(array( - 'username' => 'user', + 'uid' => 'vagrant@localhost', 'password' => 'test', - 'email' => 'vagrant@localhost', - 'displayName' => 'Test User')); + 'name' => 'Test User')); $this->assertEquals(1, $user['id']); } - + /** * @depends testPost */ public function testGet() { - $user = self::$controller->get('user'); + $user = self::$controller->get(1); $this->assertEquals(1, $user['id']); } @@ -71,26 +81,26 @@ public function testGet() { */ public function testQuery() { $all = self::$controller->get(); - - $this->assertEquals(1, count($all['_embedded']['ow_auth_test'])); - - $this->assertEquals('Test User', $all['_embedded']['ow_auth_test'][0]['displayName']); - + + $this->assertEquals(1, count($all['_embedded']['ow_user'])); + + $this->assertEquals('Test User', $all['_embedded']['ow_user'][0]['name']); + $this->assertEquals(1, $all['page']['totalElements']); $this->assertEquals(1, $all['page']['totalPages']); $this->assertEquals(0, $all['page']['number']); } - + /** * @depends testQuery */ public function testPut() { - self::$controller->put('user', array( 'displayName' => 'Updated name' )); + self::$controller->put(1, array( 'name' => 'Updated name' )); - $user = self::$controller->get('user'); + $user = self::$controller->get(1); - $this->assertEquals('Updated name', $user['displayName']); + $this->assertEquals('Updated name', $user['name']); } /** @@ -98,8 +108,8 @@ public function testPut() { * @expectedException Objectiveweb\Auth\UserException */ public function testDelete() { - self::$controller->delete('user'); - - $user = self::$controller->get('user'); + self::$controller->delete(1); + + $user = self::$controller->get(1); } } \ No newline at end of file From 776aa36a1fa69dc69396a92644316ad27d7eeb22 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 17 Feb 2019 18:53:29 -0300 Subject: [PATCH 006/217] Cleanup, fix tests --- src/Auth.php | 6 ++-- src/Auth/Controller/AuthController.php | 8 ++--- src/Auth/Controller/UserController.php | 4 +-- src/Auth/MysqlAuth.php | 21 +++-------- test/MysqlAuthTest.php | 45 +++-------------------- test/Mysql_TestCase.php | 49 ++++++++++++++++++++++++++ test/UserControllerTest.php | 40 ++++----------------- 7 files changed, 74 insertions(+), 99 deletions(-) create mode 100644 test/Mysql_TestCase.php diff --git a/src/Auth.php b/src/Auth.php index 42c82ca..77b5660 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -52,13 +52,13 @@ public function check() public function &login($uid, $password) { - $cred = $this->get_credential('local', $uid); + $credential = $this->get_credential('local', $uid); - if (!$cred || empty($cred['user'])) { + if (!$credential) { throw new Auth\UserException('User does not exist', 404); } - $user = $cred['user']; + $user = $this->get($credential['user_id']); if (\password_verify($password, $user[$this->params['password']])) { diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 468986a..e436bd4 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -98,13 +98,13 @@ function postPassword(array $form) } // find user - $account = $this->auth->get_credential('local', $form['uid']); + $credential = $this->auth->get_credential('local', $form['uid']); - if(!empty($account['user'])) { + if(!empty($credential['user_id'])) { // return new token - return $this->auth->update_token($account['user']['id']); + return $this->auth->update_token($credential['user_id']); } else { - throw new UserException('Account not found', 404); + throw new UserException('Credential not found', 404); } } } diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 05b73bd..101c594 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -58,7 +58,7 @@ public function put($user_id, $data) { return true; } - public function delete($uid) { - $this->auth->delete($uid); + public function delete($user_id) { + $this->auth->delete($user_id); } } diff --git a/src/Auth/MysqlAuth.php b/src/Auth/MysqlAuth.php index 9dade30..3759c33 100644 --- a/src/Auth/MysqlAuth.php +++ b/src/Auth/MysqlAuth.php @@ -29,8 +29,8 @@ public function __construct(\PDO $pdo, $params = []) parent::__construct(array_merge($defaults, $params)); $this->params['table'] = $this->params['prefix'] . $this->params['table']; - $this->params['credentials_table'] = $this->params['prefix'] . $this->params['credentials_table']; $this->params['with'][$this->params['credentials_table']] = 'user_id'; + $this->params['credentials_table'] = $this->params['prefix'] . $this->params['credentials_table']; } /** @@ -113,9 +113,6 @@ public function query($params = array(), $operator = "OR") */ public function get($user_id, $key = 'id') { - - $key = $key == 'credential' ? - $query = sprintf(/** @lang text */ "SELECT * FROM `%s` where `%s` = %s", $this->params['table'], @@ -135,7 +132,7 @@ public function get($user_id, $key = 'id') foreach ($this->params['with'] as $table => $fk) { $query = sprintf(/** @lang text */ "SELECT * FROM `%s` where `%s` = %s", - $table, + $this->params['prefix'] . $table, $fk, $user[$this->params['id']] ); @@ -184,12 +181,10 @@ public function register($uid, $password = null, $data = array()) $provider = empty($data['provider']) ? 'local' : $data['provider']; unset($data['provider']); - $user = $this->get_credential($provider, $uid); + $credential = $this->get_credential($provider, $uid); - if (!empty($user)) { - $ex = new UserException('User already registered', 409); - $ex->setUser($user['user']); - throw $ex; + if (!empty($credential)) { + throw new UserException('User already registered', 409); } $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; @@ -462,12 +457,6 @@ public function get_credential($provider, $accountid) $account['profile'] = json_decode($account['profile'], true); } - if (!empty($account['user_id'])) { - $account['user'] = $this->get($account['user_id']); - } else { - $account['user'] = null; - } - return $account; } else { return false; diff --git a/test/MysqlAuthTest.php b/test/MysqlAuthTest.php index 21672d6..8d375d4 100644 --- a/test/MysqlAuthTest.php +++ b/test/MysqlAuthTest.php @@ -6,52 +6,17 @@ * Time: 15:43 */ -require dirname(__DIR__) . '/vendor/autoload.php'; +#require dirname(__DIR__) . '/vendor/autoload.php'; + +require __DIR__ . '/Mysql_TestCase.php'; use Objectiveweb\Auth\MysqlAuth; -class MysqlAuthTest extends PHPUnit_Framework_TestCase +class MysqlAuthTest extends Mysql_TestCase { - /** @var Auth */ - protected static $auth; - private static $shared_session = array(); - public static function setUpBeforeClass() - { - $pdo = new PDO('mysql:dbname=objectiveweb;host=mysql', 'root', 'root'); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $pdo->exec('drop table if exists ow_credentials'); - $pdo->exec('drop table if exists ow_user'); - $pdo->query('create table ow_user - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255), - `image` VARCHAR(255), - `scopes` VARCHAR(255), - `created` DATETIME, - `password` CHAR(60), - `token` CHAR(32))'); - - $pdo->query("create table ow_credentials - ( - uid varchar(255) not null, - provider varchar(32) not null, - user_id int(11) unsigned not null, - profile text null, - modified datetime null, - primary key (uid, provider), - constraint credentials_ibfk_1 - foreign key (user_id) references ow_user (id) - - )"); - - self::$auth = new MysqlAuth($pdo, array( - 'created' => 'created', - 'token' => 'token' - )); - - } public function setUp() { @@ -137,7 +102,7 @@ public function testUpdate() { $user = self::$auth->get($account['user_id']); - $this->assertEquals(1, $user['id']); + $this->assertEquals('Test User', $user['name']); self::$auth->update($account['user_id'], array( 'name' => 'Updated name' )); diff --git a/test/Mysql_TestCase.php b/test/Mysql_TestCase.php new file mode 100644 index 0000000..eb68237 --- /dev/null +++ b/test/Mysql_TestCase.php @@ -0,0 +1,49 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('drop table if exists ow_credentials'); + $pdo->exec('drop table if exists ow_user'); + $pdo->query('create table ow_user + (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255), + `image` VARCHAR(255), + `scopes` VARCHAR(255), + `created` DATETIME, + `password` CHAR(60), + `token` CHAR(32))'); + + $pdo->query("create table ow_credentials + ( + uid varchar(255) not null, + provider varchar(32) not null, + user_id int(11) unsigned not null, + profile text null, + modified datetime null, + primary key (uid, provider), + constraint credentials_ibfk_1 + foreign key (user_id) references ow_user (id) + + )"); + + try { + + self::$auth = new \Objectiveweb\Auth\MysqlAuth($pdo, array( + 'created' => 'created', + 'token' => 'token' + )); + } catch(\Throwable $ex) { + echo $ex->getMessage(); + exit($ex->getTraceAsString()); + } + } +} \ No newline at end of file diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index dd2bafb..bc06d79 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -6,12 +6,14 @@ * Time: 15:43 */ -require dirname(__DIR__) . '/vendor/autoload.php'; +#require dirname(__DIR__) . '/vendor/autoload.php'; + +require __DIR__ . '/Mysql_TestCase.php'; use Objectiveweb\Auth\MysqlAuth; use Objectiveweb\Auth\Controller\UserController; -class UserControllerTest extends PHPUnit_Framework_TestCase +class UserControllerTest extends Mysql_TestCase { /** @var UserController */ @@ -21,38 +23,8 @@ class UserControllerTest extends PHPUnit_Framework_TestCase public static function setUpBeforeClass() { - - $pdo = new PDO('mysql:dbname=objectiveweb;host=mysql', 'root', 'root'); - $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $pdo->exec('drop table if exists ow_credentials'); - $pdo->exec('drop table if exists ow_user'); - $pdo->query('create table ow_user - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255), - `image` VARCHAR(255), - `scopes` VARCHAR(255), - `created` DATETIME, - `password` CHAR(60), - `token` CHAR(32))'); - - $pdo->query("create table ow_credentials - ( - uid varchar(255) not null, - provider varchar(32) not null, - user_id int(11) unsigned not null, - profile text null, - modified datetime null, - primary key (uid, provider), - constraint credentials_ibfk_1 - foreign key (user_id) references ow_user (id) - )"); - - $auth = new MysqlAuth($pdo, array( - 'created' => 'created', - 'token' => 'token' - )); - - self::$controller = new UserController($auth); + parent::setUpBeforeClass(); + self::$controller = new UserController(self::$auth); } public function testPost() From 2f2a75d69138cc7bc3b8be5ef9571ada3c1cf03c Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 17 Feb 2019 18:56:05 -0300 Subject: [PATCH 007/217] Include mysql_testcase only once --- test/Mysql_TestCase.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/Mysql_TestCase.php b/test/Mysql_TestCase.php index eb68237..0ae4fc1 100644 --- a/test/Mysql_TestCase.php +++ b/test/Mysql_TestCase.php @@ -1,5 +1,7 @@ Date: Sun, 17 Feb 2019 19:01:33 -0300 Subject: [PATCH 008/217] Use require_once for Mysql_TestCase --- test/MysqlAuthTest.php | 2 +- test/Mysql_TestCase.php | 6 ++---- test/UserControllerTest.php | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/test/MysqlAuthTest.php b/test/MysqlAuthTest.php index 8d375d4..f9b8232 100644 --- a/test/MysqlAuthTest.php +++ b/test/MysqlAuthTest.php @@ -8,7 +8,7 @@ #require dirname(__DIR__) . '/vendor/autoload.php'; -require __DIR__ . '/Mysql_TestCase.php'; +require_once __DIR__ . '/Mysql_TestCase.php'; use Objectiveweb\Auth\MysqlAuth; diff --git a/test/Mysql_TestCase.php b/test/Mysql_TestCase.php index 0ae4fc1..9e39da9 100644 --- a/test/Mysql_TestCase.php +++ b/test/Mysql_TestCase.php @@ -1,7 +1,5 @@ setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec('drop table if exists ow_credentials'); $pdo->exec('drop table if exists ow_user'); @@ -38,7 +37,6 @@ public static function setUpBeforeClass() )"); try { - self::$auth = new \Objectiveweb\Auth\MysqlAuth($pdo, array( 'created' => 'created', 'token' => 'token' @@ -48,4 +46,4 @@ public static function setUpBeforeClass() exit($ex->getTraceAsString()); } } -} \ No newline at end of file +} diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index bc06d79..fdb9583 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -8,7 +8,7 @@ #require dirname(__DIR__) . '/vendor/autoload.php'; -require __DIR__ . '/Mysql_TestCase.php'; +require_once __DIR__ . '/Mysql_TestCase.php'; use Objectiveweb\Auth\MysqlAuth; use Objectiveweb\Auth\Controller\UserController; From bc5d00e7fd37f7302462cdfde4231618620c80fc Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 7 Apr 2019 13:28:34 -0300 Subject: [PATCH 009/217] updates --- src/Auth.php | 1 - src/Auth/AclTrait.php | 5 +++ src/Auth/Controller/OAuthController.php | 56 +++++++++++++++---------- src/Auth/MysqlAuth.php | 24 +++++++---- 4 files changed, 55 insertions(+), 31 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 77b5660..000ccbf 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -204,7 +204,6 @@ abstract public function update_token($user_id); * @param $provider String provider * @param $userid String user_id * @return mixed account data or null if account not found - * MUST include the 'user' key with the associated user (can be NULL) * @throws \Exception */ abstract public function get_credential($provider, $accountid); diff --git a/src/Auth/AclTrait.php b/src/Auth/AclTrait.php index f3e040c..b7e3431 100644 --- a/src/Auth/AclTrait.php +++ b/src/Auth/AclTrait.php @@ -85,4 +85,9 @@ function before($method, $fn) { } } + + function user_can($scope) + { + return in_array($scope, $this->user['scopes']); + } } \ No newline at end of file diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index a83849f..8035598 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -59,7 +59,7 @@ function get($id, $query) $this->login($id, $resourceOwner); - header("Location: /" ); + header("Location: /"); } } @@ -70,40 +70,50 @@ function get($id, $query) * @param \League\OAuth2\Client\Provider\ResourceOwnerInterface $resourceOwner * @return mixed */ - private function login($provider, $resourceOwner) { + private function login($provider, $resourceOwner) + { + + $uid = $resourceOwner->getId(); + $email = $resourceOwner->getEmail(); - // Interface fields - $data = [ - 'uid' => $resourceOwner->getId(), - 'provider' => $provider, - 'profile' => $resourceOwner->toArray(), - 'name' => $resourceOwner->getName(), - 'image' => is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl() - ]; + // check if provider user exists + $credential = $this->auth->get_credential($provider, $uid); + + if (!$credential && !empty($email)) { + $credential = $this->auth->get_credential('local', $email); + } - $data['profile']['email'] = $resourceOwner->getEmail(); + if (empty($credential['user_id'])) { + // Interface fields + $data = [ + 'uid' => $uid, + 'provider' => $provider, + 'profile' => $resourceOwner->toArray(), + 'name' => $resourceOwner->getName(), + 'image' => is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl() + ]; - try { $user = $this->auth->register($data); - } catch(UserException $ex) { - if ($ex->getCode() == 409) { - $user = $ex->getUser(); - // Add account to the user + if (!empty($email)) { + // Add local credential to the user $this->auth->update_credential($user[$this->auth->params['id']], - $provider, - $uid, - $data['profile']); - } else { - throw $ex; + 'local', + $email, + []); } + } else { + $user = $this->auth->get($credential['user_id']); + // Add account to the user + $this->auth->update_credential($credential['user_id'], + $provider, + $uid, + $resourceOwner->toArray()); } // Set session $this->auth->user($user); return $user; - } - } diff --git a/src/Auth/MysqlAuth.php b/src/Auth/MysqlAuth.php index 3759c33..831f08a 100644 --- a/src/Auth/MysqlAuth.php +++ b/src/Auth/MysqlAuth.php @@ -22,7 +22,7 @@ public function __construct(\PDO $pdo, $params = []) 'prefix' => 'ow_', 'created' => NULL, 'last_login' => NULL, - 'credentials_table' => 'credentials', + 'credentials_table' => 'user_credentials', 'with' => [] ]; @@ -148,6 +148,9 @@ public function get($user_id, $key = 'id') $user[$table] = $stmt->fetchAll(PDO::FETCH_ASSOC); } + if(!empty($user['scopes'])) { + $user['scopes'] = explode(',', $user['scopes']); + } return $user; } else { throw new UserException('User not found', 404); @@ -184,7 +187,9 @@ public function register($uid, $password = null, $data = array()) $credential = $this->get_credential($provider, $uid); if (!empty($credential)) { - throw new UserException('User already registered', 409); + $ex = new UserException('User already registered', 409); + $ex->setUser($this->get($credential['user_id'])); + throw $ex; } $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; @@ -207,6 +212,13 @@ public function register($uid, $password = null, $data = array()) $fields[$this->params['created']] = date('Y-m-d H:i:s'); } +// if (!empty($fields[$this->params['scopes']])) { +// if(is_array($fields[$this->params['scopes']])) { +// $fields[$this->params['scopes']] = implode(",", $data[$this->params['scopes']]); +// } +// } else { + //$fields[$this->params['scopes']] = ""; +// } $this->pdo->beginTransaction(); $stmt = $this->pdo->prepare("INSERT INTO " . $this->params['table'] @@ -221,9 +233,7 @@ public function register($uid, $password = null, $data = array()) $fields[$this->params['id']] = $this->pdo->lastInsertId(); unset($fields[$this->params['created']]); unset($fields[$this->params['password']]); - if (!empty($fields[$this->params['scopes']])) { - $fields['params']['scopes'] = explode(",", $fields[$this->params['scopes']]); - } + } else { $this->pdo->rollBack(); $errorInfo = $this->pdo->errorInfo(); @@ -481,8 +491,8 @@ public function update_credential($userid, $provider, $uid, $profile = null) } $query = sprintf(/** @lang text */ - "INSERT INTO `%s` (user_id, provider, uid, profile) VALUES (%s, %s, %s, %s) - ON DUPLICATE KEY UPDATE profile = VALUES(profile), modified = %s", + "INSERT INTO `%s` (user_id, provider, uid, profile, last_login) VALUES (%s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE profile = VALUES(profile), last_login = VALUES(last_login)", $this->params['credentials_table'], $this->pdo->quote($userid), $this->pdo->quote($provider), From 6efcc47f0ec8aededc5af4c47e42c806246a0558 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 19 May 2019 12:56:37 -0300 Subject: [PATCH 010/217] OAuthController: Support https behind proxy --- src/Auth/Controller/OAuthController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 8035598..12a57fb 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -33,7 +33,10 @@ function get($id, $query) } $config = $this->providers[$id]; - $config['redirectUri'] = "{$_SERVER['REQUEST_SCHEME']}://{$_SERVER['HTTP_HOST']}{$_SERVER['PHP_SELF']}"; + + $config['redirectUri'] = + (empty($_SERVER['X_FORWARDED_PROTO']) ? $_SERVER['REQUEST_SCHEME'] : $_SERVER['X_FORWARDED_PROTO']) + . "://{$_SERVER['HTTP_HOST']}{$_SERVER['PHP_SELF']}"; $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); From 5b652c26737328aca708ba693eb05e11c0128ac3 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 19 May 2019 13:16:43 -0300 Subject: [PATCH 011/217] fixup OAuthController header name --- src/Auth/Controller/OAuthController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 12a57fb..e268bee 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -35,7 +35,7 @@ function get($id, $query) $config = $this->providers[$id]; $config['redirectUri'] = - (empty($_SERVER['X_FORWARDED_PROTO']) ? $_SERVER['REQUEST_SCHEME'] : $_SERVER['X_FORWARDED_PROTO']) + (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) ? $_SERVER['REQUEST_SCHEME'] : $_SERVER['HTTP_X_FORWARDED_PROTO']) . "://{$_SERVER['HTTP_HOST']}{$_SERVER['PHP_SELF']}"; $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); From 72c1a5c225f442e57208e9ae9a7bfeb9195936dc Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Fri, 18 Apr 2025 21:26:13 -0300 Subject: [PATCH 012/217] fix: return proper Forbidden code --- src/Auth/AclTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/AclTrait.php b/src/Auth/AclTrait.php index b7e3431..599799c 100644 --- a/src/Auth/AclTrait.php +++ b/src/Auth/AclTrait.php @@ -81,7 +81,7 @@ function before($method, $fn) { } if(count(array_intersect($perms, $scopes)) == 0) { - throw new AuthException("Forbidden", 403); + throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); } } From 46f5ffc1f04d479b18f2dfd24fd537b32169e985 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 19 Apr 2025 20:46:55 -0300 Subject: [PATCH 013/217] fix: allow unauthenticated users to see /index --- src/Auth/Controller/AuthController.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index e436bd4..52ec1ca 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -25,7 +25,7 @@ function __construct(\Objectiveweb\Auth $auth) '*' => Auth::ANONYMOUS, 'get' => Auth::ALL, 'callback' => Auth::ALL, - 'index' => Auth::AUTHENTICATED, + 'index' => Auth::ALL, 'getLogout' => Auth::AUTHENTICATED ]); @@ -39,6 +39,8 @@ function index() function getLogout() { $this->auth->logout(); + + return []; } /** @@ -46,7 +48,6 @@ function getLogout() */ function post(array $user) { - $uid = $user['uid']; unset($user['uid']); $password = $user[$this->auth->params['password']]; @@ -100,7 +101,7 @@ function postPassword(array $form) // find user $credential = $this->auth->get_credential('local', $form['uid']); - if(!empty($credential['user_id'])) { + if (!empty($credential['user_id'])) { // return new token return $this->auth->update_token($credential['user_id']); } else { From 24a7cc759a876f5937f1e22cba58c330f44ed1ac Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 17 May 2025 20:32:38 -0300 Subject: [PATCH 014/217] fixes --- src/Auth.php | 10 ++-- src/Auth/Controller/AuthController.php | 73 +++++++++++++++++--------- src/Auth/Controller/UserController.php | 9 ++-- src/Auth/MysqlAuth.php | 67 ++++++++++++++++++----- 4 files changed, 117 insertions(+), 42 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 000ccbf..abaafb2 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -10,16 +10,20 @@ abstract class Auth const AUTHENTICATED = ['auth']; const ALL = ['anon', 'auth']; - public $params; + public array $params; - function __construct($params) + public static array $user; + + function __construct(array $params) { $defaults = [ 'session_key' => 'ow_auth', 'id' => 'id', 'password' => 'password', 'scopes' => 'scopes', - 'token' => NULL + 'token' => NULL, // Name of the field that should store user tokens + 'register_scope' => Auth::ANONYMOUS, // who is allowed to use /register + 'register_callback' => null ]; $this->params = array_merge($defaults, $params); diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 52ec1ca..553b44d 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -26,6 +26,9 @@ function __construct(\Objectiveweb\Auth $auth) 'get' => Auth::ALL, 'callback' => Auth::ALL, 'index' => Auth::ALL, + 'postPassword' => Auth::ALL, + 'postToken' => Auth::ALL, + 'postRegister' => $auth->params['register_scope'], 'getLogout' => Auth::AUTHENTICATED ]); @@ -36,10 +39,15 @@ function index() return $this->user; } - function getLogout() + function getLogout($params = []) { $this->auth->logout(); + if (!empty($params['redirect'])) { + header("Location: {$params['redirect']}"); + exit; + } + return []; } @@ -67,50 +75,67 @@ function postRegister(array $user) $password = @$user[$this->auth->params['password']]; unset($user[$this->auth->params['password']]); + if (!filter_var($uid, FILTER_VALIDATE_EMAIL)) { + throw new AuthException('Email inválido'); + } + $user = $this->auth->register($uid, $password, $user); + $user['uid'] = $uid; + + if (is_callable($this->auth->params['register_callback'])) { + call_user_func($this->auth->params['register_callback'], $user); + } + return $user; } + function postToken(array $form) + { + if (empty($form['token'])) { + throw new UserException('Invalid request', 400); + } + + if (empty($form['password']) || $form['password'] != @$form['confirm']) { + throw new UserException('Passwords don\'t match', 400); + } + + return $this->auth->passwd_reset($form['token'], $form['password']); + } + function postPassword(array $form) { + // if user is logged in, update password if ($this->auth->check()) { // TODO validar senha anterior if (empty($form['password']) || $form['password'] != @$form['confirm']) { - throw new AuthException('Passwords dont match', 400); + throw new UserException('Passwords dont match', 400); } $user = $this->auth->user(); return $this->auth->passwd($user[$this->auth->params['id']], $form['password']); - } else { - if (!empty($form['token'])) { - if (empty($form['password']) || $form['password'] != @$form['confirm']) { - throw new AuthException('Passwords dont match', 400); - } + } // Forgot password + else { + if (empty($form['uid'])) { + throw new UserException('Invalid request', 400); + } - $user = $this->auth->passwd_reset($form['token'], $form['password']); + // find user + $credential = $this->auth->get_credential('local', $form['uid']); - // set current session and return user - return $this->auth->user($user); - } else { - if (empty($form['uid'])) { - throw new UserException('Invalid request', 400); - } + if (!empty($credential['user_id'])) { + // return new token + $credential['token'] = $this->auth->update_token($credential['user_id']); - // find user - $credential = $this->auth->get_credential('local', $form['uid']); - - if (!empty($credential['user_id'])) { - // return new token - return $this->auth->update_token($credential['user_id']); + if (is_callable($this->auth->params['token_callback'])) { + return call_user_func($this->auth->params['token_callback'], $credential); } else { - throw new UserException('Credential not found', 404); + return $credential; } + } else { + throw new UserException('Credential not found', 404); } } - } - - } diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 101c594..8400f1d 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -2,6 +2,8 @@ namespace Objectiveweb\Auth\Controller; +use Objectiveweb\Auth; +use Objectiveweb\Auth\AclTrait; use Objectiveweb\Auth\UserException; /** @@ -13,10 +15,12 @@ class UserController { /** @var \Objectiveweb\Auth */ - private $auth; + use AclTrait; public function __construct(\Objectiveweb\Auth $auth) { - $this->auth = $auth; + $this->aclSetup($auth, [ + '*' => ['ADMIN'] + ]); } public function index() { @@ -38,7 +42,6 @@ public function post($data) { } - public function put($user_id, $data) { $user = $this->auth->get($user_id); diff --git a/src/Auth/MysqlAuth.php b/src/Auth/MysqlAuth.php index 831f08a..434d84b 100644 --- a/src/Auth/MysqlAuth.php +++ b/src/Auth/MysqlAuth.php @@ -8,7 +8,7 @@ class MysqlAuth extends \Objectiveweb\Auth { - public $params; + public array $params; /** @var \PDO */ private $pdo; @@ -148,7 +148,7 @@ public function get($user_id, $key = 'id') $user[$table] = $stmt->fetchAll(PDO::FETCH_ASSOC); } - if(!empty($user['scopes'])) { + if (!empty($user['scopes'])) { $user['scopes'] = explode(',', $user['scopes']); } return $user; @@ -187,14 +187,29 @@ public function register($uid, $password = null, $data = array()) $credential = $this->get_credential($provider, $uid); if (!empty($credential)) { - $ex = new UserException('User already registered', 409); + $ex = new UserException('Email já registrado', 409); $ex->setUser($this->get($credential['user_id'])); throw $ex; } + if (empty($data['name'])) { + // set $data['name'] to first part of email stored in $uid + list($data['name'],) = explode('@', $uid); + } + $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; unset($data['profile']); + + $with = []; + foreach ($this->params['with'] as $k => $v) { + if (!empty($data[$k])) { + $with[$k] = &$data[$k]; + + unset($data[$k]); + } + } + // escape and encode fields foreach ($data as $k => $v) { $fields[str_replace(array('\\', "\0", '`'), '', $k)] = is_array($v) ? json_encode($v) : $v; @@ -212,18 +227,20 @@ public function register($uid, $password = null, $data = array()) $fields[$this->params['created']] = date('Y-m-d H:i:s'); } -// if (!empty($fields[$this->params['scopes']])) { -// if(is_array($fields[$this->params['scopes']])) { -// $fields[$this->params['scopes']] = implode(",", $data[$this->params['scopes']]); -// } -// } else { - //$fields[$this->params['scopes']] = ""; -// } + if (!empty($fields[$this->params['scopes']])) { + if (is_array($fields[$this->params['scopes']])) { + $fields[$this->params['scopes']] = implode(",", $data[$this->params['scopes']]); + } + } else { + $fields[$this->params['scopes']] = ""; + } + + $this->pdo->beginTransaction(); $stmt = $this->pdo->prepare("INSERT INTO " . $this->params['table'] - . " (" . implode(array_keys($fields), ", ") - . ") VALUES (:" . implode(array_keys($fields), ", :") . ");"); + . " (" . implode(", ", array_keys($fields)) + . ") VALUES (:" . implode(", :", array_keys($fields)) . ");"); foreach ($fields as $k => $v) { $stmt->bindValue(":" . $k, $v); @@ -245,6 +262,31 @@ public function register($uid, $password = null, $data = array()) } } + foreach ($with as $with_table => $with_content) { + + $with_fields = array_keys($with_content); + + $stmt = $this->pdo->prepare(sprintf("INSERT INTO %s (%s) VALUES (%s)", + $this->params['prefix'] . $with_table, + $this->params['with'][$with_table] . ',' . implode(',', $with_fields), + ':' . $this->params['with'][$with_table] . ',:' . implode(',:', $with_fields) + )); + + // fk + $stmt->bindValue(':' . $this->params['with'][$with_table], $fields[$this->params['id']]); + + // additional fields + foreach ($with_content as $with_k => $with_v) { + $stmt->bindValue(':' . $with_k, $with_v); + } + + if (!$stmt->execute()) { + $this->pdo->rollBack(); + $errorInfo = $this->pdo->errorInfo(); + throw new \Exception($errorInfo[2]); + } + } + $stmt = $this->pdo->prepare("INSERT INTO `{$this->params['credentials_table']}` (user_id, provider, uid, profile) VALUES (:user_id, :provider, :uid, :profile)"); $stmt->bindValue(':user_id', $fields[$this->params['id']]); @@ -260,6 +302,7 @@ public function register($uid, $password = null, $data = array()) $errorInfo = $this->pdo->errorInfo(); throw new \Exception($errorInfo[2]); } + } /** From 4c218e89213a013f44242787a1edb8f8a5ab9f57 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 28 Sep 2025 22:36:47 -0300 Subject: [PATCH 015/217] feat: Add RequireRole Middleware --- src/Auth/Attributes/RequireRole.php | 42 +++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/Auth/Attributes/RequireRole.php diff --git a/src/Auth/Attributes/RequireRole.php b/src/Auth/Attributes/RequireRole.php new file mode 100644 index 0000000..120713a --- /dev/null +++ b/src/Auth/Attributes/RequireRole.php @@ -0,0 +1,42 @@ +role = is_array($role) ? $role : [$role]; + } + + public function before($method, $fn, ...$args): mixed + { + if ($this->auth->check()) { + $scopes = \Objectiveweb\Auth::AUTHENTICATED; + + $this->user = $this->auth->user(); + + if (is_array($this->user['scopes'])) { + $scopes = array_merge($scopes, $this->user['scopes']); + } + } else { + $scopes = \Objectiveweb\Auth::ANONYMOUS; + } + + if (count(array_intersect($this->role, $scopes)) == 0) { + throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); + } + + return null; + } +} From 0afed19256a079b7a614fd0363ca7cba9bd5a345 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 12 Oct 2025 10:37:13 -0300 Subject: [PATCH 016/217] feat: replace AclTrait with RequireRole Attribute --- src/Auth.php | 6 ++ src/Auth/AclTrait.php | 93 ------------------------- src/Auth/Controller/AuthController.php | 24 +++---- src/Auth/Controller/OAuthController.php | 66 ++++++++++++------ src/Auth/Controller/UserController.php | 75 ++++++++++---------- 5 files changed, 95 insertions(+), 169 deletions(-) delete mode 100644 src/Auth/AclTrait.php diff --git a/src/Auth.php b/src/Auth.php index abaafb2..1762346 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -47,6 +47,12 @@ public function check() return !empty($_SESSION[$this->params['session_key']]); } + public function user_can($scope) { + $user = $this->user(); + + return in_array($scope, $user['scopes']); + } + /** * @param $uid * @param $password diff --git a/src/Auth/AclTrait.php b/src/Auth/AclTrait.php deleted file mode 100644 index 599799c..0000000 --- a/src/Auth/AclTrait.php +++ /dev/null @@ -1,93 +0,0 @@ - Auth::AUTHENTICATED, - * 'register' => Auth::ANONYMOUS, - * 'post' => [ 'admin' ] - * ] - * ``` - * - * So only users with the `admin` scope will have access to the `post` method - * - * Acls can also be defined as a callback that should return an array of scopes - * - * - * ``` - * $acl = [ - * 'post' => function() { - * return [ 'scope1', 'scope2' ] - * } - * ] - * ``` - * - * @var array - */ - private $acl = [ - '*' => Auth::AUTHENTICATED - ]; - - function aclSetup(Auth $auth, array $acl = []) { - $this->auth = $auth; - - $this->acl = array_merge($this->acl, $acl); - } - - function before($method, $fn) { - - if($this->auth->check()) { - $scopes = [ 'auth' ]; - - $this->user = $this->auth->user(); - - if(is_array($this->user['scopes'])) { - $scopes = array_merge($scopes, $this->user['scopes']); - } - } - else { - $scopes = [ 'anon' ]; - } - - $perms = isset($this->acl[$fn]) ? $this->acl[$fn] : $this->acl['*']; - - if(is_callable($perms)) { - $perms = call_user_func($perms); - } - - if(count(array_intersect($perms, $scopes)) == 0) { - throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); - } - - } - - function user_can($scope) - { - return in_array($scope, $this->user['scopes']); - } -} \ No newline at end of file diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 553b44d..9f3d219 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -4,9 +4,9 @@ use Objectiveweb\Auth; -use Objectiveweb\Auth\AclTrait; use Objectiveweb\Auth\AuthException; use Objectiveweb\Auth\UserException; +use Objectiveweb\Auth\Attributes\RequireRole; /** * Class AuthController @@ -15,30 +15,20 @@ * * @package Objectiveweb\Auth */ +#[RequireRole(Auth::ANONYMOUS)] class AuthController { - use AclTrait; - - function __construct(\Objectiveweb\Auth $auth) + function __construct(public \Objectiveweb\Auth $auth) { - $this->aclSetup($auth, [ - '*' => Auth::ANONYMOUS, - 'get' => Auth::ALL, - 'callback' => Auth::ALL, - 'index' => Auth::ALL, - 'postPassword' => Auth::ALL, - 'postToken' => Auth::ALL, - 'postRegister' => $auth->params['register_scope'], - 'getLogout' => Auth::AUTHENTICATED - ]); - } + #[RequireRole(Auth::ALL)] function index() { - return $this->user; + return $this->auth->user(); } + #[RequireRole(Auth::AUTHENTICATED)] function getLogout($params = []) { $this->auth->logout(); @@ -90,6 +80,7 @@ function postRegister(array $user) return $user; } + #[RequireRole(Auth::ALL)] function postToken(array $form) { if (empty($form['token'])) { @@ -103,6 +94,7 @@ function postToken(array $form) return $this->auth->passwd_reset($form['token'], $form['password']); } + #[RequireRole(Auth::ALL)] function postPassword(array $form) { // if user is logged in, update password diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index e268bee..dbe69d7 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -2,8 +2,11 @@ namespace Objectiveweb\Auth\Controller; +use Objectiveweb\Auth; use Objectiveweb\Auth\UserException; +use Objectiveweb\Auth\Attributes\RequireRole; + /** * Class OAuthController * @@ -18,7 +21,6 @@ class OAuthController extends AuthController function __construct(\Objectiveweb\Auth $auth, array $providers = []) { parent::__construct($auth); - $this->providers = $providers; } @@ -26,6 +28,7 @@ function __construct(\Objectiveweb\Auth $auth, array $providers = []) /** * Execute login on oauth provider */ + #[RequireRole(Auth::ALL)] function get($id, $query) { if (!isset($this->providers[$id])) { @@ -34,9 +37,11 @@ function get($id, $query) $config = $this->providers[$id]; - $config['redirectUri'] = - (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) ? $_SERVER['REQUEST_SCHEME'] : $_SERVER['HTTP_X_FORWARDED_PROTO']) - . "://{$_SERVER['HTTP_HOST']}{$_SERVER['PHP_SELF']}"; + $config['redirectUri'] = sprintf("%s://%s%s%s", + (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) ? $_SERVER['REQUEST_SCHEME'] : $_SERVER['HTTP_X_FORWARDED_PROTO']), + $_SERVER['HTTP_HOST'], + str_replace("/index.php", "", $_SERVER['PHP_SELF']), + $_SERVER['PATH_INFO']); $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); @@ -49,6 +54,7 @@ function get($id, $query) // generate authUrl first to update state $authUrl = $provider->getAuthorizationUrl(); $_SESSION['oauth2state'] = $provider->getState(); + error_log('oauth2state ' . $_SESSION['oauth2state']); header('Location: ' . $authUrl); exit; } elseif (empty($query['state']) || $query['state'] !== $_SESSION['oauth2state']) { @@ -63,7 +69,7 @@ function get($id, $query) $this->login($id, $resourceOwner); header("Location: /"); - + exit(); } } @@ -82,30 +88,46 @@ private function login($provider, $resourceOwner) // check if provider user exists $credential = $this->auth->get_credential($provider, $uid); + // if provider credential does not exist, check if there's a local user with the email if (!$credential && !empty($email)) { $credential = $this->auth->get_credential('local', $email); } + // This credential/email is not registered yet if (empty($credential['user_id'])) { - // Interface fields - $data = [ - 'uid' => $uid, - 'provider' => $provider, - 'profile' => $resourceOwner->toArray(), - 'name' => $resourceOwner->getName(), - 'image' => is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl() - ]; - - $user = $this->auth->register($data); - - if (!empty($email)) { - // Add local credential to the user - $this->auth->update_credential($user[$this->auth->params['id']], - 'local', - $email, - []); + // Check if the user is already logged in + if ($this->auth->check()) { + $user = $this->auth->user(); + + // Add the oauth cred to the existing user + $this->auth->update_credential($user['id'], + $provider, + $uid, + $resourceOwner->toArray()); + + } else { + // This is a new user, need to register + $data = [ + 'uid' => $uid, + 'provider' => $provider, + 'profile' => $resourceOwner->toArray(), + 'name' => $resourceOwner->getName(), + 'image' => is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl() + ]; + + $user = $this->auth->register($data); + + // if email is defined, also create a local email credential for it + if (!empty($email)) { + // Add local credential to the user + $this->auth->update_credential($user[$this->auth->params['id']], + 'local', + $email, + []); + } } } else { + // there is already a user for the oauth provider / local email $user = $this->auth->get($credential['user_id']); // Add account to the user $this->auth->update_credential($credential['user_id'], diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 8400f1d..df5b1d3 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -3,7 +3,7 @@ namespace Objectiveweb\Auth\Controller; use Objectiveweb\Auth; -use Objectiveweb\Auth\AclTrait; +use Objectiveweb\Auth\Attributes\RequireRole; use Objectiveweb\Auth\UserException; /** @@ -12,56 +12,55 @@ * * @package Objectiveweb\Auth */ -class UserController { - - /** @var \Objectiveweb\Auth */ - use AclTrait; - - public function __construct(\Objectiveweb\Auth $auth) { - $this->aclSetup($auth, [ - '*' => ['ADMIN'] - ]); - } - - public function index() { - return $this->get(); - } - - public function get($params = array()) { - if(is_array($params)) { +#[RequireRole('ADMIN')] +class UserController +{ + + public Auth $auth; + + public function index() + { + return $this->get(); + } + + public function get($params = array()) + { + if (is_array($params)) { return $this->auth->query($params); - } - else { + } else { return $this->auth->get($params); } - } - - public function post($data) { - - return $this->auth->register($data); - - } + } - public function put($user_id, $data) { + public function post($data) + { - $user = $this->auth->get($user_id); + return $this->auth->register($data); - if(!$user) { - throw new UserException('User does not exist', 404); + } + + public function put($user_id, $data) + { + + $user = $this->auth->get($user_id); + + if (!$user) { + throw new UserException('User does not exist', 404); } - if(!empty($data['password'])) { + if (!empty($data['password'])) { $this->auth->passwd($user[$this->auth->params['id']], $data['password']); } unset($data['password']); - $this->auth->update($user[$this->auth->params['id']], $data); + $this->auth->update($user[$this->auth->params['id']], $data); return true; - } - - public function delete($user_id) { - $this->auth->delete($user_id); - } + } + + public function delete($user_id) + { + $this->auth->delete($user_id); + } } From b735d5db11900ade51ed4a4f85978ecc312f9e2a Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 12 Oct 2025 10:47:29 -0300 Subject: [PATCH 017/217] fix: use array --- src/Auth/Controller/UserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index df5b1d3..153abfb 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -12,7 +12,7 @@ * * @package Objectiveweb\Auth */ -#[RequireRole('ADMIN')] +#[RequireRole(['ADMIN'])] class UserController { From 294517979e57374e8e79d297b00c83d245e10491 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 21 Dec 2025 17:46:38 -0300 Subject: [PATCH 018/217] Use DI to instantiate RequireRole --- src/Auth.php | 2 -- src/Auth/Attributes/RequireRole.php | 19 +++++++++++++------ src/Auth/Controller/AuthController.php | 7 ++++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 1762346..a4fecc2 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -12,8 +12,6 @@ abstract class Auth public array $params; - public static array $user; - function __construct(array $params) { $defaults = [ diff --git a/src/Auth/Attributes/RequireRole.php b/src/Auth/Attributes/RequireRole.php index 120713a..eb1304f 100644 --- a/src/Auth/Attributes/RequireRole.php +++ b/src/Auth/Attributes/RequireRole.php @@ -2,6 +2,7 @@ namespace Objectiveweb\Auth\Attributes; +use Objectiveweb\Auth; use Objectiveweb\Auth\AuthException; use Objectiveweb\Router\Middleware; @@ -10,16 +11,22 @@ #[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] class RequireRole extends Middleware { - public \Objectiveweb\Auth $auth; - private $role; - - public function __construct(string|array $role) + public function __construct(private Auth $auth, private string|array $role) { $this->role = is_array($role) ? $role : [$role]; } - public function before($method, $fn, ...$args): mixed + public function after($method, $fn, $params, $response): mixed + { + if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { + $response['_user'] = $this->auth->user(); + } + + return $response; + } + + public function before($method, $fn, $params): mixed { if ($this->auth->check()) { $scopes = \Objectiveweb\Auth::AUTHENTICATED; @@ -37,6 +44,6 @@ public function before($method, $fn, ...$args): mixed throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); } - return null; + return $params; } } diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 9f3d219..b6d0e72 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -25,7 +25,12 @@ function __construct(public \Objectiveweb\Auth $auth) #[RequireRole(Auth::ALL)] function index() { - return $this->auth->user(); + if($this->auth->check()){ + return $this->auth->user(); + } + else { + return null; + } } #[RequireRole(Auth::AUTHENTICATED)] From ad5c296cf9ec7d1bc9900db85f258e5b9fd89280 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 21 Dec 2025 23:54:01 -0300 Subject: [PATCH 019/217] Update Middleware syntax --- src/Auth/Controller/AuthController.php | 14 ++++++++------ src/Auth/Controller/OAuthController.php | 5 +++-- src/Auth/Controller/UserController.php | 5 +++-- .../{Attributes => Middleware}/RequireRole.php | 12 +++++------- 4 files changed, 19 insertions(+), 17 deletions(-) rename src/Auth/{Attributes => Middleware}/RequireRole.php (75%) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index b6d0e72..cbc7912 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -6,7 +6,9 @@ use Objectiveweb\Auth\AuthException; use Objectiveweb\Auth\UserException; -use Objectiveweb\Auth\Attributes\RequireRole; +use Objectiveweb\Auth\Middleware\RequireRole; + +use Objectiveweb\Router\Middleware; /** * Class AuthController @@ -15,14 +17,14 @@ * * @package Objectiveweb\Auth */ -#[RequireRole(Auth::ANONYMOUS)] +#[Middleware(RequireRole::class, Auth::ANONYMOUS)] class AuthController { function __construct(public \Objectiveweb\Auth $auth) { } - #[RequireRole(Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function index() { if($this->auth->check()){ @@ -33,7 +35,7 @@ function index() } } - #[RequireRole(Auth::AUTHENTICATED)] + #[Middleware(RequireRole::class, Auth::AUTHENTICATED)] function getLogout($params = []) { $this->auth->logout(); @@ -85,7 +87,7 @@ function postRegister(array $user) return $user; } - #[RequireRole(Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function postToken(array $form) { if (empty($form['token'])) { @@ -99,7 +101,7 @@ function postToken(array $form) return $this->auth->passwd_reset($form['token'], $form['password']); } - #[RequireRole(Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function postPassword(array $form) { // if user is logged in, update password diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index dbe69d7..0198650 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -5,7 +5,8 @@ use Objectiveweb\Auth; use Objectiveweb\Auth\UserException; -use Objectiveweb\Auth\Attributes\RequireRole; +use Objectiveweb\Auth\Middleware\RequireRole; +use Objectiveweb\Router\Middleware; /** * Class OAuthController @@ -28,7 +29,7 @@ function __construct(\Objectiveweb\Auth $auth, array $providers = []) /** * Execute login on oauth provider */ - #[RequireRole(Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function get($id, $query) { if (!isset($this->providers[$id])) { diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 153abfb..269c5df 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -3,8 +3,9 @@ namespace Objectiveweb\Auth\Controller; use Objectiveweb\Auth; -use Objectiveweb\Auth\Attributes\RequireRole; +use Objectiveweb\Auth\Middleware\RequireRole; use Objectiveweb\Auth\UserException; +use Objectiveweb\Router\Middleware; /** * Class UserController @@ -12,7 +13,7 @@ * * @package Objectiveweb\Auth */ -#[RequireRole(['ADMIN'])] +#[Middleware(RequireRole::class, ['ADMIN'])] class UserController { diff --git a/src/Auth/Attributes/RequireRole.php b/src/Auth/Middleware/RequireRole.php similarity index 75% rename from src/Auth/Attributes/RequireRole.php rename to src/Auth/Middleware/RequireRole.php index eb1304f..644cbd6 100644 --- a/src/Auth/Attributes/RequireRole.php +++ b/src/Auth/Middleware/RequireRole.php @@ -1,15 +1,13 @@ role = is_array($role) ? $role : [$role]; } - public function after($method, $fn, $params, $response): mixed + public function after(string $method, string $fn, array $params, array|null $response): mixed { if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { $response['_user'] = $this->auth->user(); @@ -26,7 +24,7 @@ public function after($method, $fn, $params, $response): mixed return $response; } - public function before($method, $fn, $params): mixed + public function before(string $method, string $fn, array $params): mixed { if ($this->auth->check()) { $scopes = \Objectiveweb\Auth::AUTHENTICATED; From fa6053622a0c768b46ce8a047fbe0a822ce4f521 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 29 Dec 2025 16:12:21 -0300 Subject: [PATCH 020/217] Use RequireScope Middleware for consistency --- src/Auth/Controller/AuthController.php | 12 ++++++------ src/Auth/Controller/OAuthController.php | 4 ++-- src/Auth/Controller/UserController.php | 4 ++-- .../Middleware/{RequireRole.php => RequireScope.php} | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) rename src/Auth/Middleware/{RequireRole.php => RequireScope.php} (84%) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index cbc7912..8a00baa 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -6,7 +6,7 @@ use Objectiveweb\Auth\AuthException; use Objectiveweb\Auth\UserException; -use Objectiveweb\Auth\Middleware\RequireRole; +use Objectiveweb\Auth\Middleware\RequireScope; use Objectiveweb\Router\Middleware; @@ -17,14 +17,14 @@ * * @package Objectiveweb\Auth */ -#[Middleware(RequireRole::class, Auth::ANONYMOUS)] +#[Middleware(RequireScope::class, Auth::ANONYMOUS)] class AuthController { function __construct(public \Objectiveweb\Auth $auth) { } - #[Middleware(RequireRole::class, Auth::ALL)] + #[Middleware(RequireScope::class, Auth::ALL)] function index() { if($this->auth->check()){ @@ -35,7 +35,7 @@ function index() } } - #[Middleware(RequireRole::class, Auth::AUTHENTICATED)] + #[Middleware(RequireScope::class, Auth::AUTHENTICATED)] function getLogout($params = []) { $this->auth->logout(); @@ -87,7 +87,7 @@ function postRegister(array $user) return $user; } - #[Middleware(RequireRole::class, Auth::ALL)] + #[Middleware(RequireScope::class, Auth::ALL)] function postToken(array $form) { if (empty($form['token'])) { @@ -101,7 +101,7 @@ function postToken(array $form) return $this->auth->passwd_reset($form['token'], $form['password']); } - #[Middleware(RequireRole::class, Auth::ALL)] + #[Middleware(RequireScope::class, Auth::ALL)] function postPassword(array $form) { // if user is logged in, update password diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 0198650..ac001c1 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -5,7 +5,7 @@ use Objectiveweb\Auth; use Objectiveweb\Auth\UserException; -use Objectiveweb\Auth\Middleware\RequireRole; +use Objectiveweb\Auth\Middleware\RequireScope; use Objectiveweb\Router\Middleware; /** @@ -29,7 +29,7 @@ function __construct(\Objectiveweb\Auth $auth, array $providers = []) /** * Execute login on oauth provider */ - #[Middleware(RequireRole::class, Auth::ALL)] + #[Middleware(RequireScope::class, Auth::ALL)] function get($id, $query) { if (!isset($this->providers[$id])) { diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 269c5df..773c717 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -3,7 +3,7 @@ namespace Objectiveweb\Auth\Controller; use Objectiveweb\Auth; -use Objectiveweb\Auth\Middleware\RequireRole; +use Objectiveweb\Auth\Middleware\RequireScope; use Objectiveweb\Auth\UserException; use Objectiveweb\Router\Middleware; @@ -13,7 +13,7 @@ * * @package Objectiveweb\Auth */ -#[Middleware(RequireRole::class, ['ADMIN'])] +#[Middleware(RequireScope::class, ['ADMIN'])] class UserController { diff --git a/src/Auth/Middleware/RequireRole.php b/src/Auth/Middleware/RequireScope.php similarity index 84% rename from src/Auth/Middleware/RequireRole.php rename to src/Auth/Middleware/RequireScope.php index 644cbd6..fc95e4e 100644 --- a/src/Auth/Middleware/RequireRole.php +++ b/src/Auth/Middleware/RequireScope.php @@ -7,12 +7,12 @@ use Objectiveweb\Router\MiddlewareInterface; -class RequireRole implements MiddlewareInterface +class RequireScope implements MiddlewareInterface { - public function __construct(private Auth $auth, private string|array $role) + public function __construct(private Auth $auth, private string|array $scopes) { - $this->role = is_array($role) ? $role : [$role]; + $this->scopes = is_array($scopes) ? $scopes : [$scopes]; } public function after(string $method, string $fn, array $params, array|null $response): mixed @@ -38,7 +38,7 @@ public function before(string $method, string $fn, array $params): mixed $scopes = \Objectiveweb\Auth::ANONYMOUS; } - if (count(array_intersect($this->role, $scopes)) == 0) { + if (count(array_intersect($this->scopes, $scopes)) == 0) { throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); } From a8fabd41b5a4fd7149e5f51c9a555447a95c4bb6 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Fri, 2 Jan 2026 22:09:04 -0300 Subject: [PATCH 021/217] fix: Test auth before fetching the user in user_can --- src/Auth.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Auth.php b/src/Auth.php index a4fecc2..747ef4e 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -45,7 +45,12 @@ public function check() return !empty($_SESSION[$this->params['session_key']]); } - public function user_can($scope) { + public function user_can($scope): bool + { + if(!$this->check()) { + return false; + } + $user = $this->user(); return in_array($scope, $user['scopes']); From acfad6e975c3e0ceee2efe255d519e33773e58d5 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Wed, 25 Feb 2026 10:18:34 -0300 Subject: [PATCH 022/217] Update dependencies --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ae8abea..655768b 100644 --- a/composer.json +++ b/composer.json @@ -11,10 +11,10 @@ "issues" : "https://github.com/objetiveweb/auth/issues" }, "require": { - "php": ">=5.6.0" + "php": ">=8.2.0" }, "require-dev": { - "phpunit/phpunit": "4.*" + "phpunit/phpunit": "10.*" }, "suggest": { "league/oauth2-google": "Google Login", From be2c8cd424b6ea028ce7ff163932ffbb38469b86 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Wed, 25 Feb 2026 11:43:31 -0300 Subject: [PATCH 023/217] Security Improvements --- composer.json | 3 +- src/Auth.php | 6 +- src/Auth/BasicAuth.php | 317 ++++++++++++++++++++---- src/Auth/Controller/AuthController.php | 29 ++- src/Auth/Controller/OAuthController.php | 2 +- src/Auth/Controller/UserController.php | 98 +++++++- src/Auth/UserException.php | 2 +- test/BasicAuthTest.php | 72 ++++++ test/UserControllerTest.php | 86 ++++--- 9 files changed, 506 insertions(+), 109 deletions(-) create mode 100644 test/BasicAuthTest.php diff --git a/composer.json b/composer.json index 655768b..155165e 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "issues" : "https://github.com/objetiveweb/auth/issues" }, "require": { - "php": ">=8.2.0" + "php": ">=8.2.0", + "objectiveweb/db": "dev-devel" }, "require-dev": { "phpunit/phpunit": "10.*" diff --git a/src/Auth.php b/src/Auth.php index 747ef4e..0bd5a69 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -29,9 +29,9 @@ function __construct(array $params) public static function hash($password = null) { - if (!$password) { + if ($password === null) { // return a random token - return md5(microtime(true)); + return bin2hex(random_bytes(16)); } return \password_hash($password, PASSWORD_BCRYPT); @@ -232,4 +232,4 @@ abstract public function get_credential($provider, $accountid); * @throws \Exception */ abstract public function update_credential($userid, $provider, $uid, $profile = null); -} \ No newline at end of file +} diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index d0f053b..77a91f9 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -8,100 +8,321 @@ * * @package Objectiveweb\Auth */ -class BasicAuth extends \Objectiveweb\Auth { +class BasicAuth extends \Objectiveweb\Auth +{ + private array $users = []; + private array $credentials = []; - private $passwd; - - function __construct($passwd = [], $params = []) { + public function __construct(array $passwd = [], array $params = []) + { + $defaults = [ + 'table' => 'basic_user', + ]; + parent::__construct(array_merge($defaults, $params)); - parent::__construct($params); + foreach ($passwd as $uid => $password) { + if (is_array($password)) { + $row = $password; + $candidateUid = $row['uid'] ?? (is_string($uid) ? $uid : null); + if (empty($candidateUid)) { + continue; + } + $this->register($candidateUid, $row[$this->params['password']] ?? null, $row); + continue; + } + if (!is_string($uid)) { + continue; + } - $this->passwd = $passwd; + $this->register($uid, (string) $password); + } } - public function query($params = array(), $operator = "OR") { - $result = []; - foreach ($this->passwd as $user => $pass) { - if(strpos($user['uid'], $params['uid']) !== FALSE) { - $result[] = $user; + $page = max(0, (int) ($params['page'] ?? 0)); + $size = max(1, (int) ($params['size'] ?? 20)); + unset($params['page'], $params['size'], $params['sort']); + + $matches = []; + foreach ($this->users as $row) { + if ($this->matchesFilters($row, $params, $operator)) { + $matches[] = $this->publicUser($row); } } - } - public function get($username, $key = 'username') - { - print_r($this->passwd); - if(!in_array($username, array_keys($this->passwd))) { - throw new UserException("User `$username` not found", 404); - } + $count = count($matches); + $slice = array_slice($matches, $page * $size, $size); return [ - $this->params['username'] => $username, - $this->params['id'] => $username + '_embedded' => [ + $this->params['table'] => $slice, + ], + 'page' => [ + 'size' => $size, + 'number' => $page, + 'totalElements' => $count, + 'totalPages' => (int) ceil($count / $size), + ], ]; } - public function &login($username, $password) + public function get($user_id, $key = 'id') { - $user = $this->get($username); + $resolved = $this->resolveUserId($user_id, $key); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException("User `$user_id` not found", 404); + } - if($user[$this->params['password']] == $user[$this->params['password']]) { - unset($user[$this->params['password']]); - $this->user($user); + return $this->users[$resolved]; + } - return $user; - } else { - throw new AuthException('Invalid username or password'); - } - // TODO: Implement login() method. + public function &login($uid, $password) + { + return parent::login($uid, $password); } - public function register($username, $password = null, $data = array()) + public function register($uid, $password = null, $data = array()) { - $this->passwd[$username] = $password; + if (is_array($uid)) { + $data = array_merge($uid, $data); + $uid = $data['uid'] ?? null; + $password = $data[$this->params['password']] ?? null; + unset($data['uid'], $data[$this->params['password']]); + } - return [ - $this->params['username'] => $username, - $this->params['id'] => $username - ]; + if (empty($uid)) { + throw new UserException('Missing uid', 400); + } + + $provider = $data['provider'] ?? 'local'; + $profile = $data['profile'] ?? null; + unset($data['provider'], $data['profile']); + + if ($this->get_credential($provider, $uid)) { + throw new UserException("User `$uid` already exists", 409); + } + + $idField = $this->params['id']; + $userId = $data[$idField] ?? $this->nextId(); + + $record = $data; + $record[$idField] = $userId; + $record['uid'] = $uid; + if ($password !== null && $password !== '') { + $record[$this->params['password']] = $this->isPasswordHash($password) + ? $password + : self::hash($password); + } elseif (!array_key_exists($this->params['password'], $record)) { + $record[$this->params['password']] = null; + } + + if (!isset($record['name'])) { + $parts = explode('@', (string) $uid, 2); + $record['name'] = $parts[0]; + } + + $scopeKey = $this->params['scopes']; + if (!isset($record[$scopeKey])) { + $record[$scopeKey] = []; + } elseif (is_string($record[$scopeKey])) { + $record[$scopeKey] = array_filter(explode(',', $record[$scopeKey])); + } + + $this->users[$userId] = $record; + $this->update_credential($userId, $provider, $uid, $profile); + + return $this->publicUser($record); } - public function passwd($username, $password, $key = 'username') + public function passwd($user_id, $password, $key = 'id') { - // TODO: Implement passwd() method. + $resolved = $this->resolveUserId($user_id, $key); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('User not found', 404); + } + + $this->users[$resolved][$this->params['password']] = self::hash($password); + return true; } public function passwd_reset($token, $password) { - // TODO: Implement passwd_reset() method. + $tokenField = $this->params['token']; + if (empty($tokenField)) { + throw new UserException('Token support is disabled', 400); + } + + foreach ($this->users as $userId => $user) { + if (($user[$tokenField] ?? null) !== $token) { + continue; + } + + $this->users[$userId][$this->params['password']] = self::hash($password); + $this->users[$userId][$tokenField] = null; + + return $this->publicUser($this->users[$userId]); + } + + throw new UserException('Hash not found', 404); } - public function update($username, array $data, $key = 'username') + public function update($user_id, array $data, $key = 'id') { - // TODO: Implement update() method. + $resolved = $this->resolveUserId($user_id, $key); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('User not found', 404); + } + + unset($data[$this->params['id']], $data[$this->params['password']]); + if ($this->params['token']) { + unset($data[$this->params['token']]); + } + + if (array_key_exists($this->params['scopes'], $data) && is_string($data[$this->params['scopes']])) { + $data[$this->params['scopes']] = array_filter(explode(',', $data[$this->params['scopes']])); + } + + foreach ($data as $field => $value) { + $this->users[$resolved][$field] = $value; + } } - public function delete($username, $key = 'username') + public function delete($user_id) { - // TODO: Implement delete() method. + $resolved = $this->resolveUserId($user_id, $this->params['id']); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('User not found', 404); + } + + if ($this->check()) { + $current = $this->user(); + if (($current[$this->params['id']] ?? null) == $resolved) { + throw new \Exception('Cannot delete yourself!'); + } + } + + unset($this->users[$resolved]); + foreach ($this->credentials as $provider => $records) { + foreach ($records as $uid => $credential) { + if (($credential['user_id'] ?? null) == $resolved) { + unset($this->credentials[$provider][$uid]); + } + } + if (empty($this->credentials[$provider])) { + unset($this->credentials[$provider]); + } + } + + return true; } - public function update_token($username) + public function update_token($user_id) { - // TODO: Implement update_token() method. + $resolved = $this->resolveUserId($user_id, $this->params['id']); + if ($resolved === null || !isset($this->users[$resolved])) { + throw new UserException('Token not found', 404); + } + + $tokenField = $this->params['token']; + if (empty($tokenField)) { + throw new UserException('Token support is disabled', 400); + } + + $token = self::hash(); + $this->users[$resolved][$tokenField] = $token; + return $token; } public function get_credential($provider, $accountid) { - // TODO: Implement get_credential() method. + return $this->credentials[$provider][$accountid] ?? false; } public function update_credential($userid, $provider, $uid, $profile = null) { - // TODO: Implement update_credential() method. + if (is_array($profile)) { + $profile = json_encode($profile); + } + + $this->credentials[$provider][$uid] = [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + 'profile' => $profile, + 'last_login' => date('Y-m-d H:i:s'), + ]; + + return true; + } + + private function nextId(): int + { + if (empty($this->users)) { + return 1; + } + + $keys = array_keys($this->users); + return ((int) max($keys)) + 1; + } + + private function resolveUserId(mixed $value, string $key): int|string|null + { + if ($key === 'id' || $key === $this->params['id']) { + return $value; + } + + if ($key === 'uid') { + $credential = $this->get_credential('local', $value); + return $credential['user_id'] ?? null; + } + + foreach ($this->users as $id => $user) { + if (($user[$key] ?? null) == $value) { + return $id; + } + } + + return null; + } + + private function matchesFilters(array $row, array $filters, string $operator): bool + { + if (empty($filters)) { + return true; + } + + $matches = []; + foreach ($filters as $field => $value) { + $rowValue = $row[$field] ?? null; + if (is_string($value) && str_contains($value, '%')) { + $pattern = '/^' . str_replace('%', '.*', preg_quote($value, '/')) . '$/i'; + $matches[] = (bool) preg_match($pattern, (string) $rowValue); + continue; + } + $matches[] = ($rowValue == $value); + } + + if (strtoupper($operator) === 'AND') { + return !in_array(false, $matches, true); + } + + return in_array(true, $matches, true); + } + + private function publicUser(array $user): array + { + unset($user[$this->params['password']]); + if ($this->params['token']) { + unset($user[$this->params['token']]); + } + return $user; + } + + private function isPasswordHash(string $value): bool + { + return password_get_info($value)['algo'] !== null; } -} \ No newline at end of file +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 8a00baa..1d7d438 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -53,10 +53,17 @@ function getLogout($params = []) */ function post(array $user) { - $uid = $user['uid']; + $uid = trim((string) ($user['uid'] ?? '')); + if ($uid === '') { + throw new UserException('Missing uid', 400); + } unset($user['uid']); - $password = $user[$this->auth->params['password']]; - unset($user[$this->auth->params['password']]); + $passwordField = $this->auth->params['password']; + $password = $user[$passwordField] ?? null; + if (!is_string($password) || $password === '') { + throw new UserException('Missing password', 400); + } + unset($user[$passwordField]); return $this->auth->login($uid, $password); } @@ -67,10 +74,14 @@ function post(array $user) */ function postRegister(array $user) { - $uid = @$user['uid']; + $uid = trim((string) ($user['uid'] ?? '')); + if ($uid === '') { + throw new UserException('Missing uid', 400); + } unset($user['uid']); - $password = @$user[$this->auth->params['password']]; - unset($user[$this->auth->params['password']]); + $passwordField = $this->auth->params['password']; + $password = $user[$passwordField] ?? null; + unset($user[$passwordField]); if (!filter_var($uid, FILTER_VALIDATE_EMAIL)) { throw new AuthException('Email inválido'); @@ -94,7 +105,8 @@ function postToken(array $form) throw new UserException('Invalid request', 400); } - if (empty($form['password']) || $form['password'] != @$form['confirm']) { + $confirm = $form['confirm'] ?? null; + if (empty($form['password']) || $form['password'] != $confirm) { throw new UserException('Passwords don\'t match', 400); } @@ -107,7 +119,8 @@ function postPassword(array $form) // if user is logged in, update password if ($this->auth->check()) { // TODO validar senha anterior - if (empty($form['password']) || $form['password'] != @$form['confirm']) { + $confirm = $form['confirm'] ?? null; + if (empty($form['password']) || $form['password'] != $confirm) { throw new UserException('Passwords dont match', 400); } diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index ac001c1..4c51783 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -101,7 +101,7 @@ private function login($provider, $resourceOwner) $user = $this->auth->user(); // Add the oauth cred to the existing user - $this->auth->update_credential($user['id'], + $this->auth->update_credential($user[$this->auth->params['id']], $provider, $uid, $resourceOwner->toArray()); diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 773c717..970c2d2 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -16,8 +16,30 @@ #[Middleware(RequireScope::class, ['ADMIN'])] class UserController { + private array $allowedQueryFields; + private array $allowedWriteFields; - public Auth $auth; + public function __construct(public Auth $auth) + { + $this->allowedQueryFields = array_values(array_unique(array_filter([ + $this->auth->params['id'] ?? 'id', + 'uid', + 'name', + 'image', + $this->auth->params['scopes'] ?? 'scopes', + $this->auth->params['created'] ?? null, + ]))); + + $this->allowedWriteFields = array_values(array_unique(array_filter([ + 'uid', + $this->auth->params['password'] ?? 'password', + 'name', + 'image', + $this->auth->params['scopes'] ?? 'scopes', + 'provider', + 'profile', + ]))); + } public function index() { @@ -27,7 +49,7 @@ public function index() public function get($params = array()) { if (is_array($params)) { - return $this->auth->query($params); + return $this->auth->query($this->sanitizeQueryParams($params)); } else { return $this->auth->get($params); } @@ -35,27 +57,46 @@ public function get($params = array()) public function post($data) { + if (!is_array($data)) { + throw new UserException('Invalid request', 400); + } - return $this->auth->register($data); + if (empty(trim((string) ($data['uid'] ?? '')))) { + throw new UserException('Missing uid', 400); + } + foreach ($data as $field => $value) { + if (!in_array((string) $field, $this->allowedWriteFields, true)) { + throw new UserException("Invalid field `$field`", 400); + } + unset($value); + } + return $this->auth->register($data); } public function put($user_id, $data) { + if (!is_array($data)) { + throw new UserException('Invalid request', 400); + } $user = $this->auth->get($user_id); - if (!$user) { - throw new UserException('User does not exist', 404); - } - if (!empty($data['password'])) { $this->auth->passwd($user[$this->auth->params['id']], $data['password']); } unset($data['password']); - $this->auth->update($user[$this->auth->params['id']], $data); + foreach (array_keys($data) as $field) { + if (!in_array((string) $field, $this->allowedWriteFields, true)) { + throw new UserException("Invalid field `$field`", 400); + } + } + + if (!empty($data)) { + $this->auth->update($user[$this->auth->params['id']], $data); + } return true; } @@ -63,5 +104,46 @@ public function put($user_id, $data) public function delete($user_id) { $this->auth->delete($user_id); + return true; + } + + private function sanitizeQueryParams(array $params): array + { + $allowedMeta = ['page', 'size', 'sort']; + foreach ($params as $key => $value) { + if (in_array((string) $key, $allowedMeta, true)) { + continue; + } + + if (!in_array((string) $key, $this->allowedQueryFields, true)) { + throw new UserException("Invalid filter `$key`", 400); + } + unset($value); + } + + if (!empty($params['sort'])) { + $sortFields = explode(',', (string) $params['sort']); + foreach ($sortFields as $sortField) { + $sortField = trim($sortField); + if ($sortField === '') { + continue; + } + + $parts = preg_split('/\s+/', $sortField); + $field = $parts[0] ?? ''; + if (!in_array($field, $this->allowedQueryFields, true)) { + throw new UserException("Invalid sort field `$field`", 400); + } + + if (isset($parts[1])) { + $dir = strtoupper($parts[1]); + if (!in_array($dir, ['ASC', 'DESC'], true)) { + throw new UserException("Invalid sort direction `$dir`", 400); + } + } + } + } + + return $params; } } diff --git a/src/Auth/UserException.php b/src/Auth/UserException.php index 9b71863..71151d9 100644 --- a/src/Auth/UserException.php +++ b/src/Auth/UserException.php @@ -8,7 +8,7 @@ class UserException extends \Exception { private $user; - function __construct($message = "", $code = 0, Throwable $previous = null) + public function __construct($message = "", $code = 0, ?Throwable $previous = null) { parent::__construct($message, $code, $previous); } diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php new file mode 100644 index 0000000..38588b5 --- /dev/null +++ b/test/BasicAuthTest.php @@ -0,0 +1,72 @@ +auth = new BasicAuth( + [ + 'alice@example.com' => 'secret', + ], + [ + 'token' => 'token', + ] + ); + } + + public function testLoginSuccessAndSessionData(): void + { + $user = $this->auth->login('alice@example.com', 'secret'); + + $this->assertSame(1, $user['id']); + $this->assertSame('alice@example.com', $user['uid']); + $this->assertTrue($this->auth->check()); + $this->assertArrayNotHasKey('password', $user); + } + + public function testLoginInvalidPassword(): void + { + $this->expectException(AuthException::class); + $this->auth->login('alice@example.com', 'wrong'); + } + + public function testRegisterAndGetCredential(): void + { + $user = $this->auth->register('bob@example.com', 'pass123', ['name' => 'Bob']); + $credential = $this->auth->get_credential('local', 'bob@example.com'); + + $this->assertSame('Bob', $user['name']); + $this->assertSame($user['id'], $credential['user_id']); + } + + public function testPasswordResetFlow(): void + { + $credential = $this->auth->get_credential('local', 'alice@example.com'); + $token = $this->auth->update_token($credential['user_id']); + + $this->auth->passwd_reset($token, 'new-secret'); + $user = $this->auth->login('alice@example.com', 'new-secret'); + + $this->assertSame('alice@example.com', $user['uid']); + } + + public function testDeleteUserRemovesCredential(): void + { + $credential = $this->auth->get_credential('local', 'alice@example.com'); + $this->auth->delete($credential['user_id']); + + $this->expectException(UserException::class); + $this->auth->login('alice@example.com', 'secret'); + } +} diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index fdb9583..d27b323 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -1,16 +1,7 @@ get(1); + public function testPostMissingUid(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + self::$controller->post([ + 'name' => 'Invalid User', + ]); + } + + /** + * @depends testPost + */ + public function testGet() { + $user = self::$controller->get(1); $this->assertEquals(1, $user['id']); - } + } - /** - * @depends testGet - */ - public function testQuery() { - $all = self::$controller->get(); + /** + * @depends testGet + */ + public function testQuery() { + $all = self::$controller->get(); - $this->assertEquals(1, count($all['_embedded']['ow_user'])); + $this->assertEquals(1, count($all['_embedded']['ow_user'])); - $this->assertEquals('Test User', $all['_embedded']['ow_user'][0]['name']); + $this->assertEquals('Test User', $all['_embedded']['ow_user'][0]['name']); - $this->assertEquals(1, $all['page']['totalElements']); - $this->assertEquals(1, $all['page']['totalPages']); - $this->assertEquals(0, $all['page']['number']); - } + $this->assertEquals(1, $all['page']['totalElements']); + $this->assertEquals(1, $all['page']['totalPages']); + $this->assertEquals(0, $all['page']['number']); + } + + public function testQueryInvalidFilterField(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + self::$controller->get(['unknown_field' => 'x']); + } - /** - * @depends testQuery - */ + /** + * @depends testQuery + */ public function testPut() { self::$controller->put(1, array( 'name' => 'Updated name' )); @@ -75,13 +82,14 @@ public function testPut() { $this->assertEquals('Updated name', $user['name']); } - /** - * @depends testPut - * @expectedException Objectiveweb\Auth\UserException + /** + * @depends testPut */ - public function testDelete() { - self::$controller->delete(1); + public function testDelete() { + $this->expectException(\Objectiveweb\Auth\UserException::class); + $deleted = self::$controller->delete(1); + $this->assertTrue($deleted); - $user = self::$controller->get(1); - } -} \ No newline at end of file + $user = self::$controller->get(1); + } +} From 9a2d425407e9f3f522f6b886c7e47a660c691ea9 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Wed, 25 Feb 2026 11:46:15 -0300 Subject: [PATCH 024/217] Replace MysqlAuth with DBAuth --- README.md | 252 ++++++++++------- src/Auth/DBAuth.php | 391 +++++++++++++++++++++++++++ src/Auth/MysqlAuth.php | 556 -------------------------------------- test/DBAuthSqliteTest.php | 166 ++++++++++++ test/MysqlAuthTest.php | 149 ---------- test/Mysql_TestCase.php | 49 ---- 6 files changed, 704 insertions(+), 859 deletions(-) create mode 100644 src/Auth/DBAuth.php delete mode 100644 src/Auth/MysqlAuth.php create mode 100644 test/DBAuthSqliteTest.php delete mode 100644 test/MysqlAuthTest.php delete mode 100644 test/Mysql_TestCase.php diff --git a/README.md b/README.md index a9cb005..991c57a 100644 --- a/README.md +++ b/README.md @@ -1,136 +1,178 @@ -# Auth [![Build Status](https://travis-ci.org/objectiveweb/auth.svg?branch=master)](https://travis-ci.org/objectiveweb/auth) +# objectiveweb/auth -Authentication Library +Authentication library with pluggable providers. -## Setup +## Install -Setup the auth dependency on your project's composer.json, then run `composer update` - - - "require": { - "objectiveweb/auth": "~0.2" - } - -Or use the command line - -``` -composer require objectiveweb "^0.4" +```bash +composer require objectiveweb/auth ``` -### Auth initialization - -Create a new $auth instance, passing the appropriate parameters - - // MysqlAuth depends on PDO - $pdo = new \PDO($dsn); - - $auth = new \Objectiveweb\Auth\MysqlAuth($pdo, [ - 'session_key' => 'ow_auth', - 'table' => 'ow_auth', - 'id' => 'id', - 'username' => 'username', - 'password' => 'password', - 'token' => NULL, - 'created' => NULL, - 'last_login' => NULL - ]); - -#### Parameters -* 'session_key' - Key used for $_SESSION storage, defaults to 'ow_auth' +This package requires: +- PHP `>=8.2` +- `objectiveweb/db` -* 'table' +## Providers - Database table which stores users, defaults to 'ow_auth' +### `DBAuth` -* 'id' +`DBAuth` uses `Objectiveweb\DB` and supports: +- local login with hashed password +- external credentials (`provider` + `uid`) +- password reset tokens +- optional related data inserts (`with`) +- immutable UUID generation on user creation (default field name: `uuid`) - Primary key of the user table, defaults to 'id' +#### Minimal setup -* 'username' +```php +use Objectiveweb\Auth\DBAuth; +use Objectiveweb\DB; - Username field on the user table, defaults to 'username' +$db = new DB('sqlite:/tmp/app.sqlite'); // or mysql:..., pgsql:... -* 'password' - - Password field on the user table, defaults to 'password', - -* 'token' - - Optional CHAR(32) field to store a random token +$auth = new DBAuth($db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'id' => 'id', + 'password' => 'password', + 'scopes' => 'scopes', + 'token' => 'token', + 'created' => 'created', + 'uuid' => 'uuid', + 'credentials_last_login' => 'last_login', +]); +``` -* 'created' +#### Required tables + +`DBAuth` expects: +- users table (`table`) +- credentials table (`credentials_table`) + +Example schema (SQLite-compatible): + +```sql +CREATE TABLE auth_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT, + image TEXT, + scopes TEXT, + created TEXT, + password TEXT, + token TEXT +); + +CREATE TABLE auth_credentials ( + uid TEXT NOT NULL, + provider TEXT NOT NULL, + user_id INTEGER NOT NULL, + profile TEXT NULL, + last_login TEXT NULL, + PRIMARY KEY(uid, provider) +); +``` - Optional DATETIME field to store the accounts creation date +#### DBAuth params + +- `session_key`: session storage key (default `ow_auth`) +- `id`: user PK field (default `id`) +- `password`: password field (default `password`) +- `scopes`: scopes field (default `scopes`) +- `token`: optional password-reset token field +- `table`: users table name (default `user`) +- `credentials_table`: credentials table name (default `user_credentials`) +- `created`: optional created-at field +- `last_login`: optional user last-login field +- `credentials_last_login`: optional credentials last-login field +- `uuid`: UUID field created on register and protected from updates (default `uuid`) +- `with`: associative array for extra related inserts, format: `['table_name' => 'foreign_key']` + +### `BasicAuth` + +In-memory provider for tests/dev/small setups. + +`BasicAuth` fully implements the provider contract: +- `query`, `get`, `register`, `login` +- `passwd`, `passwd_reset` +- `update`, `delete` +- `update_token` +- `get_credential`, `update_credential` + +#### BasicAuth setup + +```php +use Objectiveweb\Auth\BasicAuth; + +$auth = new BasicAuth( + [ + 'admin@example.com' => 'secret', + ], + [ + 'token' => 'token', + ] +); +``` -* 'last_login' +Passwords passed to the constructor/register are hashed internally unless already hashed. - Optional DATETIME field to store the last successful login date +## Common usage -## Usage +```php +use Objectiveweb\Auth\AuthException; +use Objectiveweb\Auth\UserException; - include "vendor/autoload.php"; - - # register user - try { - $user = $auth->register('username', 'password'); - } - catch(\Exception $ex) { - printf('DB Error creating user: %s', $ex->getMessage()); - } +// Register +$user = $auth->register('alice@example.com', 'secret', ['name' => 'Alice']); - # login user - try { - $user = $auth->login('username', 'password'); - } - catch(AuthException $ex) { - printf("Password mismatch"); - } - catch(UserException $ex) { - printf("Error logging in: %s", $ex->getMessage()); - } +// Login +try { + $user = $auth->login('alice@example.com', 'secret'); +} catch (AuthException $e) { + // invalid password +} catch (UserException $e) { + // user not found +} - # check if the user is logged in - if($auth->check()) { - // user is logged in - } - else { - // user is not logged in - }; +// Session +if ($auth->check()) { + $current = $auth->user(); +} - # retrieve the current user from the session - try { - $user = $auth->user(); - } - catch(UserException $ex) { - printf("User not logged in"); - } +// Logout +$auth->logout(); +``` - # logout user - $auth->logout(); +## Password reset flow -### Store additional data when registering a user +When `token` is configured: - $user = $auth->register('username', 'password', [ - 'email' => 'someone@somewhere', - 'displayName' => 'Test User' - ]); +```php +$credential = $auth->get_credential('local', 'alice@example.com'); +$token = $auth->update_token($credential['user_id']); -### Generating a password reset token +// Send token, then later: +$auth->passwd_reset($token, 'new-password'); +``` -When the `token` parameter is enabled in initialization +## Query and update - $token = $auth->update_token($username); +```php +// List users (paginated response) +$result = $auth->query(['page' => 0, 'size' => 20]); - // send $token to user, then later - try { - $auth->passwd_reset($token, $new_password); - } - catch(UserException $ex) { - printf('Invalid token provided'); - } +// Update user profile data +$auth->update($userId, ['name' => 'Alice Updated']); -### Updating user data +// Update password +$auth->passwd($userId, 'new-password'); +``` - $auth->update($username, [ 'email' => 'new@email.com' ]); +## Controllers and middleware +Included: +- `AuthController` +- `OAuthController` +- `UserController` +- `RequireScope` middleware diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php new file mode 100644 index 0000000..f85216d --- /dev/null +++ b/src/Auth/DBAuth.php @@ -0,0 +1,391 @@ + 'user', + 'created' => null, + 'last_login' => null, + 'credentials_table' => 'user_credentials', + 'credentials_last_login' => null, + 'uuid' => 'uuid', + 'with' => [], + ]; + + parent::__construct(array_merge($defaults, $params)); + } + + public function query($params = array(), $operator = "OR") + { + $page = max(0, (int) ($params['page'] ?? 0)); + $size = max(1, (int) ($params['size'] ?? 20)); + $sort = $params['sort'] ?? null; + + unset($params['page'], $params['size'], $params['sort']); + + if (strtoupper($operator) === 'OR' && count($params) > 1) { + $rowsById = []; + foreach ($params as $key => $value) { + $rows = $this->db->select($this->params['table'], [$key => $value], [ + 'order' => $sort, + ])->all(); + + foreach ($rows as $row) { + if (!isset($row[$this->params['id']])) { + continue; + } + $rowsById[$row[$this->params['id']]] = $this->normalizeUserRow($row); + } + } + + $data = array_values($rowsById); + $count = count($data); + $slice = array_slice($data, $page * $size, $size); + } else { + $filter = $params; + $queryParams = [ + 'order' => $sort, + 'offset' => $page * $size, + 'limit' => $size, + ]; + + $slice = $this->db->select($this->params['table'], $filter, $queryParams)->all(); + $slice = array_map(fn (array $row): array => $this->normalizeUserRow($row), $slice); + $count = $this->db->count($this->params['table'], $filter); + } + + return [ + '_embedded' => [ + $this->params['table'] => $slice, + ], + 'page' => [ + 'size' => $size, + 'number' => $page, + 'totalElements' => $count, + 'totalPages' => (int) ceil($count / $size), + ], + ]; + } + + public function get($user_id, $key = 'id') + { + $column = $this->params[$key] ?? $key; + $row = $this->db->select($this->params['table'], [$column => $user_id], ['limit' => 1])->fetch(); + + if (!$row) { + throw new UserException('User not found', 404); + } + + foreach ($this->params['with'] as $table => $fk) { + $row[$table] = $this->db->select($table, [$fk => $row[$this->params['id']]])->all(); + } + + return $this->normalizeUserRow($row); + } + + public function register($uid, $password = null, $data = array()) + { + if (is_array($uid)) { + $data = array_merge($uid, $data); + $uid = $data['uid'] ?? null; + $password = $data[$this->params['password']] ?? null; + unset($data['uid'], $data[$this->params['password']]); + } + + if (empty($uid)) { + throw new UserException('Missing uid', 400); + } + + $provider = empty($data['provider']) ? 'local' : $data['provider']; + unset($data['provider']); + + $credential = $this->get_credential($provider, $uid); + if (!empty($credential)) { + $ex = new UserException('Credential already registered', 409); + $ex->setUser($this->get($credential['user_id'])); + throw $ex; + } + + if (empty($data['name'])) { + [$name] = explode('@', (string) $uid, 2); + $data['name'] = $name; + } + + $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; + unset($data['profile']); + + $with = []; + foreach ($this->params['with'] as $table => $fk) { + if (!empty($data[$table])) { + $with[$table] = $data[$table]; + unset($data[$table], $fk); + } + } + + $fields = []; + foreach ($data as $key => $value) { + $fields[$key] = is_array($value) ? json_encode($value) : $value; + } + + if (!empty($this->params['uuid'])) { + unset($fields[$this->params['uuid']]); + $fields[$this->params['uuid']] = $this->uuidV4(); + } + + if ($password) { + $fields[$this->params['password']] = self::hash($password); + } + + if ($this->params['token']) { + $fields[$this->params['token']] = self::hash(); + } + + if ($this->params['created']) { + $fields[$this->params['created']] = date('Y-m-d H:i:s'); + } + + if (!empty($fields[$this->params['scopes']]) && is_array($fields[$this->params['scopes']])) { + $fields[$this->params['scopes']] = implode(',', $fields[$this->params['scopes']]); + } elseif (!array_key_exists($this->params['scopes'], $fields)) { + $fields[$this->params['scopes']] = ''; + } + + return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $with): array { + $id = $this->db->insert($this->params['table'], $fields); + if (!$id) { + throw new \Exception('Could not create user'); + } + + $userId = ctype_digit((string) $id) ? (int) $id : $id; + $fields[$this->params['id']] = $userId; + + foreach ($with as $withTable => $withContent) { + $fk = $this->params['with'][$withTable]; + $payload = [$fk => $userId]; + foreach ($withContent as $k => $v) { + $payload[$k] = $v; + } + $this->db->insert($withTable, $payload); + } + + $credentialPayload = [ + 'user_id' => $userId, + 'provider' => $provider, + 'uid' => $uid, + 'profile' => $profile, + ]; + + if (!empty($this->params['credentials_last_login'])) { + $credentialPayload[$this->params['credentials_last_login']] = date('Y-m-d H:i:s'); + } + + $this->db->insert($this->params['credentials_table'], $credentialPayload); + + unset($fields[$this->params['password']]); + if ($this->params['token']) { + unset($fields[$this->params['token']]); + } + + return $this->normalizeUserRow($fields); + }); + } + + public function passwd($user_id, $password, $key = 'id') + { + $column = $this->params[$key] ?? $key; + $updated = $this->db->update( + $this->params['table'], + [$this->params['password'] => self::hash($password)], + [$column => $user_id] + ); + + if ($updated !== 1) { + throw new UserException('User not found', 404); + } + + return true; + } + + public function passwd_reset($token, $password) + { + if (empty($this->params['token'])) { + throw new UserException('Token support is disabled', 400); + } + + $user = $this->get($token, $this->params['token']); + $updated = $this->db->update( + $this->params['table'], + [ + $this->params['password'] => self::hash($password), + $this->params['token'] => null, + ], + [$this->params['token'] => $token] + ); + + if ($updated !== 1) { + throw new UserException('Hash not found', 404); + } + + return $user; + } + + public function update($user_id, array $data, $key = 'id') + { + $column = $this->params[$key] ?? $key; + + unset($data[$this->params['id']], $data[$this->params['password']]); + + if ($this->params['token']) { + unset($data[$this->params['token']]); + } + + if ($this->params['created']) { + unset($data[$this->params['created']]); + } + + if ($this->params['last_login']) { + unset($data[$this->params['last_login']]); + } + + if (!empty($this->params['uuid'])) { + unset($data[$this->params['uuid']]); + } + + if (empty($data)) { + return; + } + + if (isset($data[$this->params['scopes']]) && is_array($data[$this->params['scopes']])) { + $data[$this->params['scopes']] = implode(',', $data[$this->params['scopes']]); + } + + foreach ($data as $k => $v) { + if (is_array($v)) { + $data[$k] = json_encode($v); + } + } + + $this->db->update($this->params['table'], $data, [$column => $user_id]); + } + + public function delete($user_id) + { + if ($this->check()) { + $user = $this->user(); + if (($user[$this->params['id']] ?? null) == $user_id) { + throw new \Exception('Cannot delete yourself!'); + } + } + + return $this->db->transaction(function () use ($user_id): bool { + $this->db->delete($this->params['credentials_table'], ['user_id' => $user_id]); + $deleted = $this->db->delete($this->params['table'], [$this->params['id'] => $user_id]); + + if ($deleted !== 1) { + throw new UserException('User not found', 404); + } + + return true; + }); + } + + public function update_token($user_id) + { + if (empty($this->params['token'])) { + throw new UserException('Token support is disabled', 400); + } + + $token = self::hash(); + $updated = $this->db->update( + $this->params['table'], + [$this->params['token'] => $token], + [$this->params['id'] => $user_id] + ); + + if ($updated !== 1) { + throw new UserException('Token not found', 404); + } + + return $token; + } + + public function get_credential($provider, $accountid) + { + $account = $this->db->select( + $this->params['credentials_table'], + [ + 'provider' => $provider, + 'uid' => $accountid, + ], + ['limit' => 1] + )->fetch(); + + if (!$account) { + return false; + } + + if (!empty($account['profile'])) { + $decoded = json_decode($account['profile'], true); + $account['profile'] = is_array($decoded) ? $decoded : $account['profile']; + } + + return $account; + } + + public function update_credential($userid, $provider, $uid, $profile = null) + { + if (is_array($profile)) { + $profile = json_encode($profile); + } + + $data = ['profile' => $profile]; + if (!empty($this->params['credentials_last_login'])) { + $data[$this->params['credentials_last_login']] = date('Y-m-d H:i:s'); + } + + if ($this->get_credential($provider, $uid)) { + $this->db->update($this->params['credentials_table'], $data, [ + 'provider' => $provider, + 'uid' => $uid, + ]); + + return true; + } + + $data = array_merge($data, [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + + $this->db->insert($this->params['credentials_table'], $data); + return true; + } + + private function normalizeUserRow(array $row): array + { + $scopeField = $this->params['scopes']; + if (!empty($row[$scopeField]) && is_string($row[$scopeField])) { + $row[$scopeField] = explode(',', $row[$scopeField]); + } + + return $row; + } + + private function uuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } +} diff --git a/src/Auth/MysqlAuth.php b/src/Auth/MysqlAuth.php deleted file mode 100644 index 434d84b..0000000 --- a/src/Auth/MysqlAuth.php +++ /dev/null @@ -1,556 +0,0 @@ -pdo = $pdo; - - $defaults = [ - 'table' => 'user', - 'prefix' => 'ow_', - 'created' => NULL, - 'last_login' => NULL, - 'credentials_table' => 'user_credentials', - 'with' => [] - ]; - - parent::__construct(array_merge($defaults, $params)); - - $this->params['table'] = $this->params['prefix'] . $this->params['table']; - $this->params['with'][$this->params['credentials_table']] = 'user_id'; - $this->params['credentials_table'] = $this->params['prefix'] . $this->params['credentials_table']; - } - - /** - * Queries the user table - */ - public function query($params = array(), $operator = "OR") - { - - $page = intval(@$params['page']); - $size = intval(@$params['size']); - $sort = @$params['sort']; - - unset($params['page']); - unset($params['size']); - unset($params['sort']); - - if (!$size) $size = 20; - - $cond = array(); - $bindings = array(); - - foreach ($params as $key => $value) { - $cond[] = sprintf("`%s` %s :where_%s", - str_replace('`', '``', $key), - is_null($value) ? 'is' : (strpos($value, '%') !== FALSE ? 'LIKE' : '='), - $key); - $bindings[":where_$key"] = $value; - } - - $query = sprintf(/** @lang text */ - "SELECT SQL_CALC_FOUND_ROWS * FROM `%s` WHERE %s", - $this->params['table'], - empty($cond) ? '1=1' : implode(" $operator ", $cond) - ); - - $query .= sprintf(" LIMIT %d, %d", $page * $size, $size); - - $stmt = $this->pdo->prepare($query); - - $stmt->execute($bindings); - - if (!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2]); - } - - $data = $stmt->fetchAll(PDO::FETCH_ASSOC); - - $stmt = $this->pdo->query("SELECT FOUND_ROWS() as count"); - - $count = $stmt->fetch(PDO::FETCH_ASSOC); - - if (!$count) { - throw new \Exception("Error fetching count"); - } - - $count = intval($count['count']); - - return array( - '_embedded' => array( - $this->params['table'] => $data - ), - 'page' => array( - 'size' => $size, - 'number' => $page, - 'totalElements' => $count, - 'totalPages' => ceil($count / $size) - ) - ); - } - - /** - * Retrieves a user from the database - * @param $user_id String user_id - * @param $key String which key to lookup (id, token) - * @return array user data - * @throws UserException - * @throws \Exception - */ - public function get($user_id, $key = 'id') - { - $query = sprintf(/** @lang text */ - "SELECT * FROM `%s` where `%s` = %s", - $this->params['table'], - $this->params[$key], - $this->pdo->quote($user_id)); - - $stmt = $this->pdo->query($query); - - if (!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2]); - } - - if ($user = $stmt->fetch(PDO::FETCH_ASSOC)) { - - foreach ($this->params['with'] as $table => $fk) { - $query = sprintf(/** @lang text */ - "SELECT * FROM `%s` where `%s` = %s", - $this->params['prefix'] . $table, - $fk, - $user[$this->params['id']] - ); - - $stmt = $this->pdo->query($query); - - if (!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2]); - } - - $user[$table] = $stmt->fetchAll(PDO::FETCH_ASSOC); - } - - if (!empty($user['scopes'])) { - $user['scopes'] = explode(',', $user['scopes']); - } - return $user; - } else { - throw new UserException('User not found', 404); - } - } - - - /** - * - * TODO usar transaction, atualizar credentials - * - * @param string $uid - * @param string $password - * @param array $data associative array of additional columns to store - */ - public function register($uid, $password = null, $data = array()) - { - $fields = []; - - if (is_array($uid)) { - $data = array_merge($uid, $data); - - $uid = @$data['uid']; - unset($data['uid']); - - $password = @$data['password']; - unset($data['password']); - - } - - $provider = empty($data['provider']) ? 'local' : $data['provider']; - unset($data['provider']); - - $credential = $this->get_credential($provider, $uid); - - if (!empty($credential)) { - $ex = new UserException('Email já registrado', 409); - $ex->setUser($this->get($credential['user_id'])); - throw $ex; - } - - if (empty($data['name'])) { - // set $data['name'] to first part of email stored in $uid - list($data['name'],) = explode('@', $uid); - } - - $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; - unset($data['profile']); - - - $with = []; - foreach ($this->params['with'] as $k => $v) { - if (!empty($data[$k])) { - $with[$k] = &$data[$k]; - - unset($data[$k]); - } - } - - // escape and encode fields - foreach ($data as $k => $v) { - $fields[str_replace(array('\\', "\0", '`'), '', $k)] = is_array($v) ? json_encode($v) : $v; - } - - if ($password) { - $fields[$this->params['password']] = self::hash($password); - } - - if ($this->params['token']) { - $fields[$this->params['token']] = self::hash(); - } - - if ($this->params['created']) { - $fields[$this->params['created']] = date('Y-m-d H:i:s'); - } - - if (!empty($fields[$this->params['scopes']])) { - if (is_array($fields[$this->params['scopes']])) { - $fields[$this->params['scopes']] = implode(",", $data[$this->params['scopes']]); - } - } else { - $fields[$this->params['scopes']] = ""; - } - - - $this->pdo->beginTransaction(); - - $stmt = $this->pdo->prepare("INSERT INTO " . $this->params['table'] - . " (" . implode(", ", array_keys($fields)) - . ") VALUES (:" . implode(", :", array_keys($fields)) . ");"); - - foreach ($fields as $k => $v) { - $stmt->bindValue(":" . $k, $v); - } - - if ($stmt->execute()) { - $fields[$this->params['id']] = $this->pdo->lastInsertId(); - unset($fields[$this->params['created']]); - unset($fields[$this->params['password']]); - - } else { - $this->pdo->rollBack(); - $errorInfo = $this->pdo->errorInfo(); - - if ($errorInfo[1] == 1062) { - throw new \Exception(sprintf("User %s already exists", $uid), 409); - } else { - throw new \Exception($errorInfo[2]); - } - } - - foreach ($with as $with_table => $with_content) { - - $with_fields = array_keys($with_content); - - $stmt = $this->pdo->prepare(sprintf("INSERT INTO %s (%s) VALUES (%s)", - $this->params['prefix'] . $with_table, - $this->params['with'][$with_table] . ',' . implode(',', $with_fields), - ':' . $this->params['with'][$with_table] . ',:' . implode(',:', $with_fields) - )); - - // fk - $stmt->bindValue(':' . $this->params['with'][$with_table], $fields[$this->params['id']]); - - // additional fields - foreach ($with_content as $with_k => $with_v) { - $stmt->bindValue(':' . $with_k, $with_v); - } - - if (!$stmt->execute()) { - $this->pdo->rollBack(); - $errorInfo = $this->pdo->errorInfo(); - throw new \Exception($errorInfo[2]); - } - } - - $stmt = $this->pdo->prepare("INSERT INTO `{$this->params['credentials_table']}` - (user_id, provider, uid, profile) VALUES (:user_id, :provider, :uid, :profile)"); - $stmt->bindValue(':user_id', $fields[$this->params['id']]); - $stmt->bindValue(':provider', $provider); - $stmt->bindValue(':uid', $uid); - $stmt->bindValue(':profile', $profile); - - if ($stmt->execute()) { - $this->pdo->commit(); - return $fields; - } else { - $this->pdo->rollBack(); - $errorInfo = $this->pdo->errorInfo(); - throw new \Exception($errorInfo[2]); - } - - } - - /** - * - * Update a user's password - * - * @param user_id - * @param $password - * @param $key String id - * @return bool TRUE on success - * @throws UserException if no rows were updated - */ - public function passwd($user_id, $password, $key = 'id') - { - - $query = sprintf(/** @lang text */ - "UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", - $this->params['table'], - $this->params['password'], - $this->pdo->quote(self::hash($password)), - $this->params[$key], - $this->pdo->quote($user_id)); - - $stmt = $this->pdo->query($query); - - if ($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('User not found'); - } - - return TRUE; - - } - - /** - * - * Reset a user's password - * - * @param $token String - * @param $password String new password - * @return $user array user data on success - * @throws UserException if no rows were updated - */ - public function passwd_reset($token, $password) - { - - $user = $this->get($token, $this->params['token']); - - $query = sprintf(/** @lang text */ - 'UPDATE `%s` SET `%s` = %s, `%s` = NULL WHERE %s = %s', - $this->params['table'], - $this->params['password'], - $this->pdo->quote(self::hash($password)), - $this->params['token'], - $this->params['token'], - $this->pdo->quote($token)); - - - $stmt = $this->pdo->query($query); - - if ($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('Hash not found'); - } - - return $user; - - } - - - /** - * Update arbitrary user data - * @param $user_id - * @param array $data associative array of data - * @param key String - * @throws UserException if no rows were updated - */ - public function update($user_id, array $data, $key = 'id') - { - $cond = array(); - unset($data[$this->params['id']]); - unset($data[$this->params['password']]); - - if ($this->params['token']) { - unset($data[$this->params['token']]); - } - - if ($this->params['created']) { - unset($data[$this->params['created']]); - } - - if ($this->params['last_login']) { - unset($data[$this->params['last_login']]); - } - - - foreach ($data as $k => $v) { - $cond[] = sprintf("`%s` = %s", str_replace(array('\\', "\0", '`'), '', $k), $this->pdo->quote($v)); - } - - $query = sprintf(/** @lang text */ - "UPDATE `%s` SET %s WHERE `%s` = %s", - $this->params['table'], - implode(', ', $cond), - $this->params[$key], - $this->pdo->quote($user_id)); - - $stmt = $this->pdo->query($query); - - if ($stmt === FALSE) { - throw new UserException(json_encode($this->pdo->errorInfo())); - } - - } - - public function delete($user_id) - { - - if ($this->check()) { - $user = $this->user(); - - if ($user[$this->params[$key]] == $user_id) { - throw new \Exception("Cannot delete yourself!"); - } - } - - $this->pdo->beginTransaction(); - - $stmt = $this->pdo->prepare(/** @lang text */ - "DELETE FROM `{$this->params['credentials_table']}` WHERE `user_id` = :user_id"); - - $stmt->bindValue(':user_id', $user_id); - - if ($stmt->execute() === FALSE) { - $this->pdo->rollBack(); - throw new \Exception(json_encode($this->pdo->errorInfo())); - } - - $stmt = $this->pdo->prepare(/** @lang text */ - "DELETE FROM `{$this->params['table']}` WHERE `{$this->params['id']}` = :user_id LIMIT 1"); - - $stmt->bindValue(':user_id', $user_id); - - if ($stmt->execute() === FALSE) { - $this->pdo->rollBack(); - throw new \Exception(json_encode($this->pdo->errorInfo())); - } - - $this->pdo->commit(); - - return true; - } - - /** - * Generates a new token for the user and update the database - * @param $user_id - * @return string new token - */ - public function update_token($user_id) - { - $token = self::hash(); - - $query = sprintf(/** @lang text */ - "UPDATE `%s` SET `%s` = %s WHERE `%s` = %s", - $this->params['table'], - $this->params['token'], - $this->pdo->quote($token), - $this->params['id'], - $this->pdo->quote($user_id)); - - $stmt = $this->pdo->query($query); - - if ($stmt === FALSE || $stmt->rowCount() !== 1) { - throw new UserException('Token not found'); - } - - return $token; - } - - /** - * Retrieves an account from credentials_table - * @param $provider provider - * @param $userid String user_id - * @return mixed account data or null if account not found - * @throws \Exception - */ - public function get_credential($provider, $accountid) - { - $query = sprintf(/** @lang text */ - "SELECT a.user_id, a.provider, a.uid, a.profile - FROM `%s` a - WHERE a.provider = %s and a.uid = %s", - $this->params['credentials_table'], - $this->pdo->quote($provider), - $this->pdo->quote($accountid)); - - $stmt = $this->pdo->query($query); - - if (!$stmt) { - $error = $this->pdo->errorInfo(); - - throw new \Exception($error[2], 500); - } - - if ($account = $stmt->fetch(PDO::FETCH_ASSOC)) { - if (!empty($account['profile'])) { - $account['profile'] = json_decode($account['profile'], true); - } - - return $account; - } else { - return false; - } - } - - /** - * Inserts a new account on $userid, or update the existing one - * - * @param $userid - * @param $provider - * @param $uid - * @param null $profile - * @return bool - * @throws \Exception - */ - public function update_credential($userid, $provider, $uid, $profile = null) - { - - if (is_array($profile)) { - $profile = json_encode($profile); - } - - $query = sprintf(/** @lang text */ - "INSERT INTO `%s` (user_id, provider, uid, profile, last_login) VALUES (%s, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE profile = VALUES(profile), last_login = VALUES(last_login)", - $this->params['credentials_table'], - $this->pdo->quote($userid), - $this->pdo->quote($provider), - $this->pdo->quote($uid), - $this->pdo->quote($profile), - $this->pdo->quote(date('Y-m-d H:i:s')) - ); - - if (!$this->pdo->query($query)) { - $errorInfo = $this->pdo->errorInfo(); - - throw new \Exception($errorInfo[2], 500); - } - - return true; - - } -} diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php new file mode 100644 index 0000000..f1f86e5 --- /dev/null +++ b/test/DBAuthSqliteTest.php @@ -0,0 +1,166 @@ +query( + 'CREATE TABLE auth_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT, + image TEXT, + scopes TEXT, + created TEXT, + password TEXT, + token TEXT + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_credentials ( + uid TEXT NOT NULL, + provider TEXT NOT NULL, + user_id INTEGER NOT NULL, + profile TEXT NULL, + last_login TEXT NULL, + PRIMARY KEY(uid, provider) + )' + )->exec(); + + self::$auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + ]); + } + + protected function setUp(): void + { + $_SESSION = []; + self::$db->query('DELETE FROM auth_credentials')->exec(); + self::$db->query('DELETE FROM auth_user')->exec(); + } + + public function testRegistration(): void + { + $user = self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + $this->assertArrayHasKey('id', $user); + $this->assertSame(1, (int) $user['id']); + $this->assertArrayHasKey('uuid', $user); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', + $user['uuid'] + ); + $this->assertSame('Alice', $user['name']); + + $logged = self::$auth->login('alice@example.com', 'secret'); + $this->assertSame($user['id'], $logged['id']); + $this->assertMatchesRegularExpression( + '/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/', + $logged['created'] + ); + $this->assertTrue(self::$auth->check()); + + $sessionUser = self::$auth->user(); + $this->assertSame($user['id'], $sessionUser['id']); + + self::$auth->logout(); + $this->assertFalse(self::$auth->check()); + } + + public function testInvalidLogin(): void + { + $this->expectException(UserException::class); + self::$auth->login('nouser@example.com', 'pass'); + } + + public function testInvalidPassword(): void + { + self::$auth->register('alice@example.com', 'secret'); + $this->expectException(AuthException::class); + self::$auth->login('alice@example.com', 'wrong'); + } + + public function testPasswd(): void + { + $user = self::$auth->register('alice@example.com', 'secret'); + $account = self::$auth->get_credential('local', 'alice@example.com'); + + $this->assertNotFalse($account); + + $updated = self::$auth->passwd($account['user_id'], 'new-secret'); + $this->assertTrue($updated); + + $logged = self::$auth->login('alice@example.com', 'new-secret'); + $this->assertSame($user['id'], $logged['id']); + } + + public function testUpdate(): void + { + $user = self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + $account = self::$auth->get_credential('local', 'alice@example.com'); + + $current = self::$auth->get($account['user_id']); + $this->assertSame('Alice', $current['name']); + $this->assertSame($user['uuid'], $current['uuid']); + + self::$auth->update($user['id'], ['name' => 'Alice Updated', 'uuid' => 'manual-value']); + $updated = self::$auth->get($user['id']); + + $this->assertSame('Alice Updated', $updated['name']); + $this->assertSame($user['uuid'], $updated['uuid']); + } + + public function testQuery(): void + { + self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + $list = self::$auth->query(); + + $this->assertSame(1, count($list['_embedded']['auth_user'])); + $this->assertSame('Alice', $list['_embedded']['auth_user'][0]['name']); + $this->assertSame(1, $list['page']['totalElements']); + $this->assertSame(1, $list['page']['totalPages']); + $this->assertSame(0, $list['page']['number']); + } + + public function testRequestToken(): void + { + $user = self::$auth->register('alice@example.com', 'secret'); + $account = self::$auth->get_credential('local', 'alice@example.com'); + $this->assertSame($user['id'], $account['user_id']); + + $token = self::$auth->update_token($account['user_id']); + self::$auth->passwd_reset($token, 'final-secret'); + + $logged = self::$auth->login('alice@example.com', 'final-secret'); + $this->assertSame($user['id'], $logged['id']); + } + + public function testDelete(): void + { + $this->expectException(UserException::class); + + self::$auth->register('alice@example.com', 'secret'); + $account = self::$auth->get_credential('local', 'alice@example.com'); + self::$auth->logout(); + + self::$auth->delete($account['user_id']); + self::$auth->login('alice@example.com', 'secret'); + } +} diff --git a/test/MysqlAuthTest.php b/test/MysqlAuthTest.php deleted file mode 100644 index f9b8232..0000000 --- a/test/MysqlAuthTest.php +++ /dev/null @@ -1,149 +0,0 @@ -register('vagrant@localhost', 'test', array( - 'name' => 'Test User' - )); - - $this->assertEquals(1, $user['id']); - - // test Login - $user = self::$auth->login('vagrant@localhost', 'test'); - - $this->assertEquals(1, $user['id'], "Check user ID"); - $this->assertRegExp('/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}/', - $user['created'], - "Check creation date"); - $this->assertTrue(self::$auth->check()); - - // test Session - $user = self::$auth->user(); - - $this->assertEquals(1, $user['id']); - - // test Logout - self::$auth->logout(); - - $this->assertFalse(self::$auth->check()); - } - - /** - * @expectedException Objectiveweb\Auth\UserException - */ - public function testInvalidLogin() - { - self::$auth->login('nouser', 'pass'); - - } - - /** - * @expectedException Objectiveweb\Auth\AuthException - */ - public function testInvalidPassword() - { - self::$auth->login('vagrant@localhost', 'pass'); - - } - - public function testPasswd() - { - - $account = self::$auth->get_credential('local', 'vagrant@localhost'); - - $this->assertNotNull($account); - - $t = self::$auth->passwd($account['user_id'], "1234"); - - $this->assertTrue($t); - - $user = self::$auth->login('vagrant@localhost', '1234'); - - $this->assertEquals(1, $user['id']); - - } - - /** - * @depends testRegistration - */ - public function testUpdate() { - - $account = self::$auth->get_credential('local', 'vagrant@localhost'); - - $user = self::$auth->get($account['user_id']); - - $this->assertEquals('Test User', $user['name']); - - self::$auth->update($account['user_id'], array( 'name' => 'Updated name' )); - - $user = self::$auth->get($account['user_id']); - - $this->assertEquals('Updated name', $user['name']); - } - - /** - * @depends testUpdate - */ - public function testQuery() { - $all = self::$auth->query(); - - $this->assertEquals(1, count($all['_embedded']['ow_user'])); - - $this->assertEquals('Updated name', $all['_embedded']['ow_user'][0]['name']); - - $this->assertEquals(1, $all['page']['totalElements']); - $this->assertEquals(1, $all['page']['totalPages']); - $this->assertEquals(0, $all['page']['number']); - } - - public function testRequestToken() { - $account = self::$auth->get_credential('local', 'vagrant@localhost'); - - $token = self::$auth->update_token($account['user_id']); - - self::$auth->passwd_reset($token, 'test'); - - self::$auth->login('vagrant@localhost', 'test'); - } - /** - * @expectedException Objectiveweb\Auth\UserException - */ - public function testDelete() { - $account = self::$auth->get_credential('local', 'vagrant@localhost'); - - self::$auth->logout(); - self::$auth->delete($account['user_id']); - self::$auth->login('vagrant@localhost', 'pass'); - } - -} \ No newline at end of file diff --git a/test/Mysql_TestCase.php b/test/Mysql_TestCase.php deleted file mode 100644 index 9e39da9..0000000 --- a/test/Mysql_TestCase.php +++ /dev/null @@ -1,49 +0,0 @@ -setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $pdo->exec('drop table if exists ow_credentials'); - $pdo->exec('drop table if exists ow_user'); - $pdo->query('create table ow_user - (`id` INT UNSIGNED PRIMARY KEY NOT NULL AUTO_INCREMENT, - `name` VARCHAR(255), - `image` VARCHAR(255), - `scopes` VARCHAR(255), - `created` DATETIME, - `password` CHAR(60), - `token` CHAR(32))'); - - $pdo->query("create table ow_credentials - ( - uid varchar(255) not null, - provider varchar(32) not null, - user_id int(11) unsigned not null, - profile text null, - modified datetime null, - primary key (uid, provider), - constraint credentials_ibfk_1 - foreign key (user_id) references ow_user (id) - - )"); - - try { - self::$auth = new \Objectiveweb\Auth\MysqlAuth($pdo, array( - 'created' => 'created', - 'token' => 'token' - )); - } catch(\Throwable $ex) { - echo $ex->getMessage(); - exit($ex->getTraceAsString()); - } - } -} From a2fb09c7784e1d6d23c8e3161df27ff249436d75 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Wed, 25 Feb 2026 11:57:16 -0300 Subject: [PATCH 025/217] Replace travis with github action --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ .travis.yml | 17 ----------------- 2 files changed, 29 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..efb716c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ["8.2", "8.3", "8.4"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run tests + run: vendor/bin/phpunit test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1769d25..0000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: php - -sudo: false - -php: - - 5.6 - - 7.0 - - 7.1 - - 7.2 - -services: mysql - -before_script: - - mysql -e 'create database if not exists objectiveweb;' - - composer install - -script: vendor/bin/phpunit test/ \ No newline at end of file From 470edffcf523a4847d5c068fd674b374767d0d5e Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Wed, 25 Feb 2026 14:14:28 -0300 Subject: [PATCH 026/217] Security improvements --- src/Auth.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Auth.php b/src/Auth.php index 0bd5a69..3710611 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -78,6 +78,9 @@ public function &login($uid, $password) unset($user[$this->params['password']]); unset($user[$this->params['token']]); + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } $this->user($user); // TODO add login ip @@ -132,7 +135,7 @@ public function &user($user = null) public function validate($user) { - $uid = trim(@$user['uid']); + $uid = trim((string) ($user['uid'] ?? '')); if (empty($uid)) { throw new \Exception("Missing uid", 400); From 146cba68a0f96bfc124e6bcae582c158b1c62ab9 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Wed, 25 Feb 2026 14:15:02 -0300 Subject: [PATCH 027/217] Add user_roles support --- README.md | 24 ++- src/Auth.php | 15 +- src/Auth/Controller/UserController.php | 2 + src/Auth/DBAuth.php | 207 ++++++++++++++++++++++--- src/Auth/Middleware/RequireRole.php | 46 ++++++ src/Auth/Middleware/RequireScope.php | 11 +- test/AuthControllerTest.php | 60 +++++++ test/DBAuthSqliteTest.php | 50 ++++++ test/OAuthControllerTest.php | 21 +++ test/RequireRoleTest.php | 72 +++++++++ test/RequireScopeTest.php | 82 ++++++++++ test/UserControllerTest.php | 147 ++++++++++++------ 12 files changed, 658 insertions(+), 79 deletions(-) create mode 100644 src/Auth/Middleware/RequireRole.php create mode 100644 test/AuthControllerTest.php create mode 100644 test/OAuthControllerTest.php create mode 100644 test/RequireRoleTest.php create mode 100644 test/RequireScopeTest.php diff --git a/README.md b/README.md index 991c57a..e101779 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# objectiveweb/auth +# objectiveweb/auth [![CI](https://github.com/objectiveweb/auth/actions/workflows/ci.yml/badge.svg)](https://github.com/objectiveweb/auth/actions/workflows/ci.yml) Authentication library with pluggable providers. @@ -37,10 +37,13 @@ $auth = new DBAuth($db, [ 'id' => 'id', 'password' => 'password', 'scopes' => 'scopes', + 'roles' => 'roles', 'token' => 'token', 'created' => 'created', 'uuid' => 'uuid', 'credentials_last_login' => 'last_login', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', ]); ``` @@ -72,6 +75,17 @@ CREATE TABLE auth_credentials ( last_login TEXT NULL, PRIMARY KEY(uid, provider) ); + +CREATE TABLE auth_role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +CREATE TABLE auth_user_role ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY(user_id, role_id) +); ``` #### DBAuth params @@ -80,9 +94,16 @@ CREATE TABLE auth_credentials ( - `id`: user PK field (default `id`) - `password`: password field (default `password`) - `scopes`: scopes field (default `scopes`) +- `roles`: roles field attached to the user payload (default `roles`) - `token`: optional password-reset token field - `table`: users table name (default `user`) - `credentials_table`: credentials table name (default `user_credentials`) +- `roles_table`: optional roles table name (default `null`) +- `user_roles_table`: optional user-role mapping table name (default `null`) +- `user_roles_user_id`: user FK column in mapping table (default `user_id`) +- `user_roles_role_id`: role FK column in mapping table (default `role_id`) +- `role_id`: role PK column in `roles_table` (default `id`) +- `role_name`: role name column in `roles_table` (default `name`) - `created`: optional created-at field - `last_login`: optional user last-login field - `credentials_last_login`: optional credentials last-login field @@ -176,3 +197,4 @@ Included: - `OAuthController` - `UserController` - `RequireScope` middleware +- `RequireRole` middleware diff --git a/src/Auth.php b/src/Auth.php index 3710611..edcb59d 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -19,6 +19,7 @@ function __construct(array $params) 'id' => 'id', 'password' => 'password', 'scopes' => 'scopes', + 'roles' => 'roles', 'token' => NULL, // Name of the field that should store user tokens 'register_scope' => Auth::ANONYMOUS, // who is allowed to use /register 'register_callback' => null @@ -52,8 +53,20 @@ public function user_can($scope): bool } $user = $this->user(); + $grants = []; - return in_array($scope, $user['scopes']); + $scopeField = $this->params['scopes'] ?? 'scopes'; + if (!empty($user[$scopeField])) { + $grants = is_array($user[$scopeField]) ? $user[$scopeField] : explode(',', (string) $user[$scopeField]); + } + + $roleField = $this->params['roles'] ?? 'roles'; + if (!empty($user[$roleField])) { + $roles = is_array($user[$roleField]) ? $user[$roleField] : explode(',', (string) $user[$roleField]); + $grants = array_merge($grants, $roles); + } + + return in_array($scope, array_values(array_unique(array_filter($grants))), true); } /** diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 970c2d2..893a177 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -27,6 +27,7 @@ public function __construct(public Auth $auth) 'name', 'image', $this->auth->params['scopes'] ?? 'scopes', + $this->auth->params['roles'] ?? 'roles', $this->auth->params['created'] ?? null, ]))); @@ -36,6 +37,7 @@ public function __construct(public Auth $auth) 'name', 'image', $this->auth->params['scopes'] ?? 'scopes', + $this->auth->params['roles'] ?? 'roles', 'provider', 'profile', ]))); diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index f85216d..07972aa 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -17,6 +17,12 @@ public function __construct(private DB $db, array $params = []) 'credentials_table' => 'user_credentials', 'credentials_last_login' => null, 'uuid' => 'uuid', + 'roles_table' => null, + 'user_roles_table' => null, + 'user_roles_user_id' => 'user_id', + 'user_roles_role_id' => 'role_id', + 'role_id' => 'id', + 'role_name' => 'name', 'with' => [], ]; @@ -42,7 +48,7 @@ public function query($params = array(), $operator = "OR") if (!isset($row[$this->params['id']])) { continue; } - $rowsById[$row[$this->params['id']]] = $this->normalizeUserRow($row); + $rowsById[$row[$this->params['id']]] = $this->hydrateUserRow($row); } } @@ -58,7 +64,7 @@ public function query($params = array(), $operator = "OR") ]; $slice = $this->db->select($this->params['table'], $filter, $queryParams)->all(); - $slice = array_map(fn (array $row): array => $this->normalizeUserRow($row), $slice); + $slice = array_map(fn (array $row): array => $this->hydrateUserRow($row), $slice); $count = $this->db->count($this->params['table'], $filter); } @@ -84,11 +90,7 @@ public function get($user_id, $key = 'id') throw new UserException('User not found', 404); } - foreach ($this->params['with'] as $table => $fk) { - $row[$table] = $this->db->select($table, [$fk => $row[$this->params['id']]])->all(); - } - - return $this->normalizeUserRow($row); + return $this->hydrateUserRow($row); } public function register($uid, $password = null, $data = array()) @@ -130,8 +132,17 @@ public function register($uid, $password = null, $data = array()) } } + $roleField = $this->params['roles'] ?? 'roles'; + $syncRoles = array_key_exists($roleField, $data); + $roles = $syncRoles ? $this->normalizeRoleNames($data[$roleField]) : []; + unset($data[$roleField]); + $fields = []; foreach ($data as $key => $value) { + if ($key === $this->params['scopes'] && is_array($value)) { + $fields[$key] = implode(',', $value); + continue; + } $fields[$key] = is_array($value) ? json_encode($value) : $value; } @@ -152,13 +163,11 @@ public function register($uid, $password = null, $data = array()) $fields[$this->params['created']] = date('Y-m-d H:i:s'); } - if (!empty($fields[$this->params['scopes']]) && is_array($fields[$this->params['scopes']])) { - $fields[$this->params['scopes']] = implode(',', $fields[$this->params['scopes']]); - } elseif (!array_key_exists($this->params['scopes'], $fields)) { + if (!array_key_exists($this->params['scopes'], $fields)) { $fields[$this->params['scopes']] = ''; } - return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $with): array { + return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $with, $syncRoles, $roles): array { $id = $this->db->insert($this->params['table'], $fields); if (!$id) { throw new \Exception('Could not create user'); @@ -189,12 +198,11 @@ public function register($uid, $password = null, $data = array()) $this->db->insert($this->params['credentials_table'], $credentialPayload); - unset($fields[$this->params['password']]); - if ($this->params['token']) { - unset($fields[$this->params['token']]); + if ($syncRoles) { + $this->syncUserRoles($userId, $roles); } - return $this->normalizeUserRow($fields); + return $this->get($userId); }); } @@ -259,21 +267,29 @@ public function update($user_id, array $data, $key = 'id') unset($data[$this->params['uuid']]); } - if (empty($data)) { - return; - } + $roleField = $this->params['roles'] ?? 'roles'; + $syncRoles = array_key_exists($roleField, $data); + $roles = $syncRoles ? $this->normalizeRoleNames($data[$roleField]) : []; + unset($data[$roleField]); - if (isset($data[$this->params['scopes']]) && is_array($data[$this->params['scopes']])) { - $data[$this->params['scopes']] = implode(',', $data[$this->params['scopes']]); - } + if (!empty($data)) { + if (isset($data[$this->params['scopes']]) && is_array($data[$this->params['scopes']])) { + $data[$this->params['scopes']] = implode(',', $data[$this->params['scopes']]); + } - foreach ($data as $k => $v) { - if (is_array($v)) { - $data[$k] = json_encode($v); + foreach ($data as $k => $v) { + if (is_array($v)) { + $data[$k] = json_encode($v); + } } + + $this->db->update($this->params['table'], $data, [$column => $user_id]); } - $this->db->update($this->params['table'], $data, [$column => $user_id]); + if ($syncRoles) { + $target = $this->get($user_id, $key); + $this->syncUserRoles($target[$this->params['id']], $roles); + } } public function delete($user_id) @@ -287,6 +303,12 @@ public function delete($user_id) return $this->db->transaction(function () use ($user_id): bool { $this->db->delete($this->params['credentials_table'], ['user_id' => $user_id]); + if ($this->rolesEnabled()) { + $this->db->delete( + $this->params['user_roles_table'], + [$this->params['user_roles_user_id'] => $user_id] + ); + } $deleted = $this->db->delete($this->params['table'], [$this->params['id'] => $user_id]); if ($deleted !== 1) { @@ -380,6 +402,141 @@ private function normalizeUserRow(array $row): array return $row; } + private function hydrateUserRow(array $row): array + { + $row = $this->normalizeUserRow($row); + + foreach ($this->params['with'] as $table => $fk) { + $row[$table] = $this->db->select($table, [$fk => $row[$this->params['id']]])->all(); + } + + $roleField = $this->params['roles'] ?? 'roles'; + $row[$roleField] = $this->loadUserRoles($row[$this->params['id']]); + + return $row; + } + + private function rolesEnabled(): bool + { + return !empty($this->params['roles_table']) && !empty($this->params['user_roles_table']); + } + + private function loadUserRoles(int|string $userId): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $roleName = $this->assertIdentifier((string) $this->params['role_name']); + $roleId = $this->assertIdentifier((string) $this->params['role_id']); + $rolesTable = $this->assertIdentifier((string) $this->params['roles_table']); + $userRolesTable = $this->assertIdentifier((string) $this->params['user_roles_table']); + $userRolesUserId = $this->assertIdentifier((string) $this->params['user_roles_user_id']); + $userRolesRoleId = $this->assertIdentifier((string) $this->params['user_roles_role_id']); + + $query = $this->db->query( + sprintf( + 'SELECT r.%s AS role_name + FROM %s r + INNER JOIN %s ur ON ur.%s = r.%s + WHERE ur.%s = :user_id + ORDER BY r.%s', + $roleName, + $rolesTable, + $userRolesTable, + $userRolesRoleId, + $roleId, + $userRolesUserId, + $roleName + ) + ); + $query->exec(['user_id' => $userId]); + + $rows = $query->all(); + return array_values(array_map( + fn (array $entry): string => (string) $entry['role_name'], + $rows + )); + } + + private function syncUserRoles(int|string $userId, array $roles): void + { + if (!$this->rolesEnabled()) { + return; + } + + $userRolesTable = (string) $this->params['user_roles_table']; + $userRolesUserId = (string) $this->params['user_roles_user_id']; + $userRolesRoleId = (string) $this->params['user_roles_role_id']; + + $this->db->delete($userRolesTable, [$userRolesUserId => $userId]); + + foreach ($this->resolveRoleIds($roles) as $roleId) { + $this->db->insert($userRolesTable, [ + $userRolesUserId => $userId, + $userRolesRoleId => $roleId, + ]); + } + } + + private function resolveRoleIds(array $roles): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $roleIds = []; + $rolesTable = (string) $this->params['roles_table']; + $roleIdField = (string) $this->params['role_id']; + $roleNameField = (string) $this->params['role_name']; + + foreach ($roles as $roleName) { + $existing = $this->db->select($rolesTable, [$roleNameField => $roleName], ['limit' => 1])->fetch(); + if ($existing && isset($existing[$roleIdField])) { + $roleIds[] = $existing[$roleIdField]; + continue; + } + + $inserted = $this->db->insert($rolesTable, [$roleNameField => $roleName]); + if ($inserted !== null) { + $roleIds[] = ctype_digit((string) $inserted) ? (int) $inserted : $inserted; + continue; + } + + $created = $this->db->select($rolesTable, [$roleNameField => $roleName], ['limit' => 1])->fetch(); + if ($created && isset($created[$roleIdField])) { + $roleIds[] = $created[$roleIdField]; + } + } + + return array_values(array_unique($roleIds)); + } + + private function normalizeRoleNames(mixed $roles): array + { + if (is_string($roles)) { + $roles = explode(',', $roles); + } + + if (!is_array($roles)) { + return []; + } + + return array_values(array_unique(array_filter(array_map( + fn (mixed $role): string => trim((string) $role), + $roles + )))); + } + + private function assertIdentifier(string $value): string + { + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $value)) { + throw new \InvalidArgumentException("Invalid SQL identifier `$value`"); + } + + return $value; + } + private function uuidV4(): string { $data = random_bytes(16); diff --git a/src/Auth/Middleware/RequireRole.php b/src/Auth/Middleware/RequireRole.php new file mode 100644 index 0000000..89b9706 --- /dev/null +++ b/src/Auth/Middleware/RequireRole.php @@ -0,0 +1,46 @@ +roles = is_array($roles) ? $roles : [$roles]; + } + + public function after(string $method, string $fn, array $params, array|null $response): mixed + { + if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { + $response['_user'] = $this->auth->user(); + } + + return $response; + } + + public function before(string $method, string $fn, array $params): mixed + { + if (!$this->auth->check()) { + throw new AuthException('Forbidden', 401); + } + + $user = $this->auth->user(); + $roleField = $this->auth->params['roles'] ?? 'roles'; + $roles = $user[$roleField] ?? []; + if (is_string($roles)) { + $roles = explode(',', $roles); + } + + if (count(array_intersect($this->roles, is_array($roles) ? $roles : [])) === 0) { + throw new AuthException('Forbidden', 403); + } + + return $params; + } +} diff --git a/src/Auth/Middleware/RequireScope.php b/src/Auth/Middleware/RequireScope.php index fc95e4e..0c4daaa 100644 --- a/src/Auth/Middleware/RequireScope.php +++ b/src/Auth/Middleware/RequireScope.php @@ -9,6 +9,7 @@ class RequireScope implements MiddlewareInterface { + private array $user = []; public function __construct(private Auth $auth, private string|array $scopes) { @@ -30,9 +31,13 @@ public function before(string $method, string $fn, array $params): mixed $scopes = \Objectiveweb\Auth::AUTHENTICATED; $this->user = $this->auth->user(); - - if (is_array($this->user['scopes'])) { - $scopes = array_merge($scopes, $this->user['scopes']); + $scopeField = $this->auth->params['scopes'] ?? 'scopes'; + $userScopes = $this->user[$scopeField] ?? []; + if (is_string($userScopes)) { + $userScopes = explode(',', $userScopes); + } + if (is_array($userScopes)) { + $scopes = array_merge($scopes, $userScopes); } } else { $scopes = \Objectiveweb\Auth::ANONYMOUS; diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php new file mode 100644 index 0000000..35c034d --- /dev/null +++ b/test/AuthControllerTest.php @@ -0,0 +1,60 @@ +auth = new BasicAuth([], ['token' => 'token']); + $this->controller = new AuthController($this->auth); + } + + public function testPostMissingUid(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->post(['password' => 'secret']); + } + + public function testPostMissingPassword(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->post(['uid' => 'a@example.com']); + } + + public function testPostRegisterInvalidEmail(): void + { + $this->expectException(AuthException::class); + $this->controller->postRegister(['uid' => 'invalid', 'password' => 'secret']); + } + + public function testPostTokenPasswordMismatch(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postToken([ + 'token' => 'abc', + 'password' => 'a', + 'confirm' => 'b', + ]); + } + + public function testPostPasswordForgotFlowMissingUid(): void + { + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postPassword(['password' => 'a', 'confirm' => 'a']); + } +} diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index f1f86e5..303525b 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -41,12 +41,30 @@ public static function setUpBeforeClass(): void )' )->exec(); + self::$db->query( + 'CREATE TABLE auth_role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_user_role ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY(user_id, role_id) + )' + )->exec(); + self::$auth = new DBAuth(self::$db, [ 'table' => 'auth_user', 'credentials_table' => 'auth_credentials', 'created' => 'created', 'token' => 'token', 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', ]); } @@ -54,6 +72,8 @@ protected function setUp(): void { $_SESSION = []; self::$db->query('DELETE FROM auth_credentials')->exec(); + self::$db->query('DELETE FROM auth_user_role')->exec(); + self::$db->query('DELETE FROM auth_role')->exec(); self::$db->query('DELETE FROM auth_user')->exec(); } @@ -163,4 +183,34 @@ public function testDelete(): void self::$auth->delete($account['user_id']); self::$auth->login('alice@example.com', 'secret'); } + + public function testUserCanChecksScopesAndRoles(): void + { + self::$auth->register('scoped@example.com', 'secret', [ + 'scopes' => ['profile:read'], + 'roles' => ['admin'], + ]); + + self::$auth->login('scoped@example.com', 'secret'); + + $this->assertTrue(self::$auth->user_can('profile:read')); + $this->assertTrue(self::$auth->user_can('admin')); + $this->assertFalse(self::$auth->user_can('operator')); + + $current = self::$auth->user(); + $this->assertSame(['admin'], $current['roles']); + $this->assertSame(['profile:read'], $current['scopes']); + } + + public function testUpdateRolesReplacesAssignments(): void + { + $user = self::$auth->register('roles@example.com', 'secret', [ + 'roles' => ['partner'], + ]); + + self::$auth->update($user['id'], ['roles' => ['operator', 'admin']]); + $reloaded = self::$auth->get($user['id']); + + $this->assertSame(['admin', 'operator'], $reloaded['roles']); + } } diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php new file mode 100644 index 0000000..bd9c4fd --- /dev/null +++ b/test/OAuthControllerTest.php @@ -0,0 +1,21 @@ + 'token']); + $controller = new OAuthController($auth, []); + + $this->expectException(\Exception::class); + $this->expectExceptionCode(406); + $controller->get('missing-provider', []); + } +} diff --git a/test/RequireRoleTest.php b/test/RequireRoleTest.php new file mode 100644 index 0000000..e382c32 --- /dev/null +++ b/test/RequireRoleTest.php @@ -0,0 +1,72 @@ +auth = new BasicAuth( + ['admin@example.com' => 'secret'], + ['token' => 'token'] + ); + } + + public function testBeforeRejectsUnauthenticatedUser(): void + { + $mw = new RequireRole($this->auth, ['admin']); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $mw->before('GET', 'index', []); + } + + public function testBeforeRejectsMissingRoleForAuthenticatedUser(): void + { + $this->auth->login('admin@example.com', 'secret'); + $mw = new RequireRole($this->auth, ['admin']); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $mw->before('GET', 'index', []); + } + + public function testBeforeAllowsMatchingRoleFromUserData(): void + { + $this->auth->register('role@example.com', 'secret', ['roles' => ['admin']]); + $this->auth->login('role@example.com', 'secret'); + $mw = new RequireRole($this->auth, ['admin']); + + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeUsesConfiguredRoleFieldAndStringRoles(): void + { + $auth = new BasicAuth( + [], + [ + 'token' => 'token', + 'roles' => 'groups', + ] + ); + $auth->register('role2@example.com', 'secret', ['groups' => 'admin,editor']); + $auth->login('role2@example.com', 'secret'); + + $mw = new RequireRole($auth, ['editor']); + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } +} diff --git a/test/RequireScopeTest.php b/test/RequireScopeTest.php new file mode 100644 index 0000000..0f344d5 --- /dev/null +++ b/test/RequireScopeTest.php @@ -0,0 +1,82 @@ +auth = new BasicAuth( + ['admin@example.com' => 'secret'], + ['token' => 'token'] + ); + } + + public function testBeforeAllowsAnonymousScope(): void + { + $mw = new RequireScope($this->auth, Auth::ANONYMOUS); + $params = ['x' => 1]; + + $out = $mw->before('GET', 'index', $params); + $this->assertSame($params, $out); + } + + public function testBeforeRejectsUnauthenticatedForAuthScope(): void + { + $mw = new RequireScope($this->auth, Auth::AUTHENTICATED); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $mw->before('GET', 'index', []); + } + + public function testBeforeRejectsMissingRoleForAuthenticatedUser(): void + { + $this->auth->login('admin@example.com', 'secret'); + $mw = new RequireScope($this->auth, ['ADMIN']); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $mw->before('GET', 'index', []); + } + + public function testBeforeAllowsCustomScopeFromUser(): void + { + $this->auth->register('scoped@example.com', 'secret', ['scopes' => ['ADMIN']]); + $this->auth->login('scoped@example.com', 'secret'); + $mw = new RequireScope($this->auth, ['ADMIN']); + + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeUsesConfiguredScopeFieldAndStringScopes(): void + { + $auth = new BasicAuth( + [], + [ + 'token' => 'token', + 'scopes' => 'permissions', + ] + ); + $auth->register('scoped2@example.com', 'secret', ['permissions' => 'ADMIN,EDITOR']); + $auth->login('scoped2@example.com', 'secret'); + + $mw = new RequireScope($auth, ['EDITOR']); + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } +} diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index d27b323..537b5a5 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -1,35 +1,72 @@ query( + 'CREATE TABLE auth_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + name TEXT, + image TEXT, + scopes TEXT, + created TEXT, + password TEXT, + token TEXT + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_credentials ( + uid TEXT NOT NULL, + provider TEXT NOT NULL, + user_id INTEGER NOT NULL, + profile TEXT NULL, + last_login TEXT NULL, + PRIMARY KEY(uid, provider) + )' + )->exec(); + + self::$auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + ]); + self::$controller = new UserController(self::$auth); } - public function testPost() + protected function setUp(): void { + $_SESSION = []; + self::$db->query('DELETE FROM auth_credentials')->exec(); + self::$db->query('DELETE FROM auth_user')->exec(); + } - $user = self::$controller->post(array( - 'uid' => 'vagrant@localhost', - 'password' => 'test', - 'name' => 'Test User')); - - $this->assertEquals(1, $user['id']); + public function testPost(): void + { + $user = self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); + $this->assertSame(1, (int) $user['id']); } public function testPostMissingUid(): void @@ -40,28 +77,33 @@ public function testPostMissingUid(): void ]); } - /** - * @depends testPost - */ - public function testGet() { - $user = self::$controller->get(1); + public function testGet(): void + { + $user = self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); - $this->assertEquals(1, $user['id']); + $loaded = self::$controller->get((int) $user['id']); + $this->assertSame((int) $user['id'], (int) $loaded['id']); } - /** - * @depends testGet - */ - public function testQuery() { - $all = self::$controller->get(); - - $this->assertEquals(1, count($all['_embedded']['ow_user'])); + public function testQuery(): void + { + self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); - $this->assertEquals('Test User', $all['_embedded']['ow_user'][0]['name']); + $all = self::$controller->get(); - $this->assertEquals(1, $all['page']['totalElements']); - $this->assertEquals(1, $all['page']['totalPages']); - $this->assertEquals(0, $all['page']['number']); + $this->assertSame(1, count($all['_embedded']['auth_user'])); + $this->assertSame('Test User', $all['_embedded']['auth_user'][0]['name']); + $this->assertSame(1, $all['page']['totalElements']); + $this->assertSame(1, $all['page']['totalPages']); + $this->assertSame(0, $all['page']['number']); } public function testQueryInvalidFilterField(): void @@ -70,26 +112,33 @@ public function testQueryInvalidFilterField(): void self::$controller->get(['unknown_field' => 'x']); } - /** - * @depends testQuery - */ - public function testPut() { - - self::$controller->put(1, array( 'name' => 'Updated name' )); + public function testPut(): void + { + $user = self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); - $user = self::$controller->get(1); + self::$controller->put((int) $user['id'], ['name' => 'Updated name']); + $updated = self::$controller->get((int) $user['id']); - $this->assertEquals('Updated name', $user['name']); + $this->assertSame('Updated name', $updated['name']); } - /** - * @depends testPut - */ - public function testDelete() { + public function testDelete(): void + { $this->expectException(\Objectiveweb\Auth\UserException::class); - $deleted = self::$controller->delete(1); + + $user = self::$controller->post([ + 'uid' => 'vagrant@localhost', + 'password' => 'test', + 'name' => 'Test User', + ]); + + $deleted = self::$controller->delete((int) $user['id']); $this->assertTrue($deleted); - $user = self::$controller->get(1); + self::$controller->get((int) $user['id']); } } From 54635e2bd92e75851baace661ada9089ef914eac Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Wed, 25 Feb 2026 18:50:26 -0300 Subject: [PATCH 028/217] Replace `with` functionality with `relations` --- README.md | 37 +++++- src/Auth.php | 88 +++++++++++++- src/Auth/DBAuth.php | 222 +++++++++++++++++++++++++++++++---- test/DBAuthSqliteTest.php | 239 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 558 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e101779..00f5e30 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ This package requires: - local login with hashed password - external credentials (`provider` + `uid`) - password reset tokens -- optional related data inserts (`with`) - immutable UUID generation on user creation (default field name: `uuid`) #### Minimal setup @@ -44,6 +43,23 @@ $auth = new DBAuth($db, [ 'credentials_last_login' => 'last_login', 'roles_table' => 'auth_role', 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'user' => [ + 'table' => 'user_delegations', + 'subject_key' => 'user_id', + 'target_key' => 'target_user_id', + 'ability_key' => 'ability', + ], + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => true // or array of parameters passed to select + // ['order' => 'item_id DESC', 'fields' => ['a', 'b']] + + ], + ], ]); ``` @@ -104,11 +120,12 @@ CREATE TABLE auth_user_role ( - `user_roles_role_id`: role FK column in mapping table (default `role_id`) - `role_id`: role PK column in `roles_table` (default `id`) - `role_name`: role name column in `roles_table` (default `name`) +- `relations`: relationship auth mapping by resource type (default `[]`) +- `relations..eager`: eager load related rows into user payload (`true` => `[]`, `array` => select params) - `created`: optional created-at field - `last_login`: optional user last-login field - `credentials_last_login`: optional credentials last-login field - `uuid`: UUID field created on register and protected from updates (default `uuid`) -- `with`: associative array for extra related inserts, format: `['table_name' => 'foreign_key']` ### `BasicAuth` @@ -190,6 +207,22 @@ $auth->update($userId, ['name' => 'Alice Updated']); $auth->passwd($userId, 'new-password'); ``` +## Authorization checks (`user_can`) + +```php +// Global grants (scopes + roles) +$auth->user_can('admin'); // bool + +// Delegation to a target user (relation type "user") +$auth->user_can('delegate', 42); // bool + +// Permission for a single resource +$auth->user_can('manage', 'item', 10); // bool + +// List resource IDs accessible for an ability +$auth->user_can('manage', 'item'); // int[] +``` + ## Controllers and middleware Included: diff --git a/src/Auth.php b/src/Auth.php index edcb59d..102a729 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -46,12 +46,65 @@ public function check() return !empty($_SESSION[$this->params['session_key']]); } - public function user_can($scope): bool + public function user_can(string $ability, mixed ...$args): bool|array { - if(!$this->check()) { + if (count($args) === 0) { + return $this->hasGlobalGrant($ability); + } + + $mode = null; + if (count($args) === 1 && is_int($args[0])) { + $mode = 'user_relation'; + } elseif (count($args) === 1 && is_string($args[0])) { + $mode = 'resource_list'; + } elseif (count($args) === 2 && is_string($args[0]) && is_int($args[1])) { + $mode = 'resource_relation'; + } + + if ($mode === null) { + throw new \InvalidArgumentException('Invalid user_can() signature'); + } + + if (!$this->check()) { + if ($mode === 'resource_list') { + return []; + } + + return false; + } + + $subjectId = $this->currentUserId(); + if ($subjectId === null) { + if ($mode === 'resource_list') { + return []; + } + + return false; + } + + if ($mode === 'user_relation') { + return $this->userCanRelation($subjectId, 'user', $args[0], $ability); + } + + if ($mode === 'resource_list') { + return $this->userCanRelationList($subjectId, $args[0], $ability); + } + + return $this->userCanRelation($subjectId, $args[0], $args[1], $ability); + } + + private function hasGlobalGrant(string $ability): bool + { + if (!$this->check()) { return false; } + $grants = $this->globalGrants(); + return in_array($ability, $grants, true); + } + + private function globalGrants(): array + { $user = $this->user(); $grants = []; @@ -66,7 +119,36 @@ public function user_can($scope): bool $grants = array_merge($grants, $roles); } - return in_array($scope, array_values(array_unique(array_filter($grants))), true); + return array_values(array_unique(array_filter(array_map( + fn (mixed $grant): string => trim((string) $grant), + $grants + )))); + } + + private function currentUserId(): ?int + { + $idField = $this->params['id'] ?? 'id'; + $user = $this->user(); + $id = $user[$idField] ?? null; + if (is_int($id)) { + return $id; + } + + if (is_string($id) && ctype_digit($id)) { + return (int) $id; + } + + return null; + } + + protected function userCanRelation(int $subjectId, string $resourceType, int $resourceId, string $ability): bool + { + return false; + } + + protected function userCanRelationList(int $subjectId, string $resourceType, string $ability): array + { + return []; } /** diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 07972aa..e9a97af 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -23,7 +23,7 @@ public function __construct(private DB $db, array $params = []) 'user_roles_role_id' => 'role_id', 'role_id' => 'id', 'role_name' => 'name', - 'with' => [], + 'relations' => [], ]; parent::__construct(array_merge($defaults, $params)); @@ -124,14 +124,6 @@ public function register($uid, $password = null, $data = array()) $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; unset($data['profile']); - $with = []; - foreach ($this->params['with'] as $table => $fk) { - if (!empty($data[$table])) { - $with[$table] = $data[$table]; - unset($data[$table], $fk); - } - } - $roleField = $this->params['roles'] ?? 'roles'; $syncRoles = array_key_exists($roleField, $data); $roles = $syncRoles ? $this->normalizeRoleNames($data[$roleField]) : []; @@ -167,7 +159,7 @@ public function register($uid, $password = null, $data = array()) $fields[$this->params['scopes']] = ''; } - return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $with, $syncRoles, $roles): array { + return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $syncRoles, $roles): array { $id = $this->db->insert($this->params['table'], $fields); if (!$id) { throw new \Exception('Could not create user'); @@ -176,15 +168,6 @@ public function register($uid, $password = null, $data = array()) $userId = ctype_digit((string) $id) ? (int) $id : $id; $fields[$this->params['id']] = $userId; - foreach ($with as $withTable => $withContent) { - $fk = $this->params['with'][$withTable]; - $payload = [$fk => $userId]; - foreach ($withContent as $k => $v) { - $payload[$k] = $v; - } - $this->db->insert($withTable, $payload); - } - $credentialPayload = [ 'user_id' => $userId, 'provider' => $provider, @@ -405,10 +388,7 @@ private function normalizeUserRow(array $row): array private function hydrateUserRow(array $row): array { $row = $this->normalizeUserRow($row); - - foreach ($this->params['with'] as $table => $fk) { - $row[$table] = $this->db->select($table, [$fk => $row[$this->params['id']]])->all(); - } + $row = $this->hydrateEagerRelations($row); $roleField = $this->params['roles'] ?? 'roles'; $row[$roleField] = $this->loadUserRoles($row[$this->params['id']]); @@ -528,6 +508,202 @@ private function normalizeRoleNames(mixed $roles): array )))); } + protected function userCanRelation(int $subjectId, string $resourceType, int $resourceId, string $ability): bool + { + $relation = $this->getRelationConfig($resourceType); + if ($relation === null) { + throw new \InvalidArgumentException("Unknown relation type `$resourceType`"); + } + + $rows = $this->db->select( + $relation['table'], + [ + $relation['subject_key'] => $subjectId, + $relation['target_key'] => $resourceId, + ] + )->all(); + + return $this->rowsAllowAbility($rows, $relation, $ability); + } + + protected function userCanRelationList(int $subjectId, string $resourceType, string $ability): array + { + $relation = $this->getRelationConfig($resourceType); + if ($relation === null) { + throw new \InvalidArgumentException("Unknown relation type `$resourceType`"); + } + + $rows = $this->db->select( + $relation['table'], + [$relation['subject_key'] => $subjectId] + )->all(); + + $ids = []; + foreach ($rows as $row) { + if (!$this->rowAllowsAbility($row, $relation, $ability)) { + continue; + } + + $targetId = $row[$relation['target_key']] ?? null; + if (is_int($targetId)) { + $ids[] = $targetId; + continue; + } + + if (is_string($targetId) && ctype_digit($targetId)) { + $ids[] = (int) $targetId; + } + } + + $ids = array_values(array_unique($ids)); + sort($ids); + return $ids; + } + + private function getRelationConfig(string $resourceType): ?array + { + $relations = $this->params['relations'] ?? []; + $relation = $relations[$resourceType] ?? null; + if (!is_array($relation)) { + return null; + } + + $table = $relation['table'] ?? null; + $subjectKey = $relation['subject_key'] ?? null; + $targetKey = $relation['target_key'] ?? null; + if (!is_string($table) || !is_string($subjectKey) || !is_string($targetKey)) { + return null; + } + + if (!isset($relation['ability_key']) && !isset($relation['role_key'])) { + return null; + } + + return $relation; + } + + private function rowsAllowAbility(array $rows, array $relation, string $ability): bool + { + foreach ($rows as $row) { + if ($this->rowAllowsAbility($row, $relation, $ability)) { + return true; + } + } + + return false; + } + + private function rowAllowsAbility(array $row, array $relation, string $ability): bool + { + $abilityKey = $relation['ability_key'] ?? null; + if (is_string($abilityKey)) { + return (($row[$abilityKey] ?? null) === $ability); + } + + $roleKey = $relation['role_key'] ?? null; + if (!is_string($roleKey)) { + return false; + } + + $role = (string) ($row[$roleKey] ?? ''); + if ($role === '') { + return false; + } + + $roleAbilities = $relation['role_abilities'] ?? []; + if (!is_array($roleAbilities)) { + return false; + } + + $abilities = $roleAbilities[$role] ?? []; + if (!is_array($abilities)) { + return false; + } + + return in_array($ability, $abilities, true); + } + + private function hydrateEagerRelations(array $row): array + { + $relations = $this->params['relations'] ?? []; + if (!is_array($relations)) { + return $row; + } + + $subjectId = $row[$this->params['id']] ?? null; + if (!is_int($subjectId)) { + if (is_string($subjectId) && ctype_digit($subjectId)) { + $subjectId = (int) $subjectId; + } else { + return $row; + } + } + + foreach ($relations as $relationName => $relation) { + if (!is_array($relation) || !array_key_exists('eager', $relation)) { + continue; + } + + $eager = $relation['eager']; + if ($eager === false || $eager === null) { + continue; + } + + if ($eager === true) { + $eagerParams = []; + } elseif (is_array($eager)) { + $eagerParams = $eager; + } else { + throw new \InvalidArgumentException("Invalid eager config for relation `$relationName`"); + } + + $targetIds = $this->relationTargetIdsForSubject($relation, $subjectId); + if ($targetIds === []) { + $row[(string) $relationName] = []; + continue; + } + + $eagerTable = $relation['table'] ?? null; + $eagerKey = $relation['target_key'] ?? null; + if (!is_string($eagerTable) || !is_string($eagerKey)) { + throw new \InvalidArgumentException("Invalid relation table/key config for relation `$relationName`"); + } + + $row[(string) $relationName] = $this->db->select( + $eagerTable, + [$eagerKey => $targetIds], + $eagerParams + )->all(); + } + + return $row; + } + + private function relationTargetIdsForSubject(array $relation, int $subjectId): array + { + $table = $relation['table'] ?? null; + $subjectKey = $relation['subject_key'] ?? null; + $targetKey = $relation['target_key'] ?? null; + if (!is_string($table) || !is_string($subjectKey) || !is_string($targetKey)) { + return []; + } + + $rows = $this->db->select($table, [$subjectKey => $subjectId])->all(); + $ids = []; + foreach ($rows as $entry) { + $targetId = $entry[$targetKey] ?? null; + if (is_int($targetId)) { + $ids[] = $targetId; + } elseif (is_string($targetId) && ctype_digit($targetId)) { + $ids[] = (int) $targetId; + } + } + + $ids = array_values(array_unique($ids)); + sort($ids); + return $ids; + } + private function assertIdentifier(string $value): string { if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $value)) { diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 303525b..1e4c9cf 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -56,6 +56,22 @@ public static function setUpBeforeClass(): void )' )->exec(); + self::$db->query( + 'CREATE TABLE user_delegations ( + user_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + ability TEXT NOT NULL + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE item_users ( + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + ability TEXT NOT NULL + )' + )->exec(); + self::$auth = new DBAuth(self::$db, [ 'table' => 'auth_user', 'credentials_table' => 'auth_credentials', @@ -65,6 +81,20 @@ public static function setUpBeforeClass(): void 'roles' => 'roles', 'roles_table' => 'auth_role', 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'user' => [ + 'table' => 'user_delegations', + 'subject_key' => 'user_id', + 'target_key' => 'target_user_id', + 'ability_key' => 'ability', + ], + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + ], + ], ]); } @@ -74,6 +104,8 @@ protected function setUp(): void self::$db->query('DELETE FROM auth_credentials')->exec(); self::$db->query('DELETE FROM auth_user_role')->exec(); self::$db->query('DELETE FROM auth_role')->exec(); + self::$db->query('DELETE FROM user_delegations')->exec(); + self::$db->query('DELETE FROM item_users')->exec(); self::$db->query('DELETE FROM auth_user')->exec(); } @@ -213,4 +245,211 @@ public function testUpdateRolesReplacesAssignments(): void $this->assertSame(['admin', 'operator'], $reloaded['roles']); } + + public function testUserCanChecksDelegationForSpecificUser(): void + { + $actor = self::$auth->register('owner@example.com', 'secret'); + $target = self::$auth->register('target@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + self::$db->insert('user_delegations', [ + 'user_id' => $actor['id'], + 'target_user_id' => $target['id'], + 'ability' => 'admin', + ]); + + $this->assertTrue(self::$auth->user_can('admin', (int) $target['id'])); + $this->assertFalse(self::$auth->user_can('manage', (int) $target['id'])); + } + + public function testUserCanChecksResourceAndListsResourceIds(): void + { + $actor = self::$auth->register('manager@example.com', 'secret'); + self::$auth->login('manager@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 10, + 'ability' => 'manage', + ]); + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 3, + 'ability' => 'manage', + ]); + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 3, + 'ability' => 'manage', + ]); + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 12, + 'ability' => 'view', + ]); + + $this->assertTrue(self::$auth->user_can('manage', 'item', 10)); + $this->assertFalse(self::$auth->user_can('manage', 'item', 12)); + $this->assertSame([3, 10], self::$auth->user_can('manage', 'item')); + $this->assertSame([12], self::$auth->user_can('view', 'item')); + } + + public function testUserCanReturnsFalseOrEmptyWhenUnauthenticatedForRelationModes(): void + { + self::$auth->register('anon@example.com', 'secret'); + $this->assertFalse(self::$auth->user_can('manage')); + $this->assertFalse(self::$auth->user_can('manage', 1)); + $this->assertFalse(self::$auth->user_can('manage', 'item', 1)); + $this->assertSame([], self::$auth->user_can('manage', 'item')); + } + + public function testUserCanGlobalCheckDoesNotReadRelationshipRows(): void + { + $actor = self::$auth->register('owner@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $actor['id'], + 'item_id' => 10, + 'ability' => 'admin', + ]); + + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertTrue(self::$auth->user_can('admin', 'item', 10)); + } + + public function testUserCanSupportsRoleBasedRelationshipMapping(): void + { + self::$db->query( + 'CREATE TABLE IF NOT EXISTS item_members ( + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + role TEXT NOT NULL + )' + )->exec(); + self::$db->query('DELETE FROM item_members')->exec(); + + $roleAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_members', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'role_key' => 'role', + 'role_abilities' => [ + 'owner' => ['manage', 'view'], + 'staff' => ['view'], + ], + ], + ], + ]); + + $actor = $roleAuth->register('rolemap@example.com', 'secret'); + $roleAuth->login('rolemap@example.com', 'secret'); + + self::$db->insert('item_members', [ + 'user_id' => $actor['id'], + 'item_id' => 20, + 'role' => 'owner', + ]); + self::$db->insert('item_members', [ + 'user_id' => $actor['id'], + 'item_id' => 22, + 'role' => 'staff', + ]); + + $this->assertTrue($roleAuth->user_can('manage', 'item', 20)); + $this->assertFalse($roleAuth->user_can('manage', 'item', 22)); + $this->assertSame([20, 22], $roleAuth->user_can('view', 'item')); + } + + public function testGetHydratesRelationWithEagerTrue(): void + { + $eagerAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => true, + ], + ], + ]); + + $user = $eagerAuth->register('eager1@example.com', 'secret'); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 4, 'ability' => 'view']); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 8, 'ability' => 'manage']); + + $loaded = $eagerAuth->get($user['id']); + $this->assertArrayHasKey('item', $loaded); + $ids = array_values(array_map(fn (array $item): int => (int) $item['item_id'], $loaded['item'])); + sort($ids); + $this->assertSame([4, 8], $ids); + } + + public function testGetHydratesRelationWithEagerArrayAsSelectParams(): void + { + $eagerAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => ['order' => 'item_id DESC'], + ], + ], + ]); + + $user = $eagerAuth->register('eager2@example.com', 'secret'); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 3, 'ability' => 'view']); + self::$db->insert('item_users', ['user_id' => $user['id'], 'item_id' => 9, 'ability' => 'view']); + + $loaded = $eagerAuth->get($user['id']); + $ids = array_values(array_map(fn (array $item): int => (int) $item['item_id'], $loaded['item'])); + $this->assertSame([9, 3], $ids); + } + + public function testUserCanThrowsForInvalidSignatureAndUnknownRelation(): void + { + self::$auth->register('owner@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + $this->expectException(\InvalidArgumentException::class); + self::$auth->user_can('manage', 'item', '1'); + } + + public function testUserCanThrowsForUnknownRelationType(): void + { + self::$auth->register('owner@example.com', 'secret'); + self::$auth->login('owner@example.com', 'secret'); + + $this->expectException(\InvalidArgumentException::class); + self::$auth->user_can('manage', 'invoice', 1); + } } From 00de2fbfe5018c952244f363dfc336826c6bacd6 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 2 Mar 2026 21:54:36 -0300 Subject: [PATCH 029/217] Fix Middleware types --- src/Auth/Middleware/RequireRole.php | 2 +- src/Auth/Middleware/RequireScope.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Auth/Middleware/RequireRole.php b/src/Auth/Middleware/RequireRole.php index 89b9706..e10dcc7 100644 --- a/src/Auth/Middleware/RequireRole.php +++ b/src/Auth/Middleware/RequireRole.php @@ -15,7 +15,7 @@ public function __construct(private Auth $auth, string|array $roles) $this->roles = is_array($roles) ? $roles : [$roles]; } - public function after(string $method, string $fn, array $params, array|null $response): mixed + public function after(string $method, string $fn, array $params, mixed $response): mixed { if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { $response['_user'] = $this->auth->user(); diff --git a/src/Auth/Middleware/RequireScope.php b/src/Auth/Middleware/RequireScope.php index 0c4daaa..963c7d2 100644 --- a/src/Auth/Middleware/RequireScope.php +++ b/src/Auth/Middleware/RequireScope.php @@ -16,7 +16,7 @@ public function __construct(private Auth $auth, private string|array $scopes) $this->scopes = is_array($scopes) ? $scopes : [$scopes]; } - public function after(string $method, string $fn, array $params, array|null $response): mixed + public function after(string $method, string $fn, array $params, mixed $response): mixed { if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { $response['_user'] = $this->auth->user(); From 1d9438f02bbfda7c71dca32f3b7545e92d6f7b17 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Tue, 3 Mar 2026 21:41:38 -0300 Subject: [PATCH 030/217] Add login_providers support, add credentials list to user --- README.md | 17 ++++++- src/Auth.php | 30 ++++++++++-- src/Auth/BasicAuth.php | 52 +++++++++++++++++++- src/Auth/Controller/AuthController.php | 24 +++++++--- src/Auth/Controller/OAuthController.php | 4 +- src/Auth/DBAuth.php | 64 ++++++++++++++++++++++++- test/AuthControllerTest.php | 39 +++++++++++++++ test/BasicAuthTest.php | 31 ++++++++++++ test/DBAuthSqliteTest.php | 38 +++++++++++++++ 9 files changed, 282 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 00f5e30..4cf2623 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ This package requires: - password reset tokens - immutable UUID generation on user creation (default field name: `uuid`) -#### Minimal setup +#### Example setup ```php use Objectiveweb\Auth\DBAuth; @@ -37,6 +37,7 @@ $auth = new DBAuth($db, [ 'password' => 'password', 'scopes' => 'scopes', 'roles' => 'roles', + 'login_providers' => ['local', 'email', 'phone'], 'token' => 'token', 'created' => 'created', 'uuid' => 'uuid', @@ -111,6 +112,7 @@ CREATE TABLE auth_user_role ( - `password`: password field (default `password`) - `scopes`: scopes field (default `scopes`) - `roles`: roles field attached to the user payload (default `roles`) +- `login_providers`: ordered providers used by `login()` (default `['local', 'email']`) - `token`: optional password-reset token field - `table`: users table name (default `user`) - `credentials_table`: credentials table name (default `user_credentials`) @@ -194,6 +196,16 @@ $token = $auth->update_token($credential['user_id']); $auth->passwd_reset($token, 'new-password'); ``` +## Credential strategy (`local`, `email`, `phone`, social) + +Recommended model: +- `local`: password-based login identity +- `email`: email identities for verification/login/magic-link +- `phone`: phone identities for SMS verification/login +- `google`, `facebook`, ...: external OAuth providers + +Store each identity in `credentials_table` as `(provider, uid)`, linked to one `user_id`. + ## Query and update ```php @@ -205,6 +217,9 @@ $auth->update($userId, ['name' => 'Alice Updated']); // Update password $auth->passwd($userId, 'new-password'); + +// List all credentials for a user +$credentials = $auth->get_credentials($userId); ``` ## Authorization checks (`user_can`) diff --git a/src/Auth.php b/src/Auth.php index 102a729..1cb5725 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -20,9 +20,11 @@ function __construct(array $params) 'password' => 'password', 'scopes' => 'scopes', 'roles' => 'roles', + 'login_providers' => ['local', 'email'], 'token' => NULL, // Name of the field that should store user tokens 'register_scope' => Auth::ANONYMOUS, // who is allowed to use /register - 'register_callback' => null + 'register_callback' => null, + 'token_callback' => null, ]; $this->params = array_merge($defaults, $params); @@ -159,8 +161,18 @@ protected function userCanRelationList(int $subjectId, string $resourceType, str */ public function &login($uid, $password) { + $credential = false; + $provider = null; + foreach ($this->params['login_providers'] as $candidateProvider) { + $candidateCredential = $this->get_credential($candidateProvider, $uid); + if (!$candidateCredential) { + continue; + } - $credential = $this->get_credential('local', $uid); + $credential = $candidateCredential; + $provider = $candidateProvider; + break; + } if (!$credential) { throw new Auth\UserException('User does not exist', 404); @@ -179,7 +191,7 @@ public function &login($uid, $password) $this->user($user); // TODO add login ip - $this->update_credential($user[$this->params['id']], 'local', $uid, []); + $this->update_credential($user[$this->params['id']], (string) $provider, $uid, []); return $user; } else { @@ -319,6 +331,18 @@ abstract public function update_token($user_id); */ abstract public function get_credential($provider, $accountid); + /** + * List all credentials for a user. + * + * Returned rows are normalized to: + * - uid + * - provider + * - profile + * - last_login + * - created + */ + abstract public function get_credentials($user_id, $key = 'id'): array; + /** * Inserts a new account on $userid, or update the existing one * diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 77a91f9..72fbfc0 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -241,18 +241,59 @@ public function get_credential($provider, $accountid) return $this->credentials[$provider][$accountid] ?? false; } + public function get_credentials($user_id, $key = 'id'): array + { + $user = $this->get($user_id, $key); + $resolvedUserId = $user[$this->params['id']] ?? null; + $result = []; + + foreach ($this->credentials as $provider => $records) { + foreach ($records as $uid => $credential) { + if (($credential['user_id'] ?? null) != $resolvedUserId) { + continue; + } + + $profile = $credential['profile'] ?? null; + if (is_string($profile) && $profile !== '') { + $decoded = json_decode($profile, true); + $profile = is_array($decoded) ? $decoded : $profile; + } + + if (is_array($profile) && isset($profile['_auth']) && is_array($profile['_auth'])) { + unset($profile['_auth']['token']); + } + + $result[] = [ + 'uid' => $uid, + 'provider' => $provider, + 'profile' => $profile, + 'last_login' => $credential['last_login'] ?? null, + 'created' => $credential['created'] ?? null, + ]; + } + } + + usort($result, function (array $a, array $b): int { + return [$a['provider'], $a['uid']] <=> [$b['provider'], $b['uid']]; + }); + + return $result; + } + public function update_credential($userid, $provider, $uid, $profile = null) { if (is_array($profile)) { $profile = json_encode($profile); } + $created = $this->credentials[$provider][$uid]['created'] ?? date('Y-m-d H:i:s'); $this->credentials[$provider][$uid] = [ 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, 'profile' => $profile, 'last_login' => date('Y-m-d H:i:s'), + 'created' => $created, ]; return true; @@ -275,8 +316,14 @@ private function resolveUserId(mixed $value, string $key): int|string|null } if ($key === 'uid') { - $credential = $this->get_credential('local', $value); - return $credential['user_id'] ?? null; + foreach ($this->params['login_providers'] as $provider) { + $credential = $this->get_credential($provider, $value); + if (!empty($credential['user_id'])) { + return $credential['user_id']; + } + } + + return null; } foreach ($this->users as $id => $user) { @@ -325,4 +372,5 @@ private function isPasswordHash(string $value): bool { return password_get_info($value)['algo'] !== null; } + } diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 1d7d438..4fcec3c 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -27,12 +27,13 @@ function __construct(public \Objectiveweb\Auth $auth) #[Middleware(RequireScope::class, Auth::ALL)] function index() { - if($this->auth->check()){ - return $this->auth->user(); - } - else { - return null; + if ($this->auth->check()) { + $user = $this->auth->user(); + $user['credentials'] = $this->auth->get_credentials($user[$this->auth->params['id']]); + return $user; } + + return null; } #[Middleware(RequireScope::class, Auth::AUTHENTICATED)] @@ -133,8 +134,17 @@ function postPassword(array $form) throw new UserException('Invalid request', 400); } - // find user - $credential = $this->auth->get_credential('local', $form['uid']); + // find user by recovery channels + $credential = false; + foreach (['email', 'phone'] as $provider) { + $candidate = $this->auth->get_credential($provider, $form['uid']); + if (empty($candidate['user_id'])) { + continue; + } + + $credential = $candidate; + break; + } if (!empty($credential['user_id'])) { // return new token diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 4c51783..8a65693 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -91,7 +91,7 @@ private function login($provider, $resourceOwner) // if provider credential does not exist, check if there's a local user with the email if (!$credential && !empty($email)) { - $credential = $this->auth->get_credential('local', $email); + $credential = $this->auth->get_credential('email', $email); } // This credential/email is not registered yet @@ -122,7 +122,7 @@ private function login($provider, $resourceOwner) if (!empty($email)) { // Add local credential to the user $this->auth->update_credential($user[$this->auth->params['id']], - 'local', + 'email', $email, []); } diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index e9a97af..205103b 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -16,6 +16,7 @@ public function __construct(private DB $db, array $params = []) 'last_login' => null, 'credentials_table' => 'user_credentials', 'credentials_last_login' => null, + 'credentials_created' => null, 'uuid' => 'uuid', 'roles_table' => null, 'user_roles_table' => null, @@ -178,6 +179,9 @@ public function register($uid, $password = null, $data = array()) if (!empty($this->params['credentials_last_login'])) { $credentialPayload[$this->params['credentials_last_login']] = date('Y-m-d H:i:s'); } + if (!empty($this->params['credentials_created'])) { + $credentialPayload[$this->params['credentials_created']] = date('Y-m-d H:i:s'); + } $this->db->insert($this->params['credentials_table'], $credentialPayload); @@ -370,18 +374,73 @@ public function update_credential($userid, $provider, $uid, $profile = null) 'provider' => $provider, 'uid' => $uid, ]); + if (!empty($this->params['credentials_created'])) { + $data[$this->params['credentials_created']] = date('Y-m-d H:i:s'); + } $this->db->insert($this->params['credentials_table'], $data); return true; } + public function get_credentials($user_id, $key = 'id'): array + { + $user = $this->get($user_id, $key); + $rows = $this->db->select( + $this->params['credentials_table'], + ['user_id' => $user[$this->params['id']]] + )->all(); + + $lastLoginField = $this->params['credentials_last_login'] ?? null; + $createdField = $this->params['credentials_created'] ?? null; + + $result = []; + foreach ($rows as $row) { + $profile = $row['profile'] ?? null; + if (is_string($profile) && $profile !== '') { + $decoded = json_decode($profile, true); + $profile = is_array($decoded) ? $decoded : $profile; + } + + if (is_array($profile) && isset($profile['_auth']) && is_array($profile['_auth'])) { + unset($profile['_auth']['token']); + } + + $result[] = [ + 'uid' => $row['uid'] ?? null, + 'provider' => $row['provider'] ?? null, + 'profile' => $profile, + 'last_login' => ($lastLoginField && array_key_exists($lastLoginField, $row)) + ? $row[$lastLoginField] + : ($row['last_login'] ?? null), + 'created' => ($createdField && array_key_exists($createdField, $row)) + ? $row[$createdField] + : ($row['created'] ?? null), + ]; + } + + usort($result, function (array $a, array $b): int { + return [$a['provider'], $a['uid']] <=> [$b['provider'], $b['uid']]; + }); + + return $result; + } + private function normalizeUserRow(array $row): array { $scopeField = $this->params['scopes']; - if (!empty($row[$scopeField]) && is_string($row[$scopeField])) { - $row[$scopeField] = explode(',', $row[$scopeField]); + $scopes = $row[$scopeField] ?? []; + + if (is_string($scopes)) { + $scopes = explode(',', $scopes); + } elseif (!is_array($scopes)) { + $scopes = []; } + $row[$scopeField] = array_values(array_filter(array_map( + fn (mixed $scope): string => trim((string) $scope), + $scopes + ))); + return $row; } @@ -721,4 +780,5 @@ private function uuidV4(): string return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); } + } diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 35c034d..5f4bc40 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -57,4 +57,43 @@ public function testPostPasswordForgotFlowMissingUid(): void $this->expectExceptionCode(400); $this->controller->postPassword(['password' => 'a', 'confirm' => 'a']); } + + public function testPostPasswordForgotFlowUsesEmailCredential(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new AuthController($auth); + + $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $result = $controller->postPassword(['uid' => 'alice@example.com']); + + $this->assertSame('email', $result['provider']); + $this->assertSame('alice@example.com', $result['uid']); + $this->assertNotEmpty($result['token']); + } + + public function testPostPasswordForgotFlowUsesPhoneCredential(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new AuthController($auth); + + $auth->register('+5511999999999', 'secret', ['provider' => 'phone']); + $result = $controller->postPassword(['uid' => '+5511999999999']); + + $this->assertSame('phone', $result['provider']); + $this->assertSame('+5511999999999', $result['uid']); + $this->assertNotEmpty($result['token']); + } + + public function testIndexReturnsUserWithCredentialsWhenLoggedIn(): void + { + $this->auth->register('alice@example.com', 'secret', ['provider' => 'local']); + $this->auth->update_credential(1, 'phone', '+5511999999999', ['country' => 'BR']); + $this->auth->login('alice@example.com', 'secret'); + + $result = $this->controller->index(); + + $this->assertSame(1, $result['id']); + $this->assertArrayHasKey('credentials', $result); + $this->assertCount(2, $result['credentials']); + } } diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index 38588b5..7f6238e 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -69,4 +69,35 @@ public function testDeleteUserRemovesCredential(): void $this->expectException(UserException::class); $this->auth->login('alice@example.com', 'secret'); } + + public function testLoginWithEmailProviderWhenConfiguredInLoginProviders(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'login_providers' => ['email', 'local'], + ]); + + $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $user = $auth->login('alice@example.com', 'secret'); + + $this->assertSame('alice@example.com', $user['uid']); + } + + public function testGetCredentialsReturnsOnlyExpectedFields(): void + { + $user = $this->auth->register('bob@example.com', 'secret', ['provider' => 'email']); + $this->auth->update_credential($user['id'], 'phone', '+5511999999999', ['country' => 'BR']); + + $credentials = $this->auth->get_credentials($user['id']); + $this->assertCount(2, $credentials); + + foreach ($credentials as $credential) { + $this->assertSame( + ['uid', 'provider', 'profile', 'last_login', 'created'], + array_keys($credential) + ); + $this->assertNotNull($credential['last_login']); + $this->assertNotNull($credential['created']); + } + } } diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 1e4c9cf..c100011 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -36,7 +36,10 @@ public static function setUpBeforeClass(): void provider TEXT NOT NULL, user_id INTEGER NOT NULL, profile TEXT NULL, + token TEXT NULL, + verified_at TEXT NULL, last_login TEXT NULL, + created TEXT NULL, PRIMARY KEY(uid, provider) )' )->exec(); @@ -78,6 +81,7 @@ public static function setUpBeforeClass(): void 'created' => 'created', 'token' => 'token', 'credentials_last_login' => 'last_login', + 'credentials_created' => 'created', 'roles' => 'roles', 'roles_table' => 'auth_role', 'user_roles_table' => 'auth_user_role', @@ -149,6 +153,22 @@ public function testInvalidPassword(): void self::$auth->login('alice@example.com', 'wrong'); } + public function testLoginWithPhoneProviderWhenConfiguredInLoginProviders(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'token' => 'token', + 'created' => 'created', + 'login_providers' => ['phone', 'email', 'local'], + ]); + + $auth->register('+5511999999999', 'secret', ['provider' => 'phone', 'name' => 'Phone User']); + $logged = $auth->login('+5511999999999', 'secret'); + + $this->assertSame('Phone User', $logged['name']); + } + public function testPasswd(): void { $user = self::$auth->register('alice@example.com', 'secret'); @@ -216,6 +236,24 @@ public function testDelete(): void self::$auth->login('alice@example.com', 'secret'); } + public function testGetCredentialsReturnsOnlyExpectedFields(): void + { + $user = self::$auth->register('alice@example.com', 'secret'); + self::$auth->update_credential($user['id'], 'phone', '+5511999999999', ['carrier' => 'test']); + + $credentials = self::$auth->get_credentials($user['id']); + + $this->assertCount(2, $credentials); + foreach ($credentials as $credential) { + $this->assertSame( + ['uid', 'provider', 'profile', 'last_login', 'created'], + array_keys($credential) + ); + $this->assertNotNull($credential['last_login']); + $this->assertNotNull($credential['created']); + } + } + public function testUserCanChecksScopesAndRoles(): void { self::$auth->register('scoped@example.com', 'secret', [ From f1af3428d36b4b9f01fac7df61deb3a9b5650d25 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Mar 2026 07:49:19 -0300 Subject: [PATCH 031/217] Rework Scope/Roles --- README.md | 6 +- composer.json | 3 +- db/20250419210134_auth_migration.php | 58 ++++++ ...224000_auth_dbauth_relations_migration.php | 36 ++++ src/Auth.php | 53 ++---- src/Auth/BasicAuth.php | 11 -- src/Auth/Controller/AuthController.php | 54 +++++- src/Auth/Controller/OAuthController.php | 5 +- src/Auth/Controller/UserController.php | 16 +- src/Auth/DBAuth.php | 167 ++++++++++-------- src/Auth/Middleware/RequireRole.php | 24 +-- src/Auth/Middleware/RequireScope.php | 52 ------ test/AuthControllerTest.php | 45 ++++- test/DBAuthSqliteTest.php | 34 +++- test/RequireRoleTest.php | 39 +++- test/RequireScopeTest.php | 82 --------- test/UserControllerTest.php | 1 - 17 files changed, 374 insertions(+), 312 deletions(-) create mode 100644 db/20250419210134_auth_migration.php create mode 100644 db/20260310224000_auth_dbauth_relations_migration.php delete mode 100644 src/Auth/Middleware/RequireScope.php delete mode 100644 test/RequireScopeTest.php diff --git a/README.md b/README.md index 4cf2623..98c8fd6 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,6 @@ $auth = new DBAuth($db, [ 'credentials_table' => 'auth_credentials', 'id' => 'id', 'password' => 'password', - 'scopes' => 'scopes', 'roles' => 'roles', 'login_providers' => ['local', 'email', 'phone'], 'token' => 'token', @@ -78,7 +77,6 @@ CREATE TABLE auth_user ( uuid TEXT NOT NULL UNIQUE, name TEXT, image TEXT, - scopes TEXT, created TEXT, password TEXT, token TEXT @@ -110,7 +108,6 @@ CREATE TABLE auth_user_role ( - `session_key`: session storage key (default `ow_auth`) - `id`: user PK field (default `id`) - `password`: password field (default `password`) -- `scopes`: scopes field (default `scopes`) - `roles`: roles field attached to the user payload (default `roles`) - `login_providers`: ordered providers used by `login()` (default `['local', 'email']`) - `token`: optional password-reset token field @@ -225,7 +222,7 @@ $credentials = $auth->get_credentials($userId); ## Authorization checks (`user_can`) ```php -// Global grants (scopes + roles) +// Global grants (roles) $auth->user_can('admin'); // bool // Delegation to a target user (relation type "user") @@ -244,5 +241,4 @@ Included: - `AuthController` - `OAuthController` - `UserController` -- `RequireScope` middleware - `RequireRole` middleware diff --git a/composer.json b/composer.json index 155165e..ba71fa2 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "objectiveweb/db": "dev-devel" }, "require-dev": { - "phpunit/phpunit": "10.*" + "phpunit/phpunit": "10.*", + "robmorgan/phinx": "^0.16" }, "suggest": { "league/oauth2-google": "Google Login", diff --git a/db/20250419210134_auth_migration.php b/db/20250419210134_auth_migration.php new file mode 100644 index 0000000..8b72fe4 --- /dev/null +++ b/db/20250419210134_auth_migration.php @@ -0,0 +1,58 @@ +table('user', ['signed' => false]); + $user + ->addColumn('password', 'string', ['limit' => 60, 'null' => true]) + ->addColumn('name', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('image', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('token', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('token_expires_at', 'datetime', ['null' => true]) + ->addColumn('created', 'datetime', [ 'default' => Literal::from('now()')]) + ->create(); + + $user_credentials = $this->table('user_credentials', [ + 'id' => false, + 'primary_key' => ['uid', 'provider'] + ]); + $user_credentials + ->addColumn('uid', 'string', ['limit' => 255]) + ->addColumn('provider', 'string', ['limit' => 32]) + ->addColumn('user_id', 'integer', ['signed' => false]) + ->addColumn('profile', 'text', ['null'=> true]) + ->addColumn('token', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('last_login', 'datetime', ['null' => true]) + ->addColumn('created', 'datetime', [ 'default' => Literal::from('now()')]) + ->addForeignKey('user_id', $user->getTable()) + ->create(); + } +} diff --git a/db/20260310224000_auth_dbauth_relations_migration.php b/db/20260310224000_auth_dbauth_relations_migration.php new file mode 100644 index 0000000..30853b6 --- /dev/null +++ b/db/20260310224000_auth_dbauth_relations_migration.php @@ -0,0 +1,36 @@ +table('role', ['signed' => false]) + ->addColumn('name', 'string', ['limit' => 255]) + ->addIndex(['name'], ['unique' => true]) + ->create(); + + $user_roles = $this->table('user_roles', [ + 'id' => false, + 'primary_key' => ['user_id', 'role_id'], + ]) + ->addColumn('user_id', 'integer', ['signed' => false]) + ->addColumn('role_id', 'integer', ['signed' => false]) + ->addIndex(['role_id']) + ->addForeignKey('user_id', 'user', 'id') + ->addForeignKey('role_id', 'role', 'id') + ->create(); + + $this->table('delegations', ['id' => false, 'primary_key' => ['user_id', 'target_user_id', 'ability']]) + ->addColumn('user_id', 'integer', ['signed' => false]) + ->addColumn('target_user_id', 'integer', ['signed' => false]) + ->addColumn('ability', 'string', ['limit' => 64]) + ->addIndex(['target_user_id']) + ->create(); + + } +} diff --git a/src/Auth.php b/src/Auth.php index 1cb5725..779bd86 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -5,7 +5,7 @@ abstract class Auth { - // Base scopes + // Base access markers const ANONYMOUS = ['anon']; const AUTHENTICATED = ['auth']; const ALL = ['anon', 'auth']; @@ -18,11 +18,14 @@ function __construct(array $params) 'session_key' => 'ow_auth', 'id' => 'id', 'password' => 'password', - 'scopes' => 'scopes', 'roles' => 'roles', 'login_providers' => ['local', 'email'], 'token' => NULL, // Name of the field that should store user tokens + 'token_expires_field' => 'token_expires_at', + 'token_ttl' => 3600, 'register_scope' => Auth::ANONYMOUS, // who is allowed to use /register + 'register_allow_grants' => false, + 'recovery_providers' => ['local', 'email', 'phone'], 'register_callback' => null, 'token_callback' => null, ]; @@ -51,7 +54,15 @@ public function check() public function user_can(string $ability, mixed ...$args): bool|array { if (count($args) === 0) { - return $this->hasGlobalGrant($ability); + if (!$this->check()) { + return false; + } + + $user = $this->user(); + $roleField = $this->params['roles']; + $roles = is_array($user[$roleField] ?? null) ? $user[$roleField] : []; + + return in_array($ability, $roles, true); } $mode = null; @@ -95,41 +106,9 @@ public function user_can(string $ability, mixed ...$args): bool|array return $this->userCanRelation($subjectId, $args[0], $args[1], $ability); } - private function hasGlobalGrant(string $ability): bool - { - if (!$this->check()) { - return false; - } - - $grants = $this->globalGrants(); - return in_array($ability, $grants, true); - } - - private function globalGrants(): array - { - $user = $this->user(); - $grants = []; - - $scopeField = $this->params['scopes'] ?? 'scopes'; - if (!empty($user[$scopeField])) { - $grants = is_array($user[$scopeField]) ? $user[$scopeField] : explode(',', (string) $user[$scopeField]); - } - - $roleField = $this->params['roles'] ?? 'roles'; - if (!empty($user[$roleField])) { - $roles = is_array($user[$roleField]) ? $user[$roleField] : explode(',', (string) $user[$roleField]); - $grants = array_merge($grants, $roles); - } - - return array_values(array_unique(array_filter(array_map( - fn (mixed $grant): string => trim((string) $grant), - $grants - )))); - } - private function currentUserId(): ?int { - $idField = $this->params['id'] ?? 'id'; + $idField = $this->params['id']; $user = $this->user(); $id = $user[$idField] ?? null; if (is_int($id)) { @@ -191,7 +170,7 @@ public function &login($uid, $password) $this->user($user); // TODO add login ip - $this->update_credential($user[$this->params['id']], (string) $provider, $uid, []); + $this->update_credential($user[$this->params['id']], (string) $provider, $uid, null); return $user; } else { diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 72fbfc0..d525c87 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -124,13 +124,6 @@ public function register($uid, $password = null, $data = array()) $record['name'] = $parts[0]; } - $scopeKey = $this->params['scopes']; - if (!isset($record[$scopeKey])) { - $record[$scopeKey] = []; - } elseif (is_string($record[$scopeKey])) { - $record[$scopeKey] = array_filter(explode(',', $record[$scopeKey])); - } - $this->users[$userId] = $record; $this->update_credential($userId, $provider, $uid, $profile); @@ -181,10 +174,6 @@ public function update($user_id, array $data, $key = 'id') unset($data[$this->params['token']]); } - if (array_key_exists($this->params['scopes'], $data) && is_string($data[$this->params['scopes']])) { - $data[$this->params['scopes']] = array_filter(explode(',', $data[$this->params['scopes']])); - } - foreach ($data as $field => $value) { $this->users[$resolved][$field] = $value; } diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 4fcec3c..83a11ed 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -5,9 +5,8 @@ use Objectiveweb\Auth; use Objectiveweb\Auth\AuthException; +use Objectiveweb\Auth\Middleware\RequireRole; use Objectiveweb\Auth\UserException; -use Objectiveweb\Auth\Middleware\RequireScope; - use Objectiveweb\Router\Middleware; /** @@ -17,14 +16,14 @@ * * @package Objectiveweb\Auth */ -#[Middleware(RequireScope::class, Auth::ANONYMOUS)] +#[Middleware(RequireRole::class, Auth::ANONYMOUS)] class AuthController { function __construct(public \Objectiveweb\Auth $auth) { } - #[Middleware(RequireScope::class, Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function index() { if ($this->auth->check()) { @@ -36,9 +35,13 @@ function index() return null; } - #[Middleware(RequireScope::class, Auth::AUTHENTICATED)] + #[Middleware(RequireRole::class, Auth::AUTHENTICATED)] function getLogout($params = []) { + if (!$this->auth->check()) { + throw new AuthException('Forbidden', 401); + } + $this->auth->logout(); if (!empty($params['redirect'])) { @@ -75,6 +78,8 @@ function post(array $user) */ function postRegister(array $user) { + $this->assertCanRegister(); + $uid = trim((string) ($user['uid'] ?? '')); if ($uid === '') { throw new UserException('Missing uid', 400); @@ -88,6 +93,10 @@ function postRegister(array $user) throw new AuthException('Email inválido'); } + if (!$this->auth->params['register_allow_grants']) { + unset($user[$this->auth->params['roles']]); + } + $user = $this->auth->register($uid, $password, $user); $user['uid'] = $uid; @@ -99,7 +108,7 @@ function postRegister(array $user) return $user; } - #[Middleware(RequireScope::class, Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function postToken(array $form) { if (empty($form['token'])) { @@ -114,7 +123,7 @@ function postToken(array $form) return $this->auth->passwd_reset($form['token'], $form['password']); } - #[Middleware(RequireScope::class, Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function postPassword(array $form) { // if user is logged in, update password @@ -136,7 +145,12 @@ function postPassword(array $form) // find user by recovery channels $credential = false; - foreach (['email', 'phone'] as $provider) { + $providers = $this->auth->params['recovery_providers']; + if (!is_array($providers)) { + $providers = []; + } + + foreach ($providers as $provider) { $candidate = $this->auth->get_credential($provider, $form['uid']); if (empty($candidate['user_id'])) { continue; @@ -153,11 +167,33 @@ function postPassword(array $form) if (is_callable($this->auth->params['token_callback'])) { return call_user_func($this->auth->params['token_callback'], $credential); } else { - return $credential; + return []; } } else { throw new UserException('Credential not found', 404); } } } + + private function assertCanRegister(): void + { + $required = $this->auth->params['register_scope']; + $required = is_array($required) ? $required : [$required]; + + if ($this->auth->check()) { + $available = Auth::AUTHENTICATED; + $user = $this->auth->user(); + $roleField = $this->auth->params['roles']; + $userRoles = $user[$roleField] ?? []; + if (is_array($userRoles)) { + $available = array_merge($available, $userRoles); + } + } else { + $available = Auth::ANONYMOUS; + } + + if (count(array_intersect($required, $available)) === 0) { + throw new AuthException('Forbidden', in_array('anon', $available, true) ? 401 : 403); + } + } } diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 8a65693..2738ae0 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -3,9 +3,8 @@ namespace Objectiveweb\Auth\Controller; use Objectiveweb\Auth; +use Objectiveweb\Auth\Middleware\RequireRole; use Objectiveweb\Auth\UserException; - -use Objectiveweb\Auth\Middleware\RequireScope; use Objectiveweb\Router\Middleware; /** @@ -29,7 +28,7 @@ function __construct(\Objectiveweb\Auth $auth, array $providers = []) /** * Execute login on oauth provider */ - #[Middleware(RequireScope::class, Auth::ALL)] + #[Middleware(RequireRole::class, Auth::ALL)] function get($id, $query) { if (!isset($this->providers[$id])) { diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 893a177..e17d545 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -3,7 +3,7 @@ namespace Objectiveweb\Auth\Controller; use Objectiveweb\Auth; -use Objectiveweb\Auth\Middleware\RequireScope; +use Objectiveweb\Auth\Middleware\RequireRole; use Objectiveweb\Auth\UserException; use Objectiveweb\Router\Middleware; @@ -13,7 +13,7 @@ * * @package Objectiveweb\Auth */ -#[Middleware(RequireScope::class, ['ADMIN'])] +#[Middleware(RequireRole::class, ['admin'])] class UserController { private array $allowedQueryFields; @@ -22,22 +22,20 @@ class UserController public function __construct(public Auth $auth) { $this->allowedQueryFields = array_values(array_unique(array_filter([ - $this->auth->params['id'] ?? 'id', + $this->auth->params['id'], 'uid', 'name', 'image', - $this->auth->params['scopes'] ?? 'scopes', - $this->auth->params['roles'] ?? 'roles', - $this->auth->params['created'] ?? null, + $this->auth->params['roles'], + $this->auth->params['created'], ]))); $this->allowedWriteFields = array_values(array_unique(array_filter([ 'uid', - $this->auth->params['password'] ?? 'password', + $this->auth->params['password'], 'name', 'image', - $this->auth->params['scopes'] ?? 'scopes', - $this->auth->params['roles'] ?? 'roles', + $this->auth->params['roles'], 'provider', 'profile', ]))); diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 205103b..bd791ff 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -28,6 +28,7 @@ public function __construct(private DB $db, array $params = []) ]; parent::__construct(array_merge($defaults, $params)); + $this->assertRoleConfig(); } public function query($params = array(), $operator = "OR") @@ -125,17 +126,16 @@ public function register($uid, $password = null, $data = array()) $profile = !empty($data['profile']) ? json_encode($data['profile']) : null; unset($data['profile']); - $roleField = $this->params['roles'] ?? 'roles'; + $roleField = $this->params['roles']; $syncRoles = array_key_exists($roleField, $data); - $roles = $syncRoles ? $this->normalizeRoleNames($data[$roleField]) : []; + $roles = ($syncRoles && is_array($data[$roleField])) ? $data[$roleField] : []; unset($data[$roleField]); + if ($syncRoles && !$this->rolesEnabled()) { + throw new \InvalidArgumentException('Role sync requested but role tables are disabled'); + } $fields = []; foreach ($data as $key => $value) { - if ($key === $this->params['scopes'] && is_array($value)) { - $fields[$key] = implode(',', $value); - continue; - } $fields[$key] = is_array($value) ? json_encode($value) : $value; } @@ -149,17 +149,13 @@ public function register($uid, $password = null, $data = array()) } if ($this->params['token']) { - $fields[$this->params['token']] = self::hash(); + $fields[$this->params['token']] = null; } if ($this->params['created']) { $fields[$this->params['created']] = date('Y-m-d H:i:s'); } - if (!array_key_exists($this->params['scopes'], $fields)) { - $fields[$this->params['scopes']] = ''; - } - return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $syncRoles, $roles): array { $id = $this->db->insert($this->params['table'], $fields); if (!$id) { @@ -215,21 +211,31 @@ public function passwd_reset($token, $password) throw new UserException('Token support is disabled', 400); } - $user = $this->get($token, $this->params['token']); + $user = $this->findUserByResetToken((string) $token); + if ($user === null) { + throw new UserException('Hash not found', 404); + } + + $payload = [ + $this->params['password'] => self::hash($password), + $this->params['token'] => null, + ]; + $tokenExpiresField = $this->params['token_expires_field']; + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $payload[$tokenExpiresField] = null; + } + $updated = $this->db->update( $this->params['table'], - [ - $this->params['password'] => self::hash($password), - $this->params['token'] => null, - ], - [$this->params['token'] => $token] + $payload, + [$this->params['id'] => $user[$this->params['id']]] ); if ($updated !== 1) { throw new UserException('Hash not found', 404); } - return $user; + return $this->get($user[$this->params['id']]); } public function update($user_id, array $data, $key = 'id') @@ -254,16 +260,15 @@ public function update($user_id, array $data, $key = 'id') unset($data[$this->params['uuid']]); } - $roleField = $this->params['roles'] ?? 'roles'; + $roleField = $this->params['roles']; $syncRoles = array_key_exists($roleField, $data); - $roles = $syncRoles ? $this->normalizeRoleNames($data[$roleField]) : []; + $roles = ($syncRoles && is_array($data[$roleField])) ? $data[$roleField] : []; unset($data[$roleField]); + if ($syncRoles && !$this->rolesEnabled()) { + throw new \InvalidArgumentException('Role sync requested but role tables are disabled'); + } if (!empty($data)) { - if (isset($data[$this->params['scopes']]) && is_array($data[$this->params['scopes']])) { - $data[$this->params['scopes']] = implode(',', $data[$this->params['scopes']]); - } - foreach ($data as $k => $v) { if (is_array($v)) { $data[$k] = json_encode($v); @@ -313,9 +318,17 @@ public function update_token($user_id) } $token = self::hash(); + $tokenHash = self::hash($token); + $payload = [$this->params['token'] => $tokenHash]; + $tokenExpiresField = $this->params['token_expires_field']; + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $ttl = max(60, (int) $this->params['token_ttl']); + $payload[$tokenExpiresField] = date('Y-m-d H:i:s', time() + $ttl); + } + $updated = $this->db->update( $this->params['table'], - [$this->params['token'] => $token], + $payload, [$this->params['id'] => $user_id] ); @@ -355,12 +368,19 @@ public function update_credential($userid, $provider, $uid, $profile = null) $profile = json_encode($profile); } - $data = ['profile' => $profile]; + $data = []; + if ($profile !== null) { + $data['profile'] = $profile; + } if (!empty($this->params['credentials_last_login'])) { $data[$this->params['credentials_last_login']] = date('Y-m-d H:i:s'); } if ($this->get_credential($provider, $uid)) { + if (empty($data)) { + return true; + } + $this->db->update($this->params['credentials_table'], $data, [ 'provider' => $provider, 'uid' => $uid, @@ -390,8 +410,8 @@ public function get_credentials($user_id, $key = 'id'): array ['user_id' => $user[$this->params['id']]] )->all(); - $lastLoginField = $this->params['credentials_last_login'] ?? null; - $createdField = $this->params['credentials_created'] ?? null; + $lastLoginField = $this->params['credentials_last_login']; + $createdField = $this->params['credentials_created']; $result = []; foreach ($rows as $row) { @@ -425,31 +445,11 @@ public function get_credentials($user_id, $key = 'id'): array return $result; } - private function normalizeUserRow(array $row): array - { - $scopeField = $this->params['scopes']; - $scopes = $row[$scopeField] ?? []; - - if (is_string($scopes)) { - $scopes = explode(',', $scopes); - } elseif (!is_array($scopes)) { - $scopes = []; - } - - $row[$scopeField] = array_values(array_filter(array_map( - fn (mixed $scope): string => trim((string) $scope), - $scopes - ))); - - return $row; - } - private function hydrateUserRow(array $row): array { - $row = $this->normalizeUserRow($row); $row = $this->hydrateEagerRelations($row); - $roleField = $this->params['roles'] ?? 'roles'; + $roleField = $this->params['roles']; $row[$roleField] = $this->loadUserRoles($row[$this->params['id']]); return $row; @@ -460,18 +460,27 @@ private function rolesEnabled(): bool return !empty($this->params['roles_table']) && !empty($this->params['user_roles_table']); } + private function assertRoleConfig(): void + { + $rolesTable = $this->params['roles_table']; + $userRolesTable = $this->params['user_roles_table']; + if (($rolesTable && !$userRolesTable) || (!$rolesTable && $userRolesTable)) { + throw new \InvalidArgumentException('Both roles_table and user_roles_table must be configured together'); + } + } + private function loadUserRoles(int|string $userId): array { if (!$this->rolesEnabled()) { return []; } - $roleName = $this->assertIdentifier((string) $this->params['role_name']); - $roleId = $this->assertIdentifier((string) $this->params['role_id']); - $rolesTable = $this->assertIdentifier((string) $this->params['roles_table']); - $userRolesTable = $this->assertIdentifier((string) $this->params['user_roles_table']); - $userRolesUserId = $this->assertIdentifier((string) $this->params['user_roles_user_id']); - $userRolesRoleId = $this->assertIdentifier((string) $this->params['user_roles_role_id']); + $roleName = (string) $this->params['role_name']; + $roleId = (string) $this->params['role_id']; + $rolesTable = (string) $this->params['roles_table']; + $userRolesTable = (string) $this->params['user_roles_table']; + $userRolesUserId = (string) $this->params['user_roles_user_id']; + $userRolesRoleId = (string) $this->params['user_roles_role_id']; $query = $this->db->query( sprintf( @@ -551,22 +560,6 @@ private function resolveRoleIds(array $roles): array return array_values(array_unique($roleIds)); } - private function normalizeRoleNames(mixed $roles): array - { - if (is_string($roles)) { - $roles = explode(',', $roles); - } - - if (!is_array($roles)) { - return []; - } - - return array_values(array_unique(array_filter(array_map( - fn (mixed $role): string => trim((string) $role), - $roles - )))); - } - protected function userCanRelation(int $subjectId, string $resourceType, int $resourceId, string $ability): bool { $relation = $this->getRelationConfig($resourceType); @@ -621,7 +614,7 @@ protected function userCanRelationList(int $subjectId, string $resourceType, str private function getRelationConfig(string $resourceType): ?array { - $relations = $this->params['relations'] ?? []; + $relations = $this->params['relations']; $relation = $relations[$resourceType] ?? null; if (!is_array($relation)) { return null; @@ -684,7 +677,7 @@ private function rowAllowsAbility(array $row, array $relation, string $ability): private function hydrateEagerRelations(array $row): array { - $relations = $this->params['relations'] ?? []; + $relations = $this->params['relations']; if (!is_array($relations)) { return $row; } @@ -763,13 +756,33 @@ private function relationTargetIdsForSubject(array $relation, int $subjectId): a return $ids; } - private function assertIdentifier(string $value): string + private function findUserByResetToken(string $token): ?array { - if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $value)) { - throw new \InvalidArgumentException("Invalid SQL identifier `$value`"); + $table = (string) $this->params['table']; + $tokenField = (string) $this->params['token']; + $tokenExpiresField = $this->params['token_expires_field']; + $params = []; + + $sql = sprintf('SELECT * FROM %s WHERE %s IS NOT NULL', $table, $tokenField); + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $sql .= sprintf(' AND %s >= :now', $tokenExpiresField); + $params['now'] = date('Y-m-d H:i:s'); + } + + $query = $this->db->query($sql); + $query->exec($params); + foreach ($query->all() as $row) { + $candidateHash = (string) ($row[$this->params['token']] ?? ''); + if ($candidateHash === '') { + continue; + } + + if (\password_verify($token, $candidateHash)) { + return $row; + } } - return $value; + return null; } private function uuidV4(): string diff --git a/src/Auth/Middleware/RequireRole.php b/src/Auth/Middleware/RequireRole.php index e10dcc7..05d44d3 100644 --- a/src/Auth/Middleware/RequireRole.php +++ b/src/Auth/Middleware/RequireRole.php @@ -26,19 +26,21 @@ public function after(string $method, string $fn, array $params, mixed $response public function before(string $method, string $fn, array $params): mixed { - if (!$this->auth->check()) { - throw new AuthException('Forbidden', 401); + if ($this->auth->check()) { + $grants = \Objectiveweb\Auth::AUTHENTICATED; + $user = $this->auth->user(); + $roleField = $this->auth->params['roles']; + $roles = $user[$roleField] ?? []; + if (is_array($roles)) { + $grants = array_merge($grants, $roles); + } + } else { + $grants = \Objectiveweb\Auth::ANONYMOUS; } - $user = $this->auth->user(); - $roleField = $this->auth->params['roles'] ?? 'roles'; - $roles = $user[$roleField] ?? []; - if (is_string($roles)) { - $roles = explode(',', $roles); - } - - if (count(array_intersect($this->roles, is_array($roles) ? $roles : [])) === 0) { - throw new AuthException('Forbidden', 403); + if (count(array_intersect($this->roles, $grants)) === 0) { + $isAnonymous = count(array_intersect($grants, \Objectiveweb\Auth::ANONYMOUS)) > 0; + throw new AuthException('Forbidden', $isAnonymous ? 401 : 403); } return $params; diff --git a/src/Auth/Middleware/RequireScope.php b/src/Auth/Middleware/RequireScope.php deleted file mode 100644 index 963c7d2..0000000 --- a/src/Auth/Middleware/RequireScope.php +++ /dev/null @@ -1,52 +0,0 @@ -scopes = is_array($scopes) ? $scopes : [$scopes]; - } - - public function after(string $method, string $fn, array $params, mixed $response): mixed - { - if ($this->auth->check() && is_array($response) && !isset($response['_user'])) { - $response['_user'] = $this->auth->user(); - } - - return $response; - } - - public function before(string $method, string $fn, array $params): mixed - { - if ($this->auth->check()) { - $scopes = \Objectiveweb\Auth::AUTHENTICATED; - - $this->user = $this->auth->user(); - $scopeField = $this->auth->params['scopes'] ?? 'scopes'; - $userScopes = $this->user[$scopeField] ?? []; - if (is_string($userScopes)) { - $userScopes = explode(',', $userScopes); - } - if (is_array($userScopes)) { - $scopes = array_merge($scopes, $userScopes); - } - } else { - $scopes = \Objectiveweb\Auth::ANONYMOUS; - } - - if (count(array_intersect($this->scopes, $scopes)) == 0) { - throw new AuthException("Forbidden", $scopes[0] == 'anon' ? 401 : 403); - } - - return $params; - } -} diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 5f4bc40..553e7b7 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -66,9 +66,7 @@ public function testPostPasswordForgotFlowUsesEmailCredential(): void $auth->register('alice@example.com', 'secret', ['provider' => 'email']); $result = $controller->postPassword(['uid' => 'alice@example.com']); - $this->assertSame('email', $result['provider']); - $this->assertSame('alice@example.com', $result['uid']); - $this->assertNotEmpty($result['token']); + $this->assertSame([], $result); } public function testPostPasswordForgotFlowUsesPhoneCredential(): void @@ -79,9 +77,44 @@ public function testPostPasswordForgotFlowUsesPhoneCredential(): void $auth->register('+5511999999999', 'secret', ['provider' => 'phone']); $result = $controller->postPassword(['uid' => '+5511999999999']); - $this->assertSame('phone', $result['provider']); - $this->assertSame('+5511999999999', $result['uid']); - $this->assertNotEmpty($result['token']); + $this->assertSame([], $result); + } + + public function testPostPasswordForgotFlowUsesLocalCredentialByDefault(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new AuthController($auth); + + $auth->register('local@example.com', 'secret', ['provider' => 'local']); + $result = $controller->postPassword(['uid' => 'local@example.com']); + + $this->assertSame([], $result); + } + + public function testPostRegisterStripsRolesByDefault(): void + { + $user = $this->controller->postRegister([ + 'uid' => 'safe@example.com', + 'password' => 'secret', + 'roles' => ['admin'], + ]); + + $this->assertSame([], $user['roles'] ?? []); + } + + public function testPostRegisterRejectsWhenRegisterScopeIsAuthenticated(): void + { + $controller = new AuthController(new BasicAuth([], [ + 'token' => 'token', + 'register_scope' => \Objectiveweb\Auth::AUTHENTICATED, + ])); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $controller->postRegister([ + 'uid' => 'blocked@example.com', + 'password' => 'secret', + ]); } public function testIndexReturnsUserWithCredentialsWhenLoggedIn(): void diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index c100011..a79e95a 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -23,10 +23,10 @@ public static function setUpBeforeClass(): void uuid TEXT NOT NULL UNIQUE, name TEXT, image TEXT, - scopes TEXT, created TEXT, password TEXT, - token TEXT + token TEXT, + token_expires_at TEXT )' )->exec(); @@ -218,12 +218,26 @@ public function testRequestToken(): void $this->assertSame($user['id'], $account['user_id']); $token = self::$auth->update_token($account['user_id']); + $stored = self::$db->select('auth_user', ['id' => $account['user_id']], ['limit' => 1])->fetch(); + $this->assertIsString($stored['token']); + $this->assertTrue(password_verify($token, $stored['token'])); + $this->assertNotNull($stored['token_expires_at']); self::$auth->passwd_reset($token, 'final-secret'); $logged = self::$auth->login('alice@example.com', 'final-secret'); $this->assertSame($user['id'], $logged['id']); } + public function testPasswdResetRejectsExpiredToken(): void + { + $user = self::$auth->register('expired@example.com', 'secret'); + $token = self::$auth->update_token($user['id']); + self::$db->update('auth_user', ['token_expires_at' => '2000-01-01 00:00:00'], ['id' => $user['id']]); + + $this->expectException(UserException::class); + self::$auth->passwd_reset($token, 'new-secret'); + } + public function testDelete(): void { $this->expectException(UserException::class); @@ -254,22 +268,19 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void } } - public function testUserCanChecksScopesAndRoles(): void + public function testUserCanChecksRoles(): void { self::$auth->register('scoped@example.com', 'secret', [ - 'scopes' => ['profile:read'], 'roles' => ['admin'], ]); self::$auth->login('scoped@example.com', 'secret'); - $this->assertTrue(self::$auth->user_can('profile:read')); $this->assertTrue(self::$auth->user_can('admin')); $this->assertFalse(self::$auth->user_can('operator')); $current = self::$auth->user(); $this->assertSame(['admin'], $current['roles']); - $this->assertSame(['profile:read'], $current['scopes']); } public function testUpdateRolesReplacesAssignments(): void @@ -490,4 +501,15 @@ public function testUserCanThrowsForUnknownRelationType(): void $this->expectException(\InvalidArgumentException::class); self::$auth->user_can('manage', 'invoice', 1); } + + public function testRoleTablesMustBeConfiguredTogether(): void + { + $this->expectException(\InvalidArgumentException::class); + new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'roles_table' => 'auth_role', + 'user_roles_table' => null, + ]); + } } diff --git a/test/RequireRoleTest.php b/test/RequireRoleTest.php index e382c32..3c8ec28 100644 --- a/test/RequireRoleTest.php +++ b/test/RequireRoleTest.php @@ -7,6 +7,7 @@ } use Objectiveweb\Auth\AuthException; +use Objectiveweb\Auth; use Objectiveweb\Auth\BasicAuth; use Objectiveweb\Auth\Middleware\RequireRole; use PHPUnit\Framework\TestCase; @@ -53,7 +54,7 @@ public function testBeforeAllowsMatchingRoleFromUserData(): void $this->assertSame(['ok' => true], $out); } - public function testBeforeUsesConfiguredRoleFieldAndStringRoles(): void + public function testBeforeUsesConfiguredRoleField(): void { $auth = new BasicAuth( [], @@ -62,11 +63,45 @@ public function testBeforeUsesConfiguredRoleFieldAndStringRoles(): void 'roles' => 'groups', ] ); - $auth->register('role2@example.com', 'secret', ['groups' => 'admin,editor']); + $auth->register('role2@example.com', 'secret', ['groups' => ['admin', 'editor']]); $auth->login('role2@example.com', 'secret'); $mw = new RequireRole($auth, ['editor']); $out = $mw->before('GET', 'index', ['ok' => true]); $this->assertSame(['ok' => true], $out); } + + public function testBeforeAllowsAnonymousMarkerWhenUnauthenticated(): void + { + $mw = new RequireRole($this->auth, Auth::ANONYMOUS); + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeRejectsAnonymousMarkerWhenAuthenticated(): void + { + $this->auth->login('admin@example.com', 'secret'); + $mw = new RequireRole($this->auth, Auth::ANONYMOUS); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $mw->before('GET', 'index', []); + } + + public function testBeforeAllowsAuthenticatedMarkerWhenLoggedIn(): void + { + $this->auth->login('admin@example.com', 'secret'); + $mw = new RequireRole($this->auth, Auth::AUTHENTICATED); + $out = $mw->before('GET', 'index', ['ok' => true]); + $this->assertSame(['ok' => true], $out); + } + + public function testBeforeRejectsAuthenticatedMarkerWhenUnauthenticated(): void + { + $mw = new RequireRole($this->auth, Auth::AUTHENTICATED); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $mw->before('GET', 'index', []); + } } diff --git a/test/RequireScopeTest.php b/test/RequireScopeTest.php deleted file mode 100644 index 0f344d5..0000000 --- a/test/RequireScopeTest.php +++ /dev/null @@ -1,82 +0,0 @@ -auth = new BasicAuth( - ['admin@example.com' => 'secret'], - ['token' => 'token'] - ); - } - - public function testBeforeAllowsAnonymousScope(): void - { - $mw = new RequireScope($this->auth, Auth::ANONYMOUS); - $params = ['x' => 1]; - - $out = $mw->before('GET', 'index', $params); - $this->assertSame($params, $out); - } - - public function testBeforeRejectsUnauthenticatedForAuthScope(): void - { - $mw = new RequireScope($this->auth, Auth::AUTHENTICATED); - - $this->expectException(AuthException::class); - $this->expectExceptionCode(401); - $mw->before('GET', 'index', []); - } - - public function testBeforeRejectsMissingRoleForAuthenticatedUser(): void - { - $this->auth->login('admin@example.com', 'secret'); - $mw = new RequireScope($this->auth, ['ADMIN']); - - $this->expectException(AuthException::class); - $this->expectExceptionCode(403); - $mw->before('GET', 'index', []); - } - - public function testBeforeAllowsCustomScopeFromUser(): void - { - $this->auth->register('scoped@example.com', 'secret', ['scopes' => ['ADMIN']]); - $this->auth->login('scoped@example.com', 'secret'); - $mw = new RequireScope($this->auth, ['ADMIN']); - - $out = $mw->before('GET', 'index', ['ok' => true]); - $this->assertSame(['ok' => true], $out); - } - - public function testBeforeUsesConfiguredScopeFieldAndStringScopes(): void - { - $auth = new BasicAuth( - [], - [ - 'token' => 'token', - 'scopes' => 'permissions', - ] - ); - $auth->register('scoped2@example.com', 'secret', ['permissions' => 'ADMIN,EDITOR']); - $auth->login('scoped2@example.com', 'secret'); - - $mw = new RequireScope($auth, ['EDITOR']); - $out = $mw->before('GET', 'index', ['ok' => true]); - $this->assertSame(['ok' => true], $out); - } -} diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 537b5a5..56b0bf9 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -23,7 +23,6 @@ public static function setUpBeforeClass(): void uuid TEXT NOT NULL UNIQUE, name TEXT, image TEXT, - scopes TEXT, created TEXT, password TEXT, token TEXT From a6dac1f73d54c0303cbce3dbdd81c40aabd31d5c Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Mar 2026 11:21:49 -0300 Subject: [PATCH 032/217] fix: last login/roles hydration --- src/Auth/DBAuth.php | 2 +- test/DBAuthSqliteTest.php | 70 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index bd791ff..b45c17a 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -15,7 +15,7 @@ public function __construct(private DB $db, array $params = []) 'created' => null, 'last_login' => null, 'credentials_table' => 'user_credentials', - 'credentials_last_login' => null, + 'credentials_last_login' => 'last_login', 'credentials_created' => null, 'uuid' => 'uuid', 'roles_table' => null, diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index a79e95a..1c19ba5 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -169,6 +169,76 @@ public function testLoginWithPhoneProviderWhenConfiguredInLoginProviders(): void $this->assertSame('Phone User', $logged['name']); } + public function testUsesCredentialLastLoginDefaultWithConfiguredRoleTables(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'token' => 'token', + 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + + $user = $auth->register('autodetect@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + + self::$db->update('auth_credentials', ['last_login' => null], [ + 'provider' => 'local', + 'uid' => 'autodetect@example.com', + ]); + + $logged = $auth->login('autodetect@example.com', 'secret'); + $credential = $auth->get_credential('local', 'autodetect@example.com'); + + $this->assertSame($user['id'], $logged['id']); + $this->assertSame(['admin'], $logged['roles']); + $this->assertNotNull($credential['last_login'] ?? null); + } + + public function testLoginHydratesRolesWhenRoleIsAssignedInDatabase(): void + { + $user = self::$auth->register('dbrole@example.com', 'secret', ['name' => 'DB Role User']); + + $roleId = self::$db->insert('auth_role', ['name' => 'manager']); + self::$db->insert('auth_user_role', [ + 'user_id' => $user['id'], + 'role_id' => $roleId, + ]); + + $logged = self::$auth->login('dbrole@example.com', 'secret'); + $this->assertArrayHasKey('roles', $logged); + $this->assertSame(['manager'], $logged['roles']); + + $sessionUser = self::$auth->user(); + $this->assertArrayHasKey('roles', $sessionUser); + $this->assertSame(['manager'], $sessionUser['roles']); + } + + public function testLoginHydratesRolesWhenRoleTablesAreConfiguredAtInstantiation(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'token' => 'token', + 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + + $user = $auth->register('autorole@example.com', 'secret'); + $roleId = self::$db->insert('auth_role', ['name' => 'viewer']); + self::$db->insert('auth_user_role', [ + 'user_id' => $user['id'], + 'role_id' => $roleId, + ]); + + $logged = $auth->login('autorole@example.com', 'secret'); + $this->assertArrayHasKey('roles', $logged); + $this->assertSame(['viewer'], $logged['roles']); + } + public function testPasswd(): void { $user = self::$auth->register('alice@example.com', 'secret'); From e5203852589806625780e4d456c229ca0d3f0c2d Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 15 Mar 2026 13:39:01 -0300 Subject: [PATCH 033/217] feat: get_users_by_role --- src/Auth.php | 8 +++ src/Auth/BasicAuth.php | 17 +++++ src/Auth/DBAuth.php | 126 +++++++++++++++++++++++++++++------- test/BasicAuthTest.php | 16 +++++ test/DBAuthSqliteTest.php | 20 ++++++ test/UserControllerTest.php | 3 + 6 files changed, 167 insertions(+), 23 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 779bd86..d11b359 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -322,6 +322,14 @@ abstract public function get_credential($provider, $accountid); */ abstract public function get_credentials($user_id, $key = 'id'): array; + /** + * List all users that have the given role name. + * + * @param string $roleName + * @return array> + */ + abstract public function get_users_by_role($roleName): array; + /** * Inserts a new account on $userid, or update the existing one * diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index d525c87..13cb5b9 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -269,6 +269,23 @@ public function get_credentials($user_id, $key = 'id'): array return $result; } + public function get_users_by_role($roleName): array + { + $roleField = $this->params['roles']; + $matches = []; + + foreach ($this->users as $user) { + $roles = $user[$roleField] ?? []; + if (!is_array($roles) || !in_array($roleName, $roles, true)) { + continue; + } + + $matches[] = $this->publicUser($user); + } + + return $matches; + } + public function update_credential($userid, $provider, $uid, $profile = null) { if (is_array($profile)) { diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index b45c17a..6702c30 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -12,14 +12,14 @@ public function __construct(private DB $db, array $params = []) { $defaults = [ 'table' => 'user', - 'created' => null, + 'created' => 'created', 'last_login' => null, 'credentials_table' => 'user_credentials', 'credentials_last_login' => 'last_login', - 'credentials_created' => null, + 'credentials_created' => 'created', 'uuid' => 'uuid', - 'roles_table' => null, - 'user_roles_table' => null, + 'roles_table' => 'role', + 'user_roles_table' => 'user_roles', 'user_roles_user_id' => 'user_id', 'user_roles_role_id' => 'role_id', 'role_id' => 'id', @@ -445,6 +445,88 @@ public function get_credentials($user_id, $key = 'id'): array return $result; } + public function get_users_by_role($roleName): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $userIdField = (string) $this->params['id']; + $userTable = (string) $this->params['table']; + $rolesTable = (string) $this->params['roles_table']; + $userRolesTable = (string) $this->params['user_roles_table']; + $userRolesUserId = (string) $this->params['user_roles_user_id']; + $userRolesRoleId = (string) $this->params['user_roles_role_id']; + $roleId = (string) $this->params['role_id']; + $roleNameField = (string) $this->params['role_name']; + + $safeFields = ['name', 'image', $userIdField]; + + foreach (['uuid', 'created', 'last_login', 'token_expires_field'] as $paramKey) { + $field = $this->params[$paramKey] ?? null; + if (is_string($field) && $field !== '' && !in_array($field, $safeFields, true)) { + $safeFields[] = $field; + } + } + + $selectFields = array_map( + fn (string $field): string => $userTable . '.' . $field, + $safeFields + ); + $selectFields['roles_csv'] = 'GROUP_CONCAT(DISTINCT r_all.' . $roleNameField . ')'; + + $rows = $this->db->select( + $userTable, + ['r_filter.' . $roleNameField => $roleName], + [ + 'fields' => $selectFields, + 'join' => [ + [ + 'type' => 'inner', + 'table' => $userRolesTable, + 'alias' => 'ur_filter', + 'on' => 'ur_filter.' . $userRolesUserId . ' = ' . $userTable . '.' . $userIdField, + ], + [ + 'type' => 'inner', + 'table' => $rolesTable, + 'alias' => 'r_filter', + 'on' => 'r_filter.' . $roleId . ' = ur_filter.' . $userRolesRoleId, + ], + [ + 'type' => 'left', + 'table' => $userRolesTable, + 'alias' => 'ur_all', + 'on' => 'ur_all.' . $userRolesUserId . ' = ' . $userTable . '.' . $userIdField, + ], + [ + 'type' => 'left', + 'table' => $rolesTable, + 'alias' => 'r_all', + 'on' => 'r_all.' . $roleId . ' = ur_all.' . $userRolesRoleId, + ], + ], + 'group' => array_map( + fn (string $field): string => $userTable . '.' . $field, + $safeFields + ), + 'order' => $userTable . '.' . $userIdField, + ] + )->all(); + + return array_values(array_map(function (array $row): array { + $rolesCsv = (string) ($row['roles_csv'] ?? ''); + unset($row['roles_csv']); + + $roleField = (string) $this->params['roles']; + $roles = $rolesCsv === '' ? [] : array_values(array_unique(array_filter(explode(',', $rolesCsv), fn (string $v): bool => $v !== ''))); + sort($roles, SORT_STRING); + $row[$roleField] = $roles; + + return $row; + }, $rows)); + } + private function hydrateUserRow(array $row): array { $row = $this->hydrateEagerRelations($row); @@ -482,27 +564,25 @@ private function loadUserRoles(int|string $userId): array $userRolesUserId = (string) $this->params['user_roles_user_id']; $userRolesRoleId = (string) $this->params['user_roles_role_id']; - $query = $this->db->query( - sprintf( - 'SELECT r.%s AS role_name - FROM %s r - INNER JOIN %s ur ON ur.%s = r.%s - WHERE ur.%s = :user_id - ORDER BY r.%s', - $roleName, - $rolesTable, - $userRolesTable, - $userRolesRoleId, - $roleId, - $userRolesUserId, - $roleName - ) - ); - $query->exec(['user_id' => $userId]); + $rows = $this->db->select( + $userRolesTable, + [$userRolesTable . '.' . $userRolesUserId => $userId], + [ + 'fields' => ['role_name' => 'r.' . $roleName], + 'join' => [ + [ + 'type' => 'inner', + 'table' => $rolesTable, + 'alias' => 'r', + 'on' => $userRolesTable . '.' . $userRolesRoleId . ' = r.' . $roleId, + ], + ], + 'order' => 'r.' . $roleName, + ] + )->all(); - $rows = $query->all(); return array_values(array_map( - fn (array $entry): string => (string) $entry['role_name'], + fn (array $entry): string => (string) ($entry['role_name'] ?? ''), $rows )); } diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index 7f6238e..5cb3c5a 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -100,4 +100,20 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void $this->assertNotNull($credential['created']); } } + + public function testGetUsersByRoleReturnsMatchingUsers(): void + { + $admin = $this->auth->register('admin@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $this->auth->register('viewer@example.com', 'secret', [ + 'roles' => ['viewer'], + ]); + + $admins = $this->auth->get_users_by_role('admin'); + + $this->assertCount(1, $admins); + $this->assertSame($admin['id'], $admins[0]['id']); + $this->assertSame(['admin'], $admins[0]['roles']); + } } diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 1c19ba5..e32657b 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -160,6 +160,8 @@ public function testLoginWithPhoneProviderWhenConfiguredInLoginProviders(): void 'credentials_table' => 'auth_credentials', 'token' => 'token', 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', 'login_providers' => ['phone', 'email', 'local'], ]); @@ -365,6 +367,24 @@ public function testUpdateRolesReplacesAssignments(): void $this->assertSame(['admin', 'operator'], $reloaded['roles']); } + public function testGetUsersByRoleReturnsOnlyUsersWithMatchingRole(): void + { + $admin = self::$auth->register('admin@example.com', 'secret', [ + 'name' => 'Admin', + 'roles' => ['admin', 'operator'], + ]); + self::$auth->register('viewer@example.com', 'secret', [ + 'name' => 'Viewer', + 'roles' => ['viewer'], + ]); + + $admins = self::$auth->get_users_by_role('admin'); + + $this->assertCount(1, $admins); + $this->assertSame($admin['id'], $admins[0]['id']); + $this->assertSame(['admin', 'operator'], $admins[0]['roles']); + } + public function testUserCanChecksDelegationForSpecificUser(): void { $actor = self::$auth->register('owner@example.com', 'secret'); diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 56b0bf9..22f54ac 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -46,6 +46,9 @@ public static function setUpBeforeClass(): void 'created' => 'created', 'token' => 'token', 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'roles_table' => null, + 'user_roles_table' => null, ]); self::$controller = new UserController(self::$auth); } From 8e920d30c332587b734928942c01f0f8abed0324 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sun, 22 Mar 2026 21:50:50 -0300 Subject: [PATCH 034/217] Add get_roles --- src/Auth.php | 7 +++++++ src/Auth/BasicAuth.php | 24 ++++++++++++++++++++++++ src/Auth/DBAuth.php | 15 +++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/src/Auth.php b/src/Auth.php index d11b359..1b6375a 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -330,6 +330,13 @@ abstract public function get_credentials($user_id, $key = 'id'): array; */ abstract public function get_users_by_role($roleName): array; + /** + * List all available role names. + * + * @return array + */ + abstract public function get_roles(): array; + /** * Inserts a new account on $userid, or update the existing one * diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 13cb5b9..add7e48 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -286,6 +286,30 @@ public function get_users_by_role($roleName): array return $matches; } + public function get_roles(): array + { + $roleField = $this->params['roles']; + $roles = []; + + foreach ($this->users as $user) { + $userRoles = $user[$roleField] ?? []; + if (!is_array($userRoles)) { + continue; + } + + foreach ($userRoles as $role) { + if (!is_string($role) || $role === '') { + continue; + } + $roles[$role] = true; + } + } + + $result = array_keys($roles); + sort($result, SORT_STRING); + return $result; + } + public function update_credential($userid, $provider, $uid, $profile = null) { if (is_array($profile)) { diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 6702c30..e5804d0 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -527,6 +527,21 @@ public function get_users_by_role($roleName): array }, $rows)); } + public function get_roles(): array + { + if (!$this->rolesEnabled()) { + return []; + } + + $rolesTable = (string) $this->params['roles_table']; + $roleIdField = (string) $this->params['role_id']; + $roleNameField = (string) $this->params['role_name']; + + return $this->db->select( + $rolesTable + )->map($roleIdField); + } + private function hydrateUserRow(array $row): array { $row = $this->hydrateEagerRelations($row); From e87b626a13ed83e3a5828da73fcf82fbc31d819c Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 25 Jul 2026 14:01:50 -0300 Subject: [PATCH 035/217] Add reusable user management API --- README.md | 44 +++ src/Auth.php | 96 ++++++ src/Auth/BasicAuth.php | 146 ++++++++- src/Auth/Controller/AuthController.php | 7 +- src/Auth/Controller/UserController.php | 402 +++++++++++++++++++------ src/Auth/DBAuth.php | 273 ++++++++++++++--- src/Auth/Middleware/RequireRole.php | 4 + test/DBAuthSqliteTest.php | 88 +++++- 8 files changed, 930 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 98c8fd6..9c735fd 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ $auth = new DBAuth($db, [ 'roles' => 'roles', 'login_providers' => ['local', 'email', 'phone'], 'token' => 'token', + 'token_expires_field' => 'token_expires_at', + 'disabled_at' => 'disabled_at', 'created' => 'created', 'uuid' => 'uuid', 'credentials_last_login' => 'last_login', @@ -49,6 +51,7 @@ $auth = new DBAuth($db, [ 'subject_key' => 'user_id', 'target_key' => 'target_user_id', 'ability_key' => 'ability', + 'target_is_user' => true, // clean both sides on user deletion ], 'item' => [ 'table' => 'item_users', @@ -60,6 +63,20 @@ $auth = new DBAuth($db, [ ], ], + 'managed_relations' => [ + 'venues' => [ + 'table' => 'venue_users', + 'subject_key' => 'user_id', + 'target_key' => 'venue_id', + 'validate_callback' => fn (array $ids) => validateVenueIds($ids), + ], + ], + 'invitation_callback' => function (array $user, array $credentials, string $plainToken): void { + // Deliver an application-owned email. Never return the token over HTTP. + }, + 'reset_callback' => function (array $user, array $credentials, string $plainToken): void {}, + 'audit_callback' => function ($actorId, $targetId, string $action, array $metadata): void {}, + 'deletion_guard_callback' => fn ($userId): bool|string => true, ]); ``` @@ -120,6 +137,12 @@ CREATE TABLE auth_user_role ( - `role_id`: role PK column in `roles_table` (default `id`) - `role_name`: role name column in `roles_table` (default `name`) - `relations`: relationship auth mapping by resource type (default `[]`) +- `managed_relations`: application-owned many-to-many associations that admins may synchronize +- `disabled_at`: nullable lifecycle field; non-null users cannot log in and active sessions are rejected on their next protected request +- `invitation_callback`, `reset_callback`: application delivery callbacks receiving the user, credentials, and one-time plaintext token +- `audit_callback`: application persistence callback for management events +- `deletion_guard_callback`: return `true` to allow deletion, or a conflict message/`false` to reject it +- `management_csrf`: require JSON management writes with `X-CSRF-Token` (default `true`) - `relations..eager`: eager load related rows into user payload (`true` => `[]`, `array` => select params) - `created`: optional created-at field - `last_login`: optional user last-login field @@ -219,6 +242,27 @@ $auth->passwd($userId, 'new-password'); $credentials = $auth->get_credentials($userId); ``` +## User-management API + +Register `Objectiveweb\Auth\Controller\UserController` at an application-owned +prefix such as `/api/users`. It requires the `admin` role and supports: + +- searchable, filtered and paginated `GET /api/users` (`q`, `role`, + `status`, `page`, `size`, whitelisted `sort`); +- role and user detail reads; +- create/invite and profile/role/managed-relation updates; +- credential create, rename and delete; +- suspend, activate, invitation and password-reset actions; +- guarded deletion with relation cleanup. + +Management GET responses include a session CSRF token. Send it as +`X-CSRF-Token` with `Content-Type: application/json` on every write. Setup and +reset tokens are passed only to delivery callbacks and are never serialized in +HTTP responses. + +Role names accepted by the management API must already exist in the configured +roles table. Use migrations/seeds for role definitions. + ## Authorization checks (`user_can`) ```php diff --git a/src/Auth.php b/src/Auth.php index 1b6375a..7625c43 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -28,6 +28,14 @@ function __construct(array $params) 'recovery_providers' => ['local', 'email', 'phone'], 'register_callback' => null, 'token_callback' => null, + 'invitation_callback' => null, + 'reset_callback' => null, + 'audit_callback' => null, + 'deletion_guard_callback' => null, + 'disabled_at' => null, + 'managed_relations' => [], + 'management_csrf' => true, + 'management_csrf_session_key' => 'ow_auth_management_csrf', ]; $this->params = array_merge($defaults, $params); @@ -51,6 +59,74 @@ public function check() return !empty($_SESSION[$this->params['session_key']]); } + public function is_active(array $user): bool + { + $field = $this->params['disabled_at']; + return !is_string($field) || $field === '' || empty($user[$field]); + } + + /** + * Reload the session principal so role and lifecycle changes take effect on + * the very next protected request. + */ + public function revalidate(): bool + { + if (!$this->check()) { + return false; + } + + try { + $user = $this->reload(); + } catch (\Throwable) { + $this->logout(); + return false; + } + + if (!$this->is_active($user)) { + $this->logout(); + return false; + } + + return true; + } + + public function management_csrf_token(): string + { + $key = (string) $this->params['management_csrf_session_key']; + if (empty($_SESSION[$key]) || !is_string($_SESSION[$key])) { + $_SESSION[$key] = bin2hex(random_bytes(32)); + } + + return $_SESSION[$key]; + } + + public function audit(string $action, int|string|null $targetUserId, array $metadata = []): void + { + $callback = $this->params['audit_callback']; + if (!is_callable($callback)) { + return; + } + + $actorId = null; + if ($this->check()) { + $actorId = $this->user()[$this->params['id']] ?? null; + } + call_user_func($callback, $actorId, $targetUserId, $action, $metadata); + } + + public function invite(int|string $userId, bool $reset = false): void + { + $user = $this->get($userId); + $token = $this->update_token($userId); + $credentials = $this->get_credentials($userId); + $callback = $this->params[$reset ? 'reset_callback' : 'invitation_callback'] + ?? ($reset ? $this->params['token_callback'] : $this->params['register_callback']); + + if (is_callable($callback)) { + call_user_func($callback, $user, $credentials, $token); + } + } + public function user_can(string $ability, mixed ...$args): bool|array { if (count($args) === 0) { @@ -159,6 +235,10 @@ public function &login($uid, $password) $user = $this->get($credential['user_id']); + if (!$this->is_active($user)) { + throw new Auth\AuthException('Account suspended', 403); + } + if (\password_verify($password, $user[$this->params['password']])) { unset($user[$this->params['password']]); @@ -348,4 +428,20 @@ abstract public function get_roles(): array; * @throws \Exception */ abstract public function update_credential($userid, $provider, $uid, $profile = null); + + abstract public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array; + + abstract public function rename_credential( + $userid, + string $provider, + string $uid, + string $newProvider, + string $newUid + ): array; + + abstract public function delete_credential($userid, string $provider, string $uid): bool; + + abstract public function get_managed_relations($userId): array; + + abstract public function sync_managed_relations($userId, array $relations): array; } diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index add7e48..13417e9 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -43,15 +43,52 @@ public function query($params = array(), $operator = "OR") { $page = max(0, (int) ($params['page'] ?? 0)); $size = max(1, (int) ($params['size'] ?? 20)); - unset($params['page'], $params['size'], $params['sort']); + $sort = (string) ($params['sort'] ?? ($this->params['id'] . ' ASC')); + $q = trim((string) ($params['q'] ?? '')); + $role = trim((string) ($params['role'] ?? '')); + $status = trim((string) ($params['status'] ?? '')); + unset($params['page'], $params['size'], $params['sort'], $params['q'], $params['role'], $params['status']); $matches = []; foreach ($this->users as $row) { - if ($this->matchesFilters($row, $params, $operator)) { - $matches[] = $this->publicUser($row); + if (!$this->matchesFilters($row, $params, $operator)) { + continue; + } + + $credentials = $this->get_credentials($row[$this->params['id']]); + if ($q !== '') { + $haystack = strtolower(implode(' ', array_merge( + [(string) ($row['name'] ?? ''), (string) ($row[$this->params['id']] ?? '')], + array_map(fn (array $credential): string => (string) $credential['uid'], $credentials) + ))); + if (!str_contains($haystack, strtolower($q))) { + continue; + } + } + $roles = is_array($row[$this->params['roles']] ?? null) ? $row[$this->params['roles']] : []; + if ($role === 'unassigned' && $roles !== []) { + continue; + } + if ($role !== '' && $role !== 'unassigned' && !in_array($role, $roles, true)) { + continue; + } + if ($status === 'active' && !$this->is_active($row)) { + continue; + } + if ($status === 'suspended' && $this->is_active($row)) { + continue; } + $public = $this->publicUser($row); + $public['credentials'] = $credentials; + $matches[] = $public; } + [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); + usort($matches, static function (array $a, array $b) use ($sortField, $sortDirection): int { + $comparison = ($a[$sortField] ?? null) <=> ($b[$sortField] ?? null); + return strtoupper($sortDirection) === 'DESC' ? -$comparison : $comparison; + }); + $count = count($matches); $slice = array_slice($matches, $page * $size, $size); @@ -110,6 +147,9 @@ public function register($uid, $password = null, $data = array()) $record = $data; $record[$idField] = $userId; $record['uid'] = $uid; + if (is_string($this->params['disabled_at']) && $this->params['disabled_at'] !== '') { + $record[$this->params['disabled_at']] = $record[$this->params['disabled_at']] ?? null; + } if ($password !== null && $password !== '') { $record[$this->params['password']] = $this->isPasswordHash($password) @@ -149,12 +189,25 @@ public function passwd_reset($token, $password) } foreach ($this->users as $userId => $user) { - if (($user[$tokenField] ?? null) !== $token) { + $hash = (string) ($user[$tokenField] ?? ''); + if ($hash === '' || !password_verify($token, $hash)) { + continue; + } + $expiresField = $this->params['token_expires_field']; + if ( + is_string($expiresField) + && $expiresField !== '' + && !empty($user[$expiresField]) + && strtotime((string) $user[$expiresField]) < time() + ) { continue; } $this->users[$userId][$this->params['password']] = self::hash($password); $this->users[$userId][$tokenField] = null; + if (is_string($expiresField) && $expiresField !== '') { + $this->users[$userId][$expiresField] = null; + } return $this->publicUser($this->users[$userId]); } @@ -193,6 +246,7 @@ public function delete($user_id) } } + $this->audit('user.deleted', $resolved); unset($this->users[$resolved]); foreach ($this->credentials as $provider => $records) { foreach ($records as $uid => $credential) { @@ -221,7 +275,14 @@ public function update_token($user_id) } $token = self::hash(); - $this->users[$resolved][$tokenField] = $token; + $this->users[$resolved][$tokenField] = self::hash($token); + $expiresField = $this->params['token_expires_field']; + if (is_string($expiresField) && $expiresField !== '') { + $this->users[$resolved][$expiresField] = date( + 'Y-m-d H:i:s', + time() + max(60, (int) $this->params['token_ttl']) + ); + } return $token; } @@ -312,6 +373,10 @@ public function get_roles(): array public function update_credential($userid, $provider, $uid, $profile = null) { + $existing = $this->get_credential($provider, $uid); + if ($existing && ($existing['user_id'] ?? null) != $userid) { + throw new UserException('Credential already registered', 409); + } if (is_array($profile)) { $profile = json_encode($profile); } @@ -329,6 +394,76 @@ public function update_credential($userid, $provider, $uid, $profile = null) return true; } + public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array + { + $this->get($userid); + $provider = trim($provider); + $uid = trim($uid); + if ($provider === '' || $uid === '') { + throw new UserException('Provider and uid are required', 400); + } + if ($this->get_credential($provider, $uid)) { + throw new UserException('Credential already registered', 409); + } + $this->update_credential($userid, $provider, $uid, $profile); + return $this->get_credential($provider, $uid); + } + + public function rename_credential( + $userid, + string $provider, + string $uid, + string $newProvider, + string $newUid + ): array { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (($provider !== $newProvider || $uid !== $newUid) && $this->get_credential($newProvider, $newUid)) { + throw new UserException('Credential already registered', 409); + } + unset($this->credentials[$provider][$uid]); + $this->update_credential($userid, trim($newProvider), trim($newUid), $credential['profile'] ?? null); + return $this->get_credential($newProvider, $newUid); + } + + public function delete_credential($userid, string $provider, string $uid): bool + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (count($this->get_credentials($userid)) <= 1) { + throw new UserException('A user must have at least one credential', 409); + } + unset($this->credentials[$provider][$uid]); + return true; + } + + public function get_managed_relations($userId): array + { + $this->get($userId); + $result = []; + foreach ((array) $this->params['managed_relations'] as $name => $_config) { + $values = $this->users[$userId]['_managed_relations'][$name] ?? []; + $result[$name] = array_values($values); + } + return $result; + } + + public function sync_managed_relations($userId, array $relations): array + { + $this->get($userId); + foreach ($relations as $name => $values) { + if (!isset($this->params['managed_relations'][$name]) || !is_array($values)) { + throw new UserException("Invalid managed relation `$name`", 400); + } + $this->users[$userId]['_managed_relations'][$name] = array_values(array_unique($values)); + } + return $this->get_managed_relations($userId); + } + private function nextId(): int { if (empty($this->users)) { @@ -391,6 +526,7 @@ private function matchesFilters(array $row, array $filters, string $operator): b private function publicUser(array $user): array { + unset($user['_managed_relations']); unset($user[$this->params['password']]); if ($this->params['token']) { unset($user[$this->params['token']]); diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 83a11ed..7ba9e29 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -101,7 +101,12 @@ function postRegister(array $user) $user['uid'] = $uid; - if (is_callable($this->auth->params['register_callback'])) { + if (!is_string($password) || $password === '') { + // A password-less registration is an invitation. The backend stores + // only a hash of the short-lived setup token and delivery happens + // through the application callback. + $this->auth->invite($user[$this->auth->params['id']]); + } elseif (is_callable($this->auth->params['register_callback'])) { call_user_func($this->auth->params['register_callback'], $user); } diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index e17d545..95228b2 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -8,142 +8,374 @@ use Objectiveweb\Router\Middleware; /** - * Class UserController - * Simple controller to manage users using the Auth service + * Reusable, admin-only JSON user-management API. * - * @package Objectiveweb\Auth + * Router examples: + * GET /api/users + * GET /api/users/roles + * GET /api/users/12 + * POST /api/users + * PUT /api/users/12 + * POST /api/users/12/credentials + * PUT /api/users/12/credentials + * DELETE /api/users/12/credentials/local/name@example.com + * POST /api/users/12/suspend|activate|password-reset|invite */ #[Middleware(RequireRole::class, ['admin'])] class UserController { - private array $allowedQueryFields; - private array $allowedWriteFields; + private array $allowedSortFields; public function __construct(public Auth $auth) { - $this->allowedQueryFields = array_values(array_unique(array_filter([ + $this->allowedSortFields = array_values(array_unique(array_filter([ $this->auth->params['id'], - 'uid', 'name', - 'image', - $this->auth->params['roles'], $this->auth->params['created'], + $this->auth->params['last_login'], + $this->auth->params['disabled_at'], ]))); + } - $this->allowedWriteFields = array_values(array_unique(array_filter([ - 'uid', - $this->auth->params['password'], - 'name', - 'image', - $this->auth->params['roles'], - 'provider', - 'profile', - ]))); + public function index(array $params = []): array + { + return $this->list($params); } - public function index() + public function get(mixed $userOrParams = [], mixed $resource = null, array $params = []): array { - return $this->get(); + if (is_array($userOrParams)) { + return $this->list($userOrParams); + } + if ($resource === 'credentials') { + return ['credentials' => $this->auth->get_credentials($userOrParams)]; + } + return $this->detail($userOrParams); } - public function get($params = array()) + public function getRoles(array $params = []): array { - if (is_array($params)) { - return $this->auth->query($this->sanitizeQueryParams($params)); - } else { - return $this->auth->get($params); + return [ + 'roles' => $this->auth->get_roles(), + '_csrf' => $this->auth->management_csrf_token(), + ]; + } + + public function post(mixed $userOrData, mixed $resource = null, ?array $data = null): array + { + $this->assertManagementWrite(); + if (is_array($userOrData)) { + return $this->create($userOrData); } + $data ??= []; + return match ((string) $resource) { + 'credentials' => $this->createCredential($userOrData, $data), + 'suspend' => $this->setSuspended($userOrData, true), + 'activate' => $this->setSuspended($userOrData, false), + 'password-reset' => $this->sendToken($userOrData, true), + 'invite' => $this->sendToken($userOrData, false), + default => throw new UserException('Invalid user action', 404), + }; } - public function post($data) + public function put(mixed $userId, mixed $resourceOrData, ?array $data = null): array { - if (!is_array($data)) { - throw new UserException('Invalid request', 400); + $this->assertManagementWrite(); + if (is_array($resourceOrData)) { + return $this->updateUser($userId, $resourceOrData); } + if ($resourceOrData === 'credentials') { + return $this->renameCredential($userId, $data ?? []); + } + throw new UserException('Invalid user action', 404); + } - if (empty(trim((string) ($data['uid'] ?? '')))) { - throw new UserException('Missing uid', 400); + public function delete( + mixed $userId, + mixed $resource = null, + mixed $provider = null, + mixed $uid = null, + array $params = [] + ): array { + $this->assertManagementWrite(); + if ($resource === 'credentials') { + $provider = rawurldecode((string) $provider); + $uid = rawurldecode((string) $uid); + $this->auth->delete_credential($userId, $provider, $uid); + $this->auth->audit('credential.deleted', $userId, ['provider' => $provider]); + return ['ok' => true]; } - foreach ($data as $field => $value) { - if (!in_array((string) $field, $this->allowedWriteFields, true)) { - throw new UserException("Invalid field `$field`", 400); - } - unset($value); + $this->assertNotSelf($userId, 'delete'); + $target = $this->auth->get($userId); + $this->assertNotFinalActiveAdmin($target); + try { + $this->auth->delete($userId); + } catch (UserException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new UserException('User has application history; suspend the account instead', 409, $exception); } - return $this->auth->register($data); + return ['ok' => true]; } - public function put($user_id, $data) + private function list(array $params): array { - if (!is_array($data)) { - throw new UserException('Invalid request', 400); + $allowed = ['q', 'role', 'status', 'page', 'size', 'sort']; + foreach (array_keys($params) as $field) { + if (!in_array((string) $field, $allowed, true)) { + throw new UserException("Invalid filter `$field`", 400); + } } + $params['page'] = max(0, (int) ($params['page'] ?? 0)); + $params['size'] = min(100, max(1, (int) ($params['size'] ?? 20))); + $params['sort'] = $this->sanitizeSort((string) ($params['sort'] ?? ($this->auth->params['id'] . ' ASC'))); + if (!in_array((string) ($params['status'] ?? ''), ['', 'active', 'suspended'], true)) { + throw new UserException('Invalid status filter', 400); + } + $result = $this->auth->query($params, 'AND'); + $key = $this->auth->params['table']; + $result['_embedded'][$key] = array_map( + fn (array $user): array => $this->sanitizeUser($user), + $result['_embedded'][$key] ?? [] + ); + $result['_csrf'] = $this->auth->management_csrf_token(); + return $result; + } - $user = $this->auth->get($user_id); + private function detail(mixed $userId): array + { + $user = $this->sanitizeUser($this->auth->get($userId)); + $user['credentials'] = $this->auth->get_credentials($userId); + $user['managed_relations'] = $this->auth->get_managed_relations($userId); + $user['status'] = $this->auth->is_active($user) ? 'active' : 'suspended'; + $user['_csrf'] = $this->auth->management_csrf_token(); + return $user; + } + + private function create(array $data): array + { + $uid = trim((string) ($data['uid'] ?? '')); + if ($uid === '') { + throw new UserException('Missing uid', 400); + } + $relations = $data['managed_relations'] ?? []; + unset($data['managed_relations'], $data['uid']); + $passwordField = $this->auth->params['password']; + $password = $data[$passwordField] ?? null; + unset($data[$passwordField]); + $this->assertWriteFields($data); + $this->assertKnownRoles((array) ($data[$this->auth->params['roles']] ?? [])); - if (!empty($data['password'])) { - $this->auth->passwd($user[$this->auth->params['id']], $data['password']); + $user = $this->auth->register($uid, is_string($password) && $password !== '' ? $password : null, $data); + $id = $user[$this->auth->params['id']]; + if (is_array($relations) && $relations !== []) { + $this->auth->sync_managed_relations($id, $relations); } + if (!is_string($password) || $password === '') { + $this->auth->invite($id); + } + $this->auth->audit('user.created', $id, [ + 'roles' => $user[$this->auth->params['roles']] ?? [], + 'invited' => !is_string($password) || $password === '', + ]); + return $this->detail($id); + } - unset($data['password']); + private function updateUser(mixed $userId, array $data): array + { + $target = $this->auth->get($userId); + $relationsPresent = array_key_exists('managed_relations', $data); + $relations = $data['managed_relations'] ?? []; + unset($data['managed_relations'], $data['uid'], $data[$this->auth->params['password']]); + $this->assertWriteFields($data); - foreach (array_keys($data) as $field) { - if (!in_array((string) $field, $this->allowedWriteFields, true)) { - throw new UserException("Invalid field `$field`", 400); + $roleField = $this->auth->params['roles']; + if (array_key_exists($roleField, $data)) { + $roles = is_array($data[$roleField]) ? $data[$roleField] : []; + $this->assertKnownRoles($roles); + $this->assertOwnAdminRolePreserved($userId, $roles); + if (in_array('admin', (array) ($target[$roleField] ?? []), true) && !in_array('admin', $roles, true)) { + $this->assertNotFinalActiveAdmin($target); } } - if (!empty($data)) { - $this->auth->update($user[$this->auth->params['id']], $data); + $this->auth->update($userId, $data); + if ($relationsPresent) { + if (!is_array($relations)) { + throw new UserException('Invalid managed relations', 400); + } + $this->auth->sync_managed_relations($userId, $relations); } + $this->auth->audit('user.updated', $userId, ['fields' => array_keys($data)]); + return $this->detail($userId); + } - return true; + private function createCredential(mixed $userId, array $data): array + { + $credential = $this->auth->create_credential( + $userId, + (string) ($data['provider'] ?? ''), + (string) ($data['uid'] ?? ''), + $data['profile'] ?? null + ); + $this->auth->audit('credential.created', $userId, ['provider' => $credential['provider'] ?? null]); + return ['credential' => $this->sanitizeCredential($credential)]; } - public function delete($user_id) + private function renameCredential(mixed $userId, array $data): array { - $this->auth->delete($user_id); - return true; + $credential = $this->auth->rename_credential( + $userId, + (string) ($data['provider'] ?? ''), + (string) ($data['uid'] ?? ''), + (string) ($data['new_provider'] ?? $data['provider'] ?? ''), + (string) ($data['new_uid'] ?? '') + ); + $this->auth->audit('credential.renamed', $userId, ['provider' => $credential['provider'] ?? null]); + return ['credential' => $this->sanitizeCredential($credential)]; } - private function sanitizeQueryParams(array $params): array + private function setSuspended(mixed $userId, bool $suspended): array { - $allowedMeta = ['page', 'size', 'sort']; - foreach ($params as $key => $value) { - if (in_array((string) $key, $allowedMeta, true)) { - continue; - } + $field = $this->auth->params['disabled_at']; + if (!is_string($field) || $field === '') { + throw new UserException('Lifecycle state is disabled', 400); + } + if ($suspended) { + $this->assertNotSelf($userId, 'suspend'); + $this->assertNotFinalActiveAdmin($this->auth->get($userId)); + } + $this->auth->update($userId, [$field => $suspended ? date('Y-m-d H:i:s') : null]); + $this->auth->audit($suspended ? 'user.suspended' : 'user.activated', $userId); + return $this->detail($userId); + } - if (!in_array((string) $key, $this->allowedQueryFields, true)) { - throw new UserException("Invalid filter `$key`", 400); - } - unset($value); - } - - if (!empty($params['sort'])) { - $sortFields = explode(',', (string) $params['sort']); - foreach ($sortFields as $sortField) { - $sortField = trim($sortField); - if ($sortField === '') { - continue; - } - - $parts = preg_split('/\s+/', $sortField); - $field = $parts[0] ?? ''; - if (!in_array($field, $this->allowedQueryFields, true)) { - throw new UserException("Invalid sort field `$field`", 400); - } - - if (isset($parts[1])) { - $dir = strtoupper($parts[1]); - if (!in_array($dir, ['ASC', 'DESC'], true)) { - throw new UserException("Invalid sort direction `$dir`", 400); - } - } + private function sendToken(mixed $userId, bool $reset): array + { + $this->auth->invite($userId, $reset); + $this->auth->audit($reset ? 'password.reset_requested' : 'invitation.sent', $userId); + return ['ok' => true]; + } + + private function assertManagementWrite(): void + { + if (!$this->auth->params['management_csrf']) { + return; + } + // Direct service/controller calls in tests have no request method. HTTP + // requests must always be JSON and carry the session-bound token. + if (empty($_SERVER['REQUEST_METHOD'])) { + return; + } + $contentType = strtolower((string) ($_SERVER['CONTENT_TYPE'] ?? '')); + if (!str_contains($contentType, 'application/json')) { + throw new UserException('Content-Type application/json is required', 415); + } + $provided = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); + $expected = $this->auth->management_csrf_token(); + if ($provided === '' || !hash_equals($expected, $provided)) { + throw new UserException('Invalid CSRF token', 403); + } + } + + private function assertKnownRoles(array $roles): void + { + $unknown = array_values(array_diff($roles, $this->auth->get_roles())); + if ($unknown !== []) { + throw new UserException('Unknown role `' . (string) $unknown[0] . '`', 400); + } + } + + private function assertNotSelf(mixed $userId, string $action): void + { + $current = $this->auth->user(); + if (($current[$this->auth->params['id']] ?? null) == $userId) { + throw new UserException("Cannot $action your own account", 409); + } + } + + private function assertOwnAdminRolePreserved(mixed $userId, array $roles): void + { + $current = $this->auth->user(); + if ( + ($current[$this->auth->params['id']] ?? null) == $userId + && in_array('admin', (array) ($current[$this->auth->params['roles']] ?? []), true) + && !in_array('admin', $roles, true) + ) { + throw new UserException('Cannot remove your own admin role', 409); + } + } + + private function assertNotFinalActiveAdmin(array $target): void + { + if ( + !in_array('admin', (array) ($target[$this->auth->params['roles']] ?? []), true) + || !$this->auth->is_active($target) + ) { + return; + } + $activeAdmins = array_filter( + $this->auth->get_users_by_role('admin'), + fn (array $user): bool => $this->auth->is_active($user) + ); + if (count($activeAdmins) <= 1) { + throw new UserException('Cannot remove or suspend the final active administrator', 409); + } + } + + private function assertWriteFields(array $data): void + { + $allowed = array_values(array_unique(array_filter([ + 'name', + 'image', + 'provider', + 'profile', + $this->auth->params['roles'], + $this->auth->params['disabled_at'], + ]))); + foreach (array_keys($data) as $field) { + if (!in_array((string) $field, $allowed, true)) { + throw new UserException("Invalid field `$field`", 400); } } + } - return $params; + private function sanitizeSort(string $sort): string + { + [$field, $direction] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); + if (!in_array($field, $this->allowedSortFields, true)) { + throw new UserException("Invalid sort field `$field`", 400); + } + $direction = strtoupper($direction); + if (!in_array($direction, ['ASC', 'DESC'], true)) { + throw new UserException("Invalid sort direction `$direction`", 400); + } + return $field . ' ' . $direction; + } + + private function sanitizeUser(array $user): array + { + unset($user[$this->auth->params['password']]); + if ($this->auth->params['token']) { + unset($user[$this->auth->params['token']]); + } + $expires = $this->auth->params['token_expires_field']; + if (is_string($expires) && $expires !== '') { + unset($user[$expires]); + } + if (isset($user['credentials']) && is_array($user['credentials'])) { + $user['credentials'] = array_map([$this, 'sanitizeCredential'], $user['credentials']); + } + return $user; + } + + private function sanitizeCredential(array $credential): array + { + unset($credential['token'], $credential['user_id']); + if (isset($credential['profile']['_auth'])) { + unset($credential['profile']['_auth']['token']); + } + return $credential; } } diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index e5804d0..26efe16 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -35,41 +35,66 @@ public function query($params = array(), $operator = "OR") { $page = max(0, (int) ($params['page'] ?? 0)); $size = max(1, (int) ($params['size'] ?? 20)); - $sort = $params['sort'] ?? null; - - unset($params['page'], $params['size'], $params['sort']); - - if (strtoupper($operator) === 'OR' && count($params) > 1) { - $rowsById = []; - foreach ($params as $key => $value) { - $rows = $this->db->select($this->params['table'], [$key => $value], [ - 'order' => $sort, - ])->all(); - - foreach ($rows as $row) { - if (!isset($row[$this->params['id']])) { - continue; - } - $rowsById[$row[$this->params['id']]] = $this->hydrateUserRow($row); + $sort = (string) ($params['sort'] ?? ($this->params['id'] . ' ASC')); + $q = trim((string) ($params['q'] ?? '')); + $role = trim((string) ($params['role'] ?? '')); + $status = trim((string) ($params['status'] ?? '')); + unset($params['page'], $params['size'], $params['sort'], $params['q'], $params['role'], $params['status']); + + // Management datasets are deliberately hydrated before filtering. This + // keeps credential UID and managed-relation search portable across the + // DB abstraction's supported engines and avoids accidental inner joins + // that hide users without roles. + $rows = $this->db->select($this->params['table'])->all(); + $data = []; + foreach ($rows as $row) { + $hydrated = $this->hydrateUserRow($row); + if (!$this->matchesUserFilters($hydrated, $params, $operator)) { + continue; + } + + $credentials = $this->get_credentials($hydrated[$this->params['id']]); + if ($q !== '') { + $haystack = strtolower(implode(' ', array_merge( + [ + (string) ($hydrated['name'] ?? ''), + (string) ($hydrated[$this->params['id']] ?? ''), + ], + array_map(fn (array $credential): string => (string) ($credential['uid'] ?? ''), $credentials) + ))); + if (!str_contains($haystack, strtolower($q))) { + continue; } } - $data = array_values($rowsById); - $count = count($data); - $slice = array_slice($data, $page * $size, $size); - } else { - $filter = $params; - $queryParams = [ - 'order' => $sort, - 'offset' => $page * $size, - 'limit' => $size, - ]; + $roles = is_array($hydrated[$this->params['roles']] ?? null) + ? $hydrated[$this->params['roles']] + : []; + if ($role === 'unassigned' && $roles !== []) { + continue; + } + if ($role !== '' && $role !== 'unassigned' && !in_array($role, $roles, true)) { + continue; + } + if ($status === 'active' && !$this->is_active($hydrated)) { + continue; + } + if ($status === 'suspended' && $this->is_active($hydrated)) { + continue; + } - $slice = $this->db->select($this->params['table'], $filter, $queryParams)->all(); - $slice = array_map(fn (array $row): array => $this->hydrateUserRow($row), $slice); - $count = $this->db->count($this->params['table'], $filter); + $hydrated['credentials'] = $credentials; + $data[] = $hydrated; } + [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); + usort($data, static function (array $a, array $b) use ($sortField, $sortDirection): int { + $comparison = ($a[$sortField] ?? null) <=> ($b[$sortField] ?? null); + return strtoupper($sortDirection) === 'DESC' ? -$comparison : $comparison; + }); + $count = count($data); + $slice = array_slice($data, $page * $size, $size); + return [ '_embedded' => [ $this->params['table'] => $slice, @@ -157,6 +182,9 @@ public function register($uid, $password = null, $data = array()) } return $this->db->transaction(function () use ($uid, $provider, $profile, $fields, $syncRoles, $roles): array { + if ($syncRoles) { + $this->resolveRoleIds($roles); + } $id = $this->db->insert($this->params['table'], $fields); if (!$id) { throw new \Exception('Could not create user'); @@ -280,6 +308,7 @@ public function update($user_id, array $data, $key = 'id') if ($syncRoles) { $target = $this->get($user_id, $key); + $this->resolveRoleIds($roles); $this->syncUserRoles($target[$this->params['id']], $roles); } } @@ -294,6 +323,16 @@ public function delete($user_id) } return $this->db->transaction(function () use ($user_id): bool { + $guard = $this->params['deletion_guard_callback']; + if (is_callable($guard)) { + $result = call_user_func($guard, $user_id); + if ($result === false || is_string($result)) { + throw new UserException( + is_string($result) ? $result : 'User has application history', + 409 + ); + } + } $this->db->delete($this->params['credentials_table'], ['user_id' => $user_id]); if ($this->rolesEnabled()) { $this->db->delete( @@ -301,6 +340,24 @@ public function delete($user_id) [$this->params['user_roles_user_id'] => $user_id] ); } + foreach ((array) $this->params['managed_relations'] as $relation) { + if (!is_array($relation) || empty($relation['table']) || empty($relation['subject_key'])) { + continue; + } + $this->db->delete((string) $relation['table'], [(string) $relation['subject_key'] => $user_id]); + } + foreach ((array) $this->params['relations'] as $relation) { + if (!is_array($relation) || empty($relation['table'])) { + continue; + } + if (!empty($relation['subject_key'])) { + $this->db->delete((string) $relation['table'], [(string) $relation['subject_key'] => $user_id]); + } + if (!empty($relation['target_key']) && ($relation['target_is_user'] ?? false)) { + $this->db->delete((string) $relation['table'], [(string) $relation['target_key'] => $user_id]); + } + } + $this->audit('user.deleted', $user_id); $deleted = $this->db->delete($this->params['table'], [$this->params['id'] => $user_id]); if ($deleted !== 1) { @@ -376,7 +433,11 @@ public function update_credential($userid, $provider, $uid, $profile = null) $data[$this->params['credentials_last_login']] = date('Y-m-d H:i:s'); } - if ($this->get_credential($provider, $uid)) { + $existing = $this->get_credential($provider, $uid); + if ($existing && ($existing['user_id'] ?? null) != $userid) { + throw new UserException('Credential already registered', 409); + } + if ($existing) { if (empty($data)) { return true; } @@ -402,6 +463,119 @@ public function update_credential($userid, $provider, $uid, $profile = null) return true; } + public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array + { + $this->get($userid); + $provider = trim($provider); + $uid = trim($uid); + if ($provider === '' || $uid === '') { + throw new UserException('Provider and uid are required', 400); + } + if ($this->get_credential($provider, $uid)) { + throw new UserException('Credential already registered', 409); + } + $this->update_credential($userid, $provider, $uid, $profile); + return $this->get_credential($provider, $uid); + } + + public function rename_credential( + $userid, + string $provider, + string $uid, + string $newProvider, + string $newUid + ): array { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + $newProvider = trim($newProvider); + $newUid = trim($newUid); + if ($newProvider === '' || $newUid === '') { + throw new UserException('Provider and uid are required', 400); + } + $duplicate = $this->get_credential($newProvider, $newUid); + if ( + ($provider !== $newProvider || $uid !== $newUid) + && $duplicate + ) { + throw new UserException('Credential already registered', 409); + } + $this->db->update($this->params['credentials_table'], [ + 'provider' => $newProvider, + 'uid' => $newUid, + ], [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + return $this->get_credential($newProvider, $newUid); + } + + public function delete_credential($userid, string $provider, string $uid): bool + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (count($this->get_credentials($userid)) <= 1) { + throw new UserException('A user must have at least one credential', 409); + } + $this->db->delete($this->params['credentials_table'], [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + return true; + } + + public function get_managed_relations($userId): array + { + $user = $this->get($userId); + $id = $user[$this->params['id']]; + $result = []; + foreach ((array) $this->params['managed_relations'] as $name => $relation) { + if (!is_array($relation) || empty($relation['table']) || empty($relation['subject_key']) || empty($relation['target_key'])) { + continue; + } + $rows = $this->db->select((string) $relation['table'], [ + (string) $relation['subject_key'] => $id, + ])->all(); + $values = array_map( + fn (array $row): mixed => $row[(string) $relation['target_key']] ?? null, + $rows + ); + $result[(string) $name] = array_values(array_unique(array_filter($values, fn ($value): bool => $value !== null))); + } + return $result; + } + + public function sync_managed_relations($userId, array $relations): array + { + $user = $this->get($userId); + $id = $user[$this->params['id']]; + return $this->db->transaction(function () use ($id, $relations): array { + foreach ($relations as $name => $values) { + $relation = $this->params['managed_relations'][$name] ?? null; + if (!is_array($relation) || !is_array($values)) { + throw new UserException("Invalid managed relation `$name`", 400); + } + $values = array_values(array_unique($values)); + if (is_callable($relation['validate_callback'] ?? null)) { + call_user_func($relation['validate_callback'], $values); + } + $table = (string) $relation['table']; + $subjectKey = (string) $relation['subject_key']; + $targetKey = (string) $relation['target_key']; + $this->db->delete($table, [$subjectKey => $id]); + foreach ($values as $value) { + $this->db->insert($table, [$subjectKey => $id, $targetKey => $value]); + } + } + return $this->get_managed_relations($id); + }); + } + public function get_credentials($user_id, $key = 'id'): array { $user = $this->get($user_id, $key); @@ -537,9 +711,11 @@ public function get_roles(): array $roleIdField = (string) $this->params['role_id']; $roleNameField = (string) $this->params['role_name']; - return $this->db->select( - $rolesTable - )->map($roleIdField); + $rows = $this->db->select($rolesTable, [], ['order' => $roleNameField])->all(); + return array_values(array_map( + fn (array $row): string => (string) ($row[$roleNameField] ?? ''), + $rows + )); } private function hydrateUserRow(array $row): array @@ -645,11 +821,7 @@ private function resolveRoleIds(array $roles): array $roleIds[] = ctype_digit((string) $inserted) ? (int) $inserted : $inserted; continue; } - - $created = $this->db->select($rolesTable, [$roleNameField => $roleName], ['limit' => 1])->fetch(); - if ($created && isset($created[$roleIdField])) { - $roleIds[] = $created[$roleIdField]; - } + throw new UserException("Unknown role `$roleName`", 400); } return array_values(array_unique($roleIds)); @@ -880,6 +1052,31 @@ private function findUserByResetToken(string $token): ?array return null; } + private function matchesUserFilters(array $row, array $filters, string $operator): bool + { + if ($filters === []) { + return true; + } + $matches = []; + foreach ($filters as $key => $value) { + if ($key === 'uid') { + $matches[] = $this->get_credential('local', $value) + && (($this->get_credential('local', $value)['user_id'] ?? null) == ($row[$this->params['id']] ?? null)); + continue; + } + $actual = $row[$key] ?? null; + if (is_string($value) && str_contains($value, '%')) { + $needle = strtolower(str_replace('%', '', $value)); + $matches[] = str_contains(strtolower((string) $actual), $needle); + } else { + $matches[] = $actual == $value; + } + } + return strtoupper($operator) === 'AND' + ? !in_array(false, $matches, true) + : in_array(true, $matches, true); + } + private function uuidV4(): string { $data = random_bytes(16); diff --git a/src/Auth/Middleware/RequireRole.php b/src/Auth/Middleware/RequireRole.php index 05d44d3..d64b4db 100644 --- a/src/Auth/Middleware/RequireRole.php +++ b/src/Auth/Middleware/RequireRole.php @@ -26,6 +26,10 @@ public function after(string $method, string $fn, array $params, mixed $response public function before(string $method, string $fn, array $params): mixed { + if ($this->auth->check() && !$this->auth->revalidate()) { + throw new AuthException('Forbidden', 401); + } + if ($this->auth->check()) { $grants = \Objectiveweb\Auth::AUTHENTICATED; $user = $this->auth->user(); diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index e32657b..e0590b6 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -26,7 +26,8 @@ public static function setUpBeforeClass(): void created TEXT, password TEXT, token TEXT, - token_expires_at TEXT + token_expires_at TEXT, + disabled_at TEXT )' )->exec(); @@ -75,6 +76,14 @@ public static function setUpBeforeClass(): void )' )->exec(); + self::$db->query( + 'CREATE TABLE managed_items ( + user_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + PRIMARY KEY(user_id, item_id) + )' + )->exec(); + self::$auth = new DBAuth(self::$db, [ 'table' => 'auth_user', 'credentials_table' => 'auth_credentials', @@ -83,6 +92,7 @@ public static function setUpBeforeClass(): void 'credentials_last_login' => 'last_login', 'credentials_created' => 'created', 'roles' => 'roles', + 'disabled_at' => 'disabled_at', 'roles_table' => 'auth_role', 'user_roles_table' => 'auth_user_role', 'relations' => [ @@ -91,6 +101,7 @@ public static function setUpBeforeClass(): void 'subject_key' => 'user_id', 'target_key' => 'target_user_id', 'ability_key' => 'ability', + 'target_is_user' => true, ], 'item' => [ 'table' => 'item_users', @@ -99,6 +110,13 @@ public static function setUpBeforeClass(): void 'ability_key' => 'ability', ], ], + 'managed_relations' => [ + 'items' => [ + 'table' => 'managed_items', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + ], + ], ]); } @@ -110,6 +128,7 @@ protected function setUp(): void self::$db->query('DELETE FROM auth_role')->exec(); self::$db->query('DELETE FROM user_delegations')->exec(); self::$db->query('DELETE FROM item_users')->exec(); + self::$db->query('DELETE FROM managed_items')->exec(); self::$db->query('DELETE FROM auth_user')->exec(); } @@ -602,4 +621,71 @@ public function testRoleTablesMustBeConfiguredTogether(): void 'user_roles_table' => null, ]); } + + public function testSuspendedUserCannotLoginAndExistingSessionIsInvalidated(): void + { + $user = self::$auth->register('suspended@example.com', 'secret'); + self::$auth->login('suspended@example.com', 'secret'); + self::$auth->update($user['id'], ['disabled_at' => '2026-07-25 12:00:00']); + + $this->assertFalse(self::$auth->revalidate()); + $this->assertFalse(self::$auth->check()); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + self::$auth->login('suspended@example.com', 'secret'); + } + + public function testCredentialLifecycleEnforcesOwnershipUniquenessAndLastCredential(): void + { + $alice = self::$auth->register('alice@example.com', 'secret'); + $bob = self::$auth->register('bob@example.com', 'secret'); + self::$auth->create_credential($alice['id'], 'local', 'alice.secondary@example.com'); + $renamed = self::$auth->rename_credential( + $alice['id'], + 'local', + 'alice.secondary@example.com', + 'local', + 'alice.renamed@example.com' + ); + $this->assertSame('alice.renamed@example.com', $renamed['uid']); + self::$auth->delete_credential($alice['id'], 'local', 'alice.renamed@example.com'); + + try { + self::$auth->create_credential($bob['id'], 'local', 'alice@example.com'); + $this->fail('Expected duplicate credential rejection'); + } catch (UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + + $this->expectException(UserException::class); + $this->expectExceptionCode(409); + self::$auth->delete_credential($alice['id'], 'local', 'alice@example.com'); + } + + public function testManagedRelationsSynchronizeAndDeletionCleansBothDelegationSides(): void + { + $actor = self::$auth->register('actor@example.com', 'secret'); + $target = self::$auth->register('target@example.com', 'secret'); + self::$auth->sync_managed_relations($actor['id'], ['items' => [8, 3, 8]]); + $this->assertEqualsCanonicalizing([3, 8], self::$auth->get_managed_relations($actor['id'])['items']); + + self::$db->insert('user_delegations', [ + 'user_id' => $actor['id'], + 'target_user_id' => $target['id'], + 'ability' => 'manage', + ]); + self::$auth->delete($target['id']); + $this->assertSame(0, self::$db->count('user_delegations', [])); + } + + public function testRoleNamesAndCredentialUidSearchAreNormalized(): void + { + self::$auth->register('searchable@example.com', 'secret', ['roles' => ['partner']]); + $this->assertSame(['partner'], self::$auth->get_roles()); + + $result = self::$auth->query(['q' => 'searchable@example.com', 'size' => 10]); + $this->assertSame(1, $result['page']['totalElements']); + $this->assertSame('searchable@example.com', $result['_embedded']['auth_user'][0]['credentials'][0]['uid']); + } } From 37f41f15879813aecc80c49a46bfc3cc7f54d257 Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Sat, 25 Jul 2026 14:03:23 -0300 Subject: [PATCH 036/217] Keep new credentials marked as never logged in --- src/Auth/BasicAuth.php | 2 ++ src/Auth/DBAuth.php | 11 ++++++++++- test/BasicAuthTest.php | 2 +- test/DBAuthSqliteTest.php | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 13417e9..573653f 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -166,6 +166,7 @@ public function register($uid, $password = null, $data = array()) $this->users[$userId] = $record; $this->update_credential($userId, $provider, $uid, $profile); + $this->credentials[$provider][$uid]['last_login'] = null; return $this->publicUser($record); } @@ -406,6 +407,7 @@ public function create_credential($userid, string $provider, string $uid, mixed throw new UserException('Credential already registered', 409); } $this->update_credential($userid, $provider, $uid, $profile); + $this->credentials[$provider][$uid]['last_login'] = null; return $this->get_credential($provider, $uid); } diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 26efe16..ba97aaf 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -201,7 +201,7 @@ public function register($uid, $password = null, $data = array()) ]; if (!empty($this->params['credentials_last_login'])) { - $credentialPayload[$this->params['credentials_last_login']] = date('Y-m-d H:i:s'); + $credentialPayload[$this->params['credentials_last_login']] = null; } if (!empty($this->params['credentials_created'])) { $credentialPayload[$this->params['credentials_created']] = date('Y-m-d H:i:s'); @@ -475,6 +475,15 @@ public function create_credential($userid, string $provider, string $uid, mixed throw new UserException('Credential already registered', 409); } $this->update_credential($userid, $provider, $uid, $profile); + if (!empty($this->params['credentials_last_login'])) { + $this->db->update($this->params['credentials_table'], [ + $this->params['credentials_last_login'] => null, + ], [ + 'user_id' => $userid, + 'provider' => $provider, + 'uid' => $uid, + ]); + } return $this->get_credential($provider, $uid); } diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index 5cb3c5a..2f3e87f 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -96,7 +96,7 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void ['uid', 'provider', 'profile', 'last_login', 'created'], array_keys($credential) ); - $this->assertNotNull($credential['last_login']); + $this->assertArrayHasKey('last_login', $credential); $this->assertNotNull($credential['created']); } } diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index e0590b6..bd4626f 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -354,7 +354,7 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void ['uid', 'provider', 'profile', 'last_login', 'created'], array_keys($credential) ); - $this->assertNotNull($credential['last_login']); + $this->assertArrayHasKey('last_login', $credential); $this->assertNotNull($credential['created']); } } From 224dfc42d0353ac1e1f19c7136e8b11f839170dd Mon Sep 17 00:00:00 2001 From: Guilherme Barile Date: Mon, 27 Jul 2026 22:28:33 -0300 Subject: [PATCH 037/217] Honor database prefix when resetting passwords --- src/Auth/DBAuth.php | 23 +++++++++++++---------- test/DBAuthSqliteTest.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index ba97aaf..9de62f8 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -1037,18 +1037,21 @@ private function findUserByResetToken(string $token): ?array $table = (string) $this->params['table']; $tokenField = (string) $this->params['token']; $tokenExpiresField = $this->params['token_expires_field']; - $params = []; + $now = date('Y-m-d H:i:s'); - $sql = sprintf('SELECT * FROM %s WHERE %s IS NOT NULL', $table, $tokenField); - if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { - $sql .= sprintf(' AND %s >= :now', $tokenExpiresField); - $params['now'] = date('Y-m-d H:i:s'); - } + // Use DB::select() so configured table prefixes and identifier quoting + // are applied consistently. Token hashes cannot be queried directly, + // because password_verify() must compare the supplied token in PHP. + $rows = $this->db->select($table, ['!' . $tokenField => null])->all(); + foreach ($rows as $row) { + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $expiresAt = $row[$tokenExpiresField] ?? null; + if (!is_string($expiresAt) || $expiresAt < $now) { + continue; + } + } - $query = $this->db->query($sql); - $query->exec($params); - foreach ($query->all() as $row) { - $candidateHash = (string) ($row[$this->params['token']] ?? ''); + $candidateHash = (string) ($row[$tokenField] ?? ''); if ($candidateHash === '') { continue; } diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index bd4626f..098adae 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -329,6 +329,38 @@ public function testPasswdResetRejectsExpiredToken(): void self::$auth->passwd_reset($token, 'new-secret'); } + public function testPasswdResetUsesConfiguredDatabasePrefix(): void + { + $db = new DB('sqlite::memory:', null, '', ['prefix' => 'app_']); + $db->query( + 'CREATE TABLE app_user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password TEXT, + token TEXT, + token_expires_at TEXT + )' + )->exec(); + + $auth = new DBAuth($db, [ + 'table' => 'user', + 'created' => null, + 'uuid' => null, + 'token' => 'token', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + + $userId = $db->insert('user', ['password' => null, 'token' => null, 'token_expires_at' => null]); + $token = $auth->update_token($userId); + $user = $auth->passwd_reset($token, 'new-secret'); + + $this->assertSame((int) $userId, (int) $user['id']); + $stored = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('new-secret', $stored['password'])); + $this->assertNull($stored['token']); + $this->assertNull($stored['token_expires_at']); + } + public function testDelete(): void { $this->expectException(UserException::class); From 10c68bd0cf0e51dcc4335d270c612c339b599ee5 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 18:38:31 -0300 Subject: [PATCH 038/217] Lock objectiveweb/db to 0.8.x --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ba71fa2..3487278 100644 --- a/composer.json +++ b/composer.json @@ -8,11 +8,11 @@ } ], "support": { - "issues" : "https://github.com/objetiveweb/auth/issues" + "issues" : "https://github.com/objectiveweb/auth/issues" }, "require": { "php": ">=8.2.0", - "objectiveweb/db": "dev-devel" + "objectiveweb/db": "^0.8.0" }, "require-dev": { "phpunit/phpunit": "10.*", From 4ba276f1e5bd5f18167a58de78f777bab16c59c7 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 18:52:22 -0300 Subject: [PATCH 039/217] Harden OAuth session establishment --- src/Auth.php | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 7625c43..a5fa387 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -65,6 +65,25 @@ public function is_active(array $user): bool return !is_string($field) || $field === '' || empty($user[$field]); } + public function establish_session(array $user): array + { + if (!$this->is_active($user)) { + throw new Auth\AuthException('Account suspended', 403); + } + + $this->regenerateSessionId(); + $this->user($user); + + return $this->user(); + } + + protected function regenerateSessionId(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + session_regenerate_id(true); + } + } + /** * Reload the session principal so role and lifecycle changes take effect on * the very next protected request. @@ -235,19 +254,8 @@ public function &login($uid, $password) $user = $this->get($credential['user_id']); - if (!$this->is_active($user)) { - throw new Auth\AuthException('Account suspended', 403); - } - if (\password_verify($password, $user[$this->params['password']])) { - - unset($user[$this->params['password']]); - unset($user[$this->params['token']]); - - if (session_status() === PHP_SESSION_ACTIVE) { - session_regenerate_id(true); - } - $this->user($user); + $user = $this->establish_session($user); // TODO add login ip $this->update_credential($user[$this->params['id']], (string) $provider, $uid, null); From b6356324371f5f9b2adbe33fb0d4fbf4965a45fa Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 18:52:24 -0300 Subject: [PATCH 040/217] Harden OAuth session establishment --- src/Auth/Controller/OAuthController.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 2738ae0..51f185c 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -136,9 +136,8 @@ private function login($provider, $resourceOwner) $resourceOwner->toArray()); } - // Set session - $this->auth->user($user); - - return $user; + // Apply the same lifecycle and session-fixation protections as + // password authentication before storing the principal. + return $this->auth->establish_session($user); } } From 88921e20bc698516ce3762327b44c96a6197f21f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 18:53:02 -0300 Subject: [PATCH 041/217] Test OAuth session security --- test/OAuthControllerTest.php | 105 ++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index bd9c4fd..fe8b54a 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -2,15 +2,30 @@ require_once dirname(__DIR__) . '/vendor/autoload.php'; +use Objectiveweb\Auth\AuthException; use Objectiveweb\Auth\BasicAuth; use Objectiveweb\Auth\Controller\OAuthController; use PHPUnit\Framework\TestCase; +class OAuthTrackingBasicAuth extends BasicAuth +{ + public bool $sessionRegenerated = false; + + protected function regenerateSessionId(): void + { + $this->sessionRegenerated = true; + } +} + class OAuthControllerTest extends TestCase { - public function testGetWithInvalidProviderThrows(): void + protected function setUp(): void { $_SESSION = []; + } + + public function testGetWithInvalidProviderThrows(): void + { $auth = new BasicAuth([], ['token' => 'token']); $controller = new OAuthController($auth, []); @@ -18,4 +33,92 @@ public function testGetWithInvalidProviderThrows(): void $this->expectExceptionCode(406); $controller->get('missing-provider', []); } + + public function testOAuthLoginRejectsSuspendedUser(): void + { + $auth = new OAuthTrackingBasicAuth([], [ + 'token' => 'token', + 'disabled_at' => 'disabled_at', + ]); + $auth->register('google-user-id', null, [ + 'provider' => 'google', + 'disabled_at' => '2026-09-27 12:00:00', + ]); + + $controller = new OAuthController($auth, []); + $resourceOwner = $this->resourceOwner('google-user-id'); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + + $this->invokeOAuthLogin($controller, 'google', $resourceOwner); + } + + public function testOAuthLoginRegeneratesSessionBeforeStoringPrincipal(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $user = $auth->register('google-user-id', null, ['provider' => 'google']); + + $controller = new OAuthController($auth, []); + $logged = $this->invokeOAuthLogin( + $controller, + 'google', + $this->resourceOwner('google-user-id') + ); + + $this->assertTrue($auth->sessionRegenerated); + $this->assertTrue($auth->check()); + $this->assertSame($user['id'], $logged['id']); + $this->assertSame($user['id'], $auth->user()['id']); + } + + private function invokeOAuthLogin( + OAuthController $controller, + string $provider, + object $resourceOwner + ): array { + $method = new ReflectionMethod($controller, 'login'); + return $method->invoke($controller, $provider, $resourceOwner); + } + + private function resourceOwner(string $id, ?string $email = null): object + { + return new class ($id, $email) { + public function __construct( + private string $id, + private ?string $email + ) { + } + + public function getId(): string + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function getName(): string + { + return 'OAuth User'; + } + + public function getAvatar(): string + { + return 'https://example.com/avatar.png'; + } + + public function getPictureUrl(): string + { + return 'https://example.com/avatar.png'; + } + + public function toArray(): array + { + return ['id' => $this->id, 'email' => $this->email]; + } + }; + } } From 7b4c91bce8a2558bebf67a15457485451dc21fdd Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 20:58:21 -0300 Subject: [PATCH 042/217] Align 0.2.0 DBAuth documentation --- README.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9c735fd..8b5170b 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,16 @@ $auth = new DBAuth($db, [ #### Required tables -`DBAuth` expects: -- users table (`table`) -- credentials table (`credentials_table`) +`DBAuth` expects the configured users and credentials tables. With the default +role configuration it also expects the roles and user-role mapping tables. Set +both `roles_table` and `user_roles_table` to `null` to disable database-backed +roles. -Example schema (SQLite-compatible): +The bundled Phinx migrations use the default logical table names (`user`, +`user_credentials`, `role`, and `user_roles`). Applications may apply their +normal Phinx table prefix instead of hardcoding prefixed names in Auth. + +Example schema for the custom `auth_*` configuration above (SQLite-compatible): ```sql CREATE TABLE auth_user ( @@ -96,7 +101,9 @@ CREATE TABLE auth_user ( image TEXT, created TEXT, password TEXT, - token TEXT + token TEXT, + token_expires_at TEXT, + disabled_at TEXT ); CREATE TABLE auth_credentials ( @@ -105,6 +112,7 @@ CREATE TABLE auth_credentials ( user_id INTEGER NOT NULL, profile TEXT NULL, last_login TEXT NULL, + created TEXT NULL, PRIMARY KEY(uid, provider) ); @@ -130,8 +138,8 @@ CREATE TABLE auth_user_role ( - `token`: optional password-reset token field - `table`: users table name (default `user`) - `credentials_table`: credentials table name (default `user_credentials`) -- `roles_table`: optional roles table name (default `null`) -- `user_roles_table`: optional user-role mapping table name (default `null`) +- `roles_table`: roles table name (default `role`; set together with `user_roles_table` to `null` to disable roles) +- `user_roles_table`: user-role mapping table name (default `user_roles`; set together with `roles_table` to `null` to disable roles) - `user_roles_user_id`: user FK column in mapping table (default `user_id`) - `user_roles_role_id`: role FK column in mapping table (default `role_id`) - `role_id`: role PK column in `roles_table` (default `id`) @@ -146,7 +154,10 @@ CREATE TABLE auth_user_role ( - `relations..eager`: eager load related rows into user payload (`true` => `[]`, `array` => select params) - `created`: optional created-at field - `last_login`: optional user last-login field -- `credentials_last_login`: optional credentials last-login field +- `credentials_last_login`: optional credentials last-login field (default `last_login`) +- `credentials_created`: optional credentials created-at field (default `created`) +- `token_expires_field`: password-reset token expiry field (default `token_expires_at`) +- `token_ttl`: reset-token lifetime in seconds (default `3600`) - `uuid`: UUID field created on register and protected from updates (default `uuid`) ### `BasicAuth` From 053142090f3aceca5d8130cfd9a85d4481d570c6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 20:58:23 -0300 Subject: [PATCH 043/217] Add Phinx CI configuration --- test/phinx.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test/phinx.php diff --git a/test/phinx.php b/test/phinx.php new file mode 100644 index 0000000..540262f --- /dev/null +++ b/test/phinx.php @@ -0,0 +1,19 @@ + [ + 'migrations' => dirname(__DIR__) . '/db', + ], + 'environments' => [ + 'default_migration_table' => 'phinxlog', + 'default_environment' => 'testing', + 'testing' => [ + 'adapter' => 'pgsql', + 'host' => getenv('PGHOST') ?: '127.0.0.1', + 'name' => getenv('PGDATABASE') ?: 'auth_test', + 'user' => getenv('PGUSER') ?: 'postgres', + 'pass' => getenv('PGPASSWORD') ?: 'postgres', + 'port' => (int) (getenv('PGPORT') ?: 5432), + ], + ], +]; From 74c277f034a0004af43e34288493f3559b5c43eb Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 20:58:26 -0300 Subject: [PATCH 044/217] Strengthen 0.2.0 release CI --- .github/workflows/ci.yml | 50 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efb716c..c829592 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ["8.2", "8.3", "8.4"] + php-version: ["8.2", "8.3", "8.4", "8.5"] steps: - name: Checkout @@ -22,8 +22,56 @@ jobs: php-version: ${{ matrix.php-version }} coverage: none + - name: Validate Composer metadata + run: composer validate --strict + - name: Install dependencies run: composer install --no-interaction --prefer-dist - name: Run tests run: vendor/bin/phpunit test + + migrations-postgresql: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: auth_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + PGHOST: 127.0.0.1 + PGPORT: 5432 + PGDATABASE: auth_test + PGUSER: postgres + PGPASSWORD: postgres + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + extensions: pdo_pgsql + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run bundled Phinx migrations + run: vendor/bin/phinx migrate --configuration test/phinx.php --environment testing + + - name: Verify migration status + run: vendor/bin/phinx status --configuration test/phinx.php --environment testing From 573f1e864a45f3e998bd845fef268ebfe5a02443 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:00:35 -0300 Subject: [PATCH 045/217] Centralize public user sanitization --- src/Auth.php | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index a5fa387..d9eaf20 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -65,6 +65,18 @@ public function is_active(array $user): bool return !is_string($field) || $field === '' || empty($user[$field]); } + public function sanitize_user(array $user): array + { + foreach (['password', 'token', 'token_expires_field'] as $param) { + $field = $this->params[$param] ?? null; + if (is_string($field) && $field !== '') { + unset($user[$field]); + } + } + + return $user; + } + public function establish_session(array $user): array { if (!$this->is_active($user)) { @@ -72,7 +84,7 @@ public function establish_session(array $user): array } $this->regenerateSessionId(); - $this->user($user); + $this->user($this->sanitize_user($user)); return $this->user(); } @@ -135,7 +147,7 @@ public function audit(string $action, int|string|null $targetUserId, array $meta public function invite(int|string $userId, bool $reset = false): void { - $user = $this->get($userId); + $user = $this->sanitize_user($this->get($userId)); $token = $this->update_token($userId); $credentials = $this->get_credentials($userId); $callback = $this->params[$reset ? 'reset_callback' : 'invitation_callback'] @@ -294,9 +306,7 @@ public function reload() public function &user($user = null) { if ($user) { - unset($user[$this->params['token']]); - unset($user[$this->params['password']]); - $_SESSION[$this->params['session_key']] = $user; + $_SESSION[$this->params['session_key']] = $this->sanitize_user($user); } else { if (!$this->check()) { throw new Auth\UserException('Not logged in', 403); From 65462ba943b0bfddb7518a19411e3261dcdb40b6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:00:37 -0300 Subject: [PATCH 046/217] Sanitize DBAuth public user results --- src/Auth/DBAuth.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 9de62f8..0046efe 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -84,7 +84,7 @@ public function query($params = array(), $operator = "OR") } $hydrated['credentials'] = $credentials; - $data[] = $hydrated; + $data[] = $this->sanitize_user($hydrated); } [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); @@ -139,7 +139,7 @@ public function register($uid, $password = null, $data = array()) $credential = $this->get_credential($provider, $uid); if (!empty($credential)) { $ex = new UserException('Credential already registered', 409); - $ex->setUser($this->get($credential['user_id'])); + $ex->setUser($this->sanitize_user($this->get($credential['user_id']))); throw $ex; } @@ -213,7 +213,7 @@ public function register($uid, $password = null, $data = array()) $this->syncUserRoles($userId, $roles); } - return $this->get($userId); + return $this->sanitize_user($this->get($userId)); }); } @@ -263,7 +263,7 @@ public function passwd_reset($token, $password) throw new UserException('Hash not found', 404); } - return $this->get($user[$this->params['id']]); + return $this->sanitize_user($this->get($user[$this->params['id']])); } public function update($user_id, array $data, $key = 'id') From ae1e4b51a540c3c7836a3c56d81c194e7806d795 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:00:42 -0300 Subject: [PATCH 047/217] Use canonical user sanitization in BasicAuth --- src/Auth/BasicAuth.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 573653f..78d38d9 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -529,11 +529,7 @@ private function matchesFilters(array $row, array $filters, string $operator): b private function publicUser(array $user): array { unset($user['_managed_relations']); - unset($user[$this->params['password']]); - if ($this->params['token']) { - unset($user[$this->params['token']]); - } - return $user; + return $this->sanitize_user($user); } private function isPasswordHash(string $value): bool From 7294c03d64c5ef92d07b28a194cce304bff097e9 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:01:20 -0300 Subject: [PATCH 048/217] Test public user secret sanitization --- test/DBAuthSqliteTest.php | 74 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 098adae..0ea02aa 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -4,6 +4,7 @@ use Objectiveweb\Auth\AuthException; use Objectiveweb\Auth\DBAuth; +use Objectiveweb\Auth\Controller\AuthController; use Objectiveweb\Auth\UserException; use Objectiveweb\DB; use PHPUnit\Framework\TestCase; @@ -143,6 +144,12 @@ public function testRegistration(): void $user['uuid'] ); $this->assertSame('Alice', $user['name']); + $this->assertArrayNotHasKey('password', $user); + $this->assertArrayNotHasKey('token', $user); + $this->assertArrayNotHasKey('token_expires_at', $user); + + $stored = self::$auth->get($user['id']); + $this->assertArrayHasKey('password', $stored); $logged = self::$auth->login('alice@example.com', 'secret'); $this->assertSame($user['id'], $logged['id']); @@ -297,6 +304,9 @@ public function testQuery(): void $this->assertSame(1, count($list['_embedded']['auth_user'])); $this->assertSame('Alice', $list['_embedded']['auth_user'][0]['name']); + $this->assertArrayNotHasKey('password', $list['_embedded']['auth_user'][0]); + $this->assertArrayNotHasKey('token', $list['_embedded']['auth_user'][0]); + $this->assertArrayNotHasKey('token_expires_at', $list['_embedded']['auth_user'][0]); $this->assertSame(1, $list['page']['totalElements']); $this->assertSame(1, $list['page']['totalPages']); $this->assertSame(0, $list['page']['number']); @@ -313,7 +323,10 @@ public function testRequestToken(): void $this->assertIsString($stored['token']); $this->assertTrue(password_verify($token, $stored['token'])); $this->assertNotNull($stored['token_expires_at']); - self::$auth->passwd_reset($token, 'final-secret'); + $reset = self::$auth->passwd_reset($token, 'final-secret'); + $this->assertArrayNotHasKey('password', $reset); + $this->assertArrayNotHasKey('token', $reset); + $this->assertArrayNotHasKey('token_expires_at', $reset); $logged = self::$auth->login('alice@example.com', 'final-secret'); $this->assertSame($user['id'], $logged['id']); @@ -361,6 +374,65 @@ public function testPasswdResetUsesConfiguredDatabasePrefix(): void $this->assertNull($stored['token_expires_at']); } + public function testAuthControllerResponsesDoNotExposeUserSecrets(): void + { + $controller = new AuthController(self::$auth); + + $registered = $controller->postRegister([ + 'uid' => 'controller@example.com', + 'password' => 'secret', + ]); + $this->assertArrayNotHasKey('password', $registered); + $this->assertArrayNotHasKey('token', $registered); + $this->assertArrayNotHasKey('token_expires_at', $registered); + + $token = self::$auth->update_token($registered['id']); + $reset = $controller->postToken([ + 'token' => $token, + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + $this->assertArrayNotHasKey('password', $reset); + $this->assertArrayNotHasKey('token', $reset); + $this->assertArrayNotHasKey('token_expires_at', $reset); + } + + public function testInvitationCallbackReceivesSanitizedUser(): void + { + $captured = null; + self::$auth->params['invitation_callback'] = function (array $user) use (&$captured): void { + $captured = $user; + }; + + try { + $user = self::$auth->register('invite@example.com', 'secret'); + self::$auth->invite($user['id']); + + $this->assertIsArray($captured); + $this->assertArrayNotHasKey('password', $captured); + $this->assertArrayNotHasKey('token', $captured); + $this->assertArrayNotHasKey('token_expires_at', $captured); + } finally { + self::$auth->params['invitation_callback'] = null; + } + } + + public function testDuplicateCredentialExceptionDoesNotExposeUserSecrets(): void + { + self::$auth->register('duplicate@example.com', 'secret'); + + try { + self::$auth->register('duplicate@example.com', 'another-secret'); + $this->fail('Expected duplicate credential exception'); + } catch (UserException $exception) { + $user = $exception->getUser(); + $this->assertIsArray($user); + $this->assertArrayNotHasKey('password', $user); + $this->assertArrayNotHasKey('token', $user); + $this->assertArrayNotHasKey('token_expires_at', $user); + } + } + public function testDelete(): void { $this->expectException(UserException::class); From 95fc5f20d9ef2de4326a1e037f540cb739fa848d Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:03:49 -0300 Subject: [PATCH 049/217] Scope eager relations to subject user --- src/Auth/DBAuth.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 0046efe..a62978e 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -992,14 +992,18 @@ private function hydrateEagerRelations(array $row): array } $eagerTable = $relation['table'] ?? null; + $subjectKey = $relation['subject_key'] ?? null; $eagerKey = $relation['target_key'] ?? null; - if (!is_string($eagerTable) || !is_string($eagerKey)) { + if (!is_string($eagerTable) || !is_string($subjectKey) || !is_string($eagerKey)) { throw new \InvalidArgumentException("Invalid relation table/key config for relation `$relationName`"); } $row[(string) $relationName] = $this->db->select( $eagerTable, - [$eagerKey => $targetIds], + [ + $subjectKey => $subjectId, + $eagerKey => $targetIds, + ], $eagerParams )->all(); } From e384322b561a14bf9227375e6f1565a12b718a1c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:04:04 -0300 Subject: [PATCH 050/217] Test eager relation subject isolation --- test/DBAuthSqliteTest.php | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 0ea02aa..6066788 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -666,6 +666,50 @@ public function testGetHydratesRelationWithEagerTrue(): void $this->assertSame([4, 8], $ids); } + public function testEagerRelationHydrationDoesNotIncludeOtherUsersRowsForSharedTarget(): void + { + $eagerAuth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'roles' => 'roles', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'item' => [ + 'table' => 'item_users', + 'subject_key' => 'user_id', + 'target_key' => 'item_id', + 'ability_key' => 'ability', + 'eager' => true, + ], + ], + ]); + + $alice = $eagerAuth->register('eager-alice@example.com', 'secret'); + $bob = $eagerAuth->register('eager-bob@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $alice['id'], + 'item_id' => 42, + 'ability' => 'view', + ]); + self::$db->insert('item_users', [ + 'user_id' => $bob['id'], + 'item_id' => 42, + 'ability' => 'manage', + ]); + + $loaded = $eagerAuth->get($alice['id']); + + $this->assertCount(1, $loaded['item']); + $this->assertSame((int) $alice['id'], (int) $loaded['item'][0]['user_id']); + $this->assertSame(42, (int) $loaded['item'][0]['item_id']); + $this->assertSame('view', $loaded['item'][0]['ability']); + } + public function testGetHydratesRelationWithEagerArrayAsSelectParams(): void { $eagerAuth = new DBAuth(self::$db, [ From 34f859df9bb498c81887dba32be0c6562b9ad2a8 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:23:01 -0300 Subject: [PATCH 051/217] Include lifecycle state in role user queries --- src/Auth/DBAuth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index a62978e..7afd4e7 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -645,7 +645,7 @@ public function get_users_by_role($roleName): array $safeFields = ['name', 'image', $userIdField]; - foreach (['uuid', 'created', 'last_login', 'token_expires_field'] as $paramKey) { + foreach (['uuid', 'created', 'last_login', 'disabled_at', 'token_expires_field'] as $paramKey) { $field = $this->params[$paramKey] ?? null; if (is_string($field) && $field !== '' && !in_array($field, $safeFields, true)) { $safeFields[] = $field; From d5fb61c057fb027aad0b6733d33b12e2a9c0f3ed Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:23:05 -0300 Subject: [PATCH 052/217] Test final active admin lifecycle guard --- test/UserControllerTest.php | 62 ++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 22f54ac..fecff3f 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -25,7 +25,8 @@ public static function setUpBeforeClass(): void image TEXT, created TEXT, password TEXT, - token TEXT + token TEXT, + disabled_at TEXT )' )->exec(); @@ -40,6 +41,21 @@ public static function setUpBeforeClass(): void )' )->exec(); + self::$db->query( + 'CREATE TABLE auth_role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + )' + )->exec(); + + self::$db->query( + 'CREATE TABLE auth_user_role ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY(user_id, role_id) + )' + )->exec(); + self::$auth = new DBAuth(self::$db, [ 'table' => 'auth_user', 'credentials_table' => 'auth_credentials', @@ -57,6 +73,8 @@ protected function setUp(): void { $_SESSION = []; self::$db->query('DELETE FROM auth_credentials')->exec(); + self::$db->query('DELETE FROM auth_user_role')->exec(); + self::$db->query('DELETE FROM auth_role')->exec(); self::$db->query('DELETE FROM auth_user')->exec(); } @@ -128,6 +146,48 @@ public function testPut(): void $this->assertSame('Updated name', $updated['name']); } + public function testCannotSuspendOnlyActiveAdminWhenAnotherAdminIsSuspended(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $controller = new UserController($auth); + + $actor = $auth->register('actor@example.com', 'secret'); + $activeAdmin = $auth->register('active-admin@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $suspendedAdmin = $auth->register('suspended-admin@example.com', 'secret', [ + 'roles' => ['admin'], + 'disabled_at' => '2026-09-27 12:00:00', + ]); + + $auth->login('actor@example.com', 'secret'); + + $admins = $auth->get_users_by_role('admin'); + $this->assertCount(2, $admins); + $suspended = array_values(array_filter( + $admins, + fn (array $user): bool => (int) $user['id'] === (int) $suspendedAdmin['id'] + )); + $this->assertCount(1, $suspended); + $this->assertSame('2026-09-27 12:00:00', $suspended[0]['disabled_at']); + + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(409); + $controller->post((int) $activeAdmin['id'], 'suspend'); + } + public function testDelete(): void { $this->expectException(\Objectiveweb\Auth\UserException::class); From 9c0539c5208f4a58178c521b3bff7e2b32157217 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:26:01 -0300 Subject: [PATCH 053/217] Align auth migration with DBAuth defaults --- db/20250419210134_auth_migration.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/db/20250419210134_auth_migration.php b/db/20250419210134_auth_migration.php index 8b72fe4..0ebc983 100644 --- a/db/20250419210134_auth_migration.php +++ b/db/20250419210134_auth_migration.php @@ -32,12 +32,15 @@ public function change() $user = $this->table('user', ['signed' => false]); $user + ->addColumn('uuid', 'string', ['limit' => 36]) ->addColumn('password', 'string', ['limit' => 60, 'null' => true]) ->addColumn('name', 'string', ['limit' => 255, 'null' => true]) ->addColumn('image', 'string', ['limit' => 255, 'null' => true]) ->addColumn('token', 'string', ['limit' => 255, 'null' => true]) ->addColumn('token_expires_at', 'datetime', ['null' => true]) - ->addColumn('created', 'datetime', [ 'default' => Literal::from('now()')]) + ->addColumn('disabled_at', 'datetime', ['null' => true]) + ->addColumn('created', 'datetime', ['default' => Literal::from('now()')]) + ->addIndex(['uuid'], ['unique' => true]) ->create(); $user_credentials = $this->table('user_credentials', [ From 9abd7e717ba4cf436daace153c4818a6889f3da6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:26:03 -0300 Subject: [PATCH 054/217] Verify migrated auth schema --- test/verify_migrations.php | 72 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 test/verify_migrations.php diff --git a/test/verify_migrations.php b/test/verify_migrations.php new file mode 100644 index 0000000..8ed2311 --- /dev/null +++ b/test/verify_migrations.php @@ -0,0 +1,72 @@ + PDO::ERRMODE_EXCEPTION] +); + +$expectedColumns = [ + 'user' => [ + 'id', + 'uuid', + 'password', + 'name', + 'image', + 'token', + 'token_expires_at', + 'disabled_at', + 'created', + ], + 'user_credentials' => [ + 'uid', + 'provider', + 'user_id', + 'profile', + 'token', + 'last_login', + 'created', + ], + 'role' => ['id', 'name'], + 'user_roles' => ['user_id', 'role_id'], +]; + +foreach ($expectedColumns as $table => $columns) { + $statement = $pdo->prepare( + 'SELECT column_name + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = :table' + ); + $statement->execute(['table' => $table]); + $actual = $statement->fetchAll(PDO::FETCH_COLUMN); + + foreach ($columns as $column) { + if (!in_array($column, $actual, true)) { + fwrite(STDERR, "Missing column {$table}.{$column}\n"); + exit(1); + } + } +} + +$uuidIndex = $pdo->query( + "SELECT indexdef + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'user' + AND indexdef ILIKE '%UNIQUE%' + AND indexdef ILIKE '%uuid%'" +)->fetchColumn(); + +if ($uuidIndex === false) { + fwrite(STDERR, "Missing unique index for user.uuid\n"); + exit(1); +} + +echo "Migration schema matches DBAuth defaults.\n"; From 6741afb3d43a949298971aefd51ba0bdbdcdb895 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:26:05 -0300 Subject: [PATCH 055/217] Verify DBAuth migration schema in CI --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c829592..a294ca6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,3 +75,6 @@ jobs: - name: Verify migration status run: vendor/bin/phinx status --configuration test/phinx.php --environment testing + + - name: Verify migrated schema + run: php test/verify_migrations.php From 2db85c447492e0e3c7da22efbcf24b98e9927d2f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:26:38 -0300 Subject: [PATCH 056/217] Align controller test schema with auth defaults --- test/UserControllerTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index fecff3f..e86839c 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -26,6 +26,7 @@ public static function setUpBeforeClass(): void created TEXT, password TEXT, token TEXT, + token_expires_at TEXT, disabled_at TEXT )' )->exec(); From 2063e6b168ca7cc89e7b078094c8bf2fe2545d78 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:28:41 -0300 Subject: [PATCH 057/217] Make role user lookup database portable --- src/Auth/DBAuth.php | 100 ++++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 7afd4e7..713d87c 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -640,11 +640,28 @@ public function get_users_by_role($roleName): array $userRolesTable = (string) $this->params['user_roles_table']; $userRolesUserId = (string) $this->params['user_roles_user_id']; $userRolesRoleId = (string) $this->params['user_roles_role_id']; - $roleId = (string) $this->params['role_id']; + $roleIdField = (string) $this->params['role_id']; $roleNameField = (string) $this->params['role_name']; - $safeFields = ['name', 'image', $userIdField]; + $role = $this->db->select( + $rolesTable, + [$roleNameField => $roleName], + ['limit' => 1] + )->fetch(); + if (!$role || !array_key_exists($roleIdField, $role)) { + return []; + } + + $memberships = $this->db->select( + $userRolesTable, + [$userRolesRoleId => $role[$roleIdField]], + [ + 'fields' => [$userRolesUserId], + 'order' => $userRolesUserId, + ] + )->all(); + $safeFields = ['name', 'image', $userIdField]; foreach (['uuid', 'created', 'last_login', 'disabled_at', 'token_expires_field'] as $paramKey) { $field = $this->params[$paramKey] ?? null; if (is_string($field) && $field !== '' && !in_array($field, $safeFields, true)) { @@ -652,62 +669,37 @@ public function get_users_by_role($roleName): array } } - $selectFields = array_map( - fn (string $field): string => $userTable . '.' . $field, - $safeFields - ); - $selectFields['roles_csv'] = 'GROUP_CONCAT(DISTINCT r_all.' . $roleNameField . ')'; + $result = []; + $seen = []; + foreach ($memberships as $membership) { + $userId = $membership[$userRolesUserId] ?? null; + if ($userId === null) { + continue; + } - $rows = $this->db->select( - $userTable, - ['r_filter.' . $roleNameField => $roleName], - [ - 'fields' => $selectFields, - 'join' => [ - [ - 'type' => 'inner', - 'table' => $userRolesTable, - 'alias' => 'ur_filter', - 'on' => 'ur_filter.' . $userRolesUserId . ' = ' . $userTable . '.' . $userIdField, - ], - [ - 'type' => 'inner', - 'table' => $rolesTable, - 'alias' => 'r_filter', - 'on' => 'r_filter.' . $roleId . ' = ur_filter.' . $userRolesRoleId, - ], - [ - 'type' => 'left', - 'table' => $userRolesTable, - 'alias' => 'ur_all', - 'on' => 'ur_all.' . $userRolesUserId . ' = ' . $userTable . '.' . $userIdField, - ], - [ - 'type' => 'left', - 'table' => $rolesTable, - 'alias' => 'r_all', - 'on' => 'r_all.' . $roleId . ' = ur_all.' . $userRolesRoleId, - ], - ], - 'group' => array_map( - fn (string $field): string => $userTable . '.' . $field, - $safeFields - ), - 'order' => $userTable . '.' . $userIdField, - ] - )->all(); + $identity = (string) $userId; + if (isset($seen[$identity])) { + continue; + } + $seen[$identity] = true; - return array_values(array_map(function (array $row): array { - $rolesCsv = (string) ($row['roles_csv'] ?? ''); - unset($row['roles_csv']); + $user = $this->db->select( + $userTable, + [$userIdField => $userId], + [ + 'fields' => $safeFields, + 'limit' => 1, + ] + )->fetch(); + if (!$user) { + continue; + } - $roleField = (string) $this->params['roles']; - $roles = $rolesCsv === '' ? [] : array_values(array_unique(array_filter(explode(',', $rolesCsv), fn (string $v): bool => $v !== ''))); - sort($roles, SORT_STRING); - $row[$roleField] = $roles; + $user[$this->params['roles']] = $this->loadUserRoles($userId); + $result[] = $user; + } - return $row; - }, $rows)); + return $result; } public function get_roles(): array From 907724e18ec0badbc3dc80dc57a6452d03eac348 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:29:28 -0300 Subject: [PATCH 058/217] Keep role lookup results public-safe --- src/Auth/DBAuth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 713d87c..2504297 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -696,7 +696,7 @@ public function get_users_by_role($roleName): array } $user[$this->params['roles']] = $this->loadUserRoles($userId); - $result[] = $user; + $result[] = $this->sanitize_user($user); } return $result; From 5e9bea79bbbf5a0e6251486ebb3a1564a49b8ca6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:29:30 -0300 Subject: [PATCH 059/217] Test role lookup on PostgreSQL --- test/verify_postgresql_role_lookup.php | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 test/verify_postgresql_role_lookup.php diff --git a/test/verify_postgresql_role_lookup.php b/test/verify_postgresql_role_lookup.php new file mode 100644 index 0000000..317d7ca --- /dev/null +++ b/test/verify_postgresql_role_lookup.php @@ -0,0 +1,63 @@ + 'token', + 'disabled_at' => 'disabled_at', +]); + +$db->insert('role', ['name' => 'admin']); +$db->insert('role', ['name' => 'operator']); + +$admin = $auth->register('postgres-admin@example.com', 'secret', [ + 'roles' => ['admin', 'operator'], +]); +$auth->register('postgres-viewer@example.com', 'secret', [ + 'roles' => ['viewer'], +]); + +$admins = $auth->get_users_by_role('admin'); + +if (count($admins) !== 1) { + fwrite(STDERR, "Expected exactly one admin\n"); + exit(1); +} + +if ((int) $admins[0]['id'] !== (int) $admin['id']) { + fwrite(STDERR, "Role lookup returned the wrong user\n"); + exit(1); +} + +if (($admins[0]['roles'] ?? []) !== ['admin', 'operator']) { + fwrite(STDERR, "Role hydration did not preserve all user roles\n"); + exit(1); +} + +foreach (['password', 'token', 'token_expires_at'] as $secret) { + if (array_key_exists($secret, $admins[0])) { + fwrite(STDERR, "Role lookup exposed {$secret}\n"); + exit(1); + } +} + +if ($auth->get_users_by_role('missing') !== []) { + fwrite(STDERR, "Unknown role should return an empty list\n"); + exit(1); +} + +echo "PostgreSQL role lookup passed.\n"; From a2225dd112de9cbec5be87f35970b7c0e9542c49 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:29:32 -0300 Subject: [PATCH 060/217] Exercise role lookup on PostgreSQL --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a294ca6..dbfcfcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,6 @@ jobs: - name: Verify migrated schema run: php test/verify_migrations.php + + - name: Verify portable role lookup + run: php test/verify_postgresql_role_lookup.php From 457a77bad7988d60f3e8994bb12a973a7459a052 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:31:05 -0300 Subject: [PATCH 061/217] Align BasicAuth provider semantics --- src/Auth/BasicAuth.php | 45 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 78d38d9..3908f68 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -247,6 +247,17 @@ public function delete($user_id) } } + $guard = $this->params['deletion_guard_callback']; + if (is_callable($guard)) { + $result = call_user_func($guard, $resolved); + if ($result === false || is_string($result)) { + throw new UserException( + is_string($result) ? $result : 'User has application history', + 409 + ); + } + } + $this->audit('user.deleted', $resolved); unset($this->users[$resolved]); foreach ($this->credentials as $provider => $records) { @@ -422,11 +433,30 @@ public function rename_credential( if (!$credential || ($credential['user_id'] ?? null) != $userid) { throw new UserException('Credential not found', 404); } - if (($provider !== $newProvider || $uid !== $newUid) && $this->get_credential($newProvider, $newUid)) { + + $newProvider = trim($newProvider); + $newUid = trim($newUid); + if ($newProvider === '' || $newUid === '') { + throw new UserException('Provider and uid are required', 400); + } + + $duplicate = $this->get_credential($newProvider, $newUid); + if ( + ($provider !== $newProvider || $uid !== $newUid) + && $duplicate + ) { throw new UserException('Credential already registered', 409); } + unset($this->credentials[$provider][$uid]); - $this->update_credential($userid, trim($newProvider), trim($newUid), $credential['profile'] ?? null); + if (isset($this->credentials[$provider]) && $this->credentials[$provider] === []) { + unset($this->credentials[$provider]); + } + + $credential['provider'] = $newProvider; + $credential['uid'] = $newUid; + $this->credentials[$newProvider][$newUid] = $credential; + return $this->get_credential($newProvider, $newUid); } @@ -458,10 +488,17 @@ public function sync_managed_relations($userId, array $relations): array { $this->get($userId); foreach ($relations as $name => $values) { - if (!isset($this->params['managed_relations'][$name]) || !is_array($values)) { + $relation = $this->params['managed_relations'][$name] ?? null; + if (!is_array($relation) || !is_array($values)) { throw new UserException("Invalid managed relation `$name`", 400); } - $this->users[$userId]['_managed_relations'][$name] = array_values(array_unique($values)); + + $values = array_values(array_unique($values)); + if (is_callable($relation['validate_callback'] ?? null)) { + call_user_func($relation['validate_callback'], $values); + } + + $this->users[$userId]['_managed_relations'][$name] = $values; } return $this->get_managed_relations($userId); } From 1d85eb33040aa457d788e250baa613ccfb79e49b Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:31:33 -0300 Subject: [PATCH 062/217] Test BasicAuth provider parity --- test/BasicAuthTest.php | 104 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index 2f3e87f..850066c 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -101,6 +101,110 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void } } + public function testRenameCredentialNormalizesTargetAndPreservesMetadata(): void + { + $user = $this->auth->register('rename@example.com', 'secret'); + $created = $this->auth->create_credential( + $user['id'], + 'phone', + '+5511999999999', + ['country' => 'BR'] + ); + + $renamed = $this->auth->rename_credential( + $user['id'], + 'phone', + '+5511999999999', + ' sms ', + ' +5511888888888 ' + ); + + $this->assertSame('sms', $renamed['provider']); + $this->assertSame('+5511888888888', $renamed['uid']); + $this->assertSame($created['created'], $renamed['created']); + $this->assertSame($created['last_login'], $renamed['last_login']); + $this->assertFalse($this->auth->get_credential('phone', '+5511999999999')); + } + + public function testRenameCredentialRejectsDuplicateAfterNormalization(): void + { + $user = $this->auth->register('duplicate-rename@example.com', 'secret'); + $this->auth->create_credential($user['id'], 'phone', '+5511999999999'); + $this->auth->create_credential($user['id'], 'sms', '+5511888888888'); + + $this->expectException(UserException::class); + $this->expectExceptionCode(409); + + $this->auth->rename_credential( + $user['id'], + 'phone', + '+5511999999999', + ' sms ', + ' +5511888888888 ' + ); + } + + public function testDeletionGuardCallbackPreventsDeletion(): void + { + $auth = new BasicAuth( + ['guarded@example.com' => 'secret'], + [ + 'token' => 'token', + 'deletion_guard_callback' => fn ($userId): string => 'User has application history', + ] + ); + $credential = $auth->get_credential('local', 'guarded@example.com'); + + try { + $auth->delete($credential['user_id']); + $this->fail('Expected deletion guard to reject deletion'); + } catch (UserException $exception) { + $this->assertSame(409, $exception->getCode()); + $this->assertSame('User has application history', $exception->getMessage()); + } + + $this->assertSame( + $credential['user_id'], + $auth->get($credential['user_id'])['id'] + ); + $this->assertNotFalse($auth->get_credential('local', 'guarded@example.com')); + } + + public function testManagedRelationValidationRunsBeforeMutation(): void + { + $validated = null; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'managed_relations' => [ + 'items' => [ + 'validate_callback' => function (array $values) use (&$validated): void { + $validated = $values; + if (in_array(99, $values, true)) { + throw new UserException('Invalid item', 400); + } + }, + ], + ], + ]); + + $user = $auth->register('relations@example.com', 'secret'); + $relations = $auth->sync_managed_relations($user['id'], [ + 'items' => [3, 3, 8], + ]); + + $this->assertSame([3, 8], $validated); + $this->assertSame([3, 8], $relations['items']); + + try { + $auth->sync_managed_relations($user['id'], ['items' => [99]]); + $this->fail('Expected managed relation validation to reject the update'); + } catch (UserException $exception) { + $this->assertSame(400, $exception->getCode()); + } + + $this->assertSame([3, 8], $auth->get_managed_relations($user['id'])['items']); + } + public function testGetUsersByRoleReturnsMatchingUsers(): void { $admin = $this->auth->register('admin@example.com', 'secret', [ From 0ec48cb2c3d30d37312fef3afdc5b161836037b1 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:33:49 -0300 Subject: [PATCH 063/217] Tighten user management input validation --- src/Auth/Controller/UserController.php | 37 ++++++++++++++++++++------ 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 95228b2..a520fff 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -164,8 +164,12 @@ private function create(array $data): array $passwordField = $this->auth->params['password']; $password = $data[$passwordField] ?? null; unset($data[$passwordField]); - $this->assertWriteFields($data); - $this->assertKnownRoles((array) ($data[$this->auth->params['roles']] ?? [])); + $this->assertWriteFields($data, true); + $roleField = $this->auth->params['roles']; + if (array_key_exists($roleField, $data)) { + $this->assertRolePayload($data[$roleField]); + $this->assertKnownRoles($data[$roleField]); + } $user = $this->auth->register($uid, is_string($password) && $password !== '' ? $password : null, $data); $id = $user[$this->auth->params['id']]; @@ -192,7 +196,8 @@ private function updateUser(mixed $userId, array $data): array $roleField = $this->auth->params['roles']; if (array_key_exists($roleField, $data)) { - $roles = is_array($data[$roleField]) ? $data[$roleField] : []; + $this->assertRolePayload($data[$roleField]); + $roles = $data[$roleField]; $this->assertKnownRoles($roles); $this->assertOwnAdminRolePreserved($userId, $roles); if (in_array('admin', (array) ($target[$roleField] ?? []), true) && !in_array('admin', $roles, true)) { @@ -279,6 +284,19 @@ private function assertManagementWrite(): void } } + private function assertRolePayload(mixed $roles): void + { + if (!is_array($roles)) { + throw new UserException('Invalid roles; expected an array', 400); + } + + foreach ($roles as $role) { + if (!is_string($role) || trim($role) === '') { + throw new UserException('Invalid role value', 400); + } + } + } + private function assertKnownRoles(array $roles): void { $unknown = array_values(array_diff($roles, $this->auth->get_roles())); @@ -324,16 +342,19 @@ private function assertNotFinalActiveAdmin(array $target): void } } - private function assertWriteFields(array $data): void + private function assertWriteFields(array $data, bool $creating = false): void { - $allowed = array_values(array_unique(array_filter([ + $allowed = [ 'name', 'image', - 'provider', - 'profile', $this->auth->params['roles'], $this->auth->params['disabled_at'], - ]))); + ]; + if ($creating) { + $allowed[] = 'provider'; + $allowed[] = 'profile'; + } + $allowed = array_values(array_unique(array_filter($allowed))); foreach (array_keys($data) as $field) { if (!in_array((string) $field, $allowed, true)) { throw new UserException("Invalid field `$field`", 400); From 9ef9221568b4da504a7f290f4ccf087c47735035 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:34:05 -0300 Subject: [PATCH 064/217] Test user management input validation --- test/UserControllerTest.php | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index e86839c..72433a7 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -133,6 +133,63 @@ public function testQueryInvalidFilterField(): void self::$controller->get(['unknown_field' => 'x']); } + public function testPostRejectsNonArrayRoles(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->post([ + 'uid' => 'roles-string@example.com', + 'password' => 'test', + 'roles' => 'admin', + ]); + } + + public function testPostAllowsCredentialMetadata(): void + { + $user = self::$controller->post([ + 'uid' => 'oauth@example.com', + 'password' => 'test', + 'provider' => 'email', + 'profile' => ['source' => 'test'], + ]); + + $this->assertCount(1, $user['credentials']); + $this->assertSame('email', $user['credentials'][0]['provider']); + $this->assertSame(['source' => 'test'], $user['credentials'][0]['profile']); + } + + public function testPutRejectsNonArrayRoles(): void + { + $user = self::$controller->post([ + 'uid' => 'roles-update@example.com', + 'password' => 'test', + ]); + + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->put((int) $user['id'], ['roles' => 'admin']); + } + + public function testPutRejectsCredentialOnlyFields(): void + { + $user = self::$controller->post([ + 'uid' => 'credential-fields@example.com', + 'password' => 'test', + ]); + + foreach (['provider', 'profile'] as $field) { + try { + self::$controller->put((int) $user['id'], [$field => 'invalid']); + $this->fail("Expected {$field} to be rejected"); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(400, $exception->getCode()); + $this->assertSame("Invalid field `{$field}`", $exception->getMessage()); + } + } + } + public function testPut(): void { $user = self::$controller->post([ From 80f643b169dfdb6b0fbdbccddb5b9a7cccc9ccbd Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:44:26 -0300 Subject: [PATCH 065/217] Harden and test OAuth callback flow --- src/Auth/Controller/OAuthController.php | 46 +++++++++++++++++++------ 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 51f185c..8d30b73 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -37,11 +37,9 @@ function get($id, $query) $config = $this->providers[$id]; - $config['redirectUri'] = sprintf("%s://%s%s%s", - (empty($_SERVER['HTTP_X_FORWARDED_PROTO']) ? $_SERVER['REQUEST_SCHEME'] : $_SERVER['HTTP_X_FORWARDED_PROTO']), - $_SERVER['HTTP_HOST'], - str_replace("/index.php", "", $_SERVER['PHP_SELF']), - $_SERVER['PATH_INFO']); + if (empty($config['redirectUri'])) { + $config['redirectUri'] = $this->resolveRedirectUri(); + } $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); @@ -54,13 +52,15 @@ function get($id, $query) // generate authUrl first to update state $authUrl = $provider->getAuthorizationUrl(); $_SESSION['oauth2state'] = $provider->getState(); - error_log('oauth2state ' . $_SESSION['oauth2state']); - header('Location: ' . $authUrl); - exit; + return $this->redirect($authUrl); } elseif (empty($query['state']) || $query['state'] !== $_SESSION['oauth2state']) { unset($_SESSION['oauth2state']); throw new \Exception('Invalid state', 406); } else { + // OAuth state is single-use. Consume it before exchanging the code + // so a callback cannot be replayed even if the provider request fails. + unset($_SESSION['oauth2state']); + $token = $provider->getAccessToken('authorization_code', [ 'code' => $query['code'] ]); @@ -68,9 +68,35 @@ function get($id, $query) $this->login($id, $resourceOwner); - header("Location: /"); - exit(); + return $this->redirect('/'); + } + } + + protected function resolveRedirectUri(): string + { + $forwardedProto = trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')); + if ($forwardedProto !== '') { + $forwardedProto = trim(explode(',', $forwardedProto, 2)[0]); + } + + $scheme = $forwardedProto !== '' + ? $forwardedProto + : (string) ($_SERVER['REQUEST_SCHEME'] ?? 'https'); + $host = (string) ($_SERVER['HTTP_HOST'] ?? ''); + if ($host === '') { + throw new \RuntimeException('Cannot determine OAuth redirect URI without HTTP_HOST'); } + + $script = str_replace('/index.php', '', (string) ($_SERVER['PHP_SELF'] ?? '')); + $pathInfo = (string) ($_SERVER['PATH_INFO'] ?? ''); + + return sprintf('%s://%s%s%s', $scheme, $host, $script, $pathInfo); + } + + protected function redirect(string $location): void + { + header('Location: ' . $location); + exit; } /** From c82fe47ce4dd355653ffd7af6c78118921715b74 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:44:35 -0300 Subject: [PATCH 066/217] Keep OAuth redirect flow void-safe --- src/Auth/Controller/OAuthController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 8d30b73..e7b3990 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -52,7 +52,8 @@ function get($id, $query) // generate authUrl first to update state $authUrl = $provider->getAuthorizationUrl(); $_SESSION['oauth2state'] = $provider->getState(); - return $this->redirect($authUrl); + $this->redirect($authUrl); + return; } elseif (empty($query['state']) || $query['state'] !== $_SESSION['oauth2state']) { unset($_SESSION['oauth2state']); throw new \Exception('Invalid state', 406); @@ -68,7 +69,8 @@ function get($id, $query) $this->login($id, $resourceOwner); - return $this->redirect('/'); + $this->redirect('/'); + return; } } From 9519b914309f5b4d8db2aa93a8ca62978fc6fe7f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:44:48 -0300 Subject: [PATCH 067/217] Add OAuth provider test fixture --- test/OAuthTestProvider.php | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 test/OAuthTestProvider.php diff --git a/test/OAuthTestProvider.php b/test/OAuthTestProvider.php new file mode 100644 index 0000000..208eccc --- /dev/null +++ b/test/OAuthTestProvider.php @@ -0,0 +1,43 @@ + 'test-token']; + } + + public function getResourceOwner(object $token): object + { + if (self::$resourceOwner === null) { + throw new \RuntimeException('Missing OAuth test resource owner'); + } + + return self::$resourceOwner; + } +} From 204615b5c6125fb3b2499dfae8db0c4c004ae4ad Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:45:21 -0300 Subject: [PATCH 068/217] Expand OAuth callback and linking tests --- test/OAuthControllerTest.php | 136 +++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index fe8b54a..e6e9206 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -1,10 +1,12 @@ redirectedTo = $location; + } +} + class OAuthControllerTest extends TestCase { protected function setUp(): void { $_SESSION = []; + $_SERVER = []; + OAuthTestProvider::$lastConfig = []; + OAuthTestProvider::$resourceOwner = null; + OAuthTestProvider::$state = 'test-oauth-state'; } public function testGetWithInvalidProviderThrows(): void @@ -34,6 +50,126 @@ public function testGetWithInvalidProviderThrows(): void $controller->get('missing-provider', []); } + public function testAuthorizationUsesConfiguredRedirectUriAndStoresState(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $controller->get('test', []); + + $this->assertSame('https://app.example/oauth/test', OAuthTestProvider::$lastConfig['redirectUri']); + $this->assertSame('test-oauth-state', $_SESSION['oauth2state']); + $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); + } + + public function testAuthorizationDerivesRedirectUriWhenNotConfigured(): void + { + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https, http'; + $_SERVER['HTTP_HOST'] = 'app.example'; + $_SERVER['PHP_SELF'] = '/index.php/oauth'; + $_SERVER['PATH_INFO'] = '/test'; + + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + ], + ]); + + $controller->get('test', []); + + $this->assertSame( + 'https://app.example/oauth/test', + OAuthTestProvider::$lastConfig['redirectUri'] + ); + } + + public function testOAuthCallbackConsumesStateAndLinksExistingEmailUser(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $user = $auth->register('linked@example.com', 'secret', ['provider' => 'email']); + + OAuthTestProvider::$resourceOwner = $this->resourceOwner( + 'provider-user-id', + 'linked@example.com' + ); + $_SESSION['oauth2state'] = 'test-oauth-state'; + + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $controller->get('test', [ + 'code' => 'oauth-code', + 'state' => 'test-oauth-state', + ]); + + $this->assertArrayNotHasKey('oauth2state', $_SESSION); + $this->assertSame('/', $controller->redirectedTo); + $this->assertTrue($auth->sessionRegenerated); + $this->assertSame($user['id'], $auth->user()['id']); + + $credential = $auth->get_credential('test', 'provider-user-id'); + $this->assertNotFalse($credential); + $this->assertSame($user['id'], $credential['user_id']); + } + + public function testInvalidOAuthStateIsCleared(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $_SESSION['oauth2state'] = 'expected-state'; + + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + try { + $controller->get('test', [ + 'code' => 'oauth-code', + 'state' => 'wrong-state', + ]); + $this->fail('Expected invalid OAuth state to be rejected'); + } catch (\Exception $exception) { + $this->assertSame(406, $exception->getCode()); + } + + $this->assertArrayNotHasKey('oauth2state', $_SESSION); + } + + public function testLoggedInUserCanLinkOAuthCredential(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $user = $auth->register('local@example.com', 'secret'); + $auth->login('local@example.com', 'secret'); + + $controller = new OAuthController($auth, []); + $logged = $this->invokeOAuthLogin( + $controller, + 'google', + $this->resourceOwner('google-linked-id') + ); + + $credential = $auth->get_credential('google', 'google-linked-id'); + $this->assertNotFalse($credential); + $this->assertSame($user['id'], $credential['user_id']); + $this->assertSame($user['id'], $logged['id']); + } + public function testOAuthLoginRejectsSuspendedUser(): void { $auth = new OAuthTrackingBasicAuth([], [ From cb464321abdf4824cd6c3a245579e72424ad489f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:45:37 -0300 Subject: [PATCH 069/217] Document explicit OAuth redirect URI --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 8b5170b..75fdfa3 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,24 @@ $auth->passwd($userId, 'new-password'); $credentials = $auth->get_credentials($userId); ``` +## OAuth controller + +Configure OAuth providers by provider name. Set an explicit `redirectUri` in production so callback URLs do not depend on request/proxy headers: + +```php +use Objectiveweb\Auth\Controller\OAuthController; + +$oauth = new OAuthController($auth, [ + 'google' => [ + 'clientId' => getenv('GOOGLE_CLIENT_ID'), + 'clientSecret' => getenv('GOOGLE_CLIENT_SECRET'), + 'redirectUri' => 'https://app.example/auth/oauth/google', + ], +]); +``` + +If `redirectUri` is omitted, the controller derives it from the current request for development/legacy setups. OAuth state is single-use and is cleared when the callback is consumed. + ## User-management API Register `Objectiveweb\Auth\Controller\UserController` at an application-owned From da445cce938b98090a44251c1ccf630b4db62f50 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 27 Sep 2026 21:45:47 -0300 Subject: [PATCH 070/217] Consume OAuth state safely --- src/Auth/Controller/OAuthController.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index e7b3990..4391861 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -47,6 +47,7 @@ function get($id, $query) $provider = new $classname($config); if (!empty($query['error'])) { + unset($_SESSION['oauth2state']); throw new \Exception("Got error {$query['error']}", 500); } elseif (empty($query['code'])) { // generate authUrl first to update state @@ -54,7 +55,10 @@ function get($id, $query) $_SESSION['oauth2state'] = $provider->getState(); $this->redirect($authUrl); return; - } elseif (empty($query['state']) || $query['state'] !== $_SESSION['oauth2state']) { + } elseif ( + empty($query['state']) + || $query['state'] !== ($_SESSION['oauth2state'] ?? null) + ) { unset($_SESSION['oauth2state']); throw new \Exception('Invalid state', 406); } else { From 25fe76ae1f592847949782f0d0d0ff1e5b565503 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 21:52:52 -0300 Subject: [PATCH 071/217] Add router 3.x dev dependency --- composer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 3487278..1b883e7 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ }, "require-dev": { "phpunit/phpunit": "10.*", - "robmorgan/phinx": "^0.16" + "robmorgan/phinx": "^0.16", + "objectiveweb/router": "3.x.x" }, "suggest": { "league/oauth2-google": "Google Login", From 01ea516d38e320d22b91e5fa26a0844dd55b4afb Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 21:53:48 -0300 Subject: [PATCH 072/217] Allow router 3.0 and newer --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 1b883e7..e0a2ae9 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require-dev": { "phpunit/phpunit": "10.*", "robmorgan/phinx": "^0.16", - "objectiveweb/router": "3.x.x" + "objectiveweb/router": ">=3.0.0" }, "suggest": { "league/oauth2-google": "Google Login", From 46c188f40af2a22b7422f3f94f2b2632c493c625 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 21:56:04 -0300 Subject: [PATCH 073/217] Use router 3.x compatibility constraint --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e0a2ae9..410013a 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "require-dev": { "phpunit/phpunit": "10.*", "robmorgan/phinx": "^0.16", - "objectiveweb/router": ">=3.0.0" + "objectiveweb/router": "^3.0" }, "suggest": { "league/oauth2-google": "Google Login", From 726a4ad06a730a0e4e94ce7c2aefedc9ac9e0e42 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 21:59:59 -0300 Subject: [PATCH 074/217] Optimize user queries and reset token lookup --- src/Auth/DBAuth.php | 311 +++++++++++++++++++++++++++++++------------- 1 file changed, 221 insertions(+), 90 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 2504297..ad4402c 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -41,63 +41,193 @@ public function query($params = array(), $operator = "OR") $status = trim((string) ($params['status'] ?? '')); unset($params['page'], $params['size'], $params['sort'], $params['q'], $params['role'], $params['status']); - // Management datasets are deliberately hydrated before filtering. This - // keeps credential UID and managed-relation search portable across the - // DB abstraction's supported engines and avoids accidental inner joins - // that hide users without roles. - $rows = $this->db->select($this->params['table'])->all(); - $data = []; - foreach ($rows as $row) { - $hydrated = $this->hydrateUserRow($row); - if (!$this->matchesUserFilters($hydrated, $params, $operator)) { - continue; + $userTable = (string) $this->params['table']; + $userIdField = (string) $this->params['id']; + [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); + $sortDirection = strtoupper($sortDirection); + if (!in_array($sortDirection, ['ASC', 'DESC'], true)) { + throw new \InvalidArgumentException('Invalid sort direction'); + } + + $where = []; + $candidateIds = null; + $operator = strtoupper((string) $operator); + + if ($params !== []) { + if ($operator === 'AND') { + foreach ($params as $key => $value) { + if ($key === 'uid') { + $credential = $this->get_credential('local', $value); + $candidateIds = $this->intersectUserIds( + $candidateIds, + $credential && isset($credential['user_id']) ? [$credential['user_id']] : [] + ); + continue; + } + + $where[(string) $key] = $value; + } + } else { + $filterIds = []; + foreach ($params as $key => $value) { + if ($key === 'uid') { + $credential = $this->get_credential('local', $value); + if ($credential && isset($credential['user_id'])) { + $filterIds[] = $credential['user_id']; + } + continue; + } + + $rows = $this->db->select( + $userTable, + [(string) $key => $value], + ['fields' => [$userIdField]] + )->all(); + foreach ($rows as $row) { + if (array_key_exists($userIdField, $row)) { + $filterIds[] = $row[$userIdField]; + } + } + } + $candidateIds = $this->intersectUserIds($candidateIds, $filterIds); } + } - $credentials = $this->get_credentials($hydrated[$this->params['id']]); - if ($q !== '') { - $haystack = strtolower(implode(' ', array_merge( - [ - (string) ($hydrated['name'] ?? ''), - (string) ($hydrated[$this->params['id']] ?? ''), - ], - array_map(fn (array $credential): string => (string) ($credential['uid'] ?? ''), $credentials) - ))); - if (!str_contains($haystack, strtolower($q))) { - continue; + if ($q !== '') { + $searchIds = []; + $nameRows = $this->db->select( + $userTable, + ['name' => '%' . $q . '%'], + ['fields' => [$userIdField]] + )->all(); + foreach ($nameRows as $row) { + if (array_key_exists($userIdField, $row)) { + $searchIds[] = $row[$userIdField]; } } - $roles = is_array($hydrated[$this->params['roles']] ?? null) - ? $hydrated[$this->params['roles']] - : []; - if ($role === 'unassigned' && $roles !== []) { - continue; + if (ctype_digit($q)) { + $idRow = $this->db->select( + $userTable, + [$userIdField => $q], + ['fields' => [$userIdField], 'limit' => 1] + )->fetch(); + if ($idRow && array_key_exists($userIdField, $idRow)) { + $searchIds[] = $idRow[$userIdField]; + } } - if ($role !== '' && $role !== 'unassigned' && !in_array($role, $roles, true)) { - continue; + + $credentialRows = $this->db->select( + (string) $this->params['credentials_table'], + ['uid' => '%' . $q . '%'], + ['fields' => ['user_id']] + )->all(); + foreach ($credentialRows as $row) { + if (array_key_exists('user_id', $row)) { + $searchIds[] = $row['user_id']; + } } - if ($status === 'active' && !$this->is_active($hydrated)) { - continue; + + $candidateIds = $this->intersectUserIds($candidateIds, $searchIds); + } + + if ($role !== '') { + if (!$this->rolesEnabled()) { + $candidateIds = $this->intersectUserIds($candidateIds, []); + } elseif ($role === 'unassigned') { + $assigned = $this->db->select( + (string) $this->params['user_roles_table'], + null, + ['fields' => [(string) $this->params['user_roles_user_id']]] + )->all(); + $assignedIds = array_values(array_unique(array_map( + fn (array $row): mixed => $row[(string) $this->params['user_roles_user_id']] ?? null, + $assigned + ))); + $assignedIds = array_values(array_filter($assignedIds, fn (mixed $id): bool => $id !== null)); + if ($assignedIds !== []) { + $where['!' . $userIdField] = $assignedIds; + } + } else { + $roleRow = $this->db->select( + (string) $this->params['roles_table'], + [(string) $this->params['role_name'] => $role], + ['fields' => [(string) $this->params['role_id']], 'limit' => 1] + )->fetch(); + + $roleUserIds = []; + if ($roleRow && array_key_exists((string) $this->params['role_id'], $roleRow)) { + $memberships = $this->db->select( + (string) $this->params['user_roles_table'], + [(string) $this->params['user_roles_role_id'] => $roleRow[(string) $this->params['role_id']]], + ['fields' => [(string) $this->params['user_roles_user_id']]] + )->all(); + foreach ($memberships as $membership) { + $id = $membership[(string) $this->params['user_roles_user_id']] ?? null; + if ($id !== null) { + $roleUserIds[] = $id; + } + } + } + $candidateIds = $this->intersectUserIds($candidateIds, $roleUserIds); } - if ($status === 'suspended' && $this->is_active($hydrated)) { - continue; + } + + if ($status !== '') { + $disabledField = $this->params['disabled_at']; + if (!is_string($disabledField) || $disabledField === '') { + if ($status === 'suspended') { + $candidateIds = $this->intersectUserIds($candidateIds, []); + } + } elseif ($status === 'active') { + $where[$disabledField] = null; + } elseif ($status === 'suspended') { + $where['!' . $disabledField] = null; + } + } + + if ($candidateIds !== null) { + if ($candidateIds === []) { + return $this->emptyQueryResult($page, $size); } - $hydrated['credentials'] = $credentials; - $data[] = $this->sanitize_user($hydrated); + if (array_key_exists($userIdField, $where)) { + $requestedIds = is_array($where[$userIdField]) + ? $where[$userIdField] + : [$where[$userIdField]]; + $candidateIds = $this->intersectUserIds($candidateIds, $requestedIds); + if ($candidateIds === []) { + return $this->emptyQueryResult($page, $size); + } + } + $where[$userIdField] = $candidateIds; } - [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); - usort($data, static function (array $a, array $b) use ($sortField, $sortDirection): int { - $comparison = ($a[$sortField] ?? null) <=> ($b[$sortField] ?? null); - return strtoupper($sortDirection) === 'DESC' ? -$comparison : $comparison; - }); - $count = count($data); - $slice = array_slice($data, $page * $size, $size); + $count = $this->db->count($userTable, $where); + if ($count === 0) { + return $this->emptyQueryResult($page, $size); + } + + $rows = $this->db->select( + $userTable, + $where, + [ + 'order' => [$sortField, $sortDirection], + 'limit' => $size, + 'offset' => $page * $size, + ] + )->all(); + + $data = []; + foreach ($rows as $row) { + $hydrated = $this->hydrateUserRow($row); + $hydrated['credentials'] = $this->get_credentials($hydrated[$userIdField]); + $data[] = $this->sanitize_user($hydrated); + } return [ '_embedded' => [ - $this->params['table'] => $slice, + $userTable => $data, ], 'page' => [ 'size' => $size, @@ -375,8 +505,7 @@ public function update_token($user_id) } $token = self::hash(); - $tokenHash = self::hash($token); - $payload = [$this->params['token'] => $tokenHash]; + $payload = [$this->params['token'] => $this->resetTokenDigest($token)]; $tokenExpiresField = $this->params['token_expires_field']; if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { $ttl = max(60, (int) $this->params['token_ttl']); @@ -1033,65 +1162,67 @@ private function findUserByResetToken(string $token): ?array $table = (string) $this->params['table']; $tokenField = (string) $this->params['token']; $tokenExpiresField = $this->params['token_expires_field']; - $now = date('Y-m-d H:i:s'); - // Use DB::select() so configured table prefixes and identifier quoting - // are applied consistently. Token hashes cannot be queried directly, - // because password_verify() must compare the supplied token in PHP. - $rows = $this->db->select($table, ['!' . $tokenField => null])->all(); - foreach ($rows as $row) { - if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { - $expiresAt = $row[$tokenExpiresField] ?? null; - if (!is_string($expiresAt) || $expiresAt < $now) { - continue; - } - } - - $candidateHash = (string) ($row[$tokenField] ?? ''); - if ($candidateHash === '') { - continue; - } + $row = $this->db->select( + $table, + [$tokenField => $this->resetTokenDigest($token)], + ['limit' => 1] + )->fetch(); + if (!$row) { + return null; + } - if (\password_verify($token, $candidateHash)) { - return $row; + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + $expiresAt = $row[$tokenExpiresField] ?? null; + if (!is_string($expiresAt) || $expiresAt < date('Y-m-d H:i:s')) { + return null; } } - return null; + return $row; } - private function matchesUserFilters(array $row, array $filters, string $operator): bool + private function resetTokenDigest(string $token): string { - if ($filters === []) { - return true; - } - $matches = []; - foreach ($filters as $key => $value) { - if ($key === 'uid') { - $matches[] = $this->get_credential('local', $value) - && (($this->get_credential('local', $value)['user_id'] ?? null) == ($row[$this->params['id']] ?? null)); - continue; - } - $actual = $row[$key] ?? null; - if (is_string($value) && str_contains($value, '%')) { - $needle = strtolower(str_replace('%', '', $value)); - $matches[] = str_contains(strtolower((string) $actual), $needle); - } else { - $matches[] = $actual == $value; + return hash('sha256', $token); + } + + private function intersectUserIds(?array $current, array $next): array + { + $normalize = static function (array $ids): array { + $normalized = []; + foreach ($ids as $id) { + if ($id === null) { + continue; + } + $normalized[(string) $id] = $id; } + return $normalized; + }; + + $nextMap = $normalize($next); + if ($current === null) { + return array_values($nextMap); } - return strtoupper($operator) === 'AND' - ? !in_array(false, $matches, true) - : in_array(true, $matches, true); + + $currentMap = $normalize($current); + return array_values(array_intersect_key($currentMap, $nextMap)); } - private function uuidV4(): string + private function emptyQueryResult(int $page, int $size): array { - $data = random_bytes(16); - $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); - $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); - - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + return [ + '_embedded' => [ + $this->params['table'] => [], + ], + 'page' => [ + 'size' => $size, + 'number' => $page, + 'totalElements' => 0, + 'totalPages' => 0, + ], + ]; } + } From 38ddbb93c075b788fd64ed48a920aa507e9092cf Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:00:33 -0300 Subject: [PATCH 075/217] Preserve unassigned role query semantics --- src/Auth/DBAuth.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index ad4402c..3182366 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -133,7 +133,9 @@ public function query($params = array(), $operator = "OR") if ($role !== '') { if (!$this->rolesEnabled()) { - $candidateIds = $this->intersectUserIds($candidateIds, []); + if ($role !== 'unassigned') { + $candidateIds = $this->intersectUserIds($candidateIds, []); + } } elseif ($role === 'unassigned') { $assigned = $this->db->select( (string) $this->params['user_roles_table'], From 1418e1a9ed09bb4df68a6a7199f2ca4275ae988a Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:00:43 -0300 Subject: [PATCH 076/217] Index reset token digests --- db/20250419210134_auth_migration.php | 1 + 1 file changed, 1 insertion(+) diff --git a/db/20250419210134_auth_migration.php b/db/20250419210134_auth_migration.php index 0ebc983..9d2897b 100644 --- a/db/20250419210134_auth_migration.php +++ b/db/20250419210134_auth_migration.php @@ -41,6 +41,7 @@ public function change() ->addColumn('disabled_at', 'datetime', ['null' => true]) ->addColumn('created', 'datetime', ['default' => Literal::from('now()')]) ->addIndex(['uuid'], ['unique' => true]) + ->addIndex(['token']) ->create(); $user_credentials = $this->table('user_credentials', [ From f3f76292d59b23e4a9b6183d259da5f6f0c89bf0 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:00:47 -0300 Subject: [PATCH 077/217] Verify reset token index --- test/verify_migrations.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/verify_migrations.php b/test/verify_migrations.php index 8ed2311..06cb98a 100644 --- a/test/verify_migrations.php +++ b/test/verify_migrations.php @@ -69,4 +69,17 @@ exit(1); } +$tokenIndex = $pdo->query( + "SELECT indexdef + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'user' + AND indexdef ILIKE '%token%'" +)->fetchColumn(); + +if ($tokenIndex === false) { + fwrite(STDERR, "Missing index for user.token\n"); + exit(1); +} + echo "Migration schema matches DBAuth defaults.\n"; From 3086bddf538b556700f02dad27a8cb8f39dc4898 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:01:07 -0300 Subject: [PATCH 078/217] Test paginated queries and reset token digests --- test/DBAuthSqliteTest.php | 88 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 6066788..b18a0c0 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -32,6 +32,8 @@ public static function setUpBeforeClass(): void )' )->exec(); + self::$db->query('CREATE INDEX auth_user_token_idx ON auth_user(token)')->exec(); + self::$db->query( 'CREATE TABLE auth_credentials ( uid TEXT NOT NULL, @@ -312,6 +314,89 @@ public function testQuery(): void $this->assertSame(0, $list['page']['number']); } + public function testQueryPaginatesAndSortsBeforeHydration(): void + { + self::$auth->register('charlie@example.com', 'secret', ['name' => 'Charlie']); + self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); + self::$auth->register('bob@example.com', 'secret', ['name' => 'Bob']); + + $first = self::$auth->query([ + 'page' => 0, + 'size' => 2, + 'sort' => 'name ASC', + ]); + $second = self::$auth->query([ + 'page' => 1, + 'size' => 2, + 'sort' => 'name ASC', + ]); + + $this->assertSame(3, $first['page']['totalElements']); + $this->assertSame(2, $first['page']['totalPages']); + $this->assertSame(['Alice', 'Bob'], array_column($first['_embedded']['auth_user'], 'name')); + $this->assertSame(['Charlie'], array_column($second['_embedded']['auth_user'], 'name')); + + foreach ($first['_embedded']['auth_user'] as $user) { + $this->assertArrayHasKey('credentials', $user); + $this->assertCount(1, $user['credentials']); + } + } + + public function testQueryCombinesSearchRoleAndLifecycleFilters(): void + { + self::$auth->register('active-admin@example.com', 'secret', [ + 'name' => 'Active Admin', + 'roles' => ['admin'], + ]); + self::$auth->register('suspended-admin@example.com', 'secret', [ + 'name' => 'Suspended Admin', + 'roles' => ['admin'], + 'disabled_at' => '2026-09-30 12:00:00', + ]); + self::$auth->register('active-viewer@example.com', 'secret', [ + 'name' => 'Active Viewer', + 'roles' => ['viewer'], + ]); + + $result = self::$auth->query([ + 'q' => 'admin@example.com', + 'role' => 'admin', + 'status' => 'active', + 'size' => 10, + ]); + + $this->assertSame(1, $result['page']['totalElements']); + $this->assertSame('Active Admin', $result['_embedded']['auth_user'][0]['name']); + $this->assertSame(['admin'], $result['_embedded']['auth_user'][0]['roles']); + $this->assertSame( + 'active-admin@example.com', + $result['_embedded']['auth_user'][0]['credentials'][0]['uid'] + ); + } + + public function testQueryUnassignedWorksWithAndWithoutRoleTables(): void + { + self::$auth->register('assigned@example.com', 'secret', ['roles' => ['admin']]); + self::$auth->register('unassigned@example.com', 'secret'); + + $withRoles = self::$auth->query(['role' => 'unassigned', 'size' => 10]); + $this->assertSame(1, $withRoles['page']['totalElements']); + $this->assertSame('unassigned', $withRoles['_embedded']['auth_user'][0]['name']); + + $withoutRoles = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => 'created', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + $result = $withoutRoles->query(['role' => 'unassigned', 'size' => 10]); + $this->assertSame(2, $result['page']['totalElements']); + } + public function testRequestToken(): void { $user = self::$auth->register('alice@example.com', 'secret'); @@ -321,7 +406,8 @@ public function testRequestToken(): void $token = self::$auth->update_token($account['user_id']); $stored = self::$db->select('auth_user', ['id' => $account['user_id']], ['limit' => 1])->fetch(); $this->assertIsString($stored['token']); - $this->assertTrue(password_verify($token, $stored['token'])); + $this->assertSame(hash('sha256', $token), $stored['token']); + $this->assertSame(64, strlen($stored['token'])); $this->assertNotNull($stored['token_expires_at']); $reset = self::$auth->passwd_reset($token, 'final-secret'); $this->assertArrayNotHasKey('password', $reset); From 46b134420a6540c148d29e5c4f03c82684e56cd8 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:01:18 -0300 Subject: [PATCH 079/217] Document indexed reset token digests --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 75fdfa3..088fddc 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,8 @@ $token = $auth->update_token($credential['user_id']); $auth->passwd_reset($token, 'new-password'); ``` +`DBAuth` stores only a SHA-256 digest of the high-entropy reset token and looks it up through the indexed token column. The plaintext token is returned only once for delivery. + ## Credential strategy (`local`, `email`, `phone`, social) Recommended model: From 146c4eb4e9ca8e262f1de53b96b9c62a9136bcba Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:01:47 -0300 Subject: [PATCH 080/217] Match Router 3 middleware contract --- src/Auth/Middleware/RequireRole.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Middleware/RequireRole.php b/src/Auth/Middleware/RequireRole.php index d64b4db..3bfcd3a 100644 --- a/src/Auth/Middleware/RequireRole.php +++ b/src/Auth/Middleware/RequireRole.php @@ -24,7 +24,7 @@ public function after(string $method, string $fn, array $params, mixed $response return $response; } - public function before(string $method, string $fn, array $params): mixed + public function before(string $method, string $fn, array $params): array { if ($this->auth->check() && !$this->auth->revalidate()) { throw new AuthException('Forbidden', 401); From 90eb7a3208207c4272da91c03188082c52dbdac7 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:02:42 -0300 Subject: [PATCH 081/217] Restore DBAuth UUID generation --- src/Auth/DBAuth.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 3182366..fcdc282 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -1226,5 +1226,13 @@ private function emptyQueryResult(int $page, int $size): array ]; } + private function uuidV4(): string + { + $data = random_bytes(16); + $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); + $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); + + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4)); + } } From 3c30e978108227356ed55d6a03e64667adf7fe50 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:38:26 -0300 Subject: [PATCH 082/217] Use Table collections for DBAuth queries --- src/Auth/DBAuth.php | 66 +++++++++++++-------------------------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index fcdc282..d91f581 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -3,6 +3,7 @@ namespace Objectiveweb\Auth; use Objectiveweb\DB; +use Objectiveweb\DB\Collection; class DBAuth extends \Objectiveweb\Auth { @@ -31,7 +32,7 @@ public function __construct(private DB $db, array $params = []) $this->assertRoleConfig(); } - public function query($params = array(), $operator = "OR") + public function query($params = array(), $operator = "OR"): Collection { $page = max(0, (int) ($params['page'] ?? 0)); $size = max(1, (int) ($params['size'] ?? 20)); @@ -41,8 +42,10 @@ public function query($params = array(), $operator = "OR") $status = trim((string) ($params['status'] ?? '')); unset($params['page'], $params['size'], $params['sort'], $params['q'], $params['role'], $params['status']); - $userTable = (string) $this->params['table']; + $userTableName = (string) $this->params['table']; $userIdField = (string) $this->params['id']; + $userTable = $this->db->table($userTableName, ['pk' => $userIdField]); + [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); $sortDirection = strtoupper($sortDirection); if (!in_array($sortDirection, ['ASC', 'DESC'], true)) { @@ -79,7 +82,7 @@ public function query($params = array(), $operator = "OR") } $rows = $this->db->select( - $userTable, + $userTableName, [(string) $key => $value], ['fields' => [$userIdField]] )->all(); @@ -96,7 +99,7 @@ public function query($params = array(), $operator = "OR") if ($q !== '') { $searchIds = []; $nameRows = $this->db->select( - $userTable, + $userTableName, ['name' => '%' . $q . '%'], ['fields' => [$userIdField]] )->all(); @@ -108,7 +111,7 @@ public function query($params = array(), $operator = "OR") if (ctype_digit($q)) { $idRow = $this->db->select( - $userTable, + $userTableName, [$userIdField => $q], ['fields' => [$userIdField], 'limit' => 1] )->fetch(); @@ -190,7 +193,7 @@ public function query($params = array(), $operator = "OR") if ($candidateIds !== null) { if ($candidateIds === []) { - return $this->emptyQueryResult($page, $size); + return new Collection([], $page * $size, ($page * $size) - 1, 0); } if (array_key_exists($userIdField, $where)) { @@ -199,45 +202,29 @@ public function query($params = array(), $operator = "OR") : [$where[$userIdField]]; $candidateIds = $this->intersectUserIds($candidateIds, $requestedIds); if ($candidateIds === []) { - return $this->emptyQueryResult($page, $size); + return new Collection([], $page * $size, ($page * $size) - 1, 0); } } $where[$userIdField] = $candidateIds; } - $count = $this->db->count($userTable, $where); - if ($count === 0) { - return $this->emptyQueryResult($page, $size); - } - - $rows = $this->db->select( - $userTable, + $start = $page * $size; + $collection = $userTable->select( $where, [ - 'order' => [$sortField, $sortDirection], - 'limit' => $size, - 'offset' => $page * $size, + 'sort' => [$sortField, $sortDirection], + 'range' => [$start, $start + $size - 1], ] - )->all(); + ); - $data = []; - foreach ($rows as $row) { + foreach ($collection as &$row) { $hydrated = $this->hydrateUserRow($row); $hydrated['credentials'] = $this->get_credentials($hydrated[$userIdField]); - $data[] = $this->sanitize_user($hydrated); + $row = $this->sanitize_user($hydrated); } + unset($row); - return [ - '_embedded' => [ - $userTable => $data, - ], - 'page' => [ - 'size' => $size, - 'number' => $page, - 'totalElements' => $count, - 'totalPages' => (int) ceil($count / $size), - ], - ]; + return $collection; } public function get($user_id, $key = 'id') @@ -1211,21 +1198,6 @@ private function intersectUserIds(?array $current, array $next): array return array_values(array_intersect_key($currentMap, $nextMap)); } - private function emptyQueryResult(int $page, int $size): array - { - return [ - '_embedded' => [ - $this->params['table'] => [], - ], - 'page' => [ - 'size' => $size, - 'number' => $page, - 'totalElements' => 0, - 'totalPages' => 0, - ], - ]; - } - private function uuidV4(): string { $data = random_bytes(16); From 22d7855bb9d4eee6438d098bb262b03838a8d1a8 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:38:41 -0300 Subject: [PATCH 083/217] Return Collection from BasicAuth queries --- src/Auth/BasicAuth.php | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 3908f68..b287ca1 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -2,6 +2,8 @@ namespace Objectiveweb\Auth; +use Objectiveweb\DB\Collection; + /** * Class BasicAuth * Basic Auth implementation with hardcoded users @@ -39,7 +41,7 @@ public function __construct(array $passwd = [], array $params = []) } } - public function query($params = array(), $operator = "OR") + public function query($params = array(), $operator = "OR"): Collection { $page = max(0, (int) ($params['page'] ?? 0)); $size = max(1, (int) ($params['size'] ?? 20)); @@ -89,20 +91,16 @@ public function query($params = array(), $operator = "OR") return strtoupper($sortDirection) === 'DESC' ? -$comparison : $comparison; }); - $count = count($matches); - $slice = array_slice($matches, $page * $size, $size); - - return [ - '_embedded' => [ - $this->params['table'] => $slice, - ], - 'page' => [ - 'size' => $size, - 'number' => $page, - 'totalElements' => $count, - 'totalPages' => (int) ceil($count / $size), - ], - ]; + $total = count($matches); + $start = $page * $size; + $slice = array_slice($matches, $start, $size); + + return new Collection( + $slice, + $start, + $start + count($slice) - 1, + $total + ); } public function get($user_id, $key = 'id') From 828dc8be4cb461360a891bd8864b4a4e856304d6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:39:18 -0300 Subject: [PATCH 084/217] Define Collection query contract --- src/Auth.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Auth.php b/src/Auth.php index d9eaf20..d0263bf 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -2,6 +2,8 @@ namespace Objectiveweb; +use Objectiveweb\DB\Collection; + abstract class Auth { @@ -339,7 +341,7 @@ public function validate($user) // - abstract public function query($params = array(), $operator = "OR"); + abstract public function query($params = array(), $operator = "OR"): Collection; /** * Retrieves a user from the database From 783af4ae1a5bdeaa16a6a3fe78db971bd562294a Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:39:25 -0300 Subject: [PATCH 085/217] Return user Collections directly --- src/Auth/Controller/UserController.php | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index a520fff..04af4dd 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -5,6 +5,7 @@ use Objectiveweb\Auth; use Objectiveweb\Auth\Middleware\RequireRole; use Objectiveweb\Auth\UserException; +use Objectiveweb\DB\Collection; use Objectiveweb\Router\Middleware; /** @@ -37,12 +38,12 @@ public function __construct(public Auth $auth) ]))); } - public function index(array $params = []): array + public function index(array $params = []): Collection { return $this->list($params); } - public function get(mixed $userOrParams = [], mixed $resource = null, array $params = []): array + public function get(mixed $userOrParams = [], mixed $resource = null, array $params = []): array|Collection { if (is_array($userOrParams)) { return $this->list($userOrParams); @@ -119,7 +120,7 @@ public function delete( return ['ok' => true]; } - private function list(array $params): array + private function list(array $params): Collection { $allowed = ['q', 'role', 'status', 'page', 'size', 'sort']; foreach (array_keys($params) as $field) { @@ -133,14 +134,8 @@ private function list(array $params): array if (!in_array((string) ($params['status'] ?? ''), ['', 'active', 'suspended'], true)) { throw new UserException('Invalid status filter', 400); } - $result = $this->auth->query($params, 'AND'); - $key = $this->auth->params['table']; - $result['_embedded'][$key] = array_map( - fn (array $user): array => $this->sanitizeUser($user), - $result['_embedded'][$key] ?? [] - ); - $result['_csrf'] = $this->auth->management_csrf_token(); - return $result; + + return $this->auth->query($params, 'AND'); } private function detail(mixed $userId): array From 897f410c29a042c2150c19d68431142871a04814 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:39:58 -0300 Subject: [PATCH 086/217] Test DBAuth Collection query results --- test/DBAuthSqliteTest.php | 45 ++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index b18a0c0..e8e69b7 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -304,14 +304,14 @@ public function testQuery(): void self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); $list = self::$auth->query(); - $this->assertSame(1, count($list['_embedded']['auth_user'])); - $this->assertSame('Alice', $list['_embedded']['auth_user'][0]['name']); - $this->assertArrayNotHasKey('password', $list['_embedded']['auth_user'][0]); - $this->assertArrayNotHasKey('token', $list['_embedded']['auth_user'][0]); - $this->assertArrayNotHasKey('token_expires_at', $list['_embedded']['auth_user'][0]); - $this->assertSame(1, $list['page']['totalElements']); - $this->assertSame(1, $list['page']['totalPages']); - $this->assertSame(0, $list['page']['number']); + $this->assertInstanceOf(\Objectiveweb\DB\Collection::class, $list); + $this->assertCount(1, $list); + $this->assertSame(1, $list->total()); + $this->assertSame('items 0-0/1', $list->contentRange()); + $this->assertSame('Alice', $list[0]['name']); + $this->assertArrayNotHasKey('password', $list[0]); + $this->assertArrayNotHasKey('token', $list[0]); + $this->assertArrayNotHasKey('token_expires_at', $list[0]); } public function testQueryPaginatesAndSortsBeforeHydration(): void @@ -331,12 +331,13 @@ public function testQueryPaginatesAndSortsBeforeHydration(): void 'sort' => 'name ASC', ]); - $this->assertSame(3, $first['page']['totalElements']); - $this->assertSame(2, $first['page']['totalPages']); - $this->assertSame(['Alice', 'Bob'], array_column($first['_embedded']['auth_user'], 'name')); - $this->assertSame(['Charlie'], array_column($second['_embedded']['auth_user'], 'name')); + $this->assertSame(3, $first->total()); + $this->assertSame('items 0-1/3', $first->contentRange()); + $this->assertSame('items 2-2/3', $second->contentRange()); + $this->assertSame(['Alice', 'Bob'], array_column($first->data(), 'name')); + $this->assertSame(['Charlie'], array_column($second->data(), 'name')); - foreach ($first['_embedded']['auth_user'] as $user) { + foreach ($first as $user) { $this->assertArrayHasKey('credentials', $user); $this->assertCount(1, $user['credentials']); } @@ -365,12 +366,12 @@ public function testQueryCombinesSearchRoleAndLifecycleFilters(): void 'size' => 10, ]); - $this->assertSame(1, $result['page']['totalElements']); - $this->assertSame('Active Admin', $result['_embedded']['auth_user'][0]['name']); - $this->assertSame(['admin'], $result['_embedded']['auth_user'][0]['roles']); + $this->assertSame(1, $result->total()); + $this->assertSame('Active Admin', $result[0]['name']); + $this->assertSame(['admin'], $result[0]['roles']); $this->assertSame( 'active-admin@example.com', - $result['_embedded']['auth_user'][0]['credentials'][0]['uid'] + $result[0]['credentials'][0]['uid'] ); } @@ -380,8 +381,8 @@ public function testQueryUnassignedWorksWithAndWithoutRoleTables(): void self::$auth->register('unassigned@example.com', 'secret'); $withRoles = self::$auth->query(['role' => 'unassigned', 'size' => 10]); - $this->assertSame(1, $withRoles['page']['totalElements']); - $this->assertSame('unassigned', $withRoles['_embedded']['auth_user'][0]['name']); + $this->assertSame(1, $withRoles->total()); + $this->assertSame('unassigned', $withRoles[0]['name']); $withoutRoles = new DBAuth(self::$db, [ 'table' => 'auth_user', @@ -394,7 +395,7 @@ public function testQueryUnassignedWorksWithAndWithoutRoleTables(): void 'user_roles_table' => null, ]); $result = $withoutRoles->query(['role' => 'unassigned', 'size' => 10]); - $this->assertSame(2, $result['page']['totalElements']); + $this->assertSame(2, $result->total()); } public function testRequestToken(): void @@ -919,7 +920,7 @@ public function testRoleNamesAndCredentialUidSearchAreNormalized(): void $this->assertSame(['partner'], self::$auth->get_roles()); $result = self::$auth->query(['q' => 'searchable@example.com', 'size' => 10]); - $this->assertSame(1, $result['page']['totalElements']); - $this->assertSame('searchable@example.com', $result['_embedded']['auth_user'][0]['credentials'][0]['uid']); + $this->assertSame(1, $result->total()); + $this->assertSame('searchable@example.com', $result[0]['credentials'][0]['uid']); } } From 7477791ec5e3c938b59e478141c7a6f2344e9423 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:40:02 -0300 Subject: [PATCH 087/217] Test user controller Collection responses --- test/UserControllerTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 72433a7..38e7331 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -120,11 +120,11 @@ public function testQuery(): void $all = self::$controller->get(); - $this->assertSame(1, count($all['_embedded']['auth_user'])); - $this->assertSame('Test User', $all['_embedded']['auth_user'][0]['name']); - $this->assertSame(1, $all['page']['totalElements']); - $this->assertSame(1, $all['page']['totalPages']); - $this->assertSame(0, $all['page']['number']); + $this->assertInstanceOf(\Objectiveweb\DB\Collection::class, $all); + $this->assertCount(1, $all); + $this->assertSame(1, $all->total()); + $this->assertSame('items 0-0/1', $all->contentRange()); + $this->assertSame('Test User', $all[0]['name']); } public function testQueryInvalidFilterField(): void From cf393c78bb8ed7726afd73e6d8e2e77eaeb1113b Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:40:40 -0300 Subject: [PATCH 088/217] Test BasicAuth Collection pagination --- test/BasicAuthTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index 850066c..68f186f 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -205,6 +205,24 @@ public function testManagedRelationValidationRunsBeforeMutation(): void $this->assertSame([3, 8], $auth->get_managed_relations($user['id'])['items']); } + public function testQueryReturnsCollectionWithPaginationMetadata(): void + { + $this->auth->register('bob@example.com', 'secret', ['name' => 'Bob']); + $this->auth->register('carol@example.com', 'secret', ['name' => 'Carol']); + + $result = $this->auth->query([ + 'page' => 1, + 'size' => 2, + 'sort' => 'name ASC', + ]); + + $this->assertInstanceOf(\Objectiveweb\DB\Collection::class, $result); + $this->assertSame(3, $result->total()); + $this->assertSame('items 2-2/3', $result->contentRange()); + $this->assertCount(1, $result); + $this->assertSame('Carol', $result[0]['name']); + } + public function testGetUsersByRoleReturnsMatchingUsers(): void { $admin = $this->auth->register('admin@example.com', 'secret', [ From 80aafa32cd78fc250690a93ee5d4b31262d71806 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:40:56 -0300 Subject: [PATCH 089/217] Document Collection query responses --- README.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 088fddc..113536a 100644 --- a/README.md +++ b/README.md @@ -242,8 +242,15 @@ Store each identity in `credentials_table` as `(provider, uid)`, linked to one ` ## Query and update ```php -// List users (paginated response) -$result = $auth->query(['page' => 0, 'size' => 20]); +// List users. query() returns Objectiveweb\DB\Collection. +$users = $auth->query(['page' => 0, 'size' => 20]); + +foreach ($users as $user) { + // ... +} + +$total = $users->total(); +$contentRange = $users->contentRange(); // e.g. "items 0-19/137" // Update user profile data $auth->update($userId, ['name' => 'Alice Updated']); @@ -279,17 +286,19 @@ Register `Objectiveweb\Auth\Controller\UserController` at an application-owned prefix such as `/api/users`. It requires the `admin` role and supports: - searchable, filtered and paginated `GET /api/users` (`q`, `role`, - `status`, `page`, `size`, whitelisted `sort`); + `status`, `page`, `size`, whitelisted `sort`), returned directly as an + `Objectiveweb\DB\Collection`; - role and user detail reads; - create/invite and profile/role/managed-relation updates; - credential create, rename and delete; - suspend, activate, invitation and password-reset actions; - guarded deletion with relation cleanup. -Management GET responses include a session CSRF token. Send it as -`X-CSRF-Token` with `Content-Type: application/json` on every write. Setup and -reset tokens are passed only to delivery callbacks and are never serialized in -HTTP responses. +User detail and role-list responses include a session CSRF token. Send it as +`X-CSRF-Token` with `Content-Type: application/json` on every write. The user +list itself is a plain `Collection`; use `total()` and `contentRange()` for +pagination metadata. Setup and reset tokens are passed only to delivery +callbacks and are never serialized in HTTP responses. Role names accepted by the management API must already exist in the configured roles table. Use migrations/seeds for role definitions. From 40b8f0167b16d1fb7527de11841ef4baa7b5ff6a Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:41:25 -0300 Subject: [PATCH 090/217] Fix BasicAuth Collection sort expectation --- test/BasicAuthTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index 68f186f..e96298a 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -220,7 +220,7 @@ public function testQueryReturnsCollectionWithPaginationMetadata(): void $this->assertSame(3, $result->total()); $this->assertSame('items 2-2/3', $result->contentRange()); $this->assertCount(1, $result); - $this->assertSame('Carol', $result[0]['name']); + $this->assertSame('alice', $result[0]['name']); } public function testGetUsersByRoleReturnsMatchingUsers(): void From 62b4f7c3d07c228d7b53442b003a37476fdb8b08 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:42:25 -0300 Subject: [PATCH 091/217] Verify Collection queries on PostgreSQL --- test/verify_postgresql_role_lookup.php | 28 +++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/test/verify_postgresql_role_lookup.php b/test/verify_postgresql_role_lookup.php index 317d7ca..cf1c569 100644 --- a/test/verify_postgresql_role_lookup.php +++ b/test/verify_postgresql_role_lookup.php @@ -4,6 +4,7 @@ use Objectiveweb\Auth\DBAuth; use Objectiveweb\DB; +use Objectiveweb\DB\Collection; $db = new DB( sprintf( @@ -60,4 +61,29 @@ exit(1); } -echo "PostgreSQL role lookup passed.\n"; +$users = $auth->query([ + 'page' => 0, + 'size' => 1, + 'sort' => 'name ASC', +]); + +if (!$users instanceof Collection) { + fwrite(STDERR, "DBAuth query did not return a Collection\n"); + exit(1); +} +if ($users->total() !== 2 || count($users) !== 1 || $users->contentRange() !== 'items 0-0/2') { + fwrite(STDERR, "Collection pagination metadata is incorrect\n"); + exit(1); +} + +$search = $auth->query([ + 'q' => 'viewer@example.com', + 'size' => 10, +]); + +if ($search->total() !== 1 || ($search[0]['credentials'][0]['uid'] ?? null) !== 'postgres-viewer@example.com') { + fwrite(STDERR, "Collection credential search failed\n"); + exit(1); +} + +echo "PostgreSQL role lookup and Collection query passed.\n"; From b3ed9742700ccbab5211742a06e355ddd7f5ea09 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:48:56 -0300 Subject: [PATCH 092/217] Reuse user Table across DBAuth --- src/Auth/DBAuth.php | 98 ++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index d91f581..c2a0995 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -4,10 +4,13 @@ use Objectiveweb\DB; use Objectiveweb\DB\Collection; +use Objectiveweb\DB\Exception\NotFoundException; +use Objectiveweb\DB\Table; class DBAuth extends \Objectiveweb\Auth { public array $params; + private Table $userTable; public function __construct(private DB $db, array $params = []) { @@ -29,6 +32,10 @@ public function __construct(private DB $db, array $params = []) ]; parent::__construct(array_merge($defaults, $params)); + $this->userTable = $this->db->table( + (string) $this->params['table'], + ['pk' => (string) $this->params['id']] + ); $this->assertRoleConfig(); } @@ -44,7 +51,6 @@ public function query($params = array(), $operator = "OR"): Collection $userTableName = (string) $this->params['table']; $userIdField = (string) $this->params['id']; - $userTable = $this->db->table($userTableName, ['pk' => $userIdField]); [$sortField, $sortDirection] = array_pad(preg_split('/\s+/', trim($sort), 2), 2, 'ASC'); $sortDirection = strtoupper($sortDirection); @@ -209,7 +215,7 @@ public function query($params = array(), $operator = "OR"): Collection } $start = $page * $size; - $collection = $userTable->select( + $collection = $this->userTable->select( $where, [ 'sort' => [$sortField, $sortDirection], @@ -229,11 +235,23 @@ public function query($params = array(), $operator = "OR"): Collection public function get($user_id, $key = 'id') { - $column = $this->params[$key] ?? $key; - $row = $this->db->select($this->params['table'], [$column => $user_id], ['limit' => 1])->fetch(); + $column = (string) ($this->params[$key] ?? $key); - if (!$row) { - throw new UserException('User not found', 404); + if ($column === (string) $this->params['id']) { + try { + $row = $this->userTable->get($user_id); + } catch (NotFoundException) { + throw new UserException('User not found', 404); + } + } else { + $rows = $this->userTable->select( + [$column => $user_id], + ['range' => [0, 0]] + ); + if (count($rows) === 0) { + throw new UserException('User not found', 404); + } + $row = $rows[0]; } return $this->hydrateUserRow($row); @@ -304,13 +322,15 @@ public function register($uid, $password = null, $data = array()) if ($syncRoles) { $this->resolveRoleIds($roles); } - $id = $this->db->insert($this->params['table'], $fields); - if (!$id) { + $inserted = $this->userTable->insert($fields); + $idField = (string) $this->params['id']; + $id = $inserted[$idField] ?? null; + if ($id === null || $id === '') { throw new \Exception('Could not create user'); } $userId = ctype_digit((string) $id) ? (int) $id : $id; - $fields[$this->params['id']] = $userId; + $fields[$idField] = $userId; $credentialPayload = [ 'user_id' => $userId, @@ -339,11 +359,10 @@ public function register($uid, $password = null, $data = array()) public function passwd($user_id, $password, $key = 'id') { $column = $this->params[$key] ?? $key; - $updated = $this->db->update( - $this->params['table'], - [$this->params['password'] => self::hash($password)], - [$column => $user_id] - ); + $updated = $this->userTable->update( + [$column => $user_id], + [$this->params['password'] => self::hash($password)] + )['updated']; if ($updated !== 1) { throw new UserException('User not found', 404); @@ -372,11 +391,10 @@ public function passwd_reset($token, $password) $payload[$tokenExpiresField] = null; } - $updated = $this->db->update( - $this->params['table'], - $payload, - [$this->params['id'] => $user[$this->params['id']]] - ); + $updated = $this->userTable->update( + $user[$this->params['id']], + $payload + )['updated']; if ($updated !== 1) { throw new UserException('Hash not found', 404); @@ -422,7 +440,7 @@ public function update($user_id, array $data, $key = 'id') } } - $this->db->update($this->params['table'], $data, [$column => $user_id]); + $this->userTable->update([$column => $user_id], $data); } if ($syncRoles) { @@ -477,7 +495,7 @@ public function delete($user_id) } } $this->audit('user.deleted', $user_id); - $deleted = $this->db->delete($this->params['table'], [$this->params['id'] => $user_id]); + $deleted = $this->userTable->delete($user_id); if ($deleted !== 1) { throw new UserException('User not found', 404); @@ -501,11 +519,7 @@ public function update_token($user_id) $payload[$tokenExpiresField] = date('Y-m-d H:i:s', time() + $ttl); } - $updated = $this->db->update( - $this->params['table'], - $payload, - [$this->params['id'] => $user_id] - ); + $updated = $this->userTable->update($user_id, $payload)['updated']; if ($updated !== 1) { throw new UserException('Token not found', 404); @@ -753,7 +767,6 @@ public function get_users_by_role($roleName): array } $userIdField = (string) $this->params['id']; - $userTable = (string) $this->params['table']; $rolesTable = (string) $this->params['roles_table']; $userRolesTable = (string) $this->params['user_roles_table']; $userRolesUserId = (string) $this->params['user_roles_user_id']; @@ -779,14 +792,6 @@ public function get_users_by_role($roleName): array ] )->all(); - $safeFields = ['name', 'image', $userIdField]; - foreach (['uuid', 'created', 'last_login', 'disabled_at', 'token_expires_field'] as $paramKey) { - $field = $this->params[$paramKey] ?? null; - if (is_string($field) && $field !== '' && !in_array($field, $safeFields, true)) { - $safeFields[] = $field; - } - } - $result = []; $seen = []; foreach ($memberships as $membership) { @@ -801,15 +806,9 @@ public function get_users_by_role($roleName): array } $seen[$identity] = true; - $user = $this->db->select( - $userTable, - [$userIdField => $userId], - [ - 'fields' => $safeFields, - 'limit' => 1, - ] - )->fetch(); - if (!$user) { + try { + $user = $this->userTable->get($userId); + } catch (NotFoundException) { continue; } @@ -1148,18 +1147,17 @@ private function relationTargetIdsForSubject(array $relation, int $subjectId): a private function findUserByResetToken(string $token): ?array { - $table = (string) $this->params['table']; $tokenField = (string) $this->params['token']; $tokenExpiresField = $this->params['token_expires_field']; - $row = $this->db->select( - $table, + $rows = $this->userTable->select( [$tokenField => $this->resetTokenDigest($token)], - ['limit' => 1] - )->fetch(); - if (!$row) { + ['range' => [0, 0]] + ); + if (count($rows) === 0) { return null; } + $row = $rows[0]; if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { $expiresAt = $row[$tokenExpiresField] ?? null; From 6444c27bc67f14c017e28295678210de21a4e5c7 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:49:36 -0300 Subject: [PATCH 093/217] Clarify low-level OR candidate scans --- src/Auth/DBAuth.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index c2a0995..dd890d4 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -87,6 +87,8 @@ public function query($params = array(), $operator = "OR"): Collection continue; } + // Table::select() also issues a COUNT query. For OR candidate + // discovery we only need IDs, so keep this as a low-level scan. $rows = $this->db->select( $userTableName, [(string) $key => $value], @@ -104,6 +106,8 @@ public function query($params = array(), $operator = "OR"): Collection if ($q !== '') { $searchIds = []; + // Same rationale as above: this is candidate-ID discovery, not the + // paginated user query itself. $nameRows = $this->db->select( $userTableName, ['name' => '%' . $q . '%'], @@ -766,7 +770,6 @@ public function get_users_by_role($roleName): array return []; } - $userIdField = (string) $this->params['id']; $rolesTable = (string) $this->params['roles_table']; $userRolesTable = (string) $this->params['user_roles_table']; $userRolesUserId = (string) $this->params['user_roles_user_id']; From a8dee0b9d67ea355637bdae8167798f5368dca35 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 22:50:02 -0300 Subject: [PATCH 094/217] Test user Table alternate-key lookup --- test/DBAuthSqliteTest.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index e8e69b7..73012ad 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -429,6 +429,16 @@ public function testPasswdResetRejectsExpiredToken(): void self::$auth->passwd_reset($token, 'new-secret'); } + public function testGetByConfiguredUuidUsesUserTable(): void + { + $user = self::$auth->register('uuid-lookup@example.com', 'secret'); + + $loaded = self::$auth->get($user['uuid'], 'uuid'); + + $this->assertSame((int) $user['id'], (int) $loaded['id']); + $this->assertSame($user['uuid'], $loaded['uuid']); + } + public function testPasswdResetUsesConfiguredDatabasePrefix(): void { $db = new DB('sqlite::memory:', null, '', ['prefix' => 'app_']); From a2bd069c72c888bc18aa5657b1591b48dee25cc2 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 23:14:05 -0300 Subject: [PATCH 095/217] Run push CI only on master --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbfcfcc..145d63f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,8 @@ name: CI on: push: + branches: + - master pull_request: jobs: From 8acaa3aedb7701d1045a4139b2194ba32bbba932 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 23:16:15 -0300 Subject: [PATCH 096/217] Add master release verification workflow --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f6d640d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release verification + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + test: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '8.2' + - '8.3' + - '8.4' + - '8.5' + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run tests + run: vendor/bin/phpunit test + + production-install: + name: Production install + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + tools: composer:v2 + + - name: Validate Composer metadata + run: composer validate --strict + + - name: Verify production dependencies + run: composer install --no-dev --prefer-dist --no-interaction --no-progress From a657fdf2bf605d041ebc18db5672469226efa338 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 23:18:36 -0300 Subject: [PATCH 097/217] Prevent password recovery account enumeration --- src/Auth/Controller/AuthController.php | 28 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 7ba9e29..0db3b38 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -157,26 +157,32 @@ function postPassword(array $form) foreach ($providers as $provider) { $candidate = $this->auth->get_credential($provider, $form['uid']); - if (empty($candidate['user_id'])) { - continue; + if ($credential === false && !empty($candidate['user_id'])) { + $credential = $candidate; } - - $credential = $candidate; - break; } if (!empty($credential['user_id'])) { - // return new token $credential['token'] = $this->auth->update_token($credential['user_id']); if (is_callable($this->auth->params['token_callback'])) { - return call_user_func($this->auth->params['token_callback'], $credential); - } else { - return []; + try { + call_user_func($this->auth->params['token_callback'], $credential); + } catch (\Throwable $exception) { + error_log( + 'Password recovery delivery failed: ' + . $exception::class + . ': ' + . $exception->getMessage() + ); + } } - } else { - throw new UserException('Credential not found', 404); } + + // Anonymous recovery requests deliberately return the same payload + // whether or not a credential exists. This prevents account + // enumeration through status codes or callback return values. + return []; } } From 56c7bd41c5b9a27abd529e6a9c8aa65a7f95cd11 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 23:18:40 -0300 Subject: [PATCH 098/217] Test recovery enumeration protection --- test/AuthControllerTest.php | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 553e7b7..5a12164 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -58,6 +58,53 @@ public function testPostPasswordForgotFlowMissingUid(): void $this->controller->postPassword(['password' => 'a', 'confirm' => 'a']); } + public function testPostPasswordForgotFlowDoesNotRevealUnknownCredential(): void + { + $callbackCalls = 0; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => function () use (&$callbackCalls): void { + $callbackCalls++; + }, + ]); + $controller = new AuthController($auth); + + $result = $controller->postPassword(['uid' => 'missing@example.com']); + + $this->assertSame([], $result); + $this->assertSame(0, $callbackCalls); + } + + public function testPostPasswordForgotFlowIgnoresCallbackReturnValue(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => fn (): array => ['account_exists' => true], + ]); + $controller = new AuthController($auth); + $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + + $result = $controller->postPassword(['uid' => 'alice@example.com']); + + $this->assertSame([], $result); + } + + public function testPostPasswordForgotFlowHidesDeliveryFailure(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => function (): void { + throw new \RuntimeException('mail transport unavailable'); + }, + ]); + $controller = new AuthController($auth); + $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + + $result = $controller->postPassword(['uid' => 'alice@example.com']); + + $this->assertSame([], $result); + } + public function testPostPasswordForgotFlowUsesEmailCredential(): void { $auth = new BasicAuth([], ['token' => 'token']); From c86ca5241d6ef795a517557ab949a48c0d803b4d Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 30 Sep 2026 23:18:55 -0300 Subject: [PATCH 099/217] Document recovery enumeration protection --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 113536a..e3f4d66 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,8 @@ $auth->passwd_reset($token, 'new-password'); `DBAuth` stores only a SHA-256 digest of the high-entropy reset token and looks it up through the indexed token column. The plaintext token is returned only once for delivery. +Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. Applications should still rate-limit recovery requests at the HTTP/application edge. + ## Credential strategy (`local`, `email`, `phone`, social) Recommended model: From 31a86b8703678364f3ec551603e6d57de1a70800 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 21:09:51 -0300 Subject: [PATCH 100/217] Add provider password verification for authenticated changes --- src/Auth.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Auth.php b/src/Auth.php index d0263bf..3135c04 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -123,6 +123,27 @@ public function revalidate(): bool return true; } + /** + * Verify an authenticated user's existing password using this provider's + * raw user record. Providers with a different password backend may override + * this method. + * + * Passwordless / OAuth-only accounts must use a verified reset or + * reauthentication flow; an active session alone cannot set a password. + */ + public function verify_password(mixed $userId, string $password): bool + { + $user = $this->get($userId); + $field = (string) $this->params['password']; + $hash = $user[$field] ?? null; + + if (!is_string($hash) || $hash === '' || password_get_info($hash)['algoName'] === 'unknown') { + throw new Auth\AuthException('Password reset or reauthentication required', 403); + } + + return password_verify($password, $hash); + } + public function management_csrf_token(): string { $key = (string) $this->params['management_csrf_session_key']; From e6203338cf369234a1082b69c6d540655a0f505a Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 21:10:19 -0300 Subject: [PATCH 101/217] Require current password and session CSRF for password changes --- src/Auth/Controller/AuthController.php | 49 ++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 0db3b38..6905384 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -29,6 +29,7 @@ function index() if ($this->auth->check()) { $user = $this->auth->user(); $user['credentials'] = $this->auth->get_credentials($user[$this->auth->params['id']]); + $user['_csrf'] = $this->auth->management_csrf_token(); return $user; } @@ -131,17 +132,28 @@ function postToken(array $form) #[Middleware(RequireRole::class, Auth::ALL)] function postPassword(array $form) { - // if user is logged in, update password + // The authenticated path is not a password-reset endpoint. Require + // session-bound CSRF and proof of the account's current password. if ($this->auth->check()) { - // TODO validar senha anterior - $confirm = $form['confirm'] ?? null; - if (empty($form['password']) || $form['password'] != $confirm) { - throw new UserException('Passwords dont match', 400); + $this->assertPasswordChangeCsrf(); + + $currentPassword = $form['current_password'] ?? null; + if (!is_string($currentPassword) || $currentPassword === '') { + throw new UserException('Current password is required', 400); + } + + $password = $form['password'] ?? null; + if (!is_string($password) || $password === '' || $password !== ($form['confirm'] ?? null)) { + throw new UserException('Passwords don\'t match', 400); } $user = $this->auth->user(); + $userId = $user[$this->auth->params['id']]; + if (!$this->auth->verify_password($userId, $currentPassword)) { + throw new AuthException('Invalid current password', 403); + } - return $this->auth->passwd($user[$this->auth->params['id']], $form['password']); + return $this->auth->passwd($userId, $password); } // Forgot password else { if (empty($form['uid'])) { @@ -186,6 +198,31 @@ function postPassword(array $form) } } + /** + * Changing an authenticated password is a session-authenticated write. + * Always require the session token, even for direct controller calls. + * HTTP clients must also submit JSON to avoid simple cross-origin forms. + */ + private function assertPasswordChangeCsrf(): void + { + if (!empty($_SERVER['REQUEST_METHOD'])) { + if (strtoupper((string) $_SERVER['REQUEST_METHOD']) !== 'POST') { + throw new UserException('POST is required', 405); + } + + $contentType = strtolower(trim(explode(';', (string) ($_SERVER['CONTENT_TYPE'] ?? ''), 2)[0])); + if ($contentType !== 'application/json') { + throw new UserException('Content-Type application/json is required', 415); + } + } + + $provided = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + $expected = $this->auth->management_csrf_token(); + if (!is_string($provided) || $provided === '' || !hash_equals($expected, $provided)) { + throw new UserException('Invalid CSRF token', 403); + } + } + private function assertCanRegister(): void { $required = $this->auth->params['register_scope']; From ec93c8465eaf369bbc804b2dba767ff6fd3f26dd Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 21:10:48 -0300 Subject: [PATCH 102/217] Cover authenticated password verification and CSRF --- test/AuthControllerTest.php | 162 ++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 5a12164..d6e4827 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -13,13 +13,23 @@ class AuthControllerTest extends TestCase private BasicAuth $auth; private AuthController $controller; + private array $originalServer; + protected function setUp(): void { + $this->originalServer = $_SERVER; + unset($_SERVER['REQUEST_METHOD'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN']); $_SESSION = []; $this->auth = new BasicAuth([], ['token' => 'token']); $this->controller = new AuthController($this->auth); } + protected function tearDown(): void + { + $_SERVER = $this->originalServer; + $_SESSION = []; + } + public function testPostMissingUid(): void { $this->expectException(UserException::class); @@ -51,6 +61,158 @@ public function testPostTokenPasswordMismatch(): void ]); } + public function testAuthenticatedPasswordChangeRequiresCsrfToken(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + + $this->expectException(UserException::class); + $this->expectExceptionCode(403); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsInvalidCsrfToken(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = 'invalid'; + + $this->expectException(UserException::class); + $this->expectExceptionCode(403); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsMissingCurrentPassword(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postPassword([ + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsIncorrectCurrentPassword(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + try { + $this->controller->postPassword([ + 'current_password' => 'wrong-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + $this->fail('Incorrect current password was accepted'); + } catch (AuthException $exception) { + $this->assertSame(403, $exception->getCode()); + } + + $this->assertTrue(password_verify('old-secret', $this->auth->get(1)['password'])); + $this->assertFalse(password_verify('new-secret', $this->auth->get(1)['password'])); + } + + public function testAuthenticatedPasswordChangeRejectsMismatchedConfirmation(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'different', + ]); + } + + public function testAuthenticatedPasswordChangeSucceedsAndRequiresNewPasswordOnNextLogin(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + + $current = $this->controller->index(); + $this->assertIsString($current['_csrf']); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $current['_csrf']; + + $this->assertTrue($this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ])); + + $this->auth->logout(); + try { + $this->auth->login('alice@example.com', 'old-secret'); + $this->fail('Old password still works'); + } catch (AuthException) { + // Expected. + } + $this->assertSame(1, $this->auth->login('alice@example.com', 'new-secret')['id']); + } + + public function testPasswordlessOAuthAccountRequiresVerifiedResetFlow(): void + { + $user = $this->auth->register('oauth@example.com', null, ['provider' => 'google']); + $this->auth->establish_session($this->auth->get($user['id'])); + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $this->controller->postPassword([ + 'current_password' => 'anything', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeRejectsFormEncodedHttpRequest(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->expectException(UserException::class); + $this->expectExceptionCode(415); + $this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + } + + public function testAuthenticatedPasswordChangeAcceptsJsonHttpRequest(): void + { + $this->auth->register('alice@example.com', 'old-secret'); + $this->auth->login('alice@example.com', 'old-secret'); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json; charset=utf-8'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $this->auth->management_csrf_token(); + + $this->assertTrue($this->controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ])); + $this->assertTrue(password_verify('new-secret', $this->auth->get(1)['password'])); + } + public function testPostPasswordForgotFlowMissingUid(): void { $this->expectException(UserException::class); From d48fe058204451d9a9c994fbad06143d276608b3 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 21:11:03 -0300 Subject: [PATCH 103/217] Verify DBAuth password changes with current password --- test/DBAuthSqliteTest.php | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 73012ad..7713372 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -471,6 +471,46 @@ public function testPasswdResetUsesConfiguredDatabasePrefix(): void $this->assertNull($stored['token_expires_at']); } + public function testDbAuthAuthenticatedPasswordChangeVerifiesCurrentPassword(): void + { + $user = self::$auth->register('db-password-change@example.com', 'old-secret'); + self::$auth->login('db-password-change@example.com', 'old-secret'); + $controller = new AuthController(self::$auth); + + $originalHeader = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $controller->index()['_csrf']; + + try { + try { + $controller->postPassword([ + 'current_password' => 'invalid', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ]); + $this->fail('Incorrect current password was accepted'); + } catch (AuthException $exception) { + $this->assertSame(403, $exception->getCode()); + } + + $this->assertTrue(password_verify('old-secret', self::$auth->get($user['id'])['password'])); + $this->assertTrue($controller->postPassword([ + 'current_password' => 'old-secret', + 'password' => 'new-secret', + 'confirm' => 'new-secret', + ])); + $this->assertTrue(password_verify('new-secret', self::$auth->get($user['id'])['password'])); + + self::$auth->logout(); + $this->assertSame($user['id'], self::$auth->login('db-password-change@example.com', 'new-secret')['id']); + } finally { + if ($originalHeader === null) { + unset($_SERVER['HTTP_X_CSRF_TOKEN']); + } else { + $_SERVER['HTTP_X_CSRF_TOKEN'] = $originalHeader; + } + } + } + public function testAuthControllerResponsesDoNotExposeUserSecrets(): void { $controller = new AuthController(self::$auth); From 60586a3275d1068161eb3945f249a7c48f34dc7c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 21:11:23 -0300 Subject: [PATCH 104/217] Document secure authenticated password change flow --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index e3f4d66..fee4182 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,27 @@ if ($auth->check()) { $auth->logout(); ``` +## Authenticated password changes + +`AuthController::postPassword()` requires proof of the existing password for +authenticated sessions. Retrieve the session-bound CSRF token from the +authenticated `AuthController::index()` response (`_csrf`) and send it as +`X-CSRF-Token` with `Content-Type: application/json`: + +```http +POST /auth/password +Content-Type: application/json +X-CSRF-Token: + +{"current_password":"old-password","password":"new-password","confirm":"new-password"} +``` + +The authenticated change is refused when the current password is missing or +incorrect, or when the user has no usable local password (for example, an +OAuth-only account). Passwordless users must complete an independently verified +password-reset or reauthentication flow instead; a session alone cannot set a +password. The CSRF token is required even for direct controller invocations. + ## Password reset flow When `token` is configured: From f3c71d27ee7bc06c79ffad4bb9b8b21a40da2710 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 21:24:35 -0300 Subject: [PATCH 105/217] Reorganize DBAuth setup, parameters, user credentials, roles and relations --- README.md | 386 +++++++++++++++++++++++++++++------------------------- 1 file changed, 206 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index fee4182..df71953 100644 --- a/README.md +++ b/README.md @@ -24,141 +24,243 @@ This package requires: #### Example setup +Apply the bundled Phinx migrations (or an equivalent schema) before using `DBAuth`. The example uses the default **logical** table names created by those migrations: `user`, `user_credentials`, `role`, `user_roles` and `delegations`. + ```php -use Objectiveweb\Auth\DBAuth; use Objectiveweb\DB; +use Objectiveweb\Auth\DBAuth; + +$db = new DB('sqlite:/tmp/app.sqlite'); // MySQL and PostgreSQL also supported + +$auth = new DBAuth($db, [ + 'token' => 'token', // Enable password resets + 'disabled_at' => 'disabled_at', // Enable account suspension + 'invitation_callback' => function (array $user, array $credentials, string $token): void { + // Deliver invitations out of band; never return a token over HTTP. + }, + 'reset_callback' => function (array $user, array $credentials, string $token): void { + // Deliver admin-initiated resets out of band. + }, + 'token_callback' => function (array $credential): void { + // Anonymous forgot-password delivery; token is in $credential['token']. + }, +]); +``` + +The table names are defaults; they do **not** need to be repeated in the constructor. `DBAuth` initializes a `$db->table('user')` object for user CRUD and pagination. -$db = new DB('sqlite:/tmp/app.sqlite'); // or mysql:..., pgsql:... +To use physical table prefixes, configure `DB` and your Phinx installation consistently, leaving `DBAuth` table names logical: +```php +$db = new DB('sqlite:/tmp/app.sqlite', null, '', ['prefix' => 'app_']); +$auth = new DBAuth($db, ['token' => 'token', 'disabled_at' => 'disabled_at']); +// Logical table "user" resolves to physical table "app_user". +``` + +#### DBAuth parameters + +These options come from both the shared `Auth` contract and `DBAuth`. Set only the options your application needs. + +| Parameter | Default | Purpose | +| --- | --- | --- | +| `table` | `user` | Logical user table (DB applies any configured prefix) | +| `id` / `password` | `id` / `password` | User primary key and password-hash columns | +| `uuid` | `uuid` | Generates UUIDs on registration; `null` disables | +| `created` / `last_login` | `created` / `null` | User-table creation and optional last-login columns | +| `credentials_table` | `user_credentials` | Logical provider-credential table | +| `credentials_last_login` / `credentials_created` | `last_login` / `created` | Credential timestamp columns; `null` disables updates | +| `login_providers` | `['local', 'email']` | Ordered provider identities considered for password login | +| `recovery_providers` | `['local', 'email', 'phone']` | Provider identities checked for anonymous recovery | +| `token` | `null` | User-table reset-token field; use `'token'` to enable | +| `token_expires_field` / `token_ttl` | `token_expires_at` / `3600` | Reset-token expiry column and lifetime in seconds | +| `disabled_at` | `null` | Nullable suspension column; use `'disabled_at'` to enable lifecycle checks | +| `roles` | `roles` | Name of the calculated user roles array (not a database column) | +| `roles_table` / `user_roles_table` | `role` / `user_roles` | Global roles and memberships; both required or both `null` | +| `role_id` / `role_name` | `id` / `name` | Role table's ID/name fields | +| `user_roles_user_id` / `user_roles_role_id` | `user_id` / `role_id` | Membership foreign-key fields | +| `relations` / `managed_relations` | `[]` / `[]` | Resource abilities/delegations and admin-managed memberships; see Relations | +| `session_key` | `ow_auth` | Authenticated principal's session key | +| `register_scope` / `register_allow_grants` | `Auth::ANONYMOUS` / `false` | Registration access and whether callers may supply roles | +| `management_csrf` / `management_csrf_session_key` | `true` / `ow_auth_management_csrf` | Management write protection and CSRF session key | +| `register_callback` / `token_callback` | `null` / `null` | Registration callback / anonymous recovery delivery callback | +| `invitation_callback` / `reset_callback` | `null` / `null` | Delivery callbacks for invitation/admin reset: `(user, credentials, plaintextToken)` | +| `audit_callback` | `null` | Management events: `(actorId, targetId, action, metadata)` | +| `deletion_guard_callback` | `null` | Return `false` or a conflict string to reject user deletion | + +The anonymous `token_callback` receives a **single** credential array containing the one-time token. `Auth::invite()` instead invokes `invitation_callback` or `reset_callback` with **three** arguments; without dedicated callbacks it falls back to `register_callback`/`token_callback`, respectively. Prefer dedicated callbacks when supporting both flows. + +#### Database tables and migrations + +The bundled migrations in `db/` use the following **default logical names**: + +| Table | Fields and purpose | +| --- | --- | +| `user` | `id`, unique `uuid`, nullable `password`, `name`, `image`, `created`, nullable `token`, `token_expires_at` and `disabled_at` | +| `user_credentials` | Unique (`uid`, `provider`) credential identity, `user_id` FK, `profile`, `token`, `last_login`, `created` | +| `role` | `id` and unique role `name` | +| `user_roles` | (`user_id`, `role_id`) memberships with both foreign keys | +| `delegations` | Optional (`user_id`, `target_user_id`, `ability`) relation table | + +Use your application's Phinx configuration to run the package's migrations; Phinx is a development/migration dependency, not a runtime dependency of this package. The reset-token column is indexed in the bundled user migration and stores only a SHA-256 digest of the generated one-time token. + +**Custom schemas:** Override `table`, `credentials_table`, `roles_table` and `user_roles_table` when integrating existing tables. For instance, the following maps to *logical* custom tables; these physical tables must already exist because the bundled migrations use the default names: + +```php $auth = new DBAuth($db, [ 'table' => 'auth_user', 'credentials_table' => 'auth_credentials', - 'id' => 'id', - 'password' => 'password', - 'roles' => 'roles', - 'login_providers' => ['local', 'email', 'phone'], - 'token' => 'token', - 'token_expires_field' => 'token_expires_at', - 'disabled_at' => 'disabled_at', - 'created' => 'created', - 'uuid' => 'uuid', - 'credentials_last_login' => 'last_login', 'roles_table' => 'auth_role', 'user_roles_table' => 'auth_user_role', + 'token' => 'token', + 'disabled_at' => 'disabled_at', +]); +``` + +Configure a prefix only on `DB` rather than manually including it in these table names. With the default configuration, **both** role tables are expected. To disable database roles, set **both** `roles_table` and `user_roles_table` to `null`; no role columns belong in the user table. + +#### Users + +`register()` creates the user and its initial credential transactionally. It returns a **sanitized** user. `get()` returns the provider's **raw** user record, including sensitive fields; sanitize it before exposing it to clients. + +```php +$user = $auth->register('alice@example.com', 'secret', ['name' => 'Alice']); +$userId = $user['id']; + +$rawUser = $auth->get($userId); +$publicUser = $auth->sanitize_user($rawUser); + +$auth->update($userId, ['name' => 'Alice Smith']); +``` + +`query()` returns `Objectiveweb\DB\Collection`, not HAL-formatted data: + +```php +$users = $auth->query([ + 'q' => 'alice', // Search name, numeric ID or credential UID + 'page' => 0, + 'size' => 20, + 'sort' => 'name ASC', + 'status' => 'active', +]); + +foreach ($users as $user) { + // Sanitized user, with roles and credentials +} +$total = $users->total(); +$range = $users->contentRange(); // e.g. "items 0-19/137" +``` + +For password-based login and session management: + +```php +use Objectiveweb\Auth\AuthException; +use Objectiveweb\Auth\UserException; + +try { + $current = $auth->login('alice@example.com', 'secret'); +} catch (UserException $e) { + // Unknown login identity +} catch (AuthException $e) { + // Bad password or suspended account +} + +if ($auth->check()) { + $current = $auth->user(); // Sanitized session principal +} +$auth->logout(); +``` + +`passwd($userId, $newPassword)` performs a **direct** password replacement and must only be called by trusted admin/service code. For an authenticated user's own change, the included `AuthController::postPassword()` verifies the old password and session CSRF token; see below. + +#### User credentials + +A user can have multiple identities, each uniquely identified by `(provider, uid)` and linked through `user_id`. The default `login_providers` are `local` and `email`; adding an OAuth/phone/email credential does not verify ownership automatically. + +```php +$auth->get_credential('local', 'alice@example.com'); // Raw provider credential or false +$credentials = $auth->get_credentials($userId); // Normalized public-safe list + +$auth->create_credential($userId, 'phone', '+5511999999999', ['country' => 'BR']); +$auth->rename_credential($userId, 'phone', '+5511999999999', 'phone', '+5511888888888'); +$auth->delete_credential($userId, 'phone', '+5511888888888'); +``` + +`create_credential()` rejects duplicates; `update_credential()` inserts or updates a provider identity and updates its last-login field when configured. `delete_credential()` refuses to remove the user's last credential. Treat raw provider profiles as potentially sensitive; send normalized/sanitized credentials to clients. + +#### Roles + +**Global roles** are attached to users and stored in `role` and `user_roles`. `roles` in returned user data is a computed array, not a SQL column. They are independent of resource-specific relations. + +```php +// Trusted bootstrap/service code: +$admin = $auth->register('admin@example.com', 'secret', [ + 'name' => 'Admin', + 'roles' => ['admin'], +]); + +$auth->get_roles(); // ['admin', ...] +$auth->get_users_by_role('admin'); // Sanitized user arrays +$auth->update($admin['id'], ['roles' => ['admin', 'editor']]); + +// With the appropriate user already logged in: +$auth->user_can('admin'); // bool +``` + +Direct `DBAuth::register()` / `update()` can create role definitions when syncing names that do not exist. The **admin UserController API** is intentionally stricter and accepts only *existing* role names: seed the allowed roles before using its role-management endpoints. Keep `register_allow_grants` disabled for public registration. Both `roles_table` and `user_roles_table` must be disabled together if roles are not wanted. + +#### Relations and authorization + +`relations` provides **resource permissions and user-to-user delegations**, separate from global roles. The bundled `delegations` table is optional until you map it; any other permission tables are owned by your application and must be created in your migrations. + +```php +$auth = new DBAuth($db, [ 'relations' => [ 'user' => [ - 'table' => 'user_delegations', + 'table' => 'delegations', // Bundled table 'subject_key' => 'user_id', 'target_key' => 'target_user_id', 'ability_key' => 'ability', - 'target_is_user' => true, // clean both sides on user deletion + 'target_is_user' => true, // Also clean target-user rows on deletion ], 'item' => [ - 'table' => 'item_users', + 'table' => 'item_users', // Application-owned relation table 'subject_key' => 'user_id', 'target_key' => 'item_id', 'ability_key' => 'ability', - 'eager' => true // or array of parameters passed to select - // ['order' => 'item_id DESC', 'fields' => ['a', 'b']] - + 'eager' => true, // Eagerly load matching rows with get()/query() ], ], +]); + +// With a user logged in: +$auth->user_can('delegate', 42); // "user" relation for target user 42 +$auth->user_can('manage', 'item', 10); // Ability on item 10 +$itemIds = $auth->user_can('manage', 'item'); // Accessible item IDs +``` + +Each permission relation requires `table`, `subject_key`, `target_key` and either `ability_key` or `role_key` together with `role_abilities` (a role-to-ability map). `eager` may be `true` or a `DB::select()` options array such as `['order' => 'item_id DESC']`. Eager relation rows remain scoped to the current subject user. + +`managed_relations` is different: these are **application-owned many-to-many associations** that admins can synchronize, not grants interpreted by `user_can()` unless separately mapped as a permission relation. + +```php +$auth = new DBAuth($db, [ 'managed_relations' => [ 'venues' => [ - 'table' => 'venue_users', + 'table' => 'venue_users', // Application-owned 'subject_key' => 'user_id', 'target_key' => 'venue_id', - 'validate_callback' => fn (array $ids) => validateVenueIds($ids), + 'validate_callback' => function (array $ids): void { + // Validate IDs; throw if any requested association is invalid. + }, ], ], - 'invitation_callback' => function (array $user, array $credentials, string $plainToken): void { - // Deliver an application-owned email. Never return the token over HTTP. - }, - 'reset_callback' => function (array $user, array $credentials, string $plainToken): void {}, - 'audit_callback' => function ($actorId, $targetId, string $action, array $metadata): void {}, - 'deletion_guard_callback' => fn ($userId): bool|string => true, ]); -``` - -#### Required tables - -`DBAuth` expects the configured users and credentials tables. With the default -role configuration it also expects the roles and user-role mapping tables. Set -both `roles_table` and `user_roles_table` to `null` to disable database-backed -roles. - -The bundled Phinx migrations use the default logical table names (`user`, -`user_credentials`, `role`, and `user_roles`). Applications may apply their -normal Phinx table prefix instead of hardcoding prefixed names in Auth. - -Example schema for the custom `auth_*` configuration above (SQLite-compatible): - -```sql -CREATE TABLE auth_user ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT NOT NULL UNIQUE, - name TEXT, - image TEXT, - created TEXT, - password TEXT, - token TEXT, - token_expires_at TEXT, - disabled_at TEXT -); - -CREATE TABLE auth_credentials ( - uid TEXT NOT NULL, - provider TEXT NOT NULL, - user_id INTEGER NOT NULL, - profile TEXT NULL, - last_login TEXT NULL, - created TEXT NULL, - PRIMARY KEY(uid, provider) -); - -CREATE TABLE auth_role ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE -); -CREATE TABLE auth_user_role ( - user_id INTEGER NOT NULL, - role_id INTEGER NOT NULL, - PRIMARY KEY(user_id, role_id) -); +$auth->sync_managed_relations($userId, ['venues' => [12, 34]]); +$assigned = $auth->get_managed_relations($userId); // ['venues' => [12, 34]] ``` -#### DBAuth params - -- `session_key`: session storage key (default `ow_auth`) -- `id`: user PK field (default `id`) -- `password`: password field (default `password`) -- `roles`: roles field attached to the user payload (default `roles`) -- `login_providers`: ordered providers used by `login()` (default `['local', 'email']`) -- `token`: optional password-reset token field -- `table`: users table name (default `user`) -- `credentials_table`: credentials table name (default `user_credentials`) -- `roles_table`: roles table name (default `role`; set together with `user_roles_table` to `null` to disable roles) -- `user_roles_table`: user-role mapping table name (default `user_roles`; set together with `roles_table` to `null` to disable roles) -- `user_roles_user_id`: user FK column in mapping table (default `user_id`) -- `user_roles_role_id`: role FK column in mapping table (default `role_id`) -- `role_id`: role PK column in `roles_table` (default `id`) -- `role_name`: role name column in `roles_table` (default `name`) -- `relations`: relationship auth mapping by resource type (default `[]`) -- `managed_relations`: application-owned many-to-many associations that admins may synchronize -- `disabled_at`: nullable lifecycle field; non-null users cannot log in and active sessions are rejected on their next protected request -- `invitation_callback`, `reset_callback`: application delivery callbacks receiving the user, credentials, and one-time plaintext token -- `audit_callback`: application persistence callback for management events -- `deletion_guard_callback`: return `true` to allow deletion, or a conflict message/`false` to reject it -- `management_csrf`: require JSON management writes with `X-CSRF-Token` (default `true`) -- `relations..eager`: eager load related rows into user payload (`true` => `[]`, `array` => select params) -- `created`: optional created-at field -- `last_login`: optional user last-login field -- `credentials_last_login`: optional credentials last-login field (default `last_login`) -- `credentials_created`: optional credentials created-at field (default `created`) -- `token_expires_field`: password-reset token expiry field (default `token_expires_at`) -- `token_ttl`: reset-token lifetime in seconds (default `3600`) -- `uuid`: UUID field created on register and protected from updates (default `uuid`) +`sync_managed_relations()` validates the incoming values, then replaces the selected memberships transactionally. When enabling relations, configure them in the **same** DBAuth constructor as the basic settings; the isolated constructors above illustrate the relevant parameters, not multiple concurrent auth instances. ### `BasicAuth` @@ -188,33 +290,6 @@ $auth = new BasicAuth( Passwords passed to the constructor/register are hashed internally unless already hashed. -## Common usage - -```php -use Objectiveweb\Auth\AuthException; -use Objectiveweb\Auth\UserException; - -// Register -$user = $auth->register('alice@example.com', 'secret', ['name' => 'Alice']); - -// Login -try { - $user = $auth->login('alice@example.com', 'secret'); -} catch (AuthException $e) { - // invalid password -} catch (UserException $e) { - // user not found -} - -// Session -if ($auth->check()) { - $current = $auth->user(); -} - -// Logout -$auth->logout(); -``` - ## Authenticated password changes `AuthController::postPassword()` requires proof of the existing password for @@ -252,39 +327,6 @@ $auth->passwd_reset($token, 'new-password'); Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. Applications should still rate-limit recovery requests at the HTTP/application edge. -## Credential strategy (`local`, `email`, `phone`, social) - -Recommended model: -- `local`: password-based login identity -- `email`: email identities for verification/login/magic-link -- `phone`: phone identities for SMS verification/login -- `google`, `facebook`, ...: external OAuth providers - -Store each identity in `credentials_table` as `(provider, uid)`, linked to one `user_id`. - -## Query and update - -```php -// List users. query() returns Objectiveweb\DB\Collection. -$users = $auth->query(['page' => 0, 'size' => 20]); - -foreach ($users as $user) { - // ... -} - -$total = $users->total(); -$contentRange = $users->contentRange(); // e.g. "items 0-19/137" - -// Update user profile data -$auth->update($userId, ['name' => 'Alice Updated']); - -// Update password -$auth->passwd($userId, 'new-password'); - -// List all credentials for a user -$credentials = $auth->get_credentials($userId); -``` - ## OAuth controller Configure OAuth providers by provider name. Set an explicit `redirectUri` in production so callback URLs do not depend on request/proxy headers: @@ -326,22 +368,6 @@ callbacks and are never serialized in HTTP responses. Role names accepted by the management API must already exist in the configured roles table. Use migrations/seeds for role definitions. -## Authorization checks (`user_can`) - -```php -// Global grants (roles) -$auth->user_can('admin'); // bool - -// Delegation to a target user (relation type "user") -$auth->user_can('delegate', 42); // bool - -// Permission for a single resource -$auth->user_can('manage', 'item', 10); // bool - -// List resource IDs accessible for an ability -$auth->user_can('manage', 'item'); // int[] -``` - ## Controllers and middleware Included: From fccb70bd89ff5ce8055d3de0ec824baf7a2678e9 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 21:25:10 -0300 Subject: [PATCH 106/217] Restore copyable default SQLite schema in DBAuth documentation --- README.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/README.md b/README.md index df71953..7b494dd 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,58 @@ The bundled migrations in `db/` use the following **default logical names**: Use your application's Phinx configuration to run the package's migrations; Phinx is a development/migration dependency, not a runtime dependency of this package. The reset-token column is indexed in the bundled user migration and stores only a SHA-256 digest of the generated one-time token. + +For a standalone SQLite setup, the following is an equivalent schema using **default table names**. Prefer the bundled migrations for a production application and adapt column types/constraints to your database: + +```sql +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid VARCHAR(36) NOT NULL UNIQUE, + password VARCHAR(60), + name VARCHAR(255), + image VARCHAR(255), + token VARCHAR(255), + token_expires_at DATETIME, + disabled_at DATETIME, + created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX user_token_idx ON user(token); + +CREATE TABLE user_credentials ( + uid VARCHAR(255) NOT NULL, + provider VARCHAR(32) NOT NULL, + user_id INTEGER NOT NULL, + profile TEXT, + token VARCHAR(255), + last_login DATETIME, + created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (uid, provider), + FOREIGN KEY (user_id) REFERENCES user(id) +); + +CREATE TABLE role ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL UNIQUE +); +CREATE TABLE user_roles ( + user_id INTEGER NOT NULL, + role_id INTEGER NOT NULL, + PRIMARY KEY (user_id, role_id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (role_id) REFERENCES role(id) +); +CREATE INDEX user_roles_role_id_idx ON user_roles(role_id); + +-- Optional, used only when explicitly configured under "relations": +CREATE TABLE delegations ( + user_id INTEGER NOT NULL, + target_user_id INTEGER NOT NULL, + ability VARCHAR(64) NOT NULL, + PRIMARY KEY (user_id, target_user_id, ability) +); +CREATE INDEX delegations_target_idx ON delegations(target_user_id); +``` + **Custom schemas:** Override `table`, `credentials_table`, `roles_table` and `user_roles_table` when integrating existing tables. For instance, the following maps to *logical* custom tables; these physical tables must already exist because the bundled migrations use the default names: ```php From 5a874a9dba5178fad918182bf0fd1c4d925c0a0f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:01:07 -0300 Subject: [PATCH 107/217] Load application-defined user context when establishing and refreshing sessions --- src/Auth.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 3135c04..e626961 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -35,7 +35,7 @@ function __construct(array $params) 'audit_callback' => null, 'deletion_guard_callback' => null, 'disabled_at' => null, - 'managed_relations' => [], + 'user_context_callback' => null, 'management_csrf' => true, 'management_csrf_session_key' => 'ow_auth_management_csrf', ]; @@ -329,7 +329,19 @@ public function reload() public function &user($user = null) { if ($user) { - $_SESSION[$this->params['session_key']] = $this->sanitize_user($user); + $user = $this->sanitize_user($user); + $callback = $this->params['user_context_callback']; + if (is_callable($callback)) { + // Context is derived by the application, never trusted from + // user-supplied fields or an earlier session snapshot. + unset($user['context']); + $context = call_user_func($callback, $user); + if (!is_array($context)) { + throw new \UnexpectedValueException('user_context_callback must return an array'); + } + $user['context'] = $context; + } + $_SESSION[$this->params['session_key']] = $user; } else { if (!$this->check()) { throw new Auth\UserException('Not logged in', 403); @@ -482,7 +494,4 @@ abstract public function rename_credential( abstract public function delete_credential($userid, string $provider, string $uid): bool; - abstract public function get_managed_relations($userId): array; - - abstract public function sync_managed_relations($userId, array $relations): array; } From 21f0a8e702d8e00de717cd5cf634ab3f423fe421 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:01:13 -0300 Subject: [PATCH 108/217] Remove app-owned managed relation persistence from DBAuth --- src/Auth/DBAuth.php | 53 --------------------------------------------- 1 file changed, 53 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index dd890d4..38df0c3 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -481,12 +481,6 @@ public function delete($user_id) [$this->params['user_roles_user_id'] => $user_id] ); } - foreach ((array) $this->params['managed_relations'] as $relation) { - if (!is_array($relation) || empty($relation['table']) || empty($relation['subject_key'])) { - continue; - } - $this->db->delete((string) $relation['table'], [(string) $relation['subject_key'] => $user_id]); - } foreach ((array) $this->params['relations'] as $relation) { if (!is_array($relation) || empty($relation['table'])) { continue; @@ -674,53 +668,6 @@ public function delete_credential($userid, string $provider, string $uid): bool return true; } - public function get_managed_relations($userId): array - { - $user = $this->get($userId); - $id = $user[$this->params['id']]; - $result = []; - foreach ((array) $this->params['managed_relations'] as $name => $relation) { - if (!is_array($relation) || empty($relation['table']) || empty($relation['subject_key']) || empty($relation['target_key'])) { - continue; - } - $rows = $this->db->select((string) $relation['table'], [ - (string) $relation['subject_key'] => $id, - ])->all(); - $values = array_map( - fn (array $row): mixed => $row[(string) $relation['target_key']] ?? null, - $rows - ); - $result[(string) $name] = array_values(array_unique(array_filter($values, fn ($value): bool => $value !== null))); - } - return $result; - } - - public function sync_managed_relations($userId, array $relations): array - { - $user = $this->get($userId); - $id = $user[$this->params['id']]; - return $this->db->transaction(function () use ($id, $relations): array { - foreach ($relations as $name => $values) { - $relation = $this->params['managed_relations'][$name] ?? null; - if (!is_array($relation) || !is_array($values)) { - throw new UserException("Invalid managed relation `$name`", 400); - } - $values = array_values(array_unique($values)); - if (is_callable($relation['validate_callback'] ?? null)) { - call_user_func($relation['validate_callback'], $values); - } - $table = (string) $relation['table']; - $subjectKey = (string) $relation['subject_key']; - $targetKey = (string) $relation['target_key']; - $this->db->delete($table, [$subjectKey => $id]); - foreach ($values as $value) { - $this->db->insert($table, [$subjectKey => $id, $targetKey => $value]); - } - } - return $this->get_managed_relations($id); - }); - } - public function get_credentials($user_id, $key = 'id'): array { $user = $this->get($user_id, $key); From 570b6b16d4deabf388ef65ae5f5b8b7a1804608f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:01:18 -0300 Subject: [PATCH 109/217] Remove application managed relations from BasicAuth --- src/Auth/BasicAuth.php | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index b287ca1..18bebd2 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -471,36 +471,6 @@ public function delete_credential($userid, string $provider, string $uid): bool return true; } - public function get_managed_relations($userId): array - { - $this->get($userId); - $result = []; - foreach ((array) $this->params['managed_relations'] as $name => $_config) { - $values = $this->users[$userId]['_managed_relations'][$name] ?? []; - $result[$name] = array_values($values); - } - return $result; - } - - public function sync_managed_relations($userId, array $relations): array - { - $this->get($userId); - foreach ($relations as $name => $values) { - $relation = $this->params['managed_relations'][$name] ?? null; - if (!is_array($relation) || !is_array($values)) { - throw new UserException("Invalid managed relation `$name`", 400); - } - - $values = array_values(array_unique($values)); - if (is_callable($relation['validate_callback'] ?? null)) { - call_user_func($relation['validate_callback'], $values); - } - - $this->users[$userId]['_managed_relations'][$name] = $values; - } - return $this->get_managed_relations($userId); - } - private function nextId(): int { if (empty($this->users)) { @@ -563,7 +533,6 @@ private function matchesFilters(array $row, array $filters, string $operator): b private function publicUser(array $user): array { - unset($user['_managed_relations']); return $this->sanitize_user($user); } From cffa18d0865cfb09e1d8db0c61ece9a4fca63ee8 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:01:24 -0300 Subject: [PATCH 110/217] Keep application association writes out of Auth user-management API --- src/Auth/Controller/UserController.php | 29 +++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 04af4dd..846c576 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -142,7 +142,6 @@ private function detail(mixed $userId): array { $user = $this->sanitizeUser($this->auth->get($userId)); $user['credentials'] = $this->auth->get_credentials($userId); - $user['managed_relations'] = $this->auth->get_managed_relations($userId); $user['status'] = $this->auth->is_active($user) ? 'active' : 'suspended'; $user['_csrf'] = $this->auth->management_csrf_token(); return $user; @@ -154,8 +153,8 @@ private function create(array $data): array if ($uid === '') { throw new UserException('Missing uid', 400); } - $relations = $data['managed_relations'] ?? []; - unset($data['managed_relations'], $data['uid']); + $this->assertNoApplicationRelations($data); + unset($data['uid']); $passwordField = $this->auth->params['password']; $password = $data[$passwordField] ?? null; unset($data[$passwordField]); @@ -168,9 +167,6 @@ private function create(array $data): array $user = $this->auth->register($uid, is_string($password) && $password !== '' ? $password : null, $data); $id = $user[$this->auth->params['id']]; - if (is_array($relations) && $relations !== []) { - $this->auth->sync_managed_relations($id, $relations); - } if (!is_string($password) || $password === '') { $this->auth->invite($id); } @@ -184,9 +180,8 @@ private function create(array $data): array private function updateUser(mixed $userId, array $data): array { $target = $this->auth->get($userId); - $relationsPresent = array_key_exists('managed_relations', $data); - $relations = $data['managed_relations'] ?? []; - unset($data['managed_relations'], $data['uid'], $data[$this->auth->params['password']]); + $this->assertNoApplicationRelations($data); + unset($data['uid'], $data[$this->auth->params['password']]); $this->assertWriteFields($data); $roleField = $this->auth->params['roles']; @@ -201,12 +196,6 @@ private function updateUser(mixed $userId, array $data): array } $this->auth->update($userId, $data); - if ($relationsPresent) { - if (!is_array($relations)) { - throw new UserException('Invalid managed relations', 400); - } - $this->auth->sync_managed_relations($userId, $relations); - } $this->auth->audit('user.updated', $userId, ['fields' => array_keys($data)]); return $this->detail($userId); } @@ -258,6 +247,16 @@ private function sendToken(mixed $userId, bool $reset): array return ['ok' => true]; } + private function assertNoApplicationRelations(array $data): void + { + if (array_key_exists('managed_relations', $data)) { + throw new UserException( + 'Application relations must be managed by the application', + 400 + ); + } + } + private function assertManagementWrite(): void { if (!$this->auth->params['management_csrf']) { From a7ecdfabd4f07d8daa64e03894c1a922b2739155 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:02:05 -0300 Subject: [PATCH 111/217] Test callback session hydration, refresh and invalid payload behavior --- test/BasicAuthTest.php | 70 ++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index e96298a..b1d021a 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -170,39 +170,63 @@ public function testDeletionGuardCallbackPreventsDeletion(): void $this->assertNotFalse($auth->get_credential('local', 'guarded@example.com')); } - public function testManagedRelationValidationRunsBeforeMutation(): void + public function testSessionContextIsLoadedAndRefreshedFromApplication(): void { - $validated = null; + $assignments = [1 => [5, 8]]; + $calls = 0; $auth = new BasicAuth([], [ - 'token' => 'token', - 'managed_relations' => [ - 'items' => [ - 'validate_callback' => function (array $values) use (&$validated): void { - $validated = $values; - if (in_array(99, $values, true)) { - throw new UserException('Invalid item', 400); - } - }, - ], - ], + 'user_context_callback' => function (array $user) use (&$assignments, &$calls): array { + $calls++; + $this->assertArrayNotHasKey('password', $user); + return ['venues' => $assignments[$user['id']] ?? []]; + }, ]); + $auth->register('context@example.com', 'secret'); + $session = $auth->login('context@example.com', 'secret'); + $this->assertSame(['venues' => [5, 8]], $session['context']); + $this->assertSame(1, $calls); + + $assignments[1] = [8, 13]; + $this->assertSame(['venues' => [5, 8]], $auth->user()['context']); + $this->assertTrue($auth->revalidate()); + $this->assertSame(['venues' => [8, 13]], $auth->user()['context']); + $this->assertSame(2, $calls); + + $auth->logout(); + $this->assertFalse($auth->check()); + } - $user = $auth->register('relations@example.com', 'secret'); - $relations = $auth->sync_managed_relations($user['id'], [ - 'items' => [3, 3, 8], + public function testContextCallbackReplacesUntrustedSessionContext(): void + { + $auth = new BasicAuth([], [ + 'user_context_callback' => function (array $user): array { + $this->assertArrayNotHasKey('context', $user); + return ['venues' => [7]]; + }, ]); + $user = $auth->register('trusted@example.com', 'secret'); + $principal = $auth->get($user['id']); + $principal['context'] = ['venues' => [999]]; - $this->assertSame([3, 8], $validated); - $this->assertSame([3, 8], $relations['items']); + $session = $auth->establish_session($principal); + $this->assertSame(['venues' => [7]], $session['context']); + } + + public function testContextCallbackMustReturnArrayWithoutMutatingSession(): void + { + $auth = new BasicAuth([], [ + 'user_context_callback' => fn (array $user): string => 'invalid', + ]); + $auth->register('invalid-context@example.com', 'secret'); try { - $auth->sync_managed_relations($user['id'], ['items' => [99]]); - $this->fail('Expected managed relation validation to reject the update'); - } catch (UserException $exception) { - $this->assertSame(400, $exception->getCode()); + $auth->login('invalid-context@example.com', 'secret'); + $this->fail('Expected callback return type validation'); + } catch (\UnexpectedValueException $exception) { + $this->assertSame('user_context_callback must return an array', $exception->getMessage()); } - $this->assertSame([3, 8], $auth->get_managed_relations($user['id'])['items']); + $this->assertFalse($auth->check()); } public function testQueryReturnsCollectionWithPaginationMetadata(): void From dbc1ba00ef05a687ac70958c06b2902870d0c979 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:02:10 -0300 Subject: [PATCH 112/217] Replace managed-relations tests with DB session context and app ownership coverage --- test/DBAuthSqliteTest.php | 48 +++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 7713372..2dc56c0 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -113,13 +113,6 @@ public static function setUpBeforeClass(): void 'ability_key' => 'ability', ], ], - 'managed_relations' => [ - 'items' => [ - 'table' => 'managed_items', - 'subject_key' => 'user_id', - 'target_key' => 'item_id', - ], - ], ]); } @@ -948,12 +941,11 @@ public function testCredentialLifecycleEnforcesOwnershipUniquenessAndLastCredent self::$auth->delete_credential($alice['id'], 'local', 'alice@example.com'); } - public function testManagedRelationsSynchronizeAndDeletionCleansBothDelegationSides(): void + public function testUserDeletionCleansDelegationsButLeavesApplicationAssociationsToApplication(): void { $actor = self::$auth->register('actor@example.com', 'secret'); $target = self::$auth->register('target@example.com', 'secret'); - self::$auth->sync_managed_relations($actor['id'], ['items' => [8, 3, 8]]); - $this->assertEqualsCanonicalizing([3, 8], self::$auth->get_managed_relations($actor['id'])['items']); + self::$db->insert('managed_items', ['user_id' => $target['id'], 'item_id' => 3]); self::$db->insert('user_delegations', [ 'user_id' => $actor['id'], @@ -962,6 +954,42 @@ public function testManagedRelationsSynchronizeAndDeletionCleansBothDelegationSi ]); self::$auth->delete($target['id']); $this->assertSame(0, self::$db->count('user_delegations', [])); + $this->assertSame(1, self::$db->count('managed_items', [])); + } + + public function testDbAuthSessionContextReloadsApplicationAssignments(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'user_context_callback' => function (array $user): array { + $rows = self::$db->select( + 'managed_items', + ['user_id' => $user['id']], + ['order' => 'item_id'] + )->all(); + return ['venues' => array_map( + fn (array $row): int => (int) $row['item_id'], + $rows + )]; + }, + ]); + + $user = $auth->register('app-context@example.com', 'secret'); + self::$db->insert('managed_items', ['user_id' => $user['id'], 'item_id' => 4]); + $login = $auth->login('app-context@example.com', 'secret'); + $this->assertSame(['venues' => [4]], $login['context']); + + self::$db->insert('managed_items', ['user_id' => $user['id'], 'item_id' => 9]); + $this->assertSame(['venues' => [4]], $auth->user()['context']); + $this->assertTrue($auth->revalidate()); + $this->assertSame(['venues' => [4, 9]], $auth->user()['context']); + $controller = new AuthController($auth); + $this->assertSame(['venues' => [4, 9]], $controller->index()['context']); } public function testRoleNamesAndCredentialUidSearchAreNormalized(): void From 8e170792d05bf2b9cb7033d19215d723e575043c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:02:15 -0300 Subject: [PATCH 113/217] Ensure generic user controller rejects app relation payloads --- test/UserControllerTest.php | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 38e7331..ef60385 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -79,6 +79,42 @@ protected function setUp(): void self::$db->query('DELETE FROM auth_user')->exec(); } + public function testManagedRelationsAreRejectedByAuthAdminCreate(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->post([ + 'uid' => 'venue-create@example.com', + 'password' => 'test', + 'managed_relations' => ['venues' => [1, 2]], + ]); + } + + public function testManagedRelationsAreRejectedByAuthAdminUpdate(): void + { + $user = self::$controller->post([ + 'uid' => 'venue-update@example.com', + 'password' => 'test', + ]); + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + + self::$controller->put((int) $user['id'], [ + 'managed_relations' => ['venues' => [1, 2]], + ]); + } + + public function testAdminUserDetailNoLongerOwnsApplicationAssociations(): void + { + $user = self::$controller->post([ + 'uid' => 'detail@example.com', + 'password' => 'test', + ]); + $detail = self::$controller->get((int) $user['id']); + $this->assertArrayNotHasKey('managed_relations', $detail); + } + public function testPost(): void { $user = self::$controller->post([ From 1d7e8c88d3fdd863b88451547850146a5a2fa797 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:02:48 -0300 Subject: [PATCH 114/217] Document application-owned associations and session context callback --- README.md | 47 ++++++++++++++++++++++++++++++----------------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 7b494dd..e10c1ac 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ These options come from both the shared `Auth` contract and `DBAuth`. Set only t | `roles_table` / `user_roles_table` | `role` / `user_roles` | Global roles and memberships; both required or both `null` | | `role_id` / `role_name` | `id` / `name` | Role table's ID/name fields | | `user_roles_user_id` / `user_roles_role_id` | `user_id` / `role_id` | Membership foreign-key fields | -| `relations` / `managed_relations` | `[]` / `[]` | Resource abilities/delegations and admin-managed memberships; see Relations | +| `relations` | `[]` | Resource abilities and user-to-user delegations; see Relations | +| `user_context_callback` | `null` | Application callback `(sanitizedUser): array` to populate the authenticated session's `context` | | `session_key` | `ow_auth` | Authenticated principal's session key | | `register_scope` / `register_allow_grants` | `Auth::ANONYMOUS` / `false` | Registration access and whether callers may supply roles | | `management_csrf` / `management_csrf_session_key` | `true` / `ow_auth_management_csrf` | Management write protection and CSRF session key | @@ -292,27 +293,37 @@ $itemIds = $auth->user_can('manage', 'item'); // Accessible item IDs Each permission relation requires `table`, `subject_key`, `target_key` and either `ability_key` or `role_key` together with `role_abilities` (a role-to-ability map). `eager` may be `true` or a `DB::select()` options array such as `['order' => 'item_id DESC']`. Eager relation rows remain scoped to the current subject user. -`managed_relations` is different: these are **application-owned many-to-many associations** that admins can synchronize, not grants interpreted by `user_can()` unless separately mapped as a permission relation. +#### Application-owned user context + +Use `user_context_callback` for application authorization context that must be available on the authenticated principal (for example, the venue IDs assigned to the current user). It receives a **sanitized** user and must return an array. Auth stores that array under `$auth->user()['context']` at login (including OAuth) and **rebuilds** it from the latest user record whenever `reload()` or `revalidate()` is called. + +The application owns the underlying relationships, validation, and writes. Auth does not read or modify the application association table itself: ```php +use Breakfastweekend\App\Service\UserService; + $auth = new DBAuth($db, [ - 'managed_relations' => [ - 'venues' => [ - 'table' => 'venue_users', // Application-owned - 'subject_key' => 'user_id', - 'target_key' => 'venue_id', - 'validate_callback' => function (array $ids): void { - // Validate IDs; throw if any requested association is invalid. - }, - ], - ], + 'user_context_callback' => function (array $user) use ($app): array { + $service = $app->create(UserService::class); + return [ + 'venues' => $service->getVenueIds($user['id']), + ]; + }, ]); -$auth->sync_managed_relations($userId, ['venues' => [12, 34]]); -$assigned = $auth->get_managed_relations($userId); // ['venues' => [12, 34]] +$auth->login('alice@example.com', 'secret'); +$venueIds = $auth->user()['context']['venues']; // e.g. [12, 34] + +// Once the application updates venue assignments, refresh session context: +$auth->revalidate(); +$venueIds = $auth->user()['context']['venues']; ``` -`sync_managed_relations()` validates the incoming values, then replaces the selected memberships transactionally. When enabling relations, configure them in the **same** DBAuth constructor as the basic settings; the isolated constructors above illustrate the relevant parameters, not multiple concurrent auth instances. +`getVenueIds()` is an **application method to implement**; it is not supplied by Auth. Keep the existing application's `validateVenueIds()` in its own service, alongside an application-owned assignment method such as `syncVenueIds($userId, $ids)`. The latter should validate IDs and authorization before changing `venue_users` transactionally. For users whose assignments change in another session, call `revalidate()` on the next protected request or otherwise invalidate/refresh that session according to your application's needs. + +Session context is not implicitly added to `get()` or `query()` results; those remain user-record APIs. `AuthController::index()` returns the authenticated session principal, including its context. If the callback is omitted, Auth does not add a context field. + +**Migration from `managed_relations` (breaking change for 0.2.0):** remove that parameter; move `get_managed_relations()` and `sync_managed_relations()` callers into your application's user service; expose venue assignments through application-specific endpoints rather than generic `UserController` create/update. The generic Auth controller now rejects `managed_relations` request fields instead of silently losing assignments, and it no longer includes them in user-detail responses. `DBAuth::delete()` no longer cleans application association tables: arrange cleanup using application transactions/foreign-key cascades and, where necessary, `deletion_guard_callback`. ### `BasicAuth` @@ -406,7 +417,7 @@ prefix such as `/api/users`. It requires the `admin` role and supports: `status`, `page`, `size`, whitelisted `sort`), returned directly as an `Objectiveweb\DB\Collection`; - role and user detail reads; -- create/invite and profile/role/managed-relation updates; +- create/invite and profile/global-role updates; - credential create, rename and delete; - suspend, activate, invitation and password-reset actions; - guarded deletion with relation cleanup. @@ -418,7 +429,9 @@ pagination metadata. Setup and reset tokens are passed only to delivery callbacks and are never serialized in HTTP responses. Role names accepted by the management API must already exist in the configured -roles table. Use migrations/seeds for role definitions. +roles table. Use migrations/seeds for role definitions. Application-owned assignments +(e.g. venues) belong in application-specific endpoints; `managed_relations` is not +accepted by the generic management API. ## Controllers and middleware From 465bca9371703d61d84ff4fde6e32b9091ef0204 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sat, 3 Oct 2026 22:03:18 -0300 Subject: [PATCH 115/217] Avoid fixed autoincrement ID assumption in user controller test --- test/UserControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index ef60385..96210e7 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -123,7 +123,7 @@ public function testPost(): void 'name' => 'Test User', ]); - $this->assertSame(1, (int) $user['id']); + $this->assertGreaterThan(0, (int) $user['id']); } public function testPostMissingUid(): void From c800feb95cb2a347a26a0a0a4060f0fc6df5d96d Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 4 Oct 2026 17:18:53 -0300 Subject: [PATCH 116/217] Restrict logout redirects to same-origin paths --- src/Auth/Controller/AuthController.php | 32 ++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 6905384..119c075 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -43,16 +43,44 @@ function getLogout($params = []) throw new AuthException('Forbidden', 401); } + $redirect = null; + if (array_key_exists('redirect', $params) && $params['redirect'] !== null && $params['redirect'] !== '') { + $redirect = $this->normalizeLogoutRedirect($params['redirect']); + } + $this->auth->logout(); - if (!empty($params['redirect'])) { - header("Location: {$params['redirect']}"); + if ($redirect !== null) { + header('Location: ' . $redirect); exit; } return []; } + /** + * Logout redirects are intentionally limited to same-origin absolute paths. + * Scheme-relative URLs, backslashes and header control characters are + * rejected instead of being normalized by a browser into another origin. + */ + protected function normalizeLogoutRedirect(mixed $redirect): string + { + if (!is_string($redirect) || $redirect === '') { + throw new UserException('Invalid redirect', 400); + } + + if ( + $redirect[0] !== '/' + || str_starts_with($redirect, '//') + || str_contains($redirect, '\\') + || preg_match('/[\\x00-\\x1F\\x7F]/', $redirect) + ) { + throw new UserException('Invalid redirect', 400); + } + + return $redirect; + } + /** * Login a local user */ From 2459d7808b45320406b73d66d473faa56a91ce33 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Sun, 4 Oct 2026 17:19:22 -0300 Subject: [PATCH 117/217] Test logout redirect open-redirect protection --- test/AuthControllerTest.php | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index d6e4827..e779dfa 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -30,6 +30,59 @@ protected function tearDown(): void $_SESSION = []; } + public function testLogoutWithoutRedirectEndsSession(): void + { + $this->auth->register('logout@example.com', 'secret'); + $this->auth->login('logout@example.com', 'secret'); + + $this->assertSame([], $this->controller->getLogout()); + $this->assertFalse($this->auth->check()); + } + + public function testLogoutRejectsUnsafeRedirectsBeforeEndingSession(): void + { + $this->auth->register('logout@example.com', 'secret'); + $this->auth->login('logout@example.com', 'secret'); + + $invalid = [ + 'https://evil.example/', + 'http://evil.example/', + '//evil.example/', + '\\evil.example\\path', + '/\\evil.example/path', + 'javascript:alert(1)', + 'account', + "/safe\r\nLocation: https://evil.example/", + ]; + + foreach ($invalid as $redirect) { + try { + $this->controller->getLogout(['redirect' => $redirect]); + $this->fail("Unsafe redirect was accepted: {$redirect}"); + } catch (UserException $exception) { + $this->assertSame(400, $exception->getCode()); + $this->assertSame('Invalid redirect', $exception->getMessage()); + $this->assertTrue($this->auth->check()); + } + } + } + + public function testLogoutRedirectValidatorAcceptsSameOriginPaths(): void + { + $controller = new class($this->auth) extends AuthController { + public function validateRedirect(mixed $redirect): string + { + return $this->normalizeLogoutRedirect($redirect); + } + }; + + $this->assertSame('/', $controller->validateRedirect('/')); + $this->assertSame( + '/account/logout?done=1#top', + $controller->validateRedirect('/account/logout?done=1#top') + ); + } + public function testPostMissingUid(): void { $this->expectException(UserException::class); From ba53ddb55794245235be3c7cca55d4dd6f85e9d3 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 17:53:07 -0300 Subject: [PATCH 118/217] fix(auth): only allow local credentials in public registration --- src/Auth/Controller/AuthController.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 119c075..d130cb6 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -122,10 +122,16 @@ function postRegister(array $user) throw new AuthException('Email inválido'); } - if (!$this->auth->params['register_allow_grants']) { - unset($user[$this->auth->params['roles']]); + // Public registration cannot claim external identities or privileged + // account attributes. Roles are always managed by trusted code. + unset($user[$this->auth->params['roles']]); + foreach (array_keys($user) as $field) { + if (!in_array($field, ['name', 'image'], true)) { + throw new UserException("Invalid registration field `$field`", 400); + } } + $user['provider'] = 'local'; $user = $this->auth->register($uid, $password, $user); $user['uid'] = $uid; From c058c233ac5e3be93c275d22eefb9e380ebf571e Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 17:53:26 -0300 Subject: [PATCH 119/217] test(auth): cover provider injection and role grants on signup --- test/AuthControllerTest.php | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index e779dfa..02b4a86 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -353,6 +353,65 @@ public function testPostPasswordForgotFlowUsesLocalCredentialByDefault(): void $this->assertSame([], $result); } + public function testPublicRegistrationCreatesOnlyLocalCredential(): void + { + $user = $this->controller->postRegister([ + 'uid' => 'new@example.com', + 'password' => 'secret', + 'name' => 'New user', + ]); + + $this->assertNotFalse($this->auth->get_credential('local', 'new@example.com')); + $this->assertFalse($this->auth->get_credential('email', 'new@example.com')); + $this->assertSame('New user', $user['name']); + } + + public function testPublicRegistrationRejectsProviderAndPrivilegedAttributes(): void + { + $payloads = [ + ['provider' => 'google'], + ['provider' => 'email'], + ['provider' => 'local'], + ['profile' => ['email_verified' => true]], + ['id' => 100], + ['uuid' => 'fake-uuid'], + ['disabled_at' => null], + ['token' => 'forged'], + ['context' => ['admin' => true]], + ]; + + foreach ($payloads as $i => $payload) { + $uid = "forbidden-{$i}@example.com"; + try { + $this->controller->postRegister(array_merge([ + 'uid' => $uid, + 'password' => 'secret', + ], $payload)); + $this->fail('Untrusted registration field was accepted'); + } catch (UserException $e) { + $this->assertSame(400, $e->getCode()); + } + $this->assertFalse($this->auth->get_credential('local', $uid)); + $this->assertFalse($this->auth->get_credential('email', $uid)); + $this->assertFalse($this->auth->get_credential('google', $uid)); + } + } + + public function testPublicRegistrationNeverAllowsRoleGrants(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'register_allow_grants' => true, + ]); + $controller = new AuthController($auth); + $user = $controller->postRegister([ + 'uid' => 'no-admin@example.com', + 'password' => 'secret', + 'roles' => ['admin'], + ]); + $this->assertSame([], $user['roles'] ?? []); + } + public function testPostRegisterStripsRolesByDefault(): void { $user = $this->controller->postRegister([ From 188251642baf819db2c0a35c2b0a4466216fc0bc Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 17:53:40 -0300 Subject: [PATCH 120/217] docs(auth): explain local-only public registration trust boundary --- README.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e10c1ac..f18c27d 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,19 @@ $auth = new DBAuth($db, [ Configure a prefix only on `DB` rather than manually including it in these table names. With the default configuration, **both** role tables are expected. To disable database roles, set **both** `roles_table` and `user_roles_table` to `null`; no role columns belong in the user table. +#### Public registration + +`AuthController::postRegister()` accepts only `uid` (email), `password`, +and optional `name` and `image`. It always creates a `local` credential. +Role requests are ignored; provider names, OAuth profiles, IDs, suspension +state, tokens, and other fields are rejected with HTTP 400. + +This prevents clients from claiming external provider identities, but **does +not verify ownership of the submitted email address**. Applications must +complete email verification before treating an email as verified. Explicit +provider registration using `DBAuth::register()` is reserved for trusted +application code after authenticating the provider's identity. + #### Users `register()` creates the user and its initial credential transactionally. It returns a **sanitized** user. `get()` returns the provider's **raw** user record, including sensitive fields; sanitize it before exposing it to clients. @@ -259,7 +272,7 @@ $auth->update($admin['id'], ['roles' => ['admin', 'editor']]); $auth->user_can('admin'); // bool ``` -Direct `DBAuth::register()` / `update()` can create role definitions when syncing names that do not exist. The **admin UserController API** is intentionally stricter and accepts only *existing* role names: seed the allowed roles before using its role-management endpoints. Keep `register_allow_grants` disabled for public registration. Both `roles_table` and `user_roles_table` must be disabled together if roles are not wanted. +Direct `DBAuth::register()` / `update()` can create role definitions when syncing names that do not exist. The **admin UserController API** is intentionally stricter and accepts only *existing* role names: seed the allowed roles before using its role-management endpoints. The public registration controller always ignores role grants, regardless of `register_allow_grants`. Both `roles_table` and `user_roles_table` must be disabled together if roles are not wanted. #### Relations and authorization From 29fb91fe4c6c1c604973e2c5a390cc2ecec1104f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:00:34 -0300 Subject: [PATCH 121/217] feat(auth): define per-credential proof and verification contract --- src/Auth.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Auth.php b/src/Auth.php index e626961..aca9464 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -494,4 +494,21 @@ abstract public function rename_credential( abstract public function delete_credential($userid, string $provider, string $uid): bool; + /** + * Issue a one-time verification token for exactly one unverified credential. + * The returned secret must be delivered out of band by trusted application code. + */ + abstract public function issue_credential_token($userid, string $provider, string $uid): string; + + /** + * Redeem a token for one provider/UID; other credentials stay unchanged. + */ + abstract public function verify_credential_token(string $provider, string $uid, string $token): bool; + + /** + * Trusted integration hook: use only after independent provider proof + * (for example, a validated OAuth callback for this provider and UID). + */ + abstract public function mark_credential_verified($userid, string $provider, string $uid): void; + } From 4170133aec61203a5fd4baa194b3875a370bdde5 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:00:50 -0300 Subject: [PATCH 122/217] feat(auth): add independent credential verification timestamps and expiration --- ..._auth_credential_verification_migration.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 db/20261019210000_auth_credential_verification_migration.php diff --git a/db/20261019210000_auth_credential_verification_migration.php b/db/20261019210000_auth_credential_verification_migration.php new file mode 100644 index 0000000..b435382 --- /dev/null +++ b/db/20261019210000_auth_credential_verification_migration.php @@ -0,0 +1,18 @@ +table('user_credentials') + ->addColumn('token_expires_at', 'datetime', ['null' => true]) + ->addColumn('verified_at', 'datetime', ['null' => true]) + ->update(); + } +} From 74bc0b01276e9bb4447b45b7160491b5d2926b8b Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:01:20 -0300 Subject: [PATCH 123/217] feat(auth): hash and consume verification tokens per DB credential --- src/Auth/DBAuth.php | 82 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 38df0c3..6024782 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -21,6 +21,7 @@ public function __construct(private DB $db, array $params = []) 'credentials_table' => 'user_credentials', 'credentials_last_login' => 'last_login', 'credentials_created' => 'created', + 'credential_token_ttl' => 3600, 'uuid' => 'uuid', 'roles_table' => 'role', 'user_roles_table' => 'user_roles', @@ -593,6 +594,81 @@ public function update_credential($userid, $provider, $uid, $profile = null) return true; } + /** + * Tokens are per credential, never per user. Only trusted service code + * should issue them and deliver them through the credential's verified + * address/channel; never return this secret from a public HTTP endpoint. + */ + public function issue_credential_token($userid, string $provider, string $uid): string + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (!empty($credential['verified_at'])) { + throw new UserException('Credential already verified', 409); + } + + $secret = bin2hex(random_bytes(32)); + $updated = $this->db->update($this->params['credentials_table'], [ + 'token' => hash('sha256', $secret), + 'token_expires_at' => date('Y-m-d H:i:s', time() + max(60, (int) $this->params['credential_token_ttl'])), + ], [ + 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, + ]); + if ($updated !== 1) { + throw new UserException('Credential not found', 404); + } + return $secret; + } + + public function verify_credential_token(string $provider, string $uid, string $token): bool + { + if ($token === '') { + return false; + } + $credential = $this->get_credential($provider, $uid); + if (!$credential || !empty($credential['verified_at'])) { + return false; + } + $digest = $credential['token'] ?? null; + $expires = $credential['token_expires_at'] ?? null; + if (!is_string($digest) || !is_string($expires) + || $expires <= date('Y-m-d H:i:s') + || !hash_equals($digest, hash('sha256', $token))) { + return false; + } + + // Consume the matching token atomically. Concurrent attempts cannot + // both mark the same credential verified. + return $this->db->update($this->params['credentials_table'], [ + 'verified_at' => date('Y-m-d H:i:s'), + 'token' => null, + 'token_expires_at' => null, + ], [ + 'user_id' => $credential['user_id'], + 'provider' => $provider, + 'uid' => $uid, + 'token' => $digest, + 'verified_at' => null, + ]) === 1; + } + + public function mark_credential_verified($userid, string $provider, string $uid): void + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + $this->db->update($this->params['credentials_table'], [ + 'verified_at' => $credential['verified_at'] ?: date('Y-m-d H:i:s'), + 'token' => null, + 'token_expires_at' => null, + ], [ + 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, + ]); + } + public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array { $this->get($userid); @@ -643,6 +719,11 @@ public function rename_credential( $this->db->update($this->params['credentials_table'], [ 'provider' => $newProvider, 'uid' => $newUid, + // Changing either identity component requires fresh proof. + 'verified_at' => ($provider === $newProvider && $uid === $newUid) + ? ($credential['verified_at'] ?? null) : null, + 'token' => null, + 'token_expires_at' => null, ], [ 'user_id' => $userid, 'provider' => $provider, @@ -695,6 +776,7 @@ public function get_credentials($user_id, $key = 'id'): array 'uid' => $row['uid'] ?? null, 'provider' => $row['provider'] ?? null, 'profile' => $profile, + 'verified_at' => $row['verified_at'] ?? null, 'last_login' => ($lastLoginField && array_key_exists($lastLoginField, $row)) ? $row[$lastLoginField] : ($row['last_login'] ?? null), From 56f23720d82bb0ce9b59b267f476a5b01677deb1 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:01:46 -0300 Subject: [PATCH 124/217] feat(auth): implement independent verification tokens for BasicAuth --- src/Auth/BasicAuth.php | 60 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 18bebd2..b2beaba 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -327,6 +327,7 @@ public function get_credentials($user_id, $key = 'id'): array 'uid' => $uid, 'provider' => $provider, 'profile' => $profile, + 'verified_at' => $credential['verified_at'] ?? null, 'last_login' => $credential['last_login'] ?? null, 'created' => $credential['created'] ?? null, ]; @@ -392,18 +393,68 @@ public function update_credential($userid, $provider, $uid, $profile = null) } $created = $this->credentials[$provider][$uid]['created'] ?? date('Y-m-d H:i:s'); - $this->credentials[$provider][$uid] = [ + $this->credentials[$provider][$uid] = array_merge($existing ?: [], [ 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, 'profile' => $profile, 'last_login' => date('Y-m-d H:i:s'), 'created' => $created, - ]; + ]); return true; } + public function issue_credential_token($userid, string $provider, string $uid): string + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + if (!empty($credential['verified_at'])) { + throw new UserException('Credential already verified', 409); + } + + $secret = bin2hex(random_bytes(32)); + $this->credentials[$provider][$uid]['token'] = hash('sha256', $secret); + $this->credentials[$provider][$uid]['token_expires_at'] = date( + 'Y-m-d H:i:s', + time() + max(60, (int) ($this->params['credential_token_ttl'] ?? 3600)) + ); + return $secret; + } + + public function verify_credential_token(string $provider, string $uid, string $token): bool + { + $credential = $this->get_credential($provider, $uid); + if ($token === '' || !$credential || !empty($credential['verified_at'])) { + return false; + } + $digest = $credential['token'] ?? null; + $expires = $credential['token_expires_at'] ?? null; + if (!is_string($digest) || !is_string($expires) + || $expires <= date('Y-m-d H:i:s') + || !hash_equals($digest, hash('sha256', $token))) { + return false; + } + + $this->credentials[$provider][$uid]['verified_at'] = date('Y-m-d H:i:s'); + $this->credentials[$provider][$uid]['token'] = null; + $this->credentials[$provider][$uid]['token_expires_at'] = null; + return true; + } + + public function mark_credential_verified($userid, string $provider, string $uid): void + { + $credential = $this->get_credential($provider, $uid); + if (!$credential || ($credential['user_id'] ?? null) != $userid) { + throw new UserException('Credential not found', 404); + } + $this->credentials[$provider][$uid]['verified_at'] = $credential['verified_at'] ?: date('Y-m-d H:i:s'); + $this->credentials[$provider][$uid]['token'] = null; + $this->credentials[$provider][$uid]['token_expires_at'] = null; + } + public function create_credential($userid, string $provider, string $uid, mixed $profile = null): array { $this->get($userid); @@ -453,6 +504,11 @@ public function rename_credential( $credential['provider'] = $newProvider; $credential['uid'] = $newUid; + if ($provider !== $newProvider || $uid !== $newUid) { + $credential['verified_at'] = null; + } + $credential['token'] = null; + $credential['token_expires_at'] = null; $this->credentials[$newProvider][$newUid] = $credential; return $this->get_credential($newProvider, $newUid); From 997af5b81108957f5e925422895762b0161210c5 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:02:27 -0300 Subject: [PATCH 125/217] fix(auth): bind OAuth logins only to provider-verified credential identities --- src/Auth/Controller/OAuthController.php | 97 ++++++++++++------------- 1 file changed, 48 insertions(+), 49 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 4391861..62e5371 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -113,63 +113,62 @@ protected function redirect(string $location): void */ private function login($provider, $resourceOwner) { + // The OAuth provider has authenticated this provider-specific subject + // through its code/token exchange. An email in the profile is NOT proof + // that this subject owns any existing local account. + $uid = (string) $resourceOwner->getId(); + if ($uid === '') { + throw new \Objectiveweb\Auth\AuthException('Invalid provider identity', 401); + } - $uid = $resourceOwner->getId(); - $email = $resourceOwner->getEmail(); - - // check if provider user exists $credential = $this->auth->get_credential($provider, $uid); + $sessionUser = $this->auth->check() ? $this->auth->user() : null; + $idField = $this->auth->params['id']; + $profile = $resourceOwner->toArray(); + + if ($credential) { + $userId = $credential['user_id']; + if ($sessionUser !== null && $sessionUser[$idField] != $userId) { + throw new UserException('Credential belongs to another account', 409); + } - // if provider credential does not exist, check if there's a local user with the email - if (!$credential && !empty($email)) { - $credential = $this->auth->get_credential('email', $email); - } - - // This credential/email is not registered yet - if (empty($credential['user_id'])) { - // Check if the user is already logged in - if ($this->auth->check()) { - $user = $this->auth->user(); - - // Add the oauth cred to the existing user - $this->auth->update_credential($user[$this->auth->params['id']], - $provider, - $uid, - $resourceOwner->toArray()); - - } else { - // This is a new user, need to register - $data = [ - 'uid' => $uid, - 'provider' => $provider, - 'profile' => $resourceOwner->toArray(), - 'name' => $resourceOwner->getName(), - 'image' => is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() : $resourceOwner->getPictureUrl() - ]; - - $user = $this->auth->register($data); - - // if email is defined, also create a local email credential for it - if (!empty($email)) { - // Add local credential to the user - $this->auth->update_credential($user[$this->auth->params['id']], - 'email', - $email, - []); + // Older or administratively created OAuth mappings without proof + // must never authorize an anonymous sign-in. A signed-in owner + // can confirm that pending link by authenticating this provider. + if (empty($credential['verified_at'])) { + if ($sessionUser === null || $sessionUser[$idField] != $userId) { + throw new \Objectiveweb\Auth\AuthException( + 'OAuth credential requires verified account linking', 403 + ); } + $this->auth->mark_credential_verified($userId, $provider, $uid); } + + $user = $this->auth->get($userId); + $this->auth->update_credential($userId, $provider, $uid, $profile); + } elseif ($sessionUser !== null) { + // Explicitly signed-in users can link a new provider after OAuth + // authentication proves control of the provider-specific identity. + $user = $this->auth->get($sessionUser[$idField]); + $this->auth->update_credential($user[$idField], $provider, $uid, $profile); + $this->auth->mark_credential_verified($user[$idField], $provider, $uid); } else { - // there is already a user for the oauth provider / local email - $user = $this->auth->get($credential['user_id']); - // Add account to the user - $this->auth->update_credential($credential['user_id'], - $provider, - $uid, - $resourceOwner->toArray()); + // A matching email does not grant ownership of any other account. + // The provider identity becomes a new, independently verified + // credential. Email credentials require their own proof token. + $data = [ + 'uid' => $uid, + 'provider' => $provider, + 'profile' => $profile, + 'name' => $resourceOwner->getName(), + 'image' => is_callable([$resourceOwner, 'getAvatar']) + ? $resourceOwner->getAvatar() + : $resourceOwner->getPictureUrl(), + ]; + $user = $this->auth->register($data); + $this->auth->mark_credential_verified($user[$idField], $provider, $uid); } - // Apply the same lifecycle and session-fixation protections as - // password authentication before storing the principal. return $this->auth->establish_session($user); } } From 12cd59b2a8dc1290bd8da4bbcb56f69365609f58 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:03:07 -0300 Subject: [PATCH 126/217] test(auth): prove OAuth email collision and pending-association isolation --- test/OAuthControllerTest.php | 62 +++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index e6e9206..4b5e592 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -91,14 +91,13 @@ public function testAuthorizationDerivesRedirectUriWhenNotConfigured(): void ); } - public function testOAuthCallbackConsumesStateAndLinksExistingEmailUser(): void + public function testOAuthCallbackDoesNotLinkAccountByEmail(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); - $user = $auth->register('linked@example.com', 'secret', ['provider' => 'email']); + $existing = $auth->register('linked@example.com', 'secret', ['provider' => 'email']); OAuthTestProvider::$resourceOwner = $this->resourceOwner( - 'provider-user-id', - 'linked@example.com' + 'provider-user-id', 'linked@example.com' ); $_SESSION['oauth2state'] = 'test-oauth-state'; @@ -109,20 +108,58 @@ public function testOAuthCallbackConsumesStateAndLinksExistingEmailUser(): void 'redirectUri' => 'https://app.example/oauth/test', ], ]); - - $controller->get('test', [ - 'code' => 'oauth-code', - 'state' => 'test-oauth-state', - ]); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); $this->assertArrayNotHasKey('oauth2state', $_SESSION); $this->assertSame('/', $controller->redirectedTo); $this->assertTrue($auth->sessionRegenerated); - $this->assertSame($user['id'], $auth->user()['id']); + $this->assertNotSame($existing['id'], $auth->user()['id']); + $this->assertSame($existing['id'], $auth->get_credential('email', 'linked@example.com')['user_id']); $credential = $auth->get_credential('test', 'provider-user-id'); - $this->assertNotFalse($credential); - $this->assertSame($user['id'], $credential['user_id']); + $this->assertSame($auth->user()['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertFalse($auth->get_credential('email', 'provider-user-id')); + } + + public function testUnverifiedOAuthCredentialCannotBeUsedToLogInAnotherAccount(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $victim = $auth->register('victim@example.com', 'secret'); + $auth->create_credential($victim['id'], 'google', 'attacker-provider-subject'); + + $controller = new OAuthController($auth); + $this->expectException(AuthException::class); + $this->expectExceptionCode(403); + $this->invokeOAuthLogin($controller, 'google', $this->resourceOwner('attacker-provider-subject')); + } + + public function testAuthenticatedAccountCanConfirmItsOwnPendingOAuthCredential(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $owner = $auth->register('owner@example.com', 'secret'); + $auth->create_credential($owner['id'], 'google', 'owner-subject'); + $auth->login('owner@example.com', 'secret'); + + $controller = new OAuthController($auth); + $logged = $this->invokeOAuthLogin($controller, 'google', $this->resourceOwner('owner-subject')); + + $this->assertSame($owner['id'], $logged['id']); + $this->assertNotEmpty($auth->get_credential('google', 'owner-subject')['verified_at']); + } + + public function testSignedInUserCannotSwitchToDifferentVerifiedOAuthAccount(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $other = $auth->register('other-subject', null, ['provider' => 'google']); + $auth->mark_credential_verified($other['id'], 'google', 'other-subject'); + $auth->register('current@example.com', 'secret'); + $auth->login('current@example.com', 'secret'); + $controller = new OAuthController($auth); + + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(409); + $this->invokeOAuthLogin($controller, 'google', $this->resourceOwner('other-subject')); } public function testInvalidOAuthStateIsCleared(): void @@ -194,6 +231,7 @@ public function testOAuthLoginRegeneratesSessionBeforeStoringPrincipal(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); $user = $auth->register('google-user-id', null, ['provider' => 'google']); + $auth->mark_credential_verified($user['id'], 'google', 'google-user-id'); $controller = new OAuthController($auth, []); $logged = $this->invokeOAuthLogin( From 21503e32fe2b9635ae3ee88d121905ae2fc0bad4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:03:55 -0300 Subject: [PATCH 127/217] test(auth): align credential verification state and schema across providers --- src/Auth/BasicAuth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index b2beaba..c9da2f9 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -450,7 +450,7 @@ public function mark_credential_verified($userid, string $provider, string $uid) if (!$credential || ($credential['user_id'] ?? null) != $userid) { throw new UserException('Credential not found', 404); } - $this->credentials[$provider][$uid]['verified_at'] = $credential['verified_at'] ?: date('Y-m-d H:i:s'); + $this->credentials[$provider][$uid]['verified_at'] = ($credential['verified_at'] ?? null) ?: date('Y-m-d H:i:s'); $this->credentials[$provider][$uid]['token'] = null; $this->credentials[$provider][$uid]['token_expires_at'] = null; } From ef2135da28bb1cdf5ee9060e9552384ecc78bf31 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:03:57 -0300 Subject: [PATCH 128/217] test(auth): align credential verification state and schema across providers --- test/DBAuthSqliteTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 2dc56c0..41c70af 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -41,6 +41,7 @@ public static function setUpBeforeClass(): void user_id INTEGER NOT NULL, profile TEXT NULL, token TEXT NULL, + token_expires_at TEXT NULL, verified_at TEXT NULL, last_login TEXT NULL, created TEXT NULL, @@ -585,7 +586,7 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void $this->assertCount(2, $credentials); foreach ($credentials as $credential) { $this->assertSame( - ['uid', 'provider', 'profile', 'last_login', 'created'], + ['uid', 'provider', 'profile', 'verified_at', 'last_login', 'created'], array_keys($credential) ); $this->assertArrayHasKey('last_login', $credential); From 47be4b659075c2d16a82309a6cdd60eea410981b Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:04:00 -0300 Subject: [PATCH 129/217] test(auth): align credential verification state and schema across providers --- src/Auth.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Auth.php b/src/Auth.php index aca9464..2fad762 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -25,6 +25,7 @@ function __construct(array $params) 'token' => NULL, // Name of the field that should store user tokens 'token_expires_field' => 'token_expires_at', 'token_ttl' => 3600, + 'credential_token_ttl' => 3600, 'register_scope' => Auth::ANONYMOUS, // who is allowed to use /register 'register_allow_grants' => false, 'recovery_providers' => ['local', 'email', 'phone'], From 71557c7d5f1d6a070444fbf026b34b7931e655d4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:04:03 -0300 Subject: [PATCH 130/217] test(auth): align credential verification state and schema across providers --- test/BasicAuthTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index b1d021a..b1965a1 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -93,7 +93,7 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void foreach ($credentials as $credential) { $this->assertSame( - ['uid', 'provider', 'profile', 'last_login', 'created'], + ['uid', 'provider', 'profile', 'verified_at', 'last_login', 'created'], array_keys($credential) ); $this->assertArrayHasKey('last_login', $credential); From fb6dcbd8445fb9520721ce8ea587b07431a2f21c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:04:05 -0300 Subject: [PATCH 131/217] test(auth): align credential verification state and schema across providers --- test/UserControllerTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 96210e7..c97af4a 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -37,6 +37,9 @@ public static function setUpBeforeClass(): void provider TEXT NOT NULL, user_id INTEGER NOT NULL, profile TEXT NULL, + token TEXT NULL, + token_expires_at TEXT NULL, + verified_at TEXT NULL, last_login TEXT NULL, PRIMARY KEY(uid, provider) )' From d8476e9290015f718f5653dd7063b3f6e56c66ae Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:04:08 -0300 Subject: [PATCH 132/217] test(auth): align credential verification state and schema across providers --- test/verify_migrations.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/verify_migrations.php b/test/verify_migrations.php index 06cb98a..4d7228d 100644 --- a/test/verify_migrations.php +++ b/test/verify_migrations.php @@ -30,6 +30,8 @@ 'user_id', 'profile', 'token', + 'token_expires_at', + 'verified_at', 'last_login', 'created', ], From 95d1d248f67a936fbabb7380cad68d1acfbf76ba Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:04:55 -0300 Subject: [PATCH 133/217] feat(auth): expose one-time credential confirmation without account login --- src/Auth/Controller/AuthController.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index d130cb6..3199128 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -148,6 +148,27 @@ function postRegister(array $user) return $user; } + /** + * Redeem a credential-specific proof sent through an independent channel. + * Never authenticates a user or links another account on its own. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postVerifyCredential(array $form): array + { + $provider = $form['provider'] ?? null; + $uid = $form['uid'] ?? null; + $token = $form['token'] ?? null; + if (!is_string($provider) || $provider === '' + || !is_string($uid) || $uid === '' + || !is_string($token) || $token === '') { + throw new UserException('Invalid verification request', 400); + } + if (!$this->auth->verify_credential_token($provider, $uid, $token)) { + throw new UserException('Invalid or expired verification token', 400); + } + return ['verified' => true]; + } + #[Middleware(RequireRole::class, Auth::ALL)] function postToken(array $form) { From e09321607bfde295a8cf051a06cff9cf659a3c58 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:05:15 -0300 Subject: [PATCH 134/217] fix(auth): require independent proof for contact credentials, OAuth proof for external identities --- src/Auth/BasicAuth.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index c9da2f9..8a5b0ab 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -407,6 +407,9 @@ public function update_credential($userid, $provider, $uid, $profile = null) public function issue_credential_token($userid, string $provider, string $uid): string { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + throw new UserException('Provider does not support out-of-band verification', 400); + } $credential = $this->get_credential($provider, $uid); if (!$credential || ($credential['user_id'] ?? null) != $userid) { throw new UserException('Credential not found', 404); @@ -426,6 +429,9 @@ public function issue_credential_token($userid, string $provider, string $uid): public function verify_credential_token(string $provider, string $uid, string $token): bool { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + return false; + } $credential = $this->get_credential($provider, $uid); if ($token === '' || !$credential || !empty($credential['verified_at'])) { return false; From 2f6b9eb4ad6ca060a6211f76fd715e07182b9a74 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:05:18 -0300 Subject: [PATCH 135/217] fix(auth): require independent proof for contact credentials, OAuth proof for external identities --- src/Auth.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Auth.php b/src/Auth.php index 2fad762..cbf0b16 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -26,6 +26,7 @@ function __construct(array $params) 'token_expires_field' => 'token_expires_at', 'token_ttl' => 3600, 'credential_token_ttl' => 3600, + 'credential_token_providers' => ['local', 'email', 'phone'], 'register_scope' => Auth::ANONYMOUS, // who is allowed to use /register 'register_allow_grants' => false, 'recovery_providers' => ['local', 'email', 'phone'], From 60556d51edbe0947a1c389320d872172e69ddf0a Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:05:20 -0300 Subject: [PATCH 136/217] fix(auth): require independent proof for contact credentials, OAuth proof for external identities --- src/Auth/DBAuth.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 6024782..d7a34ba 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -601,6 +601,9 @@ public function update_credential($userid, $provider, $uid, $profile = null) */ public function issue_credential_token($userid, string $provider, string $uid): string { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + throw new UserException('Provider does not support out-of-band verification', 400); + } $credential = $this->get_credential($provider, $uid); if (!$credential || ($credential['user_id'] ?? null) != $userid) { throw new UserException('Credential not found', 404); @@ -624,6 +627,9 @@ public function issue_credential_token($userid, string $provider, string $uid): public function verify_credential_token(string $provider, string $uid, string $token): bool { + if (!in_array($provider, (array) $this->params['credential_token_providers'], true)) { + return false; + } if ($token === '') { return false; } From 59da18e98a5319e3964f9df8975d283c00ee39ae Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:05:55 -0300 Subject: [PATCH 137/217] test(auth): exercise independent hashed credential tokens and replay rejection --- test/BasicAuthTest.php | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index b1965a1..6b10ae7 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -101,6 +101,53 @@ public function testGetCredentialsReturnsOnlyExpectedFields(): void } } + public function testCredentialVerificationIsScopedSingleUseAndHashed(): void + { + $user = $this->auth->register('token-user@example.com', 'secret'); + $this->auth->create_credential($user['id'], 'email', 'token-user@example.com'); + $this->auth->create_credential($user['id'], 'phone', '+5511999999999'); + + $emailToken = $this->auth->issue_credential_token($user['id'], 'email', 'token-user@example.com'); + $phoneToken = $this->auth->issue_credential_token($user['id'], 'phone', '+5511999999999'); + + $credential = $this->auth->get_credential('email', 'token-user@example.com'); + $this->assertSame(hash('sha256', $emailToken), $credential['token']); + $this->assertNotSame($emailToken, $credential['token']); + $this->assertNotEmpty($credential['token_expires_at']); + $this->assertFalse($this->auth->verify_credential_token('phone', '+5511999999999', $emailToken)); + $this->assertFalse($this->auth->verify_credential_token('email', 'token-user@example.com', $phoneToken)); + $this->assertFalse($this->auth->verify_credential_token('email', 'token-user@example.com', 'bad-token')); + + $public = $this->auth->get_credentials($user['id']); + foreach ($public as $item) { + $this->assertArrayNotHasKey('token', $item); + $this->assertArrayNotHasKey('token_expires_at', $item); + } + + $this->assertTrue($this->auth->verify_credential_token('email', 'token-user@example.com', $emailToken)); + $this->assertFalse($this->auth->verify_credential_token('email', 'token-user@example.com', $emailToken)); + $this->assertNotEmpty($this->auth->get_credential('email', 'token-user@example.com')['verified_at']); + $this->assertNull($this->auth->get_credential('phone', '+5511999999999')['verified_at'] ?? null); + $this->assertTrue($this->auth->verify_credential_token('phone', '+5511999999999', $phoneToken)); + + // Metadata refresh keeps verification, but a changed UID requires proof again. + $this->auth->update_credential($user['id'], 'email', 'token-user@example.com', ['source' => 'profile']); + $this->assertNotEmpty($this->auth->get_credential('email', 'token-user@example.com')['verified_at']); + $renamed = $this->auth->rename_credential( + $user['id'], 'email', 'token-user@example.com', 'email', 'renamed@example.com' + ); + $this->assertNull($renamed['verified_at']); + } + + public function testExternalOAuthCredentialCannotBeVerifiedWithContactToken(): void + { + $this->auth->create_credential(1, 'google', 'google-id'); + $this->assertFalse($this->auth->verify_credential_token('google', 'google-id', 'anything')); + $this->expectException(UserException::class); + $this->expectExceptionCode(400); + $this->auth->issue_credential_token(1, 'google', 'google-id'); + } + public function testRenameCredentialNormalizesTargetAndPreservesMetadata(): void { $user = $this->auth->register('rename@example.com', 'secret'); From 63d8696092432bf0c71e96305e5d0b144c5dba39 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:06:19 -0300 Subject: [PATCH 138/217] test(auth): verify SQLite token isolation, expiry, ownership and rename invalidation --- test/DBAuthSqliteTest.php | 65 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 41c70af..392c09a 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -915,6 +915,71 @@ public function testSuspendedUserCannotLoginAndExistingSessionIsInvalidated(): v self::$auth->login('suspended@example.com', 'secret'); } + public function testVerificationTokensArePerCredentialInSqlite(): void + { + $user = self::$auth->register('account@example.com', 'secret'); + self::$auth->create_credential($user['id'], 'email', 'account@example.com'); + self::$auth->create_credential($user['id'], 'phone', '+5511888888888'); + + $emailToken = self::$auth->issue_credential_token($user['id'], 'email', 'account@example.com'); + $phoneToken = self::$auth->issue_credential_token($user['id'], 'phone', '+5511888888888'); + + $stored = self::$auth->get_credential('email', 'account@example.com'); + $this->assertSame(hash('sha256', $emailToken), $stored['token']); + $this->assertNotSame($emailToken, $stored['token']); + $this->assertFalse(self::$auth->verify_credential_token('email', 'account@example.com', $phoneToken)); + $this->assertFalse(self::$auth->verify_credential_token('phone', '+5511888888888', $emailToken)); + + $this->assertTrue(self::$auth->verify_credential_token('email', 'account@example.com', $emailToken)); + $this->assertFalse(self::$auth->verify_credential_token('email', 'account@example.com', $emailToken)); + $this->assertNotEmpty(self::$auth->get_credential('email', 'account@example.com')['verified_at']); + $this->assertNull(self::$auth->get_credential('email', 'account@example.com')['token']); + $this->assertNull(self::$auth->get_credential('phone', '+5511888888888')['verified_at']); + $this->assertTrue(self::$auth->verify_credential_token('phone', '+5511888888888', $phoneToken)); + + $listed = self::$auth->get_credentials($user['id']); + foreach ($listed as $credential) { + $this->assertArrayNotHasKey('token', $credential); + $this->assertArrayNotHasKey('token_expires_at', $credential); + } + } + + public function testVerificationTokenExpiresAndOnlyOwnerCanIssue(): void + { + $owner = self::$auth->register('owner@example.com', 'secret'); + $other = self::$auth->register('other@example.com', 'secret'); + self::$auth->create_credential($owner['id'], 'email', 'owner@example.com'); + + try { + self::$auth->issue_credential_token($other['id'], 'email', 'owner@example.com'); + $this->fail('A different user issued verification for this credential'); + } catch (UserException $e) { + $this->assertSame(404, $e->getCode()); + } + + $token = self::$auth->issue_credential_token($owner['id'], 'email', 'owner@example.com'); + self::$db->update('auth_credentials', [ + 'token_expires_at' => date('Y-m-d H:i:s', time() - 60), + ], ['provider' => 'email', 'uid' => 'owner@example.com']); + + $this->assertFalse(self::$auth->verify_credential_token('email', 'owner@example.com', $token)); + $this->assertNull(self::$auth->get_credential('email', 'owner@example.com')['verified_at']); + } + + public function testCredentialRenameInvalidatesPriorVerification(): void + { + $owner = self::$auth->register('owner@example.com', 'secret'); + self::$auth->create_credential($owner['id'], 'phone', '+5511888888888'); + $token = self::$auth->issue_credential_token($owner['id'], 'phone', '+5511888888888'); + $this->assertTrue(self::$auth->verify_credential_token('phone', '+5511888888888', $token)); + $renamed = self::$auth->rename_credential( + $owner['id'], 'phone', '+5511888888888', 'phone', '+5511999999999' + ); + $this->assertNull($renamed['verified_at']); + $this->assertNull($renamed['token']); + $this->assertNull($renamed['token_expires_at']); + } + public function testCredentialLifecycleEnforcesOwnershipUniquenessAndLastCredential(): void { $alice = self::$auth->register('alice@example.com', 'secret'); From 102ac7c5181630b283fca3037d5911584e5b2398 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:06:37 -0300 Subject: [PATCH 139/217] test(auth): cover credential token redemption without session establishment --- test/AuthControllerTest.php | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 02b4a86..8e8597a 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -103,6 +103,52 @@ public function testPostRegisterInvalidEmail(): void $this->controller->postRegister(['uid' => 'invalid', 'password' => 'secret']); } + public function testCredentialTokenRedemptionDoesNotAuthenticateAUser(): void + { + $user = $this->auth->register('verify@example.com', 'secret'); + $token = $this->auth->issue_credential_token($user['id'], 'local', 'verify@example.com'); + + $this->assertSame(['verified' => true], $this->controller->postVerifyCredential([ + 'provider' => 'local', + 'uid' => 'verify@example.com', + 'token' => $token, + ])); + $this->assertFalse($this->auth->check()); + $this->assertNotEmpty($this->auth->get_credential('local', 'verify@example.com')['verified_at']); + + try { + $this->controller->postVerifyCredential([ + 'provider' => 'local', + 'uid' => 'verify@example.com', + 'token' => $token, + ]); + $this->fail('Reused token was accepted'); + } catch (UserException $e) { + $this->assertSame(400, $e->getCode()); + } + } + + public function testCredentialTokenRedemptionRejectsMalformedAndMismatchedInputs(): void + { + $user = $this->auth->register('verify@example.com', 'secret'); + $token = $this->auth->issue_credential_token($user['id'], 'local', 'verify@example.com'); + + foreach ([ + ['provider' => 'local', 'uid' => 'verify@example.com', 'token' => 'wrong'], + ['provider' => 'email', 'uid' => 'verify@example.com', 'token' => $token], + ['provider' => 'local', 'uid' => 'else@example.com', 'token' => $token], + ['provider' => 'local', 'uid' => 'verify@example.com', 'token' => []], + ] as $form) { + try { + $this->controller->postVerifyCredential($form); + $this->fail('Invalid token request was accepted'); + } catch (UserException $e) { + $this->assertSame(400, $e->getCode()); + } + } + $this->assertNull($this->auth->get_credential('local', 'verify@example.com')['verified_at'] ?? null); + } + public function testPostTokenPasswordMismatch(): void { $this->expectException(UserException::class); From 14fef661de4509dcf5da9b011f77b0121f227a18 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:07:09 -0300 Subject: [PATCH 140/217] docs(auth): describe individual credential proof tokens and secure OAuth association --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index f18c27d..370333a 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,52 @@ $auth->delete_credential($userId, 'phone', '+5511888888888'); `create_credential()` rejects duplicates; `update_credential()` inserts or updates a provider identity and updates its last-login field when configured. `delete_credential()` refuses to remove the user's last credential. Treat raw provider profiles as potentially sensitive; send normalized/sanitized credentials to clients. +##### Independent credential verification + +Each `(provider, uid)` credential has its own verification state: `token` +(SHA-256 digest only), `token_expires_at`, and `verified_at`. A new +credential is unverified until its specific identity has been proven. +The `user.token` field remains for **password reset/invitation**, and is +not interchangeable with credential verification tokens. + +For contact methods (`local`, `email`, `phone` by default), trusted +application code can issue an out-of-band token and deliver it **only** +through that credential's address or channel: + +```php +$auth->create_credential($userId, 'email', 'alice@example.com'); +$token = $auth->issue_credential_token($userId, 'email', 'alice@example.com'); +// Email the secret to alice@example.com; do not expose it via an API response. +// The caller redeems it with AuthController::postVerifyCredential(): +$result = $auth->verify_credential_token('email', 'alice@example.com', $token); +// true on first valid redemption; false for wrong, expired, or reused tokens. +``` + +Each token is independent; validating one credential does **not** validate +any other. Issuing a new token replaces an earlier pending one. Tokens +expire after `credential_token_ttl` seconds (default 3600; minimum 60), +and are consumed once. Credentials lists include `verified_at`, but never +return verification token digests or expirations. Renaming a credential's +provider or UID clears its prior verification state. + +An OAuth provider identity is **not** verified by a locally issued email or +SMS token. OAuth credentials are proven using the validated provider callback, +including code/token exchange and provider-specific user ID; the trusted +OAuth integration marks exactly that credential verified. OAuth sign-in never +links accounts by email equality and never automatically creates a separate +`email` credential. An existing unverified OAuth credential cannot authorize +an anonymous login; its owning user must authenticate separately and confirm +the provider link. External providers are deliberately excluded from +`credential_token_providers` unless explicitly configured by the application; +do not enable arbitrary OAuth provider names. + +The public `postVerifyCredential()` endpoint accepts `provider`, `uid`, and +`token`, marks the matching credential verified, and returns +`{"verified":true}`. It does not establish a login session. Applications +must rate-limit verification attempts and token issuance. Local password login +continues to prove knowledge of the password; it does not imply that the +credential's email address is verified. + #### Roles **Global roles** are attached to users and stored in `role` and `user_roles`. `roles` in returned user data is a computed array, not a SQL column. They are independent of resource-specific relations. From 8665df2ab17628adfb4fc0d360fb5e689f955776 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:08:54 -0300 Subject: [PATCH 141/217] fix(auth): keep BasicAuth raw credential verification fields consistent with DBAuth --- src/Auth/BasicAuth.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 8a5b0ab..95725be 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -393,7 +393,11 @@ public function update_credential($userid, $provider, $uid, $profile = null) } $created = $this->credentials[$provider][$uid]['created'] ?? date('Y-m-d H:i:s'); - $this->credentials[$provider][$uid] = array_merge($existing ?: [], [ + $this->credentials[$provider][$uid] = array_merge([ + 'token' => null, + 'token_expires_at' => null, + 'verified_at' => null, + ], $existing ?: [], [ 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, From eccdd48b0ef1201569f97c06fdac03dd0366322f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:44:34 -0300 Subject: [PATCH 142/217] fix(auth): atomically consume password-reset tokens using conditional update --- src/Auth/DBAuth.php | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index d7a34ba..cacbaca 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -396,10 +396,30 @@ public function passwd_reset($token, $password) $payload[$tokenExpiresField] = null; } - $updated = $this->userTable->update( - $user[$this->params['id']], - $payload - )['updated']; + // The password change and token consumption must be one atomic + // compare-and-swap. Checking the token with SELECT alone is not + // enough: two requests can both read the same valid token. + $where = [ + (string) $this->params['id'] => $user[$this->params['id']], + (string) $this->params['token'] => $this->resetTokenDigest((string) $token), + ]; + if (is_string($tokenExpiresField) && $tokenExpiresField !== '') { + // Also reject a concurrently replaced or invalidated expiry. + $where[$tokenExpiresField] = $user[$tokenExpiresField]; + if ($user[$tokenExpiresField] <= date('Y-m-d H:i:s')) { + throw new UserException('Hash not found', 404); + } + } + + // DB::update() uses the configured table prefix and returns the + // affected row count. Once the first request clears the token, every + // competing request fails the token predicate without changing the + // new password. + $updated = $this->db->update( + (string) $this->params['table'], + $payload, + $where + ); if ($updated !== 1) { throw new UserException('Hash not found', 404); From 6713e30c575c17dab45e3405f7f0b9990e7bacc7 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:45:37 -0300 Subject: [PATCH 143/217] test(auth): assert atomic reset consumption under interleaving requests and token rotation --- test/DBAuthSqliteTest.php | 147 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 392c09a..6d77738 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -413,6 +413,153 @@ public function testRequestToken(): void $this->assertSame($user['id'], $logged['id']); } + public function testPasswdResetTokenCannotBeReused(): void + { + $user = self::$auth->register('single-use@example.com', 'original-secret'); + $token = self::$auth->update_token($user['id']); + + self::$auth->passwd_reset($token, 'first-password'); + + try { + self::$auth->passwd_reset($token, 'second-password'); + $this->fail('A consumed password reset token was accepted again'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $row = self::$db->select('auth_user', ['id' => $user['id']], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('first-password', $row['password'])); + $this->assertFalse(password_verify('second-password', $row['password'])); + $this->assertNull($row['token']); + $this->assertNull($row['token_expires_at']); + } + + public function testPasswdResetConcurrentRequestsCannotOverwriteWinner(): void + { + // Interleave two requests after the first has read the token but + // before it updates the row. This deterministically reproduces the + // race that an ID-only UPDATE allowed. + $db = new class ('sqlite::memory:') extends DB { + public ?\\Closure $beforeResetUpdate = null; + + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'user' && array_key_exists('password', $data) + && array_key_exists('token', $data) + && $this->beforeResetUpdate !== null) { + $callback = $this->beforeResetUpdate; + $this->beforeResetUpdate = null; + $callback(); + } + return parent::update($table, $data, $where); + } + }; + + $db->query( + 'CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password TEXT, + token TEXT, + token_expires_at TEXT + )' + )->exec(); + + $auth = new DBAuth($db, [ + 'table' => 'user', + 'created' => null, + 'uuid' => null, + 'token' => 'token', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + $userId = $db->insert('user', [ + 'password' => password_hash('original-secret', PASSWORD_BCRYPT), + 'token' => null, + 'token_expires_at' => null, + ]); + $token = $auth->update_token($userId); + + $competitorSucceeded = false; + $db->beforeResetUpdate = function () use ($auth, $token, &$competitorSucceeded): void { + // This reset wins first, consuming the only valid token. + $auth->passwd_reset($token, 'winning-password'); + $competitorSucceeded = true; + }; + + try { + $auth->passwd_reset($token, 'stale-password'); + $this->fail('The losing concurrent reset overwrote the winner'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $this->assertTrue($competitorSucceeded); + $row = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('winning-password', $row['password'])); + $this->assertFalse(password_verify('stale-password', $row['password'])); + $this->assertNull($row['token']); + } + + public function testPasswdResetDoesNotOverwriteRotatedToken(): void + { + $db = new class ('sqlite::memory:') extends DB { + public ?\\Closure $beforeResetUpdate = null; + + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'user' && array_key_exists('password', $data) + && array_key_exists('token', $data) + && $this->beforeResetUpdate !== null) { + $callback = $this->beforeResetUpdate; + $this->beforeResetUpdate = null; + $callback(); + } + return parent::update($table, $data, $where); + } + }; + + $db->query( + 'CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + password TEXT, + token TEXT, + token_expires_at TEXT + )' + )->exec(); + $auth = new DBAuth($db, [ + 'table' => 'user', + 'created' => null, + 'uuid' => null, + 'token' => 'token', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + $userId = $db->insert('user', [ + 'password' => password_hash('original-secret', PASSWORD_BCRYPT), + 'token' => null, + 'token_expires_at' => null, + ]); + $oldToken = $auth->update_token($userId); + $newToken = null; + $db->beforeResetUpdate = function () use ($auth, $userId, &$newToken): void { + $newToken = $auth->update_token($userId); + }; + + try { + $auth->passwd_reset($oldToken, 'should-not-be-set'); + $this->fail('A rotated token was ignored by the conditional update'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $row = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('original-secret', $row['password'])); + $this->assertSame(hash('sha256', $newToken), $row['token']); + $auth->passwd_reset($newToken, 'new-secret'); + $row = $db->select('user', ['id' => $userId], ['limit' => 1])->fetch(); + $this->assertTrue(password_verify('new-secret', $row['password'])); + } + public function testPasswdResetRejectsExpiredToken(): void { $user = self::$auth->register('expired@example.com', 'secret'); From 10ba8ebb555b005d847e954eb6343180a3e63075 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Mon, 19 Oct 2026 18:46:16 -0300 Subject: [PATCH 144/217] fix(tests): correct Closure callback type in interleaving reset checks --- test/DBAuthSqliteTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 6d77738..f699704 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -440,7 +440,7 @@ public function testPasswdResetConcurrentRequestsCannotOverwriteWinner(): void // before it updates the row. This deterministically reproduces the // race that an ID-only UPDATE allowed. $db = new class ('sqlite::memory:') extends DB { - public ?\\Closure $beforeResetUpdate = null; + public ?Closure $beforeResetUpdate = null; public function update(string $table, array $data, ?array $where = null): int { @@ -503,7 +503,7 @@ public function update(string $table, array $data, ?array $where = null): int public function testPasswdResetDoesNotOverwriteRotatedToken(): void { $db = new class ('sqlite::memory:') extends DB { - public ?\\Closure $beforeResetUpdate = null; + public ?Closure $beforeResetUpdate = null; public function update(string $table, array $data, ?array $where = null): int { From 177072f78dbe854935393ff2b80561038e3ac396 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 10:58:12 -0300 Subject: [PATCH 145/217] fix(auth): normalize password login failures without rate limiting --- src/Auth.php | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index cbf0b16..ec0d2df 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -14,6 +14,9 @@ abstract class Auth public array $params; + // Fixed valid bcrypt hash for uniform verification of unknown accounts. + private const DUMMY_PASSWORD_HASH = '$2y$10$p7uZwkJNF9lGxXQ8ubwbEe.Ud1mjaN7gTrU59hKvMolizfH0cO0qa'; + function __construct(array $params) { $defaults = [ @@ -279,28 +282,26 @@ public function &login($uid, $password) if (!$candidateCredential) { continue; } - $credential = $candidateCredential; $provider = $candidateProvider; break; } - if (!$credential) { - throw new Auth\UserException('User does not exist', 404); + // Use a real bcrypt check for unknown identities and passwordless users + // as well as for known users. Do not distinguish invalid usernames, + // wrong passwords, or suspended accounts in the public response. + $user = $credential ? $this->get($credential['user_id']) : null; + $stored = is_array($user) ? ($user[$this->params['password']] ?? null) : null; + $hash = is_string($stored) && $stored !== '' + ? $stored : self::DUMMY_PASSWORD_HASH; + $valid = \password_verify(is_string($password) ? $password : '', $hash); + if (!$credential || !$valid || !$this->is_active($user)) { + throw new Auth\AuthException('Invalid credentials', 401); } - $user = $this->get($credential['user_id']); - - if (\password_verify($password, $user[$this->params['password']])) { - $user = $this->establish_session($user); - - // TODO add login ip - $this->update_credential($user[$this->params['id']], (string) $provider, $uid, null); - - return $user; - } else { - throw new Auth\AuthException('Password invalid', 400); - } + $user = $this->establish_session($user); + $this->update_credential($user[$this->params['id']], (string) $provider, $uid, null); + return $user; } /** From 8707ac75271fbf2fa37165a43048b7abe30c464d Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 10:59:42 -0300 Subject: [PATCH 146/217] fix(auth): bind OAuth state to provider/session and use PKCE S256 --- src/Auth/Controller/OAuthController.php | 94 +++++++++++++++++++------ 1 file changed, 71 insertions(+), 23 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 62e5371..b91f53b 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -46,36 +46,84 @@ function get($id, $query) /** @var \League\OAuth2\Client\Provider\GenericProvider $provider */ $provider = new $classname($config); + $stateKey = 'oauth2flows'; if (!empty($query['error'])) { - unset($_SESSION['oauth2state']); - throw new \Exception("Got error {$query['error']}", 500); - } elseif (empty($query['code'])) { - // generate authUrl first to update state - $authUrl = $provider->getAuthorizationUrl(); - $_SESSION['oauth2state'] = $provider->getState(); - $this->redirect($authUrl); - return; - } elseif ( - empty($query['state']) - || $query['state'] !== ($_SESSION['oauth2state'] ?? null) - ) { - unset($_SESSION['oauth2state']); - throw new \Exception('Invalid state', 406); - } else { - // OAuth state is single-use. Consume it before exchanging the code - // so a callback cannot be replayed even if the provider request fails. - unset($_SESSION['oauth2state']); + unset($_SESSION[$stateKey][$id]); + throw new \Exception('OAuth provider rejected authorization', 401); + } - $token = $provider->getAccessToken('authorization_code', [ - 'code' => $query['code'] + if (empty($query['code'])) { + // Generate a fresh PKCE S256 verifier for every authorization. + $verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $challenge = rtrim( + strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), + '=' + ); + $authUrl = $provider->getAuthorizationUrl([ + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', ]); - $resourceOwner = $provider->getResourceOwner($token); - $this->login($id, $resourceOwner); + // OAuth clients that implement PKCE themselves may override the + // supplied challenge; use their generated verifier when present. + $providerVerifier = method_exists($provider, 'getPkceCode') + ? $provider->getPkceCode() : null; + if (is_string($providerVerifier) && $providerVerifier !== '') { + $verifier = $providerVerifier; + } + + $state = $provider->getState(); + if (!is_string($state) || $state === '') { + throw new \RuntimeException('OAuth provider did not generate state'); + } - $this->redirect('/'); + $principal = $this->auth->check() + ? (string) $this->auth->user()[$this->auth->params['id']] + : null; + $_SESSION[$stateKey][$id] = [ + 'state' => $state, + 'created_at' => time(), + 'code_verifier' => $verifier, + 'principal' => $principal, + ]; + $this->redirect($authUrl); return; } + + // Keep the state, PKCE verifier and initiating principal bound to + // this provider. Never allow callbacks to reuse a different provider's + // state or silently link to a different session principal. + $flow = $_SESSION[$stateKey][$id] ?? null; + unset($_SESSION[$stateKey][$id]); + $state = $query['state'] ?? null; + if (!is_array($flow) + || !is_string($state) + || !isset($flow['state'], $flow['created_at'], $flow['code_verifier']) + || !is_string($flow['state']) + || !hash_equals($flow['state'], $state) + || !is_int($flow['created_at']) + || $flow['created_at'] > time() + || time() - $flow['created_at'] > 600 + || !is_string($flow['code_verifier']) + || $flow['code_verifier'] === '') { + throw new \Exception('Invalid or expired OAuth state', 406); + } + $currentPrincipal = $this->auth->check() + ? (string) $this->auth->user()[$this->auth->params['id']] + : null; + if (($flow['principal'] ?? null) !== $currentPrincipal) { + throw new \Exception('OAuth session changed during authorization', 403); + } + + // Consume state before exchanging the authorization code. A callback + // cannot be replayed, even if token exchange or login fails. + $token = $provider->getAccessToken('authorization_code', [ + 'code' => $query['code'], + 'code_verifier' => $flow['code_verifier'], + ]); + $resourceOwner = $provider->getResourceOwner($token); + $this->login($id, $resourceOwner); + $this->redirect('/'); } protected function resolveRedirectUri(): string From d4adf7ff621c57a1aada3c353332c8f9bfb0ff47 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:00:07 -0300 Subject: [PATCH 147/217] test(auth): verify PKCE options reach OAuth authorization/token provider --- test/OAuthTestProvider.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/OAuthTestProvider.php b/test/OAuthTestProvider.php index 208eccc..5f511d5 100644 --- a/test/OAuthTestProvider.php +++ b/test/OAuthTestProvider.php @@ -5,6 +5,8 @@ final class Test { public static array $lastConfig = []; + public static array $lastAuthorizationOptions = []; + public static array $lastTokenOptions = []; public static mixed $resourceOwner = null; public static string $state = 'test-oauth-state'; @@ -13,8 +15,9 @@ public function __construct(array $config) self::$lastConfig = $config; } - public function getAuthorizationUrl(): string + public function getAuthorizationUrl(array $options = []): string { + self::$lastAuthorizationOptions = $options; return 'https://oauth.example/authorize'; } @@ -25,7 +28,9 @@ public function getState(): string public function getAccessToken(string $grant, array $options): object { - if ($grant !== 'authorization_code' || ($options['code'] ?? null) !== 'oauth-code') { + self::$lastTokenOptions = $options; + if ($grant !== 'authorization_code' || ($options['code'] ?? null) !== 'oauth-code' + || empty($options['code_verifier'])) { throw new \RuntimeException('Unexpected OAuth token request'); } From 3ad02effe4a5a661e6f1f894a7951124306027de Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:01:12 -0300 Subject: [PATCH 148/217] test(auth): cover provider-bound state, PKCE, expiry, replay and session switching --- test/OAuthControllerTest.php | 115 ++++++++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index 4b5e592..c77af3a 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -36,6 +36,8 @@ protected function setUp(): void $_SESSION = []; $_SERVER = []; OAuthTestProvider::$lastConfig = []; + OAuthTestProvider::$lastAuthorizationOptions = []; + OAuthTestProvider::$lastTokenOptions = []; OAuthTestProvider::$resourceOwner = null; OAuthTestProvider::$state = 'test-oauth-state'; } @@ -64,7 +66,16 @@ public function testAuthorizationUsesConfiguredRedirectUriAndStoresState(): void $controller->get('test', []); $this->assertSame('https://app.example/oauth/test', OAuthTestProvider::$lastConfig['redirectUri']); - $this->assertSame('test-oauth-state', $_SESSION['oauth2state']); + $flow = $_SESSION['oauth2flows']['test']; + $this->assertSame('test-oauth-state', $flow['state']); + $this->assertNull($flow['principal']); + $this->assertGreaterThanOrEqual(43, strlen($flow['code_verifier'])); + $this->assertSame('S256', OAuthTestProvider::$lastAuthorizationOptions['code_challenge_method']); + $expectedChallenge = rtrim( + strtr(base64_encode(hash('sha256', $flow['code_verifier'], true)), '+/', '-_'), + '=' + ); + $this->assertSame($expectedChallenge, OAuthTestProvider::$lastAuthorizationOptions['code_challenge']); $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); } @@ -99,8 +110,6 @@ public function testOAuthCallbackDoesNotLinkAccountByEmail(): void OAuthTestProvider::$resourceOwner = $this->resourceOwner( 'provider-user-id', 'linked@example.com' ); - $_SESSION['oauth2state'] = 'test-oauth-state'; - $controller = new OAuthTestController($auth, [ 'test' => [ 'clientId' => 'client-id', @@ -108,9 +117,15 @@ public function testOAuthCallbackDoesNotLinkAccountByEmail(): void 'redirectUri' => 'https://app.example/oauth/test', ], ]); + $controller->get('test', []); $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); - $this->assertArrayNotHasKey('oauth2state', $_SESSION); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertSame( + $_SESSION['oauth2flows']['test']['code_verifier'] ?? null, + null + ); + $this->assertNotEmpty(OAuthTestProvider::$lastTokenOptions['code_verifier']); $this->assertSame('/', $controller->redirectedTo); $this->assertTrue($auth->sessionRegenerated); $this->assertNotSame($existing['id'], $auth->user()['id']); @@ -165,8 +180,6 @@ public function testSignedInUserCannotSwitchToDifferentVerifiedOAuthAccount(): v public function testInvalidOAuthStateIsCleared(): void { $auth = new BasicAuth([], ['token' => 'token']); - $_SESSION['oauth2state'] = 'expected-state'; - $controller = new OAuthTestController($auth, [ 'test' => [ 'clientId' => 'client-id', @@ -175,6 +188,7 @@ public function testInvalidOAuthStateIsCleared(): void ], ]); + $controller->get('test', []); try { $controller->get('test', [ 'code' => 'oauth-code', @@ -185,7 +199,94 @@ public function testInvalidOAuthStateIsCleared(): void $this->assertSame(406, $exception->getCode()); } - $this->assertArrayNotHasKey('oauth2state', $_SESSION); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOAuthCallbackStateIsProviderSpecific(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'first', 'clientSecret' => 'first', + 'redirectUri' => 'https://app.example/oauth/test', + ], + 'other' => [ + 'clientId' => 'second', 'clientSecret' => 'second', + 'redirectUri' => 'https://app.example/oauth/other', + ], + ]); + // Initiating a flow for one provider does not authorize another. + $controller->get('test', []); + try { + $controller->get('other', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('State from a different provider was accepted'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + $this->assertArrayHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOAuthCallbackRejectsExpiredState(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'id', 'clientSecret' => 'secret', + 'redirectUri' => 'https://app.example/oauth/test'], + ]); + $controller->get('test', []); + $_SESSION['oauth2flows']['test']['created_at'] = time() - 601; + + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Expired state was accepted'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOAuthCallbackRejectsPrincipalChangesDuringFlow(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $auth->register('first@example.com', 'one'); + $auth->register('second@example.com', 'two'); + $auth->login('first@example.com', 'one'); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'id', 'clientSecret' => 'secret', + 'redirectUri' => 'https://app.example/oauth/test'], + ]); + $controller->get('test', []); + $auth->logout(); + $auth->login('second@example.com', 'two'); + + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Changed principal was allowed to complete provider linking'); + } catch (\Exception $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertFalse($auth->get_credential('test', 'provider-user-id')); + } + + public function testOAuthCallbackRejectsReplay(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('new-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'id', 'clientSecret' => 'secret', + 'redirectUri' => 'https://app.example/oauth/test'], + ]); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertNotEmpty(OAuthTestProvider::$lastTokenOptions['code_verifier']); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('OAuth state could be replayed'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } } public function testLoggedInUserCanLinkOAuthCredential(): void From efafd1574fa8c338b6d1054eefaf156834b7263d Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:02:15 -0300 Subject: [PATCH 149/217] fix(auth): maintain consistent login errors with regression tests --- test/DBAuthSqliteTest.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index f699704..fdffb7f 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -164,7 +164,8 @@ public function testRegistration(): void public function testInvalidLogin(): void { - $this->expectException(UserException::class); + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); self::$auth->login('nouser@example.com', 'pass'); } @@ -630,7 +631,7 @@ public function testDbAuthAuthenticatedPasswordChangeVerifiesCurrentPassword(): ]); $this->fail('Incorrect current password was accepted'); } catch (AuthException $exception) { - $this->assertSame(403, $exception->getCode()); + $this->assertSame(401, $exception->getCode()); } $this->assertTrue(password_verify('old-secret', self::$auth->get($user['id'])['password'])); @@ -713,7 +714,8 @@ public function testDuplicateCredentialExceptionDoesNotExposeUserSecrets(): void public function testDelete(): void { - $this->expectException(UserException::class); + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); self::$auth->register('alice@example.com', 'secret'); $account = self::$auth->get_credential('local', 'alice@example.com'); @@ -1058,7 +1060,7 @@ public function testSuspendedUserCannotLoginAndExistingSessionIsInvalidated(): v $this->assertFalse(self::$auth->check()); $this->expectException(AuthException::class); - $this->expectExceptionCode(403); + $this->expectExceptionCode(401); self::$auth->login('suspended@example.com', 'secret'); } From 32e6dcca64afb35f118ffa1202fd74381c45b8ad Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:02:33 -0300 Subject: [PATCH 150/217] fix(auth): maintain consistent login errors with regression tests --- test/BasicAuthTest.php | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index 6b10ae7..da237f0 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -41,6 +41,31 @@ public function testLoginInvalidPassword(): void $this->auth->login('alice@example.com', 'wrong'); } + public function testPasswordLoginFailuresHaveIdenticalPublicErrors(): void + { + $auth = new BasicAuth([], ['disabled_at' => 'disabled_at']); + $auth->register('known@example.com', 'secret'); + $auth->register('disabled@example.com', 'secret', [ + 'disabled_at' => '2026-01-01 00:00:00', + ]); + $auth->register('passwordless@example.com', null); + + foreach ([ + ['absent@example.com', 'secret'], + ['known@example.com', 'wrong'], + ['disabled@example.com', 'secret'], + ['passwordless@example.com', 'anything'], + ] as [$uid, $password]) { + try { + $auth->login($uid, $password); + $this->fail('Invalid login unexpectedly succeeded'); + } catch (AuthException $e) { + $this->assertSame(401, $e->getCode()); + $this->assertSame('Invalid credentials', $e->getMessage()); + } + } + } + public function testRegisterAndGetCredential(): void { $user = $this->auth->register('bob@example.com', 'pass123', ['name' => 'Bob']); @@ -66,7 +91,8 @@ public function testDeleteUserRemovesCredential(): void $credential = $this->auth->get_credential('local', 'alice@example.com'); $this->auth->delete($credential['user_id']); - $this->expectException(UserException::class); + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); $this->auth->login('alice@example.com', 'secret'); } From 5fb9c266c7169c35afe1d7d9d6fed6fef085fad0 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:02:44 -0300 Subject: [PATCH 151/217] fix(auth): maintain consistent login errors with regression tests --- test/AuthControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 8e8597a..2a30ce4 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -217,7 +217,7 @@ public function testAuthenticatedPasswordChangeRejectsIncorrectCurrentPassword() ]); $this->fail('Incorrect current password was accepted'); } catch (AuthException $exception) { - $this->assertSame(403, $exception->getCode()); + $this->assertSame(401, $exception->getCode()); } $this->assertTrue(password_verify('old-secret', $this->auth->get(1)['password'])); From 657f5909b5d13e6e9c429db55f971c3ffdc0f590 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:02:56 -0300 Subject: [PATCH 152/217] fix(auth): maintain consistent login errors with regression tests --- src/Auth/Controller/AuthController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 3199128..b1b7f24 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -205,7 +205,7 @@ function postPassword(array $form) $user = $this->auth->user(); $userId = $user[$this->auth->params['id']]; if (!$this->auth->verify_password($userId, $currentPassword)) { - throw new AuthException('Invalid current password', 403); + throw new AuthException('Invalid credentials', 401); } return $this->auth->passwd($userId, $password); From 327ee8001297b5e3bc082046c77038e93ed7a887 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:03:47 -0300 Subject: [PATCH 153/217] test(auth): add second OAuth mock provider for state isolation --- test/OAuthTestProvider.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/OAuthTestProvider.php b/test/OAuthTestProvider.php index 5f511d5..7ced65e 100644 --- a/test/OAuthTestProvider.php +++ b/test/OAuthTestProvider.php @@ -2,7 +2,7 @@ namespace League\OAuth2\Client\Provider; -final class Test +class Test { public static array $lastConfig = []; public static array $lastAuthorizationOptions = []; @@ -46,3 +46,8 @@ public function getResourceOwner(object $token): object return self::$resourceOwner; } } + +// A second provider class for cross-provider OAuth state isolation tests. +class Other extends Test +{ +} From 4b5300dd15788b656e22585e6b7b11c879f2c0a3 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 11:04:39 -0300 Subject: [PATCH 154/217] docs(auth): explain generic login errors, OAuth PKCE and deferred throttling --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 370333a..1621928 100644 --- a/README.md +++ b/README.md @@ -220,14 +220,12 @@ For password-based login and session management: ```php use Objectiveweb\Auth\AuthException; -use Objectiveweb\Auth\UserException; try { $current = $auth->login('alice@example.com', 'secret'); -} catch (UserException $e) { - // Unknown login identity } catch (AuthException $e) { - // Bad password or suspended account + // 401 "Invalid credentials" for unknown users, wrong passwords, or + // suspended accounts. Do not reveal which case applies. } if ($auth->check()) { @@ -447,7 +445,7 @@ $auth->passwd_reset($token, 'new-password'); `DBAuth` stores only a SHA-256 digest of the high-entropy reset token and looks it up through the indexed token column. The plaintext token is returned only once for delivery. -Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. Applications should still rate-limit recovery requests at the HTTP/application edge. +Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. **There is no built-in rate limiting in v0.2.0.** Applications must enforce external rate limits at their web server, reverse proxy, gateway, or middleware for login, public registration, recovery, reset-token redemption, and credential verification. Future non-database throttling is tracked in [issue #2](https://github.com/objectiveweb/auth/issues/2). ## OAuth controller @@ -465,7 +463,11 @@ $oauth = new OAuthController($auth, [ ]); ``` -If `redirectUri` is omitted, the controller derives it from the current request for development/legacy setups. OAuth state is single-use and is cleared when the callback is consumed. +If `redirectUri` is omitted, the controller derives it from the current request for development/legacy setups. **Use an explicit trusted redirect URI in production:** request-derived host and proxy headers are not an authorization boundary. + +OAuth authorization includes a fresh **PKCE S256** challenge and stores a per-provider authorization flow in the PHP session (`oauth2flows`). The callback requires the matching provider's state, the original initiating session principal, and a state created within the past 10 minutes. The state is consumed before exchanging the code, and the matching PKCE verifier is supplied to the token endpoint. Providers must support PKCE S256. OAuth accounts are linked by the provider's stable subject identifier only; matching email addresses never establishes or links identities. + +A pending OAuth credential can only be confirmed by the already authenticated owner completing the matching provider login; anonymous sign-in through an unverified provider association is rejected. ## User-management API From 563727d773a16f2f384c25f5272fa8f52e914b1c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 12:50:41 -0300 Subject: [PATCH 155/217] fix(auth): separate explicit CSRF-protected OAuth linking from ordinary sign-in --- src/Auth/Controller/OAuthController.php | 227 ++++++++++++++---------- 1 file changed, 136 insertions(+), 91 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index b91f53b..f87a316 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -18,6 +18,8 @@ class OAuthController extends AuthController { private $providers; + private const FLOW_KEY = 'oauth2flows'; + function __construct(\Objectiveweb\Auth $auth, array $providers = []) { parent::__construct($auth); @@ -26,106 +28,142 @@ function __construct(\Objectiveweb\Auth $auth, array $providers = []) } /** - * Execute login on oauth provider + * Start a normal OAuth sign-in or receive the provider callback. + * This route never authorizes linking an unassociated provider identity. */ #[Middleware(RequireRole::class, Auth::ALL)] function get($id, $query) { - if (!isset($this->providers[$id])) { - throw new \Exception("Invalid provider $id", 406); - } + $provider = $this->buildProvider($id); - $config = $this->providers[$id]; - - if (empty($config['redirectUri'])) { - $config['redirectUri'] = $this->resolveRedirectUri(); - } - - $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); - - /** @var \League\OAuth2\Client\Provider\GenericProvider $provider */ - $provider = new $classname($config); - - $stateKey = 'oauth2flows'; if (!empty($query['error'])) { - unset($_SESSION[$stateKey][$id]); + unset($_SESSION[self::FLOW_KEY][$id]); throw new \Exception('OAuth provider rejected authorization', 401); } if (empty($query['code'])) { - // Generate a fresh PKCE S256 verifier for every authorization. - $verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); - $challenge = rtrim( - strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), - '=' - ); - $authUrl = $provider->getAuthorizationUrl([ - 'code_challenge' => $challenge, - 'code_challenge_method' => 'S256', - ]); - - // OAuth clients that implement PKCE themselves may override the - // supplied challenge; use their generated verifier when present. - $providerVerifier = method_exists($provider, 'getPkceCode') - ? $provider->getPkceCode() : null; - if (is_string($providerVerifier) && $providerVerifier !== '') { - $verifier = $providerVerifier; - } - - $state = $provider->getState(); - if (!is_string($state) || $state === '') { - throw new \RuntimeException('OAuth provider did not generate state'); - } - - $principal = $this->auth->check() - ? (string) $this->auth->user()[$this->auth->params['id']] - : null; - $_SESSION[$stateKey][$id] = [ - 'state' => $state, - 'created_at' => time(), - 'code_verifier' => $verifier, - 'principal' => $principal, - ]; - $this->redirect($authUrl); + $this->startAuthorization($id, $provider, 'login'); return; } - // Keep the state, PKCE verifier and initiating principal bound to - // this provider. Never allow callbacks to reuse a different provider's - // state or silently link to a different session principal. - $flow = $_SESSION[$stateKey][$id] ?? null; - unset($_SESSION[$stateKey][$id]); + // The purpose of an authorization is fixed when it starts, not by + // callback query parameters. Consume it before any network request. + $flow = $_SESSION[self::FLOW_KEY][$id] ?? null; + unset($_SESSION[self::FLOW_KEY][$id]); $state = $query['state'] ?? null; if (!is_array($flow) || !is_string($state) - || !isset($flow['state'], $flow['created_at'], $flow['code_verifier']) + || !isset($flow['state'], $flow['created_at'], $flow['code_verifier'], $flow['intent']) || !is_string($flow['state']) || !hash_equals($flow['state'], $state) || !is_int($flow['created_at']) || $flow['created_at'] > time() || time() - $flow['created_at'] > 600 || !is_string($flow['code_verifier']) - || $flow['code_verifier'] === '') { + || $flow['code_verifier'] === '' + || !in_array($flow['intent'], ['login', 'link'], true)) { throw new \Exception('Invalid or expired OAuth state', 406); } + + // A flow started under a particular account cannot be completed by a + // different session (including a logout followed by another login). $currentPrincipal = $this->auth->check() ? (string) $this->auth->user()[$this->auth->params['id']] : null; if (($flow['principal'] ?? null) !== $currentPrincipal) { throw new \Exception('OAuth session changed during authorization', 403); } + if ($flow['intent'] === 'link' && !$this->auth->revalidate()) { + throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); + } - // Consume state before exchanging the authorization code. A callback - // cannot be replayed, even if token exchange or login fails. $token = $provider->getAccessToken('authorization_code', [ 'code' => $query['code'], 'code_verifier' => $flow['code_verifier'], ]); $resourceOwner = $provider->getResourceOwner($token); - $this->login($id, $resourceOwner); + $this->login($id, $resourceOwner, $flow['intent']); $this->redirect('/'); } + /** + * Explicitly request an account link. Only a CSRF-protected POST made by + * the already-authenticated user can authorize a link to this account. + * The OAuth callback itself remains GET, as required by OAuth providers. + */ + #[Middleware(RequireRole::class, Auth::AUTHENTICATED)] + function postLink($id, array $form = []): void + { + if (!$this->auth->revalidate()) { + throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); + } + $this->assertLinkRequest(); + $provider = $this->buildProvider($id); + $this->startAuthorization($id, $provider, 'link'); + } + + private function assertLinkRequest(): void + { + if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') { + throw new UserException('POST is required', 405); + } + $contentType = strtolower(trim(explode(';', (string) ($_SERVER['CONTENT_TYPE'] ?? ''), 2)[0])); + if ($contentType !== 'application/json') { + throw new UserException('Content-Type application/json is required', 415); + } + $provided = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null; + $expected = $this->auth->management_csrf_token(); + if (!is_string($provided) || $provided === '' || !hash_equals($expected, $provided)) { + throw new UserException('Invalid CSRF token', 403); + } + } + + private function buildProvider($id): object + { + if (!isset($this->providers[$id])) { + throw new \Exception("Invalid provider $id", 406); + } + $config = $this->providers[$id]; + if (empty($config['redirectUri'])) { + $config['redirectUri'] = $this->resolveRedirectUri(); + } + $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); + + return new $classname($config); + } + + private function startAuthorization($id, object $provider, string $intent): void + { + $verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + $authUrl = $provider->getAuthorizationUrl([ + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + ]); + + $providerVerifier = method_exists($provider, 'getPkceCode') + ? $provider->getPkceCode() : null; + if (is_string($providerVerifier) && $providerVerifier !== '') { + $verifier = $providerVerifier; + } + $state = $provider->getState(); + if (!is_string($state) || $state === '') { + throw new \RuntimeException('OAuth provider did not generate state'); + } + + $principal = $this->auth->check() + ? (string) $this->auth->user()[$this->auth->params['id']] + : null; + $_SESSION[self::FLOW_KEY][$id] = [ + 'state' => $state, + 'created_at' => time(), + 'code_verifier' => $verifier, + 'principal' => $principal, + 'intent' => $intent, + ]; + $this->redirect($authUrl); + } + protected function resolveRedirectUri(): string { $forwardedProto = trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')); @@ -154,64 +192,71 @@ protected function redirect(string $location): void } /** - * Login with oauth2 credentials. Create a new user if it doesn't exist - * @param $provider - * @param \League\OAuth2\Client\Provider\ResourceOwnerInterface $resourceOwner - * @return mixed + * Complete OAuth sign-in or an explicitly initiated account-link flow. + * A normal sign-in is never permission to attach an external identity. */ - private function login($provider, $resourceOwner) + private function login($provider, $resourceOwner, string $intent = 'login') { - // The OAuth provider has authenticated this provider-specific subject - // through its code/token exchange. An email in the profile is NOT proof - // that this subject owns any existing local account. $uid = (string) $resourceOwner->getId(); if ($uid === '') { throw new \Objectiveweb\Auth\AuthException('Invalid provider identity', 401); } + if (!in_array($intent, ['login', 'link'], true)) { + throw new UserException('Invalid OAuth intent', 400); + } $credential = $this->auth->get_credential($provider, $uid); $sessionUser = $this->auth->check() ? $this->auth->user() : null; $idField = $this->auth->params['id']; $profile = $resourceOwner->toArray(); + if ($intent === 'link') { + if ($sessionUser === null || !$this->auth->revalidate()) { + throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); + } + $sessionUser = $this->auth->user(); + $userId = $sessionUser[$idField]; + + if ($credential && $credential['user_id'] != $userId) { + throw new UserException('Credential belongs to another account', 409); + } + $this->auth->update_credential($userId, $provider, $uid, $profile); + $this->auth->mark_credential_verified($userId, $provider, $uid); + $this->auth->audit('credential.linked', $userId, ['provider' => $provider]); + return $this->auth->establish_session($this->auth->get($userId)); + } + + // This is sign-in only. Existing verified credentials authenticate + // their owner, but unknown or unverified identities cannot be attached + // to an unrelated session. if ($credential) { $userId = $credential['user_id']; if ($sessionUser !== null && $sessionUser[$idField] != $userId) { throw new UserException('Credential belongs to another account', 409); } - - // Older or administratively created OAuth mappings without proof - // must never authorize an anonymous sign-in. A signed-in owner - // can confirm that pending link by authenticating this provider. if (empty($credential['verified_at'])) { - if ($sessionUser === null || $sessionUser[$idField] != $userId) { - throw new \Objectiveweb\Auth\AuthException( - 'OAuth credential requires verified account linking', 403 - ); - } - $this->auth->mark_credential_verified($userId, $provider, $uid); + throw new \Objectiveweb\Auth\AuthException( + 'OAuth credential requires explicit account linking', 403 + ); } - $user = $this->auth->get($userId); $this->auth->update_credential($userId, $provider, $uid, $profile); - } elseif ($sessionUser !== null) { - // Explicitly signed-in users can link a new provider after OAuth - // authentication proves control of the provider-specific identity. - $user = $this->auth->get($sessionUser[$idField]); - $this->auth->update_credential($user[$idField], $provider, $uid, $profile); - $this->auth->mark_credential_verified($user[$idField], $provider, $uid); } else { - // A matching email does not grant ownership of any other account. - // The provider identity becomes a new, independently verified - // credential. Email credentials require their own proof token. + if ($sessionUser !== null) { + throw new UserException('Use explicit OAuth account linking', 409); + } + // Unassociated provider identity creates a separate account. + // Email matching must never link it to another local user. $data = [ 'uid' => $uid, 'provider' => $provider, 'profile' => $profile, - 'name' => $resourceOwner->getName(), + 'name' => is_callable([$resourceOwner, 'getName']) + ? $resourceOwner->getName() : null, 'image' => is_callable([$resourceOwner, 'getAvatar']) ? $resourceOwner->getAvatar() - : $resourceOwner->getPictureUrl(), + : (is_callable([$resourceOwner, 'getPictureUrl']) + ? $resourceOwner->getPictureUrl() : null), ]; $user = $this->auth->register($data); $this->auth->mark_credential_verified($user[$idField], $provider, $uid); From 66f5d7750fd3db9cf1977a0d0675861af5c040b3 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 12:52:18 -0300 Subject: [PATCH 156/217] test(auth): cover explicit OAuth link flow, CSRF, login isolation and hijack prevention --- test/OAuthControllerTest.php | 224 +++++++++++++++++++++++++++++++++-- 1 file changed, 211 insertions(+), 13 deletions(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index c77af3a..b58947b 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -153,14 +153,25 @@ public function testAuthenticatedAccountCanConfirmItsOwnPendingOAuthCredential() { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); $owner = $auth->register('owner@example.com', 'secret'); - $auth->create_credential($owner['id'], 'google', 'owner-subject'); + $auth->create_credential($owner['id'], 'test', 'owner-subject'); $auth->login('owner@example.com', 'secret'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('owner-subject'); - $controller = new OAuthController($auth); - $logged = $this->invokeOAuthLogin($controller, 'google', $this->resourceOwner('owner-subject')); + $this->withLinkHeaders($auth); + $controller->postLink('test'); + $this->assertSame('link', $_SESSION['oauth2flows']['test']['intent']); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); - $this->assertSame($owner['id'], $logged['id']); - $this->assertNotEmpty($auth->get_credential('google', 'owner-subject')['verified_at']); + $this->assertSame($owner['id'], $auth->user()['id']); + $this->assertNotEmpty($auth->get_credential('test', 'owner-subject')['verified_at']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); } public function testSignedInUserCannotSwitchToDifferentVerifiedOAuthAccount(): void @@ -295,17 +306,197 @@ public function testLoggedInUserCanLinkOAuthCredential(): void $user = $auth->register('local@example.com', 'secret'); $auth->login('local@example.com', 'secret'); - $controller = new OAuthController($auth, []); - $logged = $this->invokeOAuthLogin( - $controller, - 'google', - $this->resourceOwner('google-linked-id') - ); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('new-linked-id'); + $this->withLinkHeaders($auth); + $controller->postLink('test'); + $this->assertSame('link', $_SESSION['oauth2flows']['test']['intent']); + $this->assertSame((string) $user['id'], $_SESSION['oauth2flows']['test']['principal']); + $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); + + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); - $credential = $auth->get_credential('google', 'google-linked-id'); + $credential = $auth->get_credential('test', 'new-linked-id'); $this->assertNotFalse($credential); $this->assertSame($user['id'], $credential['user_id']); - $this->assertSame($user['id'], $logged['id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertSame($user['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + $this->assertNotEmpty(OAuthTestProvider::$lastTokenOptions['code_verifier']); + } + + public function testOrdinaryOAuthLoginCannotLinkNewCredentialToLoggedInUser(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $owner = $auth->register('local@example.com', 'secret'); + $auth->login('local@example.com', 'secret'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('unrelated-subject'); + + $controller->get('test', ['link' => '1']); + $this->assertSame('login', $_SESSION['oauth2flows']['test']['intent']); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state', 'intent' => 'link']); + $this->fail('OAuth login silently linked a credential'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(409, $e->getCode()); + } + + $this->assertFalse($auth->get_credential('test', 'unrelated-subject')); + $this->assertSame($owner['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testOrdinaryOAuthLoginCannotConfirmPendingCredential(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $owner = $auth->register('local@example.com', 'secret'); + $auth->create_credential($owner['id'], 'test', 'pending-subject'); + $auth->login('local@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('pending-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $controller->get('test', []); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Unverified OAuth credential was confirmed without explicit linking'); + } catch (AuthException $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertEmpty($auth->get_credential('test', 'pending-subject')['verified_at']); + } + + public function testLinkRequiresAuthenticationAndCsrfProtectedPost(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + try { + $controller->postLink('test'); + $this->fail('Anonymous account was allowed to link a credential'); + } catch (AuthException $e) { + $this->assertSame(401, $e->getCode()); + } + + $auth->register('owner@example.com', 'secret'); + $auth->login('owner@example.com', 'secret'); + try { + $controller->postLink('test'); + $this->fail('CSRF-free account linking was accepted'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(403, $e->getCode()); + } + + $_SERVER['HTTP_X_CSRF_TOKEN'] = $auth->management_csrf_token(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + try { + $controller->postLink('test'); + $this->fail('GET was allowed to initiate linking'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(405, $e->getCode()); + } + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + try { + $controller->postLink('test'); + $this->fail('Cross-origin form POST was accepted'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(415, $e->getCode()); + } + + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows'] ?? []); + } + + public function testLinkCannotTakeOverProviderCredentialOfAnotherUser(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $victim = $auth->register('victim@example.com', 'secret'); + $auth->create_credential($victim['id'], 'test', 'victim-subject'); + $auth->mark_credential_verified($victim['id'], 'test', 'victim-subject'); + $attacker = $auth->register('attacker@example.com', 'secret'); + $auth->login('attacker@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('victim-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $this->withLinkHeaders($auth); + $controller->postLink('test'); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Linked credential was stolen from another user'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(409, $e->getCode()); + } + $this->assertSame($victim['id'], $auth->get_credential('test', 'victim-subject')['user_id']); + $this->assertSame($attacker['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + } + + public function testExplicitLinkRejectsChangedSessionAndCannotBeReplayed(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $auth->register('first@example.com', 'secret'); + $auth->register('second@example.com', 'secret'); + $auth->login('first@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('provider-subject'); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ]); + + $this->withLinkHeaders($auth); + $controller->postLink('test'); + $auth->logout(); + $auth->login('second@example.com', 'secret'); + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Switched user completed someone else\'s OAuth linking flow'); + } catch (\Exception $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'provider-subject')); + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); + + try { + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->fail('Linking callback replayed'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } } public function testOAuthLoginRejectsSuspendedUser(): void @@ -347,6 +538,13 @@ public function testOAuthLoginRegeneratesSessionBeforeStoringPrincipal(): void $this->assertSame($user['id'], $auth->user()['id']); } + private function withLinkHeaders(BasicAuth $auth): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json; charset=utf-8'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $auth->management_csrf_token(); + } + private function invokeOAuthLogin( OAuthController $controller, string $provider, From 5196e3c95b688dcde1906638120a6a1670473e70 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 12:53:07 -0300 Subject: [PATCH 157/217] feat(auth): return OAuth linking authorization URL from JSON POST --- src/Auth/Controller/OAuthController.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index f87a316..98a113f 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -42,7 +42,7 @@ function get($id, $query) } if (empty($query['code'])) { - $this->startAuthorization($id, $provider, 'login'); + $this->redirect($this->startAuthorization($id, $provider, 'login')); return; } @@ -92,14 +92,17 @@ function get($id, $query) * The OAuth callback itself remains GET, as required by OAuth providers. */ #[Middleware(RequireRole::class, Auth::AUTHENTICATED)] - function postLink($id, array $form = []): void + function postLink($id, array $form = []): array { if (!$this->auth->revalidate()) { throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); } $this->assertLinkRequest(); $provider = $this->buildProvider($id); - $this->startAuthorization($id, $provider, 'link'); + // Return the URL for the application's browser to navigate to. + // A JSON POST cannot reliably initiate cross-origin navigation by + // sending a 302 to fetch/XMLHttpRequest. + return ['authorization_url' => $this->startAuthorization($id, $provider, 'link')]; } private function assertLinkRequest(): void @@ -132,7 +135,7 @@ private function buildProvider($id): object return new $classname($config); } - private function startAuthorization($id, object $provider, string $intent): void + private function startAuthorization($id, object $provider, string $intent): string { $verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); @@ -161,7 +164,7 @@ private function startAuthorization($id, object $provider, string $intent): void 'principal' => $principal, 'intent' => $intent, ]; - $this->redirect($authUrl); + return $authUrl; } protected function resolveRedirectUri(): string From a86c1cb57763703e988192e312c08d5109ad47d1 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 12:53:28 -0300 Subject: [PATCH 158/217] test(auth): assert JSON-based OAuth link initiation contract --- test/OAuthControllerTest.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index b58947b..d6facf7 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -165,7 +165,8 @@ public function testAuthenticatedAccountCanConfirmItsOwnPendingOAuthCredential() OAuthTestProvider::$resourceOwner = $this->resourceOwner('owner-subject'); $this->withLinkHeaders($auth); - $controller->postLink('test'); + $linkResponse = $controller->postLink('test'); + $this->assertSame('https://oauth.example/authorize', $linkResponse['authorization_url']); $this->assertSame('link', $_SESSION['oauth2flows']['test']['intent']); $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); @@ -315,10 +316,10 @@ public function testLoggedInUserCanLinkOAuthCredential(): void ]); OAuthTestProvider::$resourceOwner = $this->resourceOwner('new-linked-id'); $this->withLinkHeaders($auth); - $controller->postLink('test'); + $linkResponse = $controller->postLink('test'); $this->assertSame('link', $_SESSION['oauth2flows']['test']['intent']); $this->assertSame((string) $user['id'], $_SESSION['oauth2flows']['test']['principal']); - $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); + $this->assertSame('https://oauth.example/authorize', $linkResponse['authorization_url']); $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); From c23ef783e1aa26b7c2a0403f78926de63fa5f0c2 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 12:54:41 -0300 Subject: [PATCH 159/217] docs(auth): document explicit CSRF-protected OAuth account linking flow --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1621928..b958693 100644 --- a/README.md +++ b/README.md @@ -467,7 +467,32 @@ If `redirectUri` is omitted, the controller derives it from the current request OAuth authorization includes a fresh **PKCE S256** challenge and stores a per-provider authorization flow in the PHP session (`oauth2flows`). The callback requires the matching provider's state, the original initiating session principal, and a state created within the past 10 minutes. The state is consumed before exchanging the code, and the matching PKCE verifier is supplied to the token endpoint. Providers must support PKCE S256. OAuth accounts are linked by the provider's stable subject identifier only; matching email addresses never establishes or links identities. -A pending OAuth credential can only be confirmed by the already authenticated owner completing the matching provider login; anonymous sign-in through an unverified provider association is rejected. +### Explicit OAuth account linking + +**OAuth login and account linking are separate operations.** A normal `OAuthController::get($provider, $query)` authorization/callback can authenticate an already verified external identity or register a **new, separate account** for an anonymous user. It never attaches a new provider credential to an existing authenticated account, even when email addresses match. A signed-in user attempting to sign in through an unassociated provider receives HTTP 409 and must explicitly link the account first. Existing unverified OAuth identities cannot sign in anonymously or be silently confirmed. + +To link a provider, the application must expose `OAuthController::postLink($provider)` on an authenticated **POST** route (for example, `POST /auth/oauth/link/google` using the application's router). The request requires `Content-Type: application/json` and a valid session-bound `X-CSRF-Token` from `Auth::management_csrf_token()` (also available in the logged-in `AuthController::index()` response as `_csrf`). Do not allow GET links or unprotected forms to initiate linking. + +The action returns JSON containing an `authorization_url`. **The frontend must navigate to that URL** rather than relying on an AJAX redirect: + +```js +const response = await fetch('/auth/oauth/link/google', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: '{}', +}); +if (!response.ok) throw new Error('Could not start account linking'); +const { authorization_url } = await response.json(); +window.location.assign(authorization_url); +``` + +The provider's usual OAuth callback still uses `get()`. The session flow records an explicit `link` intent, the initiating user ID, provider, PKCE verifier, expiring one-time state and login session. Only a valid callback for the same active user may create/verify that user's provider credential. Identities already associated with a different user are rejected with HTTP 409; unverified mappings owned by the current user require this explicit flow to be confirmed. State is consumed even when linking fails. + +Applications should make the action visible only after the user chooses **Link account**, and may require additional recent-authentication or step-up checks as local policy dictates. All existing authorization, CSRF, and provider-account security checks continue to apply. ## User-management API From 99ca1c472a4aa33a904aacaefec540ba42a7362a Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:14:48 -0300 Subject: [PATCH 160/217] feat(auth): require explicit account choice after first-time OAuth authentication --- src/Auth/Controller/OAuthController.php | 198 +++++++++++++++++++++--- 1 file changed, 180 insertions(+), 18 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 98a113f..ec5ba17 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -19,12 +19,20 @@ class OAuthController extends AuthController private $providers; private const FLOW_KEY = 'oauth2flows'; + private const PENDING_KEY = 'oauth2pending'; + private const PENDING_TTL = 600; - function __construct(\Objectiveweb\Auth $auth, array $providers = []) - { + private string $pendingAccountRedirect; + + function __construct( + \Objectiveweb\Auth $auth, + array $providers = [], + string $pendingAccountRedirect = '/?oauth=choose-account' + ) { parent::__construct($auth); $this->providers = $providers; - + // Never allow an OAuth callback to redirect to an external origin. + $this->pendingAccountRedirect = $this->normalizeLogoutRedirect($pendingAccountRedirect); } /** @@ -42,6 +50,8 @@ function get($id, $query) } if (empty($query['code'])) { + // Beginning a new login discards any unfinished account choice. + unset($_SESSION[self::PENDING_KEY]); $this->redirect($this->startAuthorization($id, $provider, 'login')); return; } @@ -82,7 +92,11 @@ function get($id, $query) 'code_verifier' => $flow['code_verifier'], ]); $resourceOwner = $provider->getResourceOwner($token); - $this->login($id, $resourceOwner, $flow['intent']); + $result = $this->login($id, $resourceOwner, $flow['intent']); + if (($result['status'] ?? null) === 'pending') { + $this->redirect($this->pendingAccountRedirect); + return; + } $this->redirect('/'); } @@ -97,7 +111,7 @@ function postLink($id, array $form = []): array if (!$this->auth->revalidate()) { throw new \Objectiveweb\Auth\AuthException('Authentication required to link account', 401); } - $this->assertLinkRequest(); + $this->assertJsonCsrfPost(); $provider = $this->buildProvider($id); // Return the URL for the application's browser to navigate to. // A JSON POST cannot reliably initiate cross-origin navigation by @@ -105,7 +119,149 @@ function postLink($id, array $form = []): array return ['authorization_url' => $this->startAuthorization($id, $provider, 'link')]; } - private function assertLinkRequest(): void + /** + * The application renders its account-choice page using this safe DTO. + * Nothing here constitutes a logged-in account or a verified email. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function getPendingAccount(): array + { + $pending = $this->requirePendingAccount(); + return [ + 'status' => 'account_choice_required', + 'provider' => $pending['provider'], + 'name' => $pending['name'], + 'email_hint' => $pending['email_hint'], + 'expires_at' => $pending['created_at'] + self::PENDING_TTL, + 'choices' => ['create', 'existing'], + '_csrf' => $this->auth->management_csrf_token(), + ]; + } + + /** + * Explicit user choice: create an OAuth-only account. + * This is NEVER called automatically by the OAuth callback. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postCreateAccount(array $form = []): array + { + $this->assertJsonCsrfPost(); + $pending = $this->requirePendingAccount(); + $this->assertCanRegister(); + + foreach (array_keys($form) as $field) { + if ($field !== 'name') { + throw new UserException("Invalid account creation field `$field`", 400); + } + } + if (isset($form['name']) && (!is_string($form['name']) || trim($form['name']) === '')) { + throw new UserException('Invalid account name', 400); + } + + if ($this->auth->get_credential($pending['provider'], $pending['uid'])) { + unset($_SESSION[self::PENDING_KEY]); + throw new UserException('OAuth identity is already associated with an account', 409); + } + + $data = [ + 'uid' => $pending['uid'], + 'provider' => $pending['provider'], + 'profile' => $pending['profile'], + 'name' => isset($form['name']) ? trim($form['name']) : $pending['name'], + 'image' => $pending['image'], + ]; + + // Single-use, including in the event of a database failure. + unset($_SESSION[self::PENDING_KEY]); + $user = $this->auth->register($data); + $userId = $user[$this->auth->params['id']]; + $this->auth->mark_credential_verified($userId, $pending['provider'], $pending['uid']); + $this->auth->audit('oauth.account_created', $userId, ['provider' => $pending['provider']]); + return ['status' => 'created', 'user' => $this->auth->establish_session($this->auth->get($userId))]; + } + + /** + * Explicit user choice: prove ownership of an existing account using + * its email/password and attach the independently proven OAuth subject. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postExistingAccount(array $form): array + { + $this->assertJsonCsrfPost(); + $pending = $this->requirePendingAccount(); + + foreach (array_keys($form) as $field) { + if (!in_array($field, ['uid', 'password'], true)) { + throw new UserException("Invalid existing-account field `$field`", 400); + } + } + $uid = $form['uid'] ?? null; + $password = $form['password'] ?? null; + if (!is_string($uid) || trim($uid) === '' + || !is_string($password) || $password === '') { + throw new UserException('Email and password are required', 400); + } + + $existing = $this->auth->get_credential($pending['provider'], $pending['uid']); + if ($existing) { + unset($_SESSION[self::PENDING_KEY]); + throw new UserException('OAuth identity is already associated with an account', 409); + } + + // Invalid passwords leave the pending identity available for retry + // during its limited lifetime. Successful login regenerates the + // session ID; the pending OAuth identity stays in the PHP session. + $user = $this->auth->login(trim($uid), $password); + $userId = $user[$this->auth->params['id']]; + + // Do not allow a competing request to attach this OAuth identity to + // a different account. Backend uniqueness constraints also apply. + $existing = $this->auth->get_credential($pending['provider'], $pending['uid']); + unset($_SESSION[self::PENDING_KEY]); + if ($existing) { + throw new UserException('OAuth identity is already associated with an account', 409); + } + $this->auth->update_credential( + $userId, $pending['provider'], $pending['uid'], $pending['profile'] + ); + $this->auth->mark_credential_verified($userId, $pending['provider'], $pending['uid']); + $this->auth->audit('credential.linked', $userId, ['provider' => $pending['provider']]); + return ['status' => 'linked', 'user' => $this->auth->user()]; + } + + /** + * Explicitly cancel a pending first-time OAuth sign-in. + */ + #[Middleware(RequireRole::class, Auth::ALL)] + function postCancelPendingAccount(): array + { + $this->assertJsonCsrfPost(); + $this->requirePendingAccount(); + unset($_SESSION[self::PENDING_KEY]); + return ['status' => 'cancelled']; + } + + private function requirePendingAccount(): array + { + if ($this->auth->check()) { + throw new UserException('Finish or cancel the OAuth account choice before signing in', 409); + } + $pending = $_SESSION[self::PENDING_KEY] ?? null; + if (!is_array($pending) + || !isset($pending['provider'], $pending['uid'], $pending['created_at']) + || !is_string($pending['provider']) || $pending['provider'] === '' + || !is_string($pending['uid']) || $pending['uid'] === '' + || !is_int($pending['created_at']) + || $pending['created_at'] > time() + || time() - $pending['created_at'] > self::PENDING_TTL + || !array_key_exists($pending['provider'], $this->providers)) { + unset($_SESSION[self::PENDING_KEY]); + throw new UserException('No valid pending OAuth account choice', 410); + } + return $pending; + } + + private function assertJsonCsrfPost(): void { if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') { throw new UserException('POST is required', 405); @@ -248,21 +404,27 @@ private function login($provider, $resourceOwner, string $intent = 'login') if ($sessionUser !== null) { throw new UserException('Use explicit OAuth account linking', 409); } - // Unassociated provider identity creates a separate account. - // Email matching must never link it to another local user. - $data = [ - 'uid' => $uid, + // Provider authentication proves an external identity, but the + // user must choose whether to create or link a local account. + // The OAuth callback must not create any account by itself. + $name = is_callable([$resourceOwner, 'getName']) + ? $resourceOwner->getName() : null; + $image = is_callable([$resourceOwner, 'getAvatar']) + ? $resourceOwner->getAvatar() + : (is_callable([$resourceOwner, 'getPictureUrl']) + ? $resourceOwner->getPictureUrl() : null); + $email = is_callable([$resourceOwner, 'getEmail']) + ? $resourceOwner->getEmail() : null; + $_SESSION[self::PENDING_KEY] = [ 'provider' => $provider, + 'uid' => $uid, 'profile' => $profile, - 'name' => is_callable([$resourceOwner, 'getName']) - ? $resourceOwner->getName() : null, - 'image' => is_callable([$resourceOwner, 'getAvatar']) - ? $resourceOwner->getAvatar() - : (is_callable([$resourceOwner, 'getPictureUrl']) - ? $resourceOwner->getPictureUrl() : null), + 'name' => is_string($name) ? $name : null, + 'image' => is_string($image) ? $image : null, + 'email_hint' => is_string($email) ? $email : null, + 'created_at' => time(), ]; - $user = $this->auth->register($data); - $this->auth->mark_credential_verified($user[$idField], $provider, $uid); + return ['status' => 'pending']; } return $this->auth->establish_session($user); From b2bee71c4b9bda3abc42d340b6094777bd99e8da Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:15:21 -0300 Subject: [PATCH 161/217] refactor(auth): share registration authorization with OAuth account choice --- src/Auth/Controller/AuthController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index b1b7f24..132655e 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -278,7 +278,7 @@ private function assertPasswordChangeCsrf(): void } } - private function assertCanRegister(): void + protected function assertCanRegister(): void { $required = $this->auth->params['register_scope']; $required = is_array($required) ? $required : [$required]; From 98a6b727ee8fde62797eea13d78c0841b0a78d7b Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:16:08 -0300 Subject: [PATCH 162/217] test(auth): verify OAuth account choice prevents duplicates and supports existing-account linking --- test/OAuthControllerTest.php | 105 ++++++++++++++++++++++++++++------- 1 file changed, 84 insertions(+), 21 deletions(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index d6facf7..6144801 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -102,41 +102,93 @@ public function testAuthorizationDerivesRedirectUriWhenNotConfigured(): void ); } - public function testOAuthCallbackDoesNotLinkAccountByEmail(): void + public function testFirstTimeOAuthOffersAccountChoiceWithoutCreatingUser(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); $existing = $auth->register('linked@example.com', 'secret', ['provider' => 'email']); - OAuthTestProvider::$resourceOwner = $this->resourceOwner( 'provider-user-id', 'linked@example.com' ); - $controller = new OAuthTestController($auth, [ - 'test' => [ - 'clientId' => 'client-id', - 'clientSecret' => 'client-secret', - 'redirectUri' => 'https://app.example/oauth/test', - ], - ]); + $controller = $this->testController($auth); $controller->get('test', []); $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows']); - $this->assertSame( - $_SESSION['oauth2flows']['test']['code_verifier'] ?? null, - null - ); - $this->assertNotEmpty(OAuthTestProvider::$lastTokenOptions['code_verifier']); - $this->assertSame('/', $controller->redirectedTo); - $this->assertTrue($auth->sessionRegenerated); - $this->assertNotSame($existing['id'], $auth->user()['id']); + $this->assertSame('/?oauth=choose-account', $controller->redirectedTo); + $this->assertFalse($auth->check()); + $this->assertFalse($auth->get_credential('test', 'provider-user-id')); $this->assertSame($existing['id'], $auth->get_credential('email', 'linked@example.com')['user_id']); - $credential = $auth->get_credential('test', 'provider-user-id'); - $this->assertSame($auth->user()['id'], $credential['user_id']); - $this->assertNotEmpty($credential['verified_at']); - $this->assertFalse($auth->get_credential('email', 'provider-user-id')); + $choice = $controller->getPendingAccount(); + $this->assertSame('account_choice_required', $choice['status']); + $this->assertSame(['create', 'existing'], $choice['choices']); + $this->assertSame('test', $choice['provider']); + $this->assertSame('linked@example.com', $choice['email_hint']); + $this->assertNotEmpty($choice['_csrf']); + $this->assertArrayNotHasKey('uid', $choice); + $this->assertArrayNotHasKey('profile', $choice); } + public function testFirstTimeOAuthCanExplicitlyCreateNewAccount(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $local = $auth->register('matching@example.com', 'old-password'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner( + 'new-oauth-subject', 'matching@example.com' + ); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $this->withLinkHeaders($auth); + $result = $controller->postCreateAccount(['name' => 'New OAuth Account']); + $this->assertSame('created', $result['status']); + $created = $result['user']; + $this->assertNotSame($local['id'], $created['id']); + $this->assertSame('New OAuth Account', $created['name']); + $this->assertSame($created['id'], $auth->get_credential('test', 'new-oauth-subject')['user_id']); + $this->assertNotEmpty($auth->get_credential('test', 'new-oauth-subject')['verified_at']); + $this->assertSame($local['id'], $auth->get_credential('local', 'matching@example.com')['user_id']); + $this->assertFalse($auth->get_credential('local', 'new-oauth-subject')); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertTrue($auth->check()); + } + + public function testFirstTimeOAuthCanAuthenticateExistingAccountAndLink(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + $local = $auth->register('old@example.com', 'local-secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner( + 'pending-oauth-subject', 'provider-email@example.com' + ); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $this->withLinkHeaders($auth); + try { + $controller->postExistingAccount(['uid' => 'old@example.com', 'password' => 'wrong']); + $this->fail('Incorrect local password was accepted'); + } catch (AuthException $e) { + $this->assertSame(401, $e->getCode()); + } + $this->assertFalse($auth->check()); + $this->assertArrayHasKey('oauth2pending', $_SESSION); + $this->assertFalse($auth->get_credential('test', 'pending-oauth-subject')); + + $result = $controller->postExistingAccount([ + 'uid' => 'old@example.com', + 'password' => 'local-secret', + ]); + $this->assertSame('linked', $result['status']); + $this->assertSame($local['id'], $result['user']['id']); + $this->assertSame($local['id'], $auth->get_credential('test', 'pending-oauth-subject')['user_id']); + $this->assertNotEmpty($auth->get_credential('test', 'pending-oauth-subject')['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertTrue($auth->check()); + } + + public function testUnverifiedOAuthCredentialCannotBeUsedToLogInAnotherAccount(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); @@ -539,6 +591,17 @@ public function testOAuthLoginRegeneratesSessionBeforeStoringPrincipal(): void $this->assertSame($user['id'], $auth->user()['id']); } + private function testController(BasicAuth $auth, string $pendingRedirect = '/?oauth=choose-account'): OAuthTestController + { + return new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => 'https://app.example/oauth/test', + ], + ], $pendingRedirect); + } + private function withLinkHeaders(BasicAuth $auth): void { $_SERVER['REQUEST_METHOD'] = 'POST'; From 22c00fd7a5d36b64408bc73895648496a64b00c5 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:17:29 -0300 Subject: [PATCH 163/217] test(auth): cover pending OAuth expiry, replay, registration policy, CSRF, and ownership conflicts --- test/OAuthControllerTest.php | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index 6144801..88b1fbd 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -189,6 +189,166 @@ public function testFirstTimeOAuthCanAuthenticateExistingAccountAndLink(): void } + public function testPendingAccountCannotBeCreatedWithoutExplicitChoice(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('new-id'); + $controller = $this->testController($auth); + + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->assertFalse($auth->get_credential('test', 'new-id')); + $this->assertFalse($auth->check()); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + try { + $controller->postCreateAccount(); + $this->fail('Creating without CSRF token succeeded'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'new-id')); + + $this->withLinkHeaders($auth); + try { + $controller->postCreateAccount(['provider' => 'local']); + $this->fail('Provider injection during OAuth account creation succeeded'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(400, $e->getCode()); + } + $this->assertArrayHasKey('oauth2pending', $_SESSION); + } + + public function testPendingAccountCreationHonorsRegistrationPolicy(): void + { + $auth = new BasicAuth([], [ + 'token' => 'token', + 'register_scope' => \Objectiveweb\Auth::AUTHENTICATED, + ]); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('restricted-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + + try { + $controller->postCreateAccount(); + $this->fail('Restricted account creation succeeded'); + } catch (\Objectiveweb\Auth\AuthException $e) { + $this->assertSame(403, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'restricted-id')); + } + + public function testPendingAccountExpiresAndIsRemoved(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('expiring-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $_SESSION['oauth2pending']['created_at'] = time() - 601; + + try { + $controller->getPendingAccount(); + $this->fail('Expired pending account still available'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(410, $e->getCode()); + } + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertFalse($auth->get_credential('test', 'expiring-id')); + } + + public function testAccountChoiceCannotBeReplayedOrUsedAfterCancellation(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('choice-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + $this->assertSame('cancelled', $controller->postCancelPendingAccount()['status']); + try { + $controller->postCreateAccount(); + $this->fail('Cancelled account choice still usable'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(410, $e->getCode()); + } + + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $result = $controller->postCreateAccount(); + $this->assertSame('created', $result['status']); + $auth->logout(); + try { + $controller->postExistingAccount(['uid' => 'other@example.com', 'password' => 'secret']); + $this->fail('A consumed OAuth proof was reused'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(410, $e->getCode()); + } + } + + public function testAccountChoiceRejectsPreviouslyLinkedProviderIdentity(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $owner = $auth->register('existing@example.com', 'password'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('race-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + // A different request associates the identity while the choice is pending. + $auth->create_credential($owner['id'], 'test', 'race-id'); + $auth->mark_credential_verified($owner['id'], 'test', 'race-id'); + $this->withLinkHeaders($auth); + try { + $controller->postCreateAccount(); + $this->fail('Duplicate OAuth identity was accepted'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(409, $e->getCode()); + } + $this->assertSame($owner['id'], $auth->get_credential('test', 'race-id')['user_id']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + public function testExistingAccountChoiceRejectsInvalidPayloadAndUntrustedMethods(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $auth->register('existing@example.com', 'secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('pending-id'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + $_SERVER['REQUEST_METHOD'] = 'GET'; + try { + $controller->postExistingAccount(['uid' => 'existing@example.com', 'password' => 'secret']); + $this->fail('GET linked a provider credential'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(405, $e->getCode()); + } + $_SERVER['REQUEST_METHOD'] = 'POST'; + try { + $controller->postExistingAccount([ + 'uid' => 'existing@example.com', + 'password' => 'secret', + 'provider' => 'test', + ]); + $this->fail('Provider injection accepted in existing-account choice'); + } catch (\Objectiveweb\Auth\UserException $e) { + $this->assertSame(400, $e->getCode()); + } + $this->assertFalse($auth->get_credential('test', 'pending-id')); + $this->assertArrayHasKey('oauth2pending', $_SESSION); + } + + public function testPendingChoiceRedirectRejectsExternalUrl(): void + { + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(400); + $this->testController(new BasicAuth(), '//attacker.example/receive'); + } + public function testUnverifiedOAuthCredentialCannotBeUsedToLogInAnotherAccount(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); From 1335fe8f03c06f0260b986b9366c48490f4c68f8 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:18:41 -0300 Subject: [PATCH 164/217] feat(auth): add reusable account-choice page for first-time OAuth login --- examples/oauth-account-choice.html | 128 +++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 examples/oauth-account-choice.html diff --git a/examples/oauth-account-choice.html b/examples/oauth-account-choice.html new file mode 100644 index 0000000..a0c7a2e --- /dev/null +++ b/examples/oauth-account-choice.html @@ -0,0 +1,128 @@ + + + + + + Choose how to continue + + + +
+

Continue with your account

+

Checking the authentication result…

+

+ +
+ + + From ebf12e96c572f3ad029c4d5fbeaefab5a9544745 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:20:16 -0300 Subject: [PATCH 165/217] docs(auth): document first-time OAuth account choice and account-linking routes --- README.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b958693..b3470be 100644 --- a/README.md +++ b/README.md @@ -467,9 +467,46 @@ If `redirectUri` is omitted, the controller derives it from the current request OAuth authorization includes a fresh **PKCE S256** challenge and stores a per-provider authorization flow in the PHP session (`oauth2flows`). The callback requires the matching provider's state, the original initiating session principal, and a state created within the past 10 minutes. The state is consumed before exchanging the code, and the matching PKCE verifier is supplied to the token endpoint. Providers must support PKCE S256. OAuth accounts are linked by the provider's stable subject identifier only; matching email addresses never establishes or links identities. +### First-time OAuth account choice (no automatic duplicate accounts) + +After the OAuth authorization-code/PKCE exchange, if no `(provider, uid)` credential exists, the user is **not logged in** and no local user is created. A short-lived `oauth2pending` entry remains in their PHP session; it contains the provider's verified subject, profile, safe display hints, and creation timestamp, and expires after 10 minutes. The OAuth state is already consumed. + +The callback redirects to **`/?oauth=choose-account`** by default. The application must render an account-choice page at that URL (or configure a different same-origin path in the controller constructor): + +```php +$oauth = new OAuthController($auth, $providers, '/account/choose'); +``` + +A ready-to-adapt frontend is included as [`examples/oauth-account-choice.html`](examples/oauth-account-choice.html). The library does not manage application UI routes, so mount the page at the configured redirect path and map the following methods to your application router: + +| Example route | OAuthController method | Function | +| --- | --- | --- | +| `GET /auth/oauth/pending` | `getPendingAccount()` | Safe choice metadata, email/name hints and session `_csrf` | +| `POST /auth/oauth/pending/create` | `postCreateAccount(array $form)` | Explicitly create a new **OAuth-only** local account | +| `POST /auth/oauth/pending/existing` | `postExistingAccount(array $form)` | Authenticate existing `uid` and `password`, then link the provider identity | +| `POST /auth/oauth/pending/cancel` | `postCancelPendingAccount()` | Discard the pending identity | + +All three POST actions require JSON, the `X-CSRF-Token` from `getPendingAccount()`, and the same browser session. Creation permits only optional `name` and respects `register_scope`; arbitrary provider, user ID, role and profile fields are rejected. Existing-account linking requires the actual password for that existing account. Wrong passwords do not consume the pending proof, so users can retry before expiry. Neither option treats the provider email as an automatically verified local email credential. + +On successful creation, the library creates an external-provider credential and establishes a new local session. On successful existing-account login, it attaches **that same provider identity to the password-authenticated local user**, marks the provider credential verified, and keeps that existing account. Both actions consume the pending proof; it cannot be reused. Concurrent ownership conflicts return HTTP 409; expired/cancelled/consumed choices return HTTP 410. For security, do not store the pending proof in the browser or return OAuth tokens/provider subject IDs as client data. The application must maintain secure, HttpOnly, SameSite session cookies; enforce external rate limits (see issue #2); and avoid caching the pending-account API response. + +```js +const pending = await fetch('/auth/oauth/pending', { credentials: 'same-origin' }).then(r => r.json()); +// Render two user-selected actions: "Create new account" and "Use existing account". +const response = await fetch('/auth/oauth/pending/existing', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': pending._csrf }, + body: JSON.stringify({ uid: email, password }), +}); +// Do not navigate to the signed-in area unless response.ok. +``` + +If the OAuth credential already belongs to a verified local user, normal OAuth login still signs that user in immediately. If the local user is already signed in and wants another OAuth credential, use the explicit account-linking flow below instead. + ### Explicit OAuth account linking -**OAuth login and account linking are separate operations.** A normal `OAuthController::get($provider, $query)` authorization/callback can authenticate an already verified external identity or register a **new, separate account** for an anonymous user. It never attaches a new provider credential to an existing authenticated account, even when email addresses match. A signed-in user attempting to sign in through an unassociated provider receives HTTP 409 and must explicitly link the account first. Existing unverified OAuth identities cannot sign in anonymously or be silently confirmed. +**OAuth login and account linking are separate operations.** A normal `OAuthController::get($provider, $query)` authorization/callback authenticates an already verified external identity. If an anonymous user's provider identity has no local credential yet, the callback **does not create an account**: it stores a ten-minute, session-bound pending identity and redirects to a configurable account-choice page. It never attaches a new provider credential to an existing authenticated account, even when email addresses match. A signed-in user attempting to sign in through an unassociated provider receives HTTP 409 and must explicitly link the account first. Existing unverified OAuth identities cannot sign in anonymously or be silently confirmed. To link a provider, the application must expose `OAuthController::postLink($provider)` on an authenticated **POST** route (for example, `POST /auth/oauth/link/google` using the application's router). The request requires `Content-Type: application/json` and a valid session-bound `X-CSRF-Token` from `Auth::management_csrf_token()` (also available in the logged-in `AuthController::index()` response as `_csrf`). Do not allow GET links or unprotected forms to initiate linking. @@ -494,6 +531,8 @@ The provider's usual OAuth callback still uses `get()`. The session flow records Applications should make the action visible only after the user chooses **Link account**, and may require additional recent-authentication or step-up checks as local policy dictates. All existing authorization, CSRF, and provider-account security checks continue to apply. +The explicit authenticated `postLink()` flow remains distinct from the *anonymous first-time* account choice above. Never interpret an OAuth callback, query-string `intent`, or matching email as permission to link accounts. + ## User-management API Register `Objectiveweb\Auth\Controller\UserController` at an application-owned From d64086ab232f3950468cce7c1a5fa5a0f0ae98f8 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:21:15 -0300 Subject: [PATCH 166/217] test(auth): match anonymous registration scope denial status --- test/OAuthControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index 88b1fbd..d691c11 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -236,7 +236,7 @@ public function testPendingAccountCreationHonorsRegistrationPolicy(): void $controller->postCreateAccount(); $this->fail('Restricted account creation succeeded'); } catch (\Objectiveweb\Auth\AuthException $e) { - $this->assertSame(403, $e->getCode()); + $this->assertSame(401, $e->getCode()); } $this->assertFalse($auth->get_credential('test', 'restricted-id')); } From fa32e3d1d3c37374ad835cb013a1a18fd02984d4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:21:52 -0300 Subject: [PATCH 167/217] test(auth): exercise OAuth existing-account choice against SQLite DBAuth --- test/DBAuthSqliteTest.php | 57 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index fdffb7f..e8ce60d 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -129,6 +129,63 @@ protected function setUp(): void self::$db->query('DELETE FROM auth_user')->exec(); } + public function testOAuthAccountChoiceLinksExistingUserWithSqlite(): void + { + require_once __DIR__ . '/OAuthTestProvider.php'; + $local = self::$auth->register('sqlite-user@example.com', 'secret', [ + 'name' => 'Existing User', + ]); + + \League\OAuth2\Client\Provider\Test::$resourceOwner = new class { + public function getId(): string { return 'sqlite-oauth-subject'; } + public function getName(): string { return 'OAuth Profile'; } + public function getEmail(): string { return 'sqlite-user@example.com'; } + public function toArray(): array { return ['id' => 'sqlite-oauth-subject']; } + }; + \League\OAuth2\Client\Provider\Test::$state = 'test-oauth-state'; + + $controller = new class (self::$auth, [ + 'test' => [ + 'clientId' => 'test-client', + 'clientSecret' => 'test-secret', + 'redirectUri' => 'https://example.test/oauth/test', + ], + ]) extends \Objectiveweb\Auth\Controller\OAuthController { + public ?string $redirectedTo = null; + protected function redirect(string $location): void + { + $this->redirectedTo = $location; + } + }; + + $controller->get('test', []); + $controller->get('test', [ + 'code' => 'oauth-code', 'state' => 'test-oauth-state', + ]); + $this->assertSame('/?oauth=choose-account', $controller->redirectedTo); + $this->assertFalse(self::$auth->check()); + $this->assertFalse(self::$auth->get_credential('test', 'sqlite-oauth-subject')); + + $choice = $controller->getPendingAccount(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $choice['_csrf']; + $result = $controller->postExistingAccount([ + 'uid' => 'sqlite-user@example.com', + 'password' => 'secret', + ]); + $this->assertSame('linked', $result['status']); + $this->assertSame($local['id'], $result['user']['id']); + $credential = self::$auth->get_credential('test', 'sqlite-oauth-subject'); + $this->assertSame($local['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertSame(1, (int) self::$db->select( + 'auth_user', ['id' => $local['id']] + )->count()); + unset($_SERVER['REQUEST_METHOD'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN']); + } + public function testRegistration(): void { $user = self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); From 9ef9d9a629c6bb627b1d1a3e38339006b56db5aa Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:23:16 -0300 Subject: [PATCH 168/217] test(auth): fix SQLite account-choice assertion and preserve test order --- test/DBAuthSqliteTest.php | 111 +++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 56 deletions(-) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index e8ce60d..fbb2e3a 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -129,62 +129,6 @@ protected function setUp(): void self::$db->query('DELETE FROM auth_user')->exec(); } - public function testOAuthAccountChoiceLinksExistingUserWithSqlite(): void - { - require_once __DIR__ . '/OAuthTestProvider.php'; - $local = self::$auth->register('sqlite-user@example.com', 'secret', [ - 'name' => 'Existing User', - ]); - - \League\OAuth2\Client\Provider\Test::$resourceOwner = new class { - public function getId(): string { return 'sqlite-oauth-subject'; } - public function getName(): string { return 'OAuth Profile'; } - public function getEmail(): string { return 'sqlite-user@example.com'; } - public function toArray(): array { return ['id' => 'sqlite-oauth-subject']; } - }; - \League\OAuth2\Client\Provider\Test::$state = 'test-oauth-state'; - - $controller = new class (self::$auth, [ - 'test' => [ - 'clientId' => 'test-client', - 'clientSecret' => 'test-secret', - 'redirectUri' => 'https://example.test/oauth/test', - ], - ]) extends \Objectiveweb\Auth\Controller\OAuthController { - public ?string $redirectedTo = null; - protected function redirect(string $location): void - { - $this->redirectedTo = $location; - } - }; - - $controller->get('test', []); - $controller->get('test', [ - 'code' => 'oauth-code', 'state' => 'test-oauth-state', - ]); - $this->assertSame('/?oauth=choose-account', $controller->redirectedTo); - $this->assertFalse(self::$auth->check()); - $this->assertFalse(self::$auth->get_credential('test', 'sqlite-oauth-subject')); - - $choice = $controller->getPendingAccount(); - $_SERVER['REQUEST_METHOD'] = 'POST'; - $_SERVER['CONTENT_TYPE'] = 'application/json'; - $_SERVER['HTTP_X_CSRF_TOKEN'] = $choice['_csrf']; - $result = $controller->postExistingAccount([ - 'uid' => 'sqlite-user@example.com', - 'password' => 'secret', - ]); - $this->assertSame('linked', $result['status']); - $this->assertSame($local['id'], $result['user']['id']); - $credential = self::$auth->get_credential('test', 'sqlite-oauth-subject'); - $this->assertSame($local['id'], $credential['user_id']); - $this->assertNotEmpty($credential['verified_at']); - $this->assertArrayNotHasKey('oauth2pending', $_SESSION); - $this->assertSame(1, (int) self::$db->select( - 'auth_user', ['id' => $local['id']] - )->count()); - unset($_SERVER['REQUEST_METHOD'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN']); - } public function testRegistration(): void { @@ -1273,4 +1217,59 @@ public function testRoleNamesAndCredentialUidSearchAreNormalized(): void $this->assertSame(1, $result->total()); $this->assertSame('searchable@example.com', $result[0]['credentials'][0]['uid']); } + public function testOAuthAccountChoiceLinksExistingUserWithSqlite(): void + { + require_once __DIR__ . '/OAuthTestProvider.php'; + $local = self::$auth->register('sqlite-user@example.com', 'secret', [ + 'name' => 'Existing User', + ]); + + \League\OAuth2\Client\Provider\Test::$resourceOwner = new class { + public function getId(): string { return 'sqlite-oauth-subject'; } + public function getName(): string { return 'OAuth Profile'; } + public function getEmail(): string { return 'sqlite-user@example.com'; } + public function toArray(): array { return ['id' => 'sqlite-oauth-subject']; } + }; + \League\OAuth2\Client\Provider\Test::$state = 'test-oauth-state'; + + $controller = new class (self::$auth, [ + 'test' => [ + 'clientId' => 'test-client', + 'clientSecret' => 'test-secret', + 'redirectUri' => 'https://example.test/oauth/test', + ], + ]) extends \Objectiveweb\Auth\Controller\OAuthController { + public ?string $redirectedTo = null; + protected function redirect(string $location): void + { + $this->redirectedTo = $location; + } + }; + + $controller->get('test', []); + $controller->get('test', [ + 'code' => 'oauth-code', 'state' => 'test-oauth-state', + ]); + $this->assertSame('/?oauth=choose-account', $controller->redirectedTo); + $this->assertFalse(self::$auth->check()); + $this->assertFalse(self::$auth->get_credential('test', 'sqlite-oauth-subject')); + + $choice = $controller->getPendingAccount(); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $choice['_csrf']; + $result = $controller->postExistingAccount([ + 'uid' => 'sqlite-user@example.com', + 'password' => 'secret', + ]); + $this->assertSame('linked', $result['status']); + $this->assertSame($local['id'], $result['user']['id']); + $credential = self::$auth->get_credential('test', 'sqlite-oauth-subject'); + $this->assertSame($local['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertCount(1, self::$db->select('auth_user')->all()); + unset($_SERVER['REQUEST_METHOD'], $_SERVER['CONTENT_TYPE'], $_SERVER['HTTP_X_CSRF_TOKEN']); + } + } From bb5eaef33aeed6a0c4cf56b3f98f1ff15f34a3e3 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:51:08 -0300 Subject: [PATCH 169/217] fix(auth): normalize optional OAuth resource-owner profile fields safely --- src/Auth/Controller/OAuthController.php | 131 +++++++++++++++++++++--- 1 file changed, 118 insertions(+), 13 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index ec5ba17..36f76e9 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -356,10 +356,14 @@ protected function redirect(string $location): void */ private function login($provider, $resourceOwner, string $intent = 'login') { - $uid = (string) $resourceOwner->getId(); - if ($uid === '') { + $subject = $resourceOwner->getId(); + // League OAuth2 resource owners may return a string or integer ID. + // Do not coerce arrays, objects or null into a credential UID. + if ((!is_string($subject) && !is_int($subject)) + || trim((string) $subject) === '') { throw new \Objectiveweb\Auth\AuthException('Invalid provider identity', 401); } + $uid = (string) $subject; if (!in_array($intent, ['login', 'link'], true)) { throw new UserException('Invalid OAuth intent', 400); } @@ -407,21 +411,16 @@ private function login($provider, $resourceOwner, string $intent = 'login') // Provider authentication proves an external identity, but the // user must choose whether to create or link a local account. // The OAuth callback must not create any account by itself. - $name = is_callable([$resourceOwner, 'getName']) - ? $resourceOwner->getName() : null; - $image = is_callable([$resourceOwner, 'getAvatar']) - ? $resourceOwner->getAvatar() - : (is_callable([$resourceOwner, 'getPictureUrl']) - ? $resourceOwner->getPictureUrl() : null); - $email = is_callable([$resourceOwner, 'getEmail']) - ? $resourceOwner->getEmail() : null; + // ResourceOwnerInterface guarantees only getId() and toArray(). + // Display fields are optional hints, never identity proofs. + $hints = $this->resourceOwnerHints($resourceOwner, $profile); $_SESSION[self::PENDING_KEY] = [ 'provider' => $provider, 'uid' => $uid, 'profile' => $profile, - 'name' => is_string($name) ? $name : null, - 'image' => is_string($image) ? $image : null, - 'email_hint' => is_string($email) ? $email : null, + 'name' => $hints['name'], + 'image' => $hints['image'], + 'email_hint' => $hints['email_hint'], 'created_at' => time(), ]; return ['status' => 'pending']; @@ -429,4 +428,110 @@ private function login($provider, $resourceOwner, string $intent = 'login') return $this->auth->establish_session($user); } + /** + * Optional profile fields vary by provider. League's ResourceOwnerInterface + * guarantees only getId() and toArray(); everything else is a hint. + * The caller has already authenticated the provider-specific subject. + * + * @return array{name: ?string, image: ?string, email_hint: ?string} + */ + private function resourceOwnerHints(object $owner, array $profile): array + { + $text = static fn (string $value): bool => true; + $name = $this->optionalProfileValue( + $owner, $profile, + ['getName', 'getDisplayName'], + ['name', 'display_name', 'displayName', 'full_name', 'fullName'], + $text + ); + + if ($name === null) { + $given = $this->optionalProfileValue( + $owner, $profile, ['getFirstName', 'getGivenName'], + ['given_name', 'first_name', 'firstName'], $text + ); + $family = $this->optionalProfileValue( + $owner, $profile, ['getLastName', 'getFamilyName'], + ['family_name', 'last_name', 'lastName'], $text + ); + $joined = trim(implode(' ', array_filter([$given, $family]))); + $name = $joined !== '' ? $joined : null; + } + + $name ??= $this->optionalProfileValue( + $owner, $profile, ['getNickname'], + ['preferred_username', 'login', 'username', 'nickname'], $text + ); + + // URLs are used for presentation only. Reject schemes such as + // javascript:, data: and file: even if supplied by an OAuth provider. + $image = $this->optionalProfileValue( + $owner, $profile, + ['getAvatar', 'getPictureUrl', 'getAvatarUrl', 'getPicture'], + ['avatar_url', 'avatarUrl', 'picture', 'picture_url', + 'image', 'image_url', 'profile_image_url', 'profile_image_url_https'], + static fn (string $url): bool => + filter_var($url, FILTER_VALIDATE_URL) !== false + && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) + ); + + // Facebook-like providers may nest the picture URL in picture.data. + if ($image === null && is_array($profile['picture'] ?? null)) { + $url = $profile['picture']['data']['url'] ?? null; + if (is_string($url) && filter_var($url, FILTER_VALIDATE_URL) !== false + && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) { + $image = $url; + } + } + + $email = $this->optionalProfileValue( + $owner, $profile, ['getEmail'], + ['email', 'email_address', 'emailAddress', 'mail'], + static fn (string $value): bool => + filter_var($value, FILTER_VALIDATE_EMAIL) !== false + ); + + return ['name' => $name, 'image' => $image, 'email_hint' => $email]; + } + + /** + * Tolerate missing/throwing optional getters and unexpected value types. + * Do not invoke arbitrary profile methods or coerce objects into strings. + */ + private function optionalProfileValue( + object $owner, + array $profile, + array $methods, + array $fields, + callable $accept + ): ?string { + foreach ($methods as $method) { + if (!is_callable([$owner, $method])) { + continue; + } + try { + $value = $owner->{$method}(); + } catch (\Throwable) { + continue; + } + if (is_string($value)) { + $value = trim($value); + if ($value !== '' && $accept($value)) { + return $value; + } + } + } + + foreach ($fields as $field) { + $value = $profile[$field] ?? null; + if (is_string($value)) { + $value = trim($value); + if ($value !== '' && $accept($value)) { + return $value; + } + } + } + return null; + } + } From ed255cb378acd49c1283095f48984b9624342697 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:52:30 -0300 Subject: [PATCH 170/217] test(auth): cover minimal League resource owners and safe OAuth profile fallbacks --- test/OAuthControllerTest.php | 154 +++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index d691c11..123ceaa 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -129,6 +129,160 @@ public function testFirstTimeOAuthOffersAccountChoiceWithoutCreatingUser(): void $this->assertArrayNotHasKey('profile', $choice); } + public function testMinimalLeagueResourceOwnerDoesNotRequireOptionalMethods(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + public function getId(): string { return 'minimal-provider-subject'; } + public function toArray(): array { return ['id' => 'minimal-provider-subject']; } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertSame('account_choice_required', $pending['status']); + $this->assertNull($pending['name']); + $this->assertNull($pending['image'] ?? null); + $this->assertNull($pending['email_hint']); + $this->assertFalse($auth->get_credential('test', 'minimal-provider-subject')); + + $this->withLinkHeaders($auth); + $created = $controller->postCreateAccount(); + $this->assertSame('created', $created['status']); + $this->assertSame( + $created['user']['id'], + $auth->get_credential('test', 'minimal-provider-subject')['user_id'] + ); + $this->assertNotEmpty($auth->get_credential('test', 'minimal-provider-subject')['verified_at']); + } + + public function testLeagueProfileArraySuppliesSafeNameAvatarAndEmailHints(): void + { + $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + public function getId(): int { return 92735; } + public function toArray(): array + { + return [ + 'name' => ' Profile Name ', + 'avatar_url' => 'https://example.com/avatar.png', + 'email' => 'profile@example.com', + ]; + } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $pending = $controller->getPendingAccount(); + $this->assertSame('Profile Name', $pending['name']); + $this->assertSame('profile@example.com', $pending['email_hint']); + $this->assertSame('https://example.com/avatar.png', $_SESSION['oauth2pending']['image']); + $this->assertArrayNotHasKey('uid', $pending); + + $this->withLinkHeaders($auth); + $result = $controller->postCreateAccount(); + $this->assertSame('Profile Name', $result['user']['name']); + $this->assertSame('https://example.com/avatar.png', $result['user']['image']); + $this->assertNotFalse($auth->get_credential('test', '92735')); + } + + public function testNestedPictureAndSplitNameProfileFallbacks(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + public function getId(): string { return 'nested-fields'; } + public function toArray(): array + { + return [ + 'given_name' => ' Alice ', + 'family_name' => 'Smith', + 'picture' => ['data' => ['url' => 'https://cdn.example.com/photo.jpg']], + 'email_address' => 'alice@example.com', + ]; + } + }; + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertSame('Alice Smith', $pending['name']); + $this->assertSame('alice@example.com', $pending['email_hint']); + $this->assertSame('https://cdn.example.com/photo.jpg', $_SESSION['oauth2pending']['image']); + } + + public function testMalformedOrThrowingOptionalGettersSafelyFallBackToProfile(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + public function getId(): string { return 'broken-optional-fields'; } + public function getName(): string { throw new \RuntimeException('Name unavailable'); } + public function getAvatar(): array { return ['unexpected' => 'value']; } + public function getPictureUrl(): string { throw new \RuntimeException('Picture unavailable'); } + public function getEmail(): array { return ['bad' => 'value']; } + public function toArray(): array + { + return [ + 'display_name' => ' Resilient User ', + 'picture_url' => 'javascript:alert(1)', + 'image_url' => 'https://images.example.com/photo.jpg', + 'mail' => ' valid@example.com ', + ]; + } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertSame('Resilient User', $pending['name']); + $this->assertSame('valid@example.com', $pending['email_hint']); + $this->assertSame('https://images.example.com/photo.jpg', $_SESSION['oauth2pending']['image']); + } + + public function testUnsafeAvatarAndInvalidEmailHintsAreIgnored(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + public function getId(): string { return 'unsafe-fields'; } + public function toArray(): array + { + return [ + 'name' => ['not' => 'text'], + 'picture' => 'data:image/svg+xml;base64,PHN2Zz4=', + 'avatar_url' => 'javascript:alert(1)', + 'email' => 'not-an-email', + ]; + } + }; + + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + + $pending = $controller->getPendingAccount(); + $this->assertNull($pending['name']); + $this->assertNull($pending['email_hint']); + $this->assertNull($_SESSION['oauth2pending']['image']); + } + + public function testInvalidProviderSubjectTypeIsRejected(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = $this->testController($auth); + $owner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + public function getId(): array { return ['not' => 'an id']; } + public function toArray(): array { return []; } + }; + $this->expectException(AuthException::class); + $this->expectExceptionCode(401); + $this->invokeOAuthLogin($controller, 'test', $owner); + } + public function testFirstTimeOAuthCanExplicitlyCreateNewAccount(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); From 6bf98dbc46a483c01f7c9b4138d280dd4fba3f82 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:53:15 -0300 Subject: [PATCH 171/217] docs(auth): document League resource-owner compatibility and safe profile hints --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index b3470be..f528d36 100644 --- a/README.md +++ b/README.md @@ -467,6 +467,30 @@ If `redirectUri` is omitted, the controller derives it from the current request OAuth authorization includes a fresh **PKCE S256** challenge and stores a per-provider authorization flow in the PHP session (`oauth2flows`). The callback requires the matching provider's state, the original initiating session principal, and a state created within the past 10 minutes. The state is consumed before exchanging the code, and the matching PKCE verifier is supplied to the token endpoint. Providers must support PKCE S256. OAuth accounts are linked by the provider's stable subject identifier only; matching email addresses never establishes or links identities. +### Resource-owner profile compatibility + +The League OAuth2 `ResourceOwnerInterface` requires only `getId()` and +`toArray()`. `OAuthController` therefore never assumes provider-specific +`getName()`, `getAvatar()`, `getPictureUrl()` or `getEmail()` methods +exist. It first reads usable values from optional getters when available, then +uses well-known keys in `toArray()`: + +- **Name:** `name`, `display_name`/`displayName`, `full_name`, + `given_name` + `family_name`, `first_name` + `last_name`, and + finally username/login-style display hints. +- **Avatar:** `avatar_url`, `picture`, `picture_url`, `image`, + `image_url`, `profile_image_url`, or a nested `picture.data.url`; + only valid HTTP(S) URLs are accepted. +- **Email hint:** `email`, `email_address` or `mail`, when syntactically + valid. This is **only a display hint**, never proof of email ownership + and never grounds for automatic account linking. + +Optional methods returning empty/malformed values or throwing are ignored. +Missing fields remain `null`, and the ordinary local-account display-name +default applies when the user explicitly creates an account. The raw provider +profile remains associated only with its provider credential, not as proof of +unrelated local identities. + ### First-time OAuth account choice (no automatic duplicate accounts) After the OAuth authorization-code/PKCE exchange, if no `(provider, uid)` credential exists, the user is **not logged in** and no local user is created. A short-lived `oauth2pending` entry remains in their PHP session; it contains the provider's verified subject, profile, safe display hints, and creation timestamp, and expires after 10 minutes. The OAuth state is already consumed. From 94222f488d69f987b7776a4bca2d244f2bed08f6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 13:54:25 -0300 Subject: [PATCH 172/217] test(auth): keep League OAuth2 optional while testing minimal resource owners --- test/OAuthControllerTest.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index 123ceaa..6af6fa5 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -132,7 +132,7 @@ public function testFirstTimeOAuthOffersAccountChoiceWithoutCreatingUser(): void public function testMinimalLeagueResourceOwnerDoesNotRequireOptionalMethods(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); - OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + OAuthTestProvider::$resourceOwner = new class { public function getId(): string { return 'minimal-provider-subject'; } public function toArray(): array { return ['id' => 'minimal-provider-subject']; } }; @@ -161,7 +161,7 @@ public function toArray(): array { return ['id' => 'minimal-provider-subject']; public function testLeagueProfileArraySuppliesSafeNameAvatarAndEmailHints(): void { $auth = new OAuthTrackingBasicAuth([], ['token' => 'token']); - OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + OAuthTestProvider::$resourceOwner = new class { public function getId(): int { return 92735; } public function toArray(): array { @@ -192,7 +192,7 @@ public function toArray(): array public function testNestedPictureAndSplitNameProfileFallbacks(): void { $auth = new BasicAuth([], ['token' => 'token']); - OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + OAuthTestProvider::$resourceOwner = new class { public function getId(): string { return 'nested-fields'; } public function toArray(): array { @@ -217,7 +217,7 @@ public function toArray(): array public function testMalformedOrThrowingOptionalGettersSafelyFallBackToProfile(): void { $auth = new BasicAuth([], ['token' => 'token']); - OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + OAuthTestProvider::$resourceOwner = new class { public function getId(): string { return 'broken-optional-fields'; } public function getName(): string { throw new \RuntimeException('Name unavailable'); } public function getAvatar(): array { return ['unexpected' => 'value']; } @@ -247,7 +247,7 @@ public function toArray(): array public function testUnsafeAvatarAndInvalidEmailHintsAreIgnored(): void { $auth = new BasicAuth([], ['token' => 'token']); - OAuthTestProvider::$resourceOwner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + OAuthTestProvider::$resourceOwner = new class { public function getId(): string { return 'unsafe-fields'; } public function toArray(): array { @@ -274,7 +274,7 @@ public function testInvalidProviderSubjectTypeIsRejected(): void { $auth = new BasicAuth([], ['token' => 'token']); $controller = $this->testController($auth); - $owner = new class implements \League\OAuth2\Client\Provider\ResourceOwnerInterface { + $owner = new class { public function getId(): array { return ['not' => 'an id']; } public function toArray(): array { return []; } }; From ba2f362fa5c7cedad926693d7f3ab70f7cc1a58f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:26:06 -0300 Subject: [PATCH 173/217] fix(auth): require explicitly configured trusted OAuth callback URI --- src/Auth/Controller/OAuthController.php | 54 +++++++++++++++++-------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 36f76e9..e9d23cf 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -283,9 +283,11 @@ private function buildProvider($id): object throw new \Exception("Invalid provider $id", 406); } $config = $this->providers[$id]; - if (empty($config['redirectUri'])) { - $config['redirectUri'] = $this->resolveRedirectUri(); - } + // Redirect URIs are part of the OAuth client registration. Never + // synthesize them from HTTP_HOST, forwarded headers, or request paths. + // This is enforced in all environments so production cannot silently + // fall back to attacker-controlled request metadata. + $config['redirectUri'] = $this->validateRedirectUri($config['redirectUri'] ?? null); $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); return new $classname($config); @@ -323,25 +325,45 @@ private function startAuthorization($id, object $provider, string $intent): stri return $authUrl; } - protected function resolveRedirectUri(): string + /** + * Require a trusted, pre-registered callback URL for every OAuth provider. + * Only HTTPS is accepted, except HTTP loopback URLs for local development. + * Static query parameters may be used if they are registered with the + * provider; fragments and embedded credentials are never valid callbacks. + */ + private function validateRedirectUri(mixed $uri): string { - $forwardedProto = trim((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')); - if ($forwardedProto !== '') { - $forwardedProto = trim(explode(',', $forwardedProto, 2)[0]); + if (!is_string($uri) || $uri === '') { + throw new \InvalidArgumentException( + 'OAuth redirectUri must be explicitly configured for each provider' + ); } - $scheme = $forwardedProto !== '' - ? $forwardedProto - : (string) ($_SERVER['REQUEST_SCHEME'] ?? 'https'); - $host = (string) ($_SERVER['HTTP_HOST'] ?? ''); - if ($host === '') { - throw new \RuntimeException('Cannot determine OAuth redirect URI without HTTP_HOST'); + if (preg_match('/[\x00-\x20\x7f\\\\]/', $uri) || filter_var($uri, FILTER_VALIDATE_URL) === false) { + throw new \InvalidArgumentException('Invalid OAuth redirectUri'); } - $script = str_replace('/index.php', '', (string) ($_SERVER['PHP_SELF'] ?? '')); - $pathInfo = (string) ($_SERVER['PATH_INFO'] ?? ''); + $parts = parse_url($uri); + if (!is_array($parts) + || empty($parts['host']) + || !isset($parts['scheme']) + || isset($parts['user']) + || isset($parts['pass']) + || isset($parts['fragment'])) { + throw new \InvalidArgumentException('Invalid OAuth redirectUri'); + } + + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host']); + $loopback = in_array($host, ['localhost', '127.0.0.1', '[::1]'], true); + + if ($scheme !== 'https' && !($scheme === 'http' && $loopback)) { + throw new \InvalidArgumentException( + 'OAuth redirectUri must use HTTPS (HTTP is only allowed on loopback)' + ); + } - return sprintf('%s://%s%s%s', $scheme, $host, $script, $pathInfo); + return $uri; } protected function redirect(string $location): void From 07723f148008da5f78e139269778091c1137a318 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:26:59 -0300 Subject: [PATCH 174/217] test(auth): reject request-derived and unsafe callback URLs; allow explicit loopback development --- test/OAuthControllerTest.php | 134 +++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 7 deletions(-) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index 6af6fa5..815b791 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -79,27 +79,147 @@ public function testAuthorizationUsesConfiguredRedirectUriAndStoresState(): void $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); } - public function testAuthorizationDerivesRedirectUriWhenNotConfigured(): void + public function testAuthorizationRequiresExplicitRedirectUriEvenWithRequestHeaders(): void { - $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https, http'; - $_SERVER['HTTP_HOST'] = 'app.example'; + $_SERVER['HTTP_HOST'] = 'attacker.example'; + $_SERVER['HTTP_X_FORWARDED_HOST'] = 'trusted.example'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + $_SERVER['REQUEST_SCHEME'] = 'https'; $_SERVER['PHP_SELF'] = '/index.php/oauth'; $_SERVER['PATH_INFO'] = '/test'; + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'client-id', 'clientSecret' => 'client-secret'], + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('OAuth redirectUri must be explicitly configured'); + $controller->get('test', []); + } + + public function testOauthRejectsEmptyRedirectUri(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', 'clientSecret' => 'client-secret', + 'redirectUri' => '', + ], + ]); + $this->expectException(\InvalidArgumentException::class); + $controller->get('test', []); + } + + /** + * @dataProvider invalidRedirectUris + */ + public function testAuthorizationRejectsUnsafeConfiguredRedirectUris(mixed $redirectUri): void + { $auth = new BasicAuth([], ['token' => 'token']); $controller = new OAuthTestController($auth, [ 'test' => [ 'clientId' => 'client-id', 'clientSecret' => 'client-secret', + 'redirectUri' => $redirectUri, ], ]); + $this->expectException(\InvalidArgumentException::class); $controller->get('test', []); + } - $this->assertSame( - 'https://app.example/oauth/test', - OAuthTestProvider::$lastConfig['redirectUri'] - ); + public static function invalidRedirectUris(): array + { + return [ + 'relative callback' => ['/oauth/test'], + 'scheme-relative callback' => ['//attacker.example/oauth/test'], + 'insecure public HTTP' => ['http://app.example/oauth/test'], + 'insecure non-loopback private address' => ['http://192.168.1.4/oauth/test'], + 'javascript scheme' => ['javascript:alert(1)'], + 'file scheme' => ['file:///tmp/callback'], + 'no host' => ['https:///oauth/test'], + 'embedded credentials' => ['https://alice:secret@app.example/oauth/test'], + 'URL fragment' => ['https://app.example/oauth/test#code'], + 'embedded newline' => ["https://app.example/oauth/test\r\nInjected: yes"], + 'backslash' => ['https://app.example\attacker.example/oauth/test'], + 'non-string value' => [['https://app.example/oauth/test']], + ]; + } + + /** + * @dataProvider validRedirectUris + */ + public function testAuthorizationAcceptsTrustedConfiguredRedirectUris(string $redirectUri): void + { + $_SERVER['HTTP_HOST'] = 'attacker.example'; + $_SERVER['HTTP_X_FORWARDED_HOST'] = 'attacker.example'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'http'; + + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => [ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + 'redirectUri' => $redirectUri, + ], + ]); + $controller->get('test', []); + + $this->assertSame($redirectUri, OAuthTestProvider::$lastConfig['redirectUri']); + $this->assertSame('https://oauth.example/authorize', $controller->redirectedTo); + $this->assertArrayHasKey('test', $_SESSION['oauth2flows']); + } + + public static function validRedirectUris(): array + { + return [ + 'production HTTPS' => ['https://app.example/oauth/test'], + 'custom HTTPS port' => ['https://auth.example:8443/oauth/test'], + 'static query' => ['https://app.example/oauth/test?tenant=example'], + 'localhost development' => ['http://localhost:8000/oauth/test'], + 'IPv4 loopback development' => ['http://127.0.0.1:8080/oauth/test'], + 'IPv6 loopback development' => ['http://[::1]:8080/oauth/test'], + ]; + } + + public function testExplicitLinkAlsoRequiresTrustedRedirectUri(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $auth->register('owner@example.com', 'password'); + $auth->login('owner@example.com', 'password'); + $this->withLinkHeaders($auth); + $_SERVER['HTTP_HOST'] = 'trusted.example'; + $_SERVER['HTTP_X_FORWARDED_PROTO'] = 'https'; + + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'client-id', 'clientSecret' => 'client-secret'], + ]); + try { + $controller->postLink('test'); + $this->fail('Explicit link permitted a request-derived callback URI'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('explicitly configured', $e->getMessage()); + } + $this->assertArrayNotHasKey('test', $_SESSION['oauth2flows'] ?? []); + } + + public function testOAuthCallbackAlsoRequiresConfiguredRedirectUri(): void + { + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new OAuthTestController($auth, [ + 'test' => ['clientId' => 'client-id', 'clientSecret' => 'client-secret'], + ]); + $_SESSION['oauth2flows']['test'] = [ + 'state' => 'test-oauth-state', + 'created_at' => time(), + 'code_verifier' => 'test-verifier', + 'principal' => null, + 'intent' => 'login', + ]; + + $this->expectException(\InvalidArgumentException::class); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); } public function testFirstTimeOAuthOffersAccountChoiceWithoutCreatingUser(): void From 0a14726722e4014c0499be9cbd8aec4aa04488e0 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:27:33 -0300 Subject: [PATCH 175/217] docs(auth): require trusted configured callback URI and document loopback-only HTTP --- README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f528d36..4da6cf6 100644 --- a/README.md +++ b/README.md @@ -449,7 +449,7 @@ Anonymous password-recovery requests intentionally return the same empty success ## OAuth controller -Configure OAuth providers by provider name. Set an explicit `redirectUri` in production so callback URLs do not depend on request/proxy headers: +Configure OAuth providers by provider name. **Every provider must have an explicitly configured, trusted `redirectUri`** that exactly matches its callback URL registered at the provider: ```php use Objectiveweb\Auth\Controller\OAuthController; @@ -463,7 +463,23 @@ $oauth = new OAuthController($auth, [ ]); ``` -If `redirectUri` is omitted, the controller derives it from the current request for development/legacy setups. **Use an explicit trusted redirect URI in production:** request-derived host and proxy headers are not an authorization boundary. +`redirectUri` is **required**, including in development. Missing or malformed values fail immediately before OAuth authorization or token exchange begins. The controller never reads `HTTP_HOST`, `HTTP_X_FORWARDED_PROTO`, the current request scheme, or request paths to construct a callback URL. The configured URL is passed unchanged to the OAuth client. + +Only absolute **HTTPS** callback URLs are accepted in production. **HTTP** is permitted solely for the loopback hosts `localhost`, `127.0.0.1`, and `[::1]` for local development; even local URLs must be explicitly configured. Relative URLs, credentials embedded in URLs, URL fragments, control characters, and other schemes are rejected. Static query parameters can be used if the provider has registered the exact callback URL. + +For example, a local development configuration may use: + +```php +$oauth = new OAuthController($auth, [ + 'google' => [ + 'clientId' => getenv('GOOGLE_CLIENT_ID'), + 'clientSecret' => getenv('GOOGLE_CLIENT_SECRET'), + 'redirectUri' => 'http://localhost:8080/auth/oauth/google', + ], +]); +``` + +**Breaking behavior change in v0.2.0:** applications that previously omitted `redirectUri` must supply one for every enabled OAuth provider. Do not derive it from user input or untrusted forwarding headers in application code. This policy applies to both ordinary OAuth login and explicit account linking. OAuth authorization includes a fresh **PKCE S256** challenge and stores a per-provider authorization flow in the PHP session (`oauth2flows`). The callback requires the matching provider's state, the original initiating session principal, and a state created within the past 10 minutes. The state is consumed before exchanging the code, and the matching PKCE verifier is supplied to the token endpoint. Providers must support PKCE S256. OAuth accounts are linked by the provider's stable subject identifier only; matching email addresses never establishes or links identities. From 9afe5a07d28e2a2280e94dbfc2cf2f56f9ccaa06 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:33:47 -0300 Subject: [PATCH 176/217] refactor(auth): allow injecting real OAuth provider HTTP collaborators in integration tests --- src/Auth/Controller/OAuthController.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index e9d23cf..1fcf6f7 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -290,7 +290,17 @@ private function buildProvider($id): object $config['redirectUri'] = $this->validateRedirectUri($config['redirectUri'] ?? null); $classname = "\\League\\OAuth2\\Client\\Provider\\" . ucfirst($id); - return new $classname($config); + return $this->createProvider($classname, $config); + } + + /** + * Optional construction hook for integration tests and custom provider + * collaborators (e.g. an injected PSR HTTP transport). The callback URL + * has already been validated before this method is called. + */ + protected function createProvider(string $class, array $config): object + { + return new $class($config); } private function startAuthorization($id, object $provider, string $intent): string From 7c4c08b073240fc51fcb93e5be42d42705f61747 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:35:52 -0300 Subject: [PATCH 177/217] test(auth): exercise real Google and Facebook OAuth providers with mocked HTTP --- test/RealOAuthProviderTest.php | 323 +++++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 test/RealOAuthProviderTest.php diff --git a/test/RealOAuthProviderTest.php b/test/RealOAuthProviderTest.php new file mode 100644 index 0000000..18b7fd6 --- /dev/null +++ b/test/RealOAuthProviderTest.php @@ -0,0 +1,323 @@ + $this->testHttpClient]); + } + + protected function redirect(string $location): void + { + $this->redirectedTo = $location; + } +} + +final class RealOAuthProviderTest extends TestCase +{ + protected function setUp(): void + { + if (!class_exists(\League\OAuth2\Client\Provider\Google::class) + || !class_exists(\League\OAuth2\Client\Provider\Facebook::class) + || !class_exists(Client::class)) { + $this->markTestSkipped('Optional League OAuth2 provider libraries are not installed.'); + } + + $_SESSION = []; + $_SERVER = []; + } + + /** + * Use Guzzle's real request pipeline; return deterministic JSON bodies + * at the token and profile endpoints without connecting to the network. + * + * @return array{0: Client, 1: array} + */ + private function clientWithResponses(array $responses, array &$history): Client + { + $mock = new MockHandler(array_map( + static fn (array $json): Response => + new Response(200, ['Content-Type' => 'application/json'], json_encode($json, JSON_THROW_ON_ERROR)), + $responses + )); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + return new Client(['handler' => $stack]); + } + + private function providerConfig(string $provider): array + { + $config = [ + 'clientId' => 'integration-client-id', + 'clientSecret' => 'integration-client-secret', + 'redirectUri' => 'https://application.example/auth/oauth/' . $provider, + ]; + if ($provider === 'facebook') { + // Required by the real Facebook provider. + $config['graphApiVersion'] = 'v22.0'; + } + return [$provider => $config]; + } + + private function assertAuthorizationRequest( + RealOAuthProviderController $controller, + string $provider + ): array { + $uri = $controller->redirectedTo; + $this->assertIsString($uri); + $this->assertSame('https', parse_url($uri, PHP_URL_SCHEME)); + $this->assertSame( + $provider === 'google' ? 'accounts.google.com' : 'www.facebook.com', + parse_url($uri, PHP_URL_HOST) + ); + parse_str((string) parse_url($uri, PHP_URL_QUERY), $query); + + $this->assertSame('integration-client-id', $query['client_id']); + $this->assertSame( + 'https://application.example/auth/oauth/' . $provider, + $query['redirect_uri'] + ); + $this->assertSame('code', $query['response_type']); + $this->assertSame('S256', $query['code_challenge_method']); + $this->assertArrayHasKey('state', $query); + $this->assertNotSame('', $query['state']); + + $flow = $_SESSION['oauth2flows'][$provider]; + $this->assertSame($query['state'], $flow['state']); + $this->assertSame('login', $flow['intent']); + + $expectedChallenge = rtrim( + strtr(base64_encode(hash('sha256', $flow['code_verifier'], true)), '+/', '-_'), + '=' + ); + $this->assertSame($expectedChallenge, $query['code_challenge']); + + $scopes = explode(' ', str_replace(',', ' ', $query['scope'])); + if ($provider === 'google') { + $this->assertContains('openid', $scopes); + $this->assertContains('email', $scopes); + $this->assertContains('profile', $scopes); + } else { + $this->assertContains('public_profile', $scopes); + $this->assertContains('email', $scopes); + } + + return $flow; + } + + private function assertTokenAndProfileRequests( + array $history, + string $provider, + string $verifier + ): void { + $this->assertCount(2, $history); + $token = $history[0]['request']; + $profile = $history[1]['request']; + $this->assertSame('POST', $token->getMethod()); + $this->assertSame( + $provider === 'google' ? 'oauth2.googleapis.com' : 'graph.facebook.com', + $token->getUri()->getHost() + ); + $this->assertStringContainsString('/token', (string) $token->getUri()); + parse_str((string) $token->getBody(), $body); + $this->assertSame('authorization_code', $body['grant_type']); + $this->assertSame('integration-auth-code', $body['code']); + $this->assertSame('integration-client-id', $body['client_id']); + $this->assertSame( + 'https://application.example/auth/oauth/' . $provider, + $body['redirect_uri'] + ); + $this->assertSame($verifier, $body['code_verifier']); + + $this->assertSame('GET', $profile->getMethod()); + if ($provider === 'google') { + $this->assertSame('openidconnect.googleapis.com', $profile->getUri()->getHost()); + $this->assertSame('/v1/userinfo', $profile->getUri()->getPath()); + $this->assertSame('Bearer integration-access-token', $profile->getHeaderLine('Authorization')); + } else { + $this->assertSame('graph.facebook.com', $profile->getUri()->getHost()); + $this->assertSame('/v22.0/me', $profile->getUri()->getPath()); + parse_str($profile->getUri()->getQuery(), $params); + $this->assertSame('integration-access-token', $params['access_token']); + $this->assertNotEmpty($params['appsecret_proof']); + } + } + + public function testRealGoogleProviderCanCompleteFirstTimeOAuthAndLinkExistingAccount(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600], + [ + 'sub' => 'google-stable-subject', 'name' => 'Google Person', + 'given_name' => 'Google', 'family_name' => 'Person', + 'picture' => 'https://images.example.com/google.png', + 'email' => 'google.profile@example.com', 'email_verified' => true, + ], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $existing = $auth->register('existing@example.com', 'correct-password'); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('google'), $http); + + $controller->get('google', []); + $flow = $this->assertAuthorizationRequest($controller, 'google'); + $controller->get('google', [ + 'code' => 'integration-auth-code', + 'state' => $flow['state'], + ]); + + $this->assertTokenAndProfileRequests($history, 'google', $flow['code_verifier']); + $this->assertSame('/account/choose', $controller->redirectedTo); + $this->assertArrayNotHasKey('google', $_SESSION['oauth2flows']); + $this->assertFalse($auth->check()); + $this->assertFalse($auth->get_credential('google', 'google-stable-subject')); + $this->assertSame('Google Person', $controller->getPendingAccount()['name']); + $this->assertSame('google.profile@example.com', $controller->getPendingAccount()['email_hint']); + $this->assertSame('https://images.example.com/google.png', $_SESSION['oauth2pending']['image']); + + $this->authorizeAccountChoice($auth); + $linked = $controller->postExistingAccount([ + 'uid' => 'existing@example.com', 'password' => 'correct-password', + ]); + $this->assertSame('linked', $linked['status']); + $this->assertSame($existing['id'], $linked['user']['id']); + $credential = $auth->get_credential('google', 'google-stable-subject'); + $this->assertSame($existing['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + public function testRealFacebookProviderCanCompleteFirstTimeOAuthAndCreateAccount(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'bearer', 'expires_in' => 3600], + [ + 'id' => 'facebook-stable-subject', 'name' => 'Facebook Person', + 'first_name' => 'Facebook', 'last_name' => 'Person', + 'email' => 'facebook.profile@example.com', + 'picture' => ['data' => [ + 'url' => 'https://images.example.com/facebook.png', + 'is_silhouette' => false, + ]], + ], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('facebook'), $http); + + $controller->get('facebook', []); + $flow = $this->assertAuthorizationRequest($controller, 'facebook'); + $controller->get('facebook', [ + 'code' => 'integration-auth-code', + 'state' => $flow['state'], + ]); + + $this->assertTokenAndProfileRequests($history, 'facebook', $flow['code_verifier']); + $this->assertSame('/account/choose', $controller->redirectedTo); + $this->assertFalse($auth->check()); + $this->assertFalse($auth->get_credential('facebook', 'facebook-stable-subject')); + $pending = $controller->getPendingAccount(); + $this->assertSame('Facebook Person', $pending['name']); + $this->assertSame('facebook.profile@example.com', $pending['email_hint']); + $this->assertSame('https://images.example.com/facebook.png', $_SESSION['oauth2pending']['image']); + + $this->authorizeAccountChoice($auth); + $created = $controller->postCreateAccount(); + $this->assertSame('created', $created['status']); + $this->assertTrue($auth->check()); + $this->assertSame('Facebook Person', $created['user']['name']); + $this->assertSame('https://images.example.com/facebook.png', $created['user']['image']); + $credential = $auth->get_credential('facebook', 'facebook-stable-subject'); + $this->assertSame($created['user']['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + } + + public function testRealGoogleProviderRejectsWrongOAuthStateBeforeHttp(): void + { + $history = []; + $http = $this->clientWithResponses([], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('google'), $http); + $controller->get('google', []); + + try { + $controller->get('google', [ + 'code' => 'integration-auth-code', 'state' => 'attacker-state', + ]); + $this->fail('Unexpectedly accepted mismatched provider state'); + } catch (\Exception $e) { + $this->assertSame(406, $e->getCode()); + } + $this->assertCount(0, $history); + $this->assertArrayNotHasKey('google', $_SESSION['oauth2flows']); + } + + public function testRealFacebookProviderRejectsTokenErrorsWithoutCreatingAccount(): void + { + $history = []; + $mock = new MockHandler([ + new Response(400, ['Content-Type' => 'application/json'], json_encode([ + 'error' => ['type' => 'OAuthException', 'message' => 'Invalid code', 'code' => 190], + ], JSON_THROW_ON_ERROR)), + ]); + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + $http = new Client(['handler' => $stack]); + $auth = new BasicAuth([], ['token' => 'token']); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('facebook'), $http); + $controller->get('facebook', []); + $flow = $_SESSION['oauth2flows']['facebook']; + + try { + $controller->get('facebook', [ + 'code' => 'integration-auth-code', 'state' => $flow['state'], + ]); + $this->fail('Token error unexpectedly created an account'); + } catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) { + $this->assertSame(190, $e->getCode()); + } + + $this->assertCount(1, $history); + $this->assertFalse($auth->check()); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + $this->assertArrayNotHasKey('facebook', $_SESSION['oauth2flows']); + } + + private function authorizeAccountChoice(BasicAuth $auth): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['HTTP_X_CSRF_TOKEN'] = $auth->management_csrf_token(); + } +} From 7f8e9310e68c75a8de49d4c774bf317f1a565cf6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:36:25 -0300 Subject: [PATCH 178/217] ci(auth): run real Google/Facebook OAuth integration using optional provider packages --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 145d63f..3558194 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,40 @@ jobs: - name: Run tests run: vendor/bin/phpunit test + oauth-real-providers: + # Validate integration using the actual optional League provider classes + # without sending requests to Google/Facebook or requiring live secrets. + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + coverage: none + + - name: Install test dependencies + run: composer install --no-interaction --prefer-dist + + - name: Install optional OAuth provider libraries for integration tests + run: | + composer require --dev --no-interaction --prefer-dist --no-progress \ + 'league/oauth2-google:^4.0' \ + 'league/oauth2-facebook:^2.0' \ + 'guzzlehttp/guzzle:^7.0' + + - name: Assert real OAuth providers are available + run: | + php -r 'require "vendor/autoload.php"; exit( + class_exists("League\\OAuth2\\Client\\Provider\\Google") && + class_exists("League\\OAuth2\\Client\\Provider\\Facebook") ? 0 : 1 + );' + + - name: Run real-provider OAuth integration tests + run: vendor/bin/phpunit test/RealOAuthProviderTest.php + migrations-postgresql: runs-on: ubuntu-latest From 4acaa088307570d5b46a66f96cf71ffd4064f510 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:37:29 -0300 Subject: [PATCH 179/217] test(auth): assert provider-specific OAuth token endpoint paths --- test/RealOAuthProviderTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/RealOAuthProviderTest.php b/test/RealOAuthProviderTest.php index 18b7fd6..04b9d41 100644 --- a/test/RealOAuthProviderTest.php +++ b/test/RealOAuthProviderTest.php @@ -149,7 +149,10 @@ private function assertTokenAndProfileRequests( $provider === 'google' ? 'oauth2.googleapis.com' : 'graph.facebook.com', $token->getUri()->getHost() ); - $this->assertStringContainsString('/token', (string) $token->getUri()); + $this->assertSame( + $provider === 'google' ? '/token' : '/v22.0/oauth/access_token', + $token->getUri()->getPath() + ); parse_str((string) $token->getBody(), $body); $this->assertSame('authorization_code', $body['grant_type']); $this->assertSame('integration-auth-code', $body['code']); From 289857170dca23b79b77e2e541832efa96302cff Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:38:19 -0300 Subject: [PATCH 180/217] docs(auth): document real-provider OAuth integration tests and CI limitations --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 4da6cf6..78a5a63 100644 --- a/README.md +++ b/README.md @@ -483,6 +483,37 @@ $oauth = new OAuthController($auth, [ OAuth authorization includes a fresh **PKCE S256** challenge and stores a per-provider authorization flow in the PHP session (`oauth2flows`). The callback requires the matching provider's state, the original initiating session principal, and a state created within the past 10 minutes. The state is consumed before exchanging the code, and the matching PKCE verifier is supplied to the token endpoint. Providers must support PKCE S256. OAuth accounts are linked by the provider's stable subject identifier only; matching email addresses never establishes or links identities. +### Real Google and Facebook provider integration tests + +The optional `league/oauth2-google` and `league/oauth2-facebook` packages are +tested in a dedicated `oauth-real-providers` CI job. These tests instantiate +the **real League provider classes** and pass a Guzzle mock HTTP transport +instead of making network calls or using production OAuth secrets. They verify: + +- Provider-specific authorization URLs, redirect URIs, scopes, state, and + PKCE S256 challenges and token-exchange `code_verifier` parameters. +- Google OpenID Connect userinfo and Facebook Graph API profile parsing, + including avatar/email display hints from real resource-owner classes. +- Unknown OAuth identities remain pending until the user chooses to + create an account or authenticate an existing account for linking. +- Invalid state is rejected before network access; provider token errors + do not create sessions or pending identities. + +The provider packages remain optional for library users (the runtime +`require` dependencies are unchanged). The dedicated job temporarily +installs them via Composer: + +```sh +composer require --dev 'league/oauth2-google:^4.0' \ + 'league/oauth2-facebook:^2.0' 'guzzlehttp/guzzle:^7.0' +vendor/bin/phpunit test/RealOAuthProviderTest.php +``` + +These are deterministic interoperability tests against real provider +**implementations**, not live Google/Facebook service checks. Live validation +still requires developer-owned OAuth applications, registered callback URLs, +and test users; do not place provider secrets in CI or test fixtures. + ### Resource-owner profile compatibility The League OAuth2 `ResourceOwnerInterface` requires only `getId()` and From 39dea866d36160298c46e999dbd3250708d09ac9 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 14:39:25 -0300 Subject: [PATCH 181/217] test(auth): cover real-provider returning login and explicit Facebook account linking --- test/RealOAuthProviderTest.php | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/RealOAuthProviderTest.php b/test/RealOAuthProviderTest.php index 04b9d41..584ef7e 100644 --- a/test/RealOAuthProviderTest.php +++ b/test/RealOAuthProviderTest.php @@ -266,6 +266,77 @@ public function testRealFacebookProviderCanCompleteFirstTimeOAuthAndCreateAccoun $this->assertNotEmpty($credential['verified_at']); } + public function testRealGoogleProviderSignsInExistingLinkedAccountWithoutNewChoice(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600], + ['sub' => 'known-google-id', 'name' => 'Returning Google User', 'email' => 'returning@example.com'], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $owner = $auth->register('returning@example.com', 'secret'); + $auth->create_credential($owner['id'], 'google', 'known-google-id'); + $auth->mark_credential_verified($owner['id'], 'google', 'known-google-id'); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('google'), $http); + + $controller->get('google', []); + $flow = $this->assertAuthorizationRequest($controller, 'google'); + $controller->get('google', [ + 'code' => 'integration-auth-code', 'state' => $flow['state'], + ]); + + $this->assertTokenAndProfileRequests($history, 'google', $flow['code_verifier']); + $this->assertSame('/', $controller->redirectedTo); + $this->assertTrue($auth->check()); + $this->assertSame($owner['id'], $auth->user()['id']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + public function testRealFacebookProviderExplicitLinkUsesSameAccountAndPkce(): void + { + $history = []; + $http = $this->clientWithResponses([ + ['access_token' => 'integration-access-token', 'token_type' => 'bearer', 'expires_in' => 3600], + ['id' => 'new-facebook-identity', 'name' => 'Linked Facebook User'], + ], $history); + $auth = new BasicAuth([], ['token' => 'token']); + $owner = $auth->register('facebook-link@example.com', 'correct-password'); + $auth->login('facebook-link@example.com', 'correct-password'); + $controller = new RealOAuthProviderController($auth, $this->providerConfig('facebook'), $http); + $this->authorizeAccountChoice($auth); + + $link = $controller->postLink('facebook'); + $this->assertArrayHasKey('authorization_url', $link); + $controller->redirectedTo = $link['authorization_url']; + $flow = $this->assertLinkAuthorizationRequest($controller); + + $controller->get('facebook', [ + 'code' => 'integration-auth-code', 'state' => $flow['state'], + ]); + $this->assertTokenAndProfileRequests($history, 'facebook', $flow['code_verifier']); + $this->assertSame('/', $controller->redirectedTo); + $this->assertSame($owner['id'], $auth->user()['id']); + $credential = $auth->get_credential('facebook', 'new-facebook-identity'); + $this->assertSame($owner['id'], $credential['user_id']); + $this->assertNotEmpty($credential['verified_at']); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + + private function assertLinkAuthorizationRequest(RealOAuthProviderController $controller): array + { + $flow = $_SESSION['oauth2flows']['facebook']; + $this->assertSame('link', $flow['intent']); + $this->assertNotNull($flow['principal']); + parse_str((string) parse_url($controller->redirectedTo, PHP_URL_QUERY), $query); + $this->assertSame($flow['state'], $query['state']); + $this->assertSame('S256', $query['code_challenge_method']); + $this->assertSame( + rtrim(strtr(base64_encode(hash('sha256', $flow['code_verifier'], true)), '+/', '-_'), '='), + $query['code_challenge'] + ); + return $flow; + } + public function testRealGoogleProviderRejectsWrongOAuthStateBeforeHttp(): void { $history = []; From 96930465d98365722653c6d98e8734e4de4fe196 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:12:02 -0300 Subject: [PATCH 182/217] ci(auth): add dedicated MySQL Phinx migration environment --- test/phinx.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/phinx.php b/test/phinx.php index 540262f..a2953d5 100644 --- a/test/phinx.php +++ b/test/phinx.php @@ -15,5 +15,16 @@ 'pass' => getenv('PGPASSWORD') ?: 'postgres', 'port' => (int) (getenv('PGPORT') ?: 5432), ], + // Separate environment shares the package migrations with PostgreSQL. + // Provide MYSQL_* variables in the dedicated MySQL CI job. + 'mysql_testing' => [ + 'adapter' => 'mysql', + 'host' => getenv('MYSQL_HOST') ?: '127.0.0.1', + 'name' => getenv('MYSQL_DATABASE') ?: 'auth_test', + 'user' => getenv('MYSQL_USER') ?: 'auth_user', + 'pass' => getenv('MYSQL_PASSWORD') ?: '', + 'port' => (int) (getenv('MYSQL_PORT') ?: 3306), + 'charset' => 'utf8mb4', + ], ], ]; From 0dfc8b24e68bc3cf0ae4ea034af6457e741e4d52 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:13:26 -0300 Subject: [PATCH 183/217] test(auth): verify MySQL migration schema, indexes and foreign keys --- test/verify_mysql_migrations.php | 127 +++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 test/verify_mysql_migrations.php diff --git a/test/verify_mysql_migrations.php b/test/verify_mysql_migrations.php new file mode 100644 index 0000000..e59cb9d --- /dev/null +++ b/test/verify_mysql_migrations.php @@ -0,0 +1,127 @@ + PDO::ERRMODE_EXCEPTION] +); + +$expectedColumns = [ + 'user' => [ + 'id', 'uuid', 'password', 'name', 'image', 'token', + 'token_expires_at', 'disabled_at', 'created', + ], + 'user_credentials' => [ + 'uid', 'provider', 'user_id', 'profile', 'token', + 'token_expires_at', 'verified_at', 'last_login', 'created', + ], + 'role' => ['id', 'name'], + 'user_roles' => ['user_id', 'role_id'], + 'delegations' => ['user_id', 'target_user_id', 'ability'], +]; + +$assert = static function (bool $condition, string $message): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; + +$columnsStmt = $pdo->prepare( + 'SELECT COLUMN_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table' +); +$engineStmt = $pdo->prepare( + 'SELECT ENGINE FROM information_schema.TABLES + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table' +); + +foreach ($expectedColumns as $table => $columns) { + $columnsStmt->execute(['db' => $database, 'table' => $table]); + $actual = $columnsStmt->fetchAll(PDO::FETCH_COLUMN); + foreach ($columns as $column) { + $assert(in_array($column, $actual, true), "Missing MySQL column {$table}.{$column}"); + } + $engineStmt->execute(['db' => $database, 'table' => $table]); + $assert($engineStmt->fetchColumn() === 'InnoDB', "Expected InnoDB table {$table}"); +} + +// Check indexes by indexed column, including whether they enforce uniqueness. +$indexStmt = $pdo->prepare( + 'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, SEQ_IN_INDEX + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table + ORDER BY INDEX_NAME, SEQ_IN_INDEX' +); +$requiredIndexes = [ + 'user' => [ + ['uuid', true], + ['token', false], + ], + 'user_credentials' => [ + ['uid', true], + ['provider', true], + ], + 'role' => [['name', true]], + 'user_roles' => [ + ['user_id', true], + ['role_id', false], + ], +]; +foreach ($requiredIndexes as $table => $requirements) { + $indexStmt->execute(['db' => $database, 'table' => $table]); + $indexes = $indexStmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($requirements as [$column, $unique]) { + $found = false; + foreach ($indexes as $index) { + if ($index['COLUMN_NAME'] === $column + && (!$unique || (int) $index['NON_UNIQUE'] === 0)) { + $found = true; + break; + } + } + $assert($found, "Missing MySQL index for {$table}.{$column}"); + } +} + +$foreignKeyStmt = $pdo->prepare( + 'SELECT COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table + AND REFERENCED_TABLE_NAME IS NOT NULL' +); +$foreignKeys = [ + 'user_credentials' => ['user_id' => ['user', 'id']], + 'user_roles' => [ + 'user_id' => ['user', 'id'], + 'role_id' => ['role', 'id'], + ], +]; +foreach ($foreignKeys as $table => $requirements) { + $foreignKeyStmt->execute(['db' => $database, 'table' => $table]); + $actual = $foreignKeyStmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($requirements as $column => [$referencedTable, $referencedColumn]) { + $found = false; + foreach ($actual as $key) { + if ($key['COLUMN_NAME'] === $column + && $key['REFERENCED_TABLE_NAME'] === $referencedTable + && $key['REFERENCED_COLUMN_NAME'] === $referencedColumn) { + $found = true; + break; + } + } + $assert($found, "Missing MySQL FK {$table}.{$column} -> {$referencedTable}.{$referencedColumn}"); + } +} + +echo "MySQL 8 migration schema, indexes and foreign keys verified.\n"; From a70ab6652f9b0c1243f26d3dacb5f152e70803cd Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:14:40 -0300 Subject: [PATCH 184/217] test(auth): exercise DBAuth authentication and verification workflows on MySQL 8 --- test/verify_mysql_auth.php | 143 +++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 test/verify_mysql_auth.php diff --git a/test/verify_mysql_auth.php b/test/verify_mysql_auth.php new file mode 100644 index 0000000..f98c351 --- /dev/null +++ b/test/verify_mysql_auth.php @@ -0,0 +1,143 @@ + 'token', + 'disabled_at' => 'disabled_at', +]); + +$assert = static function (bool $condition, string $message): void { + if (!$condition) { + throw new RuntimeException($message); + } +}; + +$expectFailure = static function ( + callable $callback, string $expectedClass, int $expectedCode +) use ($assert): void { + try { + $callback(); + } catch (Throwable $exception) { + $assert($exception instanceof $expectedClass, 'Unexpected error type: ' . get_class($exception)); + $assert($exception->getCode() === $expectedCode, 'Unexpected error code: ' . $exception->getCode()); + return; + } + throw new RuntimeException("Expected {$expectedClass} ({$expectedCode})"); +}; + +// Real MySQL INSERTs and foreign-key-backed role assignment. +$admin = $auth->register('mysql-admin@example.com', 'original-secret', [ + 'name' => 'MySQL Admin', + 'roles' => ['admin', 'operator'], +]); +$viewer = $auth->register('mysql-viewer@example.com', 'viewer-secret', [ + 'name' => 'MySQL Viewer', + 'roles' => ['viewer'], +]); + +$assert(!empty($admin['uuid']) && $admin['id'] !== $viewer['id'], 'Missing distinct user IDs or UUID'); +$assert(!array_key_exists('password', $admin), 'Registration exposed password hash'); +$assert($auth->get_users_by_role('admin')[0]['id'] == $admin['id'], 'Role join returned wrong user'); +$assert($auth->get_users_by_role('admin')[0]['roles'] === ['admin', 'operator'], 'Role join lost other roles'); +$assert($auth->get_users_by_role('nonexistent') === [], 'Unknown role returned users'); + +$listing = $auth->query(['page' => 0, 'size' => 1, 'sort' => 'name ASC']); +$assert($listing instanceof Collection, 'Query must return a Collection'); +$assert($listing->total() === 2 && count($listing) === 1, 'Query pagination totals are wrong'); +$assert($listing->contentRange() === 'items 0-0/2', 'Query content range is wrong'); +$assert($listing[0]['name'] === 'MySQL Admin', 'Query returned wrong sorted user'); +$search = $auth->query(['q' => 'mysql-viewer@example.com', 'size' => 10]); +$assert($search->total() === 1, 'Credential UID search on MySQL failed'); + +$expectFailure( + static fn () => $auth->login('missing@example.com', 'invalid'), + AuthException::class, + 401 +); +$expectFailure( + static fn () => $auth->login('mysql-admin@example.com', 'invalid'), + AuthException::class, + 401 +); +$logged = $auth->login('mysql-admin@example.com', 'original-secret'); +$assert($logged['id'] == $admin['id'] && $auth->check(), 'MySQL login/session failed'); +$assert($auth->user()['roles'] === ['admin', 'operator'], 'Session did not hydrate roles'); +$auth->logout(); + +// Reset tokens must be hashed at rest, expired on consumption and usable once. +$reset = $auth->update_token($admin['id']); +$stored = $db->select('user', ['id' => $admin['id']], ['limit' => 1])->fetch(); +$assert($stored['token'] === hash('sha256', $reset), 'Reset token not stored as digest'); +$assert(!empty($stored['token_expires_at']), 'Reset token expiry missing'); +$auth->passwd_reset($reset, 'replacement-secret'); +$expectFailure( + static fn () => $auth->passwd_reset($reset, 'replay-secret'), + UserException::class, + 404 +); +$expectFailure( + static fn () => $auth->login('mysql-admin@example.com', 'original-secret'), + AuthException::class, + 401 +); +$auth->login('mysql-admin@example.com', 'replacement-secret'); +$auth->logout(); + +// Each credential has its own token, expiration and verification state. +$credential = $auth->create_credential($admin['id'], 'email', 'admin-contact@example.com'); +$assert(empty($credential['verified_at']), 'New email credential should be unverified'); +$verification = $auth->issue_credential_token($admin['id'], 'email', 'admin-contact@example.com'); +$storedCredential = $auth->get_credential('email', 'admin-contact@example.com'); +$assert( + $storedCredential['token'] === hash('sha256', $verification), + 'Credential verification token not stored as digest' +); +$assert( + !$auth->verify_credential_token('email', 'admin-contact@example.com', 'bad-token'), + 'Bad credential token was accepted' +); +$assert( + $auth->verify_credential_token('email', 'admin-contact@example.com', $verification), + 'Valid credential token failed' +); +$assert( + !$auth->verify_credential_token('email', 'admin-contact@example.com', $verification), + 'Credential verification token was reused' +); +$assert( + !empty($auth->get_credential('email', 'admin-contact@example.com')['verified_at']), + 'Verified credential is missing verified_at' +); + +// Roles are synced portably with MySQL's SQL dialect. +$auth->update($admin['id'], ['roles' => ['operator']]); +$assert($auth->get($admin['id'])['roles'] === ['operator'], 'Role replacement failed'); +$assert($auth->get_users_by_role('admin') === [], 'Removed role still grants access'); +$auth->delete($viewer['id']); +$assert($auth->get_users_by_role('viewer') === [], 'Deleted user still appears in role lookup'); +$assert(count($auth->query()) === 1, 'Deleted user still appears in MySQL query'); + +echo "MySQL 8 DBAuth registration, roles, login, pagination, password reset, credential verification and deletion passed.\n"; From 869e1933e12a4f71bd6b5c71de93533165c9e270 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:15:22 -0300 Subject: [PATCH 185/217] ci(auth): test MySQL 8.0/8.4 migrations and DBAuth flows --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3558194..ec75da3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,63 @@ jobs: - name: Run real-provider OAuth integration tests run: vendor/bin/phpunit test/RealOAuthProviderTest.php + migrations-mysql: + name: MySQL ${{ matrix.mysql-version }} migrations and DBAuth + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + mysql-version: ["8.0", "8.4"] + + services: + mysql: + image: mysql:${{ matrix.mysql-version }} + env: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: auth_test + MYSQL_USER: auth_user + MYSQL_PASSWORD: auth_password + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --host=127.0.0.1 --user=auth_user --password=auth_password --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + MYSQL_HOST: 127.0.0.1 + MYSQL_PORT: 3306 + MYSQL_DATABASE: auth_test + MYSQL_USER: auth_user + MYSQL_PASSWORD: auth_password + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: pdo_mysql + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run bundled Phinx migrations on MySQL + run: vendor/bin/phinx migrate --configuration test/phinx.php --environment mysql_testing + + - name: Verify MySQL migration status + run: vendor/bin/phinx status --configuration test/phinx.php --environment mysql_testing + + - name: Verify MySQL schema, indexes and foreign keys + run: php test/verify_mysql_migrations.php + + - name: Verify MySQL DBAuth authentication workflows + run: php test/verify_mysql_auth.php + migrations-postgresql: runs-on: ubuntu-latest From 8e4b838dc1fa1ca5c8a809e700122b42068767c5 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:16:31 -0300 Subject: [PATCH 186/217] docs(auth): document MySQL 8.0/8.4 CI and local migration checks --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 78a5a63..24e06ef 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,31 @@ $auth->passwd_reset($token, 'new-password'); Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. **There is no built-in rate limiting in v0.2.0.** Applications must enforce external rate limits at their web server, reverse proxy, gateway, or middleware for login, public registration, recovery, reset-token redemption, and credential verification. Future non-database throttling is tracked in [issue #2](https://github.com/objectiveweb/auth/issues/2). +### Database CI coverage + +The `CI` workflow validates the bundled Phinx migrations and DBAuth +operations against **MySQL 8.0 and MySQL 8.4 (LTS)**, in addition to the +existing PostgreSQL 16 migration check and SQLite unit tests. MySQL CI checks +the migrated columns, indexes, foreign keys and InnoDB tables, then executes +registration, login, role assignment/queries, credential verification, +single-use password reset, pagination and deletion against MySQL. + +The MySQL-specific Phinx environment is `mysql_testing` in +[`test/phinx.php`](test/phinx.php). To run locally, create an empty MySQL +database and set `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, +`MYSQL_USER` and `MYSQL_PASSWORD` with a user able to create tables and +indexes. Then run: + +```sh +vendor/bin/phinx migrate --configuration test/phinx.php --environment mysql_testing +vendor/bin/phinx status --configuration test/phinx.php --environment mysql_testing +php test/verify_mysql_migrations.php +php test/verify_mysql_auth.php +``` + +These test scripts **create and delete users/credentials** and require a +dedicated empty database; do not run them against an application database. + ## OAuth controller Configure OAuth providers by provider name. **Every provider must have an explicitly configured, trusted `redirectUri`** that exactly matches its callback URL registered at the provider: From a370893a92551c74fd63cf8342b9e763f5b73974 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:17:19 -0300 Subject: [PATCH 187/217] fix(auth): declare credential composite primary key columns NOT NULL for MySQL --- db/20250419210134_auth_migration.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/20250419210134_auth_migration.php b/db/20250419210134_auth_migration.php index 9d2897b..f8b3134 100644 --- a/db/20250419210134_auth_migration.php +++ b/db/20250419210134_auth_migration.php @@ -49,8 +49,8 @@ public function change() 'primary_key' => ['uid', 'provider'] ]); $user_credentials - ->addColumn('uid', 'string', ['limit' => 255]) - ->addColumn('provider', 'string', ['limit' => 32]) + ->addColumn('uid', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('provider', 'string', ['limit' => 32, 'null' => false]) ->addColumn('user_id', 'integer', ['signed' => false]) ->addColumn('profile', 'text', ['null'=> true]) ->addColumn('token', 'string', ['limit' => 255, 'null' => true]) From c94096e84a36aef0994e78f430640da7506af381 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:17:42 -0300 Subject: [PATCH 188/217] fix(auth): declare role and delegation composite-key columns NOT NULL for MySQL --- db/20260310224000_auth_dbauth_relations_migration.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/db/20260310224000_auth_dbauth_relations_migration.php b/db/20260310224000_auth_dbauth_relations_migration.php index 30853b6..b10e588 100644 --- a/db/20260310224000_auth_dbauth_relations_migration.php +++ b/db/20260310224000_auth_dbauth_relations_migration.php @@ -18,17 +18,17 @@ public function change(): void 'id' => false, 'primary_key' => ['user_id', 'role_id'], ]) - ->addColumn('user_id', 'integer', ['signed' => false]) - ->addColumn('role_id', 'integer', ['signed' => false]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('role_id', 'integer', ['signed' => false, 'null' => false]) ->addIndex(['role_id']) ->addForeignKey('user_id', 'user', 'id') ->addForeignKey('role_id', 'role', 'id') ->create(); $this->table('delegations', ['id' => false, 'primary_key' => ['user_id', 'target_user_id', 'ability']]) - ->addColumn('user_id', 'integer', ['signed' => false]) - ->addColumn('target_user_id', 'integer', ['signed' => false]) - ->addColumn('ability', 'string', ['limit' => 64]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('target_user_id', 'integer', ['signed' => false, 'null' => false]) + ->addColumn('ability', 'string', ['limit' => 64, 'null' => false]) ->addIndex(['target_user_id']) ->create(); From c1d6baa8ab86c8a517b39c506567583136219d35 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:27:49 -0300 Subject: [PATCH 189/217] test(auth): add standalone no-dev production-install smoke test --- test/verify_production_install.php | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/verify_production_install.php diff --git a/test/verify_production_install.php b/test/verify_production_install.php new file mode 100644 index 0000000..9d9964f --- /dev/null +++ b/test/verify_production_install.php @@ -0,0 +1,61 @@ +register('production@example.com', 'production-password', ['name' => 'Production User']); +$assert(!empty($new['id']), 'BasicAuth registration failed without dev dependencies'); +$assert(!array_key_exists('password', $new), 'Registration response exposed password'); +$logged = $auth->login('production@example.com', 'production-password'); +$assert($logged['id'] === $new['id'] && $auth->check(), 'BasicAuth login failed'); +$assert($auth->user()['name'] === 'Production User', 'BasicAuth session context failed'); +$auth->logout(); +$assert(!$auth->check(), 'BasicAuth logout failed'); + +// The DB abstraction must resolve from require, not require-dev. Instantiate +// DBAuth against SQLite without relying on Phinx or PHPUnit to create schema. +$db = new \Objectiveweb\DB('sqlite::memory:'); +$dbAuth = new \Objectiveweb\Auth\DBAuth($db, ['roles_table' => null, 'user_roles_table' => null]); +$assert(!$dbAuth->check(), 'DBAuth did not initialize in production'); + +echo "Production Composer autoload, runtime dependencies and authentication smoke test passed.\n"; From 1088a7a04498cee149a5183f60fb8b54e538137b Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:28:48 -0300 Subject: [PATCH 190/217] ci(auth): gate PR on no-dev production install and isolated consumer smoke test --- .github/workflows/release.yml | 50 ++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f6d640d..c732139 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,16 @@ on: push: branches: - master + pull_request: + branches: + - master workflow_dispatch: jobs: test: + # The regular CI workflow already tests the full PHP matrix on PRs. + # This release workflow runs the matrix after merge or when dispatched. + if: github.event_name != 'pull_request' name: PHP ${{ matrix.php }} runs-on: ubuntu-latest strategy: @@ -58,4 +64,46 @@ jobs: run: composer validate --strict - name: Verify production dependencies - run: composer install --no-dev --prefer-dist --no-interaction --no-progress + run: | + composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + composer check-platform-reqs --no-dev + + - name: Smoke-test production autoload and authentication + run: php test/verify_production_install.php vendor/autoload.php + + - name: Install as a separate production consumer + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/auth-consumer" + # This prerelease is not yet published on Packagist. Use a copied + # Composer path package (not a symlink) pinned to the candidate + # version, as a real consumer would install it into vendor/. + php -r ' + $manifest = [ + "name" => "objectiveweb/auth-production-consumer", + "description" => "Isolated no-dev install verification", + "require" => [ + "php" => ">=8.2", + "objectiveweb/auth" => "^0.2", + ], + "repositories" => [[ + "type" => "path", + "url" => getenv("GITHUB_WORKSPACE"), + "options" => [ + "symlink" => false, + "versions" => ["objectiveweb/auth" => "0.2.0"], + ], + ]], + ]; + file_put_contents( + getenv("RUNNER_TEMP") . "/auth-consumer/composer.json", + json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n" + ); + ' + cd "$RUNNER_TEMP/auth-consumer" + composer install --no-dev --prefer-dist --no-interaction --no-progress --optimize-autoloader + composer check-platform-reqs --no-dev + test -f vendor/objectiveweb/auth/src/Auth.php + php "$GITHUB_WORKSPACE/test/verify_production_install.php" "$PWD/vendor/autoload.php" + From 18b330263038961cf0547635b66c95796ee0c499 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:30:42 -0300 Subject: [PATCH 191/217] docs(auth): describe pre-merge production and consumer install verification --- README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/README.md b/README.md index 24e06ef..9ff4c0b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,35 @@ This package requires: - PHP `>=8.2` - `objectiveweb/db` +### Pre-merge production installation check + +Every pull request targeting `master` runs the **Production install** job in +[`release.yml`](.github/workflows/release.yml), using PHP 8.2 (the minimum +supported version). This job verifies `composer validate --strict`, installs +the library's runtime dependencies using `composer install --no-dev`, checks +platform requirements, and executes +[`test/verify_production_install.php`](test/verify_production_install.php). +The smoke test confirms that the public Auth, BasicAuth, DBAuth, controller +and database classes autoload, and that registration, login and logout work +without PHPUnit or Phinx installed. + +It also installs the unreleased source into a **separate consumer project** +with a non-symlinked Composer path repository, then repeats the smoke checks +against that consumer's `vendor/autoload.php`. Until v0.2.0 is published, the +path package is explicitly assigned the candidate `0.2.0` version; this +checks real consumer autoloading and `require` dependencies without claiming +that Packagist already serves the new release. The standard PHP test matrix +continues running in `ci.yml`, while `release.yml` runs its additional +full matrix only after merging or on manual dispatch. + +For a local no-dev check: + +```sh +composer install --no-dev --prefer-dist --optimize-autoloader +composer check-platform-reqs --no-dev +php test/verify_production_install.php vendor/autoload.php +``` + ## Providers ### `DBAuth` From 29effdb9b0bc650f88242b9908c90d821971789f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:40:19 -0300 Subject: [PATCH 192/217] docs(auth): add v0.2.0 changelog and breaking change summary --- CHANGELOG.md | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c618dc8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to `objectiveweb/auth` are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) conventions and [Semantic Versioning](https://semver.org/); releases prior to 1.0 may include breaking changes in minor versions. + +## [0.2.0] — Unreleased + +**Breaking release.** This is a rewrite of the 0.1.x public API and storage model, not a drop-in upgrade. Read the [0.2.0 upgrade guide](docs/UPGRADING-0.2.0.md) before updating an existing application. + +### Added + +- Abstract `Objectiveweb\Auth` provider contract, `Objectiveweb\Auth\DBAuth` backed by `objectiveweb/db ^0.8`, and an in-memory `BasicAuth` provider. +- Separate user and `(provider, uid)` credential records, provider-specific profiles, UUIDs, configurable login/recovery providers, credential lifecycle (create, rename, delete) and per-credential verification status. +- Bundled Phinx migrations for `user`, `user_credentials`, `role`, `user_roles`, and `delegations`, including credential verification/expiry columns. +- Role-based authorization, `RequireRole` middleware, relational permissions/delegations, configurable eager loading, and `user_context_callback` for refreshing application-owned context in sessions. +- `Controller\AuthController`, `Controller\OAuthController`, and admin-only `Controller\UserController` with paginated, filterable `Collection` results, credential management, invitations, password resets, lifecycle management and CSRF-protected writes. +- Optional OAuth client login with Google/Facebook provider support, PKCE S256, provider/session-bound single-use state, and explicit, CSRF-protected account linking. +- A **first-time OAuth account-choice flow**: an unknown provider identity remains pending for up to ten minutes. The user explicitly chooses to create a new account or sign into an existing password account to link; the provider callback creates no account by itself. Includes an example frontend. +- Configurable callbacks for registration, token delivery, invitation/reset, audit events, deletion guards and application context. +- PHP 8.2–8.5 tests, SQLite unit tests, PostgreSQL 16 migration/integration checks, MySQL 8.0/8.4 schema and authentication integration checks, real Google/Facebook provider-class tests with mocked HTTP, and pre-merge production/consumer Composer installation tests. + +### Changed + +- **Minimum PHP version raised from 5.3 to 8.2.** Runtime dependency: `objectiveweb/db ^0.8.0`. Router/Phinx/PHPUnit packages are development dependencies; applications using router middleware or OAuth providers must install the relevant integration packages themselves. +- Database access moved from direct PDO/MySQL-specific SQL in `Objectiveweb\Auth` to the portable `DBAuth` abstraction. Queries now return `Objectiveweb\DB\Collection`, rather than the 0.1.x HAL-style `_embedded`/pagination array. +- Local identifiers are represented as credentials (`provider = local`, `uid`); the user table no longer contains the legacy `username` identity column by default. +- Password login failures return the same `AuthException` HTTP 401 for unknown identifiers, bad passwords and suspended accounts. +- Password reset tokens are cryptographically random, stored only as SHA-256 digests, expire and are consumed atomically; previously issued plaintext legacy tokens do **not** carry forward. +- Session IDs regenerate upon authentication, protected requests can revalidate account status and roles, and public user payloads are sanitized. +- User-facing password changes require the existing password, a session-bound `X-CSRF-Token`, and a JSON request; public registration is restricted to local credentials and safe profile fields. +- OAuth callbacks require a **configured, trusted `redirectUri`** (HTTPS, or explicitly configured HTTP loopback for local development), no longer inferred from Host/proxy request headers. +- OAuth provider-resource-owner display names, emails and avatars are optional hints with safe fallbacks; matching email addresses do **not** link accounts. +- User-managed application associations (`managed_relations`) now belong to application services. Use `user_context_callback` to expose current assignments to the authenticated session. +- Project testing/release verification moved from Travis to GitHub Actions; Composer metadata and support URL updated. + +### Security fixes + +- Prevent account enumeration through password-login errors and anonymous password-recovery responses. +- Consume password reset and credential-verification tokens at most once; separate proof for each provider credential. +- Prevent OAuth login CSRF, state replay, cross-provider state confusion, unconsented linking, email-based account takeover and cross-account credential reassignment. +- Restrict logout redirects to safe same-origin absolute paths and reject attacker-controlled OAuth callback URLs. +- Enforce management CSRF checks, prevent removal of the last active admin and protect the caller from self-deletion/suspension. +- Avoid returning passwords, reset tokens or other internal secrets in normal user/session/management payloads. + +### Removed / incompatible + +- Direct construction of `new Objectiveweb\Auth($pdo, ...)`; instantiate `DBAuth` or `BasicAuth` instead. +- `Objectiveweb\Auth\AuthController` (old class path), `PasswordMismatchException`, legacy `src/password.php`, and the 0.1.x MySQL-only query format. +- Implicit ownership of application-managed relations and implicit OAuth account linking based on email. +- Legacy plaintext password-reset token storage/consumption. + +### Deferred / not included + +- Built-in rate limiting is intentionally excluded. Deployments **must** configure throttling at the reverse proxy, gateway, WAF or application middleware. Planned non-SQL pluggable throttling: [#2](https://github.com/objectiveweb/auth/issues/2). +- Acting as an OAuth2/OIDC identity provider, JWT signing/JWKS: [#3](https://github.com/objectiveweb/auth/issues/3). +- WebAuthn/passkeys, TOTP and MFA: [#4](https://github.com/objectiveweb/auth/issues/4). +- Refresh-token rotation, device/session management and advanced audit sinks: [#5](https://github.com/objectiveweb/auth/issues/5). + +See [release notes](docs/RELEASE-0.2.0.md) for a concise announcement and the [upgrade guide](docs/UPGRADING-0.2.0.md) for required migration steps. This entry is **unreleased** until the PR is merged and a release tag is published. + +## 0.1.x — Legacy series + +The previous public API used a concrete, PDO-backed `Objectiveweb\Auth`, a single `ow_auth` user table with a `username` column, `Objectiveweb\Auth\AuthController` and PHP 5.3-era dependencies. This entry describes the legacy series for comparison; its individual release dates and tag history are not reconstructed here. From c0ec2cfe2d052cb55d4576f28d3c9d817fbe17a8 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:42:50 -0300 Subject: [PATCH 193/217] docs(auth): add practical 0.1.x to 0.2.0 upgrade guide and rollout checklist --- docs/UPGRADING-0.2.0.md | 143 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/UPGRADING-0.2.0.md diff --git a/docs/UPGRADING-0.2.0.md b/docs/UPGRADING-0.2.0.md new file mode 100644 index 0000000..7f68362 --- /dev/null +++ b/docs/UPGRADING-0.2.0.md @@ -0,0 +1,143 @@ +# Upgrading objectiveweb/auth from 0.1.x to 0.2.0 + +> **Breaking rewrite — manual migration required.** There is no automatic schema/data converter or compatibility adapter from 0.1.x. Do not deploy v0.2.0 against the old \`ow_auth\` table and assume that existing users will work. Migrate a copy of your application first, and plan a coordinated code + schema rollout. + +## Before you start + +- Confirm the new runtime can run **PHP 8.2+** and install \`objectiveweb/db ^0.8.0\`. +- Review every caller of \`Objectiveweb\Auth\`, the old \`Objectiveweb\Auth\AuthController\`, the old HAL-style \`query()\` response and legacy exception classes. +- Inventory existing \`ow_auth\` columns, password hashes, primary IDs, external foreign keys, username/email semantics, sessions, outstanding password reset links and application permission tables. +- Prepare a current **restorable backup** of both the database and application configuration. Use a staging clone and run the migration with writes paused or with a proven incremental catch-up strategy. +- Decide which legacy user IDs must be retained to preserve external FK references. Verify there are no duplicate login identifiers in the new \`(uid, provider)\` namespace. +- Plan a rollback point. The old and new user models are not schema-compatible; rolling back code alone after new writes is unsafe. + +## 1. Change application code and dependencies + +| In 0.1.x | In 0.2.0 | Required action | +| --- | --- | --- | +| PHP >=5.3, concrete \`Objectiveweb\Auth\` | PHP >=8.2, abstract \`Objectiveweb\Auth\` | Update PHP/dependencies, instantiate an implementation | +| \`new Auth($pdo, $params)\` | \`new DBAuth(new DB($dsn, ...), $params)\` | Replace direct PDO construction with \`objectiveweb/db\` | +| One \`ow_auth\` table, \`username\` stored on user row | \`user\` + \`user_credentials\`, \`(provider, uid)\` identity | Create schema and import old identities | +| \`Auth::get($username)\` / \`update($username, ...)\` by inferred username | \`DBAuth::get($id)\`, \`update($id, ...)\` by user ID (or explicit alternate key where supported) | Resolve the credential first or use retained user IDs | +| \`Auth::query()\` returns \`_embedded\` + page arrays | Returns \`Objectiveweb\DB\Collection\` | Rewrite list controllers and pagination rendering | +| \`Objectiveweb\Auth\AuthController\` (old class) | \`Objectiveweb\Auth\Controller\AuthController\` and \`UserController\` | Update imports, routes, authorization and write payloads | +| \`PasswordMismatchException\` / distinct unknown-user response | \`AuthException('Invalid credentials', 401)\` for password login | Update exception handling without leaking which condition failed | +| 0.1.x plaintext password-reset tokens | Expiring, one-use SHA-256 token digests | Invalidate old links and issue new ones | +| Legacy session principal format | Sanitized principal with credentials, roles and optional context | Expire old sessions and require a fresh login | +| Implicit application assignments in Auth-managed relations | Application-owned relationships and \`user_context_callback\` | Move write/validation logic to application services | + +Minimal setup for a new application database: + +\`\`\`php + 'token', + 'disabled_at' => 'disabled_at', +]); +\`\`\` + +Install/upgrade the package when the migrated schema is ready: + +\`\`\`sh +composer require 'objectiveweb/auth:^0.2' +\`\`\` + +Until v0.2.0 is published, the package cannot be installed from a public release tag; use PR/test environments rather than changing production prematurely. If your application uses the packaged router controllers, add the compatible \`objectiveweb/router\` integration explicitly. The bundled Phinx package and OAuth provider implementations are optional; include them in the **application** deployment/build environment if used. + +## 2. Provision and inspect the new schema + +The bundled Phinx migrations in \`db/\` create the **default logical tables**: + +| Table | Meaning | +| --- | --- | +| \`user\` | User IDs, unique UUID, optional bcrypt password, name/image, password reset token digest/expiry, suspension timestamp, created timestamp | +| \`user_credentials\` | Unique \`(uid, provider)\`, \`user_id\` foreign key, provider profile, last login, token digest, \`token_expires_at\`, \`verified_at\` | +| \`role\` / \`user_roles\` | Global roles and membership | +| \`delegations\` | Optional application-configured authorization relationship | + +Apply all bundled migrations **in timestamp order**, including +\`20261019210000_auth_credential_verification_migration.php\`. The last migration adds credential-level \`token_expires_at\` and \`verified_at\`; it does **not** migrate legacy users. + +Point your application's Phinx configuration to the installed package's \`db/\` directory (or copy those migrations into your application's migration plan). The repository's \`test/phinx.php\` is for integration tests, **not** a production deployment configuration. + +If the application already has conflicting table names or uses prefixes, configure explicit table mappings in \`DBAuth\` and migrate into a compatible schema. Check foreign keys, collations and column sizes on your target engine; CI covers SQLite, PostgreSQL 16, MySQL 8.0 and MySQL 8.4 but cannot validate an arbitrary application's existing schema. + +## 3. Migrate legacy user data explicitly + +The 0.1.x default table was \`ow_auth\`, with fields such as \`id\`, \`username\`, \`password\`, \`created\`, \`last_login\` and optionally \`token\`, plus any application-specific fields. + +A **migration script that you write and test** should apply the following mapping. These are mapping rules, not an executable generic migration: actual schemas and data constraints vary. + +| Old value | New value | Rule | +| --- | --- | --- | +| \`ow_auth.id\` | \`user.id\` | Preserve the numeric ID if other tables reference it, otherwise build and persist an old-to-new ID map | +| \`ow_auth.password\` | \`user.password\` | Retain valid bcrypt hash **as-is**; do not double-hash it, decrypt it or replace it with a plaintext password | +| \`ow_auth.username\` | \`user_credentials.uid\` | Use \`provider = 'local'\`; do not assume that the username is necessarily an email address | +| Old username/password identity | \`user_credentials.user_id\` | Link to exactly one migrated user; enforce uniqueness of \`(uid, provider)\` | +| Existing display name/image | \`user.name\` / \`user.image\` | Map approved application-specific columns explicitly | +| \`ow_auth.created\` | \`user.created\` | Preserve if valid; otherwise select a documented import value | +| New UUID | \`user.uuid\` | Generate a **unique, valid UUID v4** for each migrated user; the old model did not have this column by default | +| \`ow_auth.last_login\` | \`user_credentials.last_login\` | Map only if it represents that credential's login; otherwise leave nullable | +| \`ow_auth.token\` | **do not migrate** | Revoke outstanding legacy password-reset links; set \`user.token\` and \`token_expires_at\` to \`NULL\` | +| \`user_credentials.verified_at\` | Initially \`NULL\` unless independently proven | Never mark legacy email/phone/OAuth ownership verified solely because a matching address was stored | +| Existing roles and relations | \`role\`, \`user_roles\` or application tables | Review grants and target IDs; do not silently grant \`admin\` to a legacy user | + +**Data migration order:** import user rows; verify ID/UUID uniqueness; import linked \`local\` credentials; migrate or remap application foreign keys; import the intended role memberships; migrate any Auth-owned delegations; then run validation queries. Do this in a controlled transaction where the database/schema permits it, and stop deployment on conflicts. + +Preserving an existing bcrypt hash allows a password user to sign in without knowing the plaintext. But a legacy username like \`jdoe\` will not automatically work with the new public registration endpoint, which requires an email-formatted UID. Choose and document your application's identity/registration strategy before launch. Password login itself checks configured \`login_providers\` (default \`['local', 'email']\`). + +If you preserve numeric \`id\` values, also reset the auto-increment/identity sequence for the migrated user table above the maximum imported ID before any new registration. Migrate and verify application-side FKs before enabling deletes, and confirm the DBAuth default table names or configured aliases resolve to the intended physical tables. + +## 4. Update requests, controllers and authorization + +- Replace old \`Objectiveweb\Auth\AuthController\` routes with \`Objectiveweb\Auth\Controller\AuthController\` for authentication and \`UserController\` for admin user management. +- Public registration accepts an email UID, an optional password and safe \`name\`/\`image\` profile fields, not arbitrary \`provider\` or role grants. +- Privileged management operations require the **admin** role. Seed valid roles before accepting role assignments; the management API rejects unknown roles. +- Management writes and authenticated password changes use **JSON**, the authenticated PHP session and \`X-CSRF-Token\` from \`AuthController::index()\` (\`_csrf\`). Authenticated password changes also require \`current_password\`. +- Replace HAL queries such as \`$result['_embedded']['ow_auth']\` with iteration over a \`Collection\` and \`$collection->total()\` / \`$collection->contentRange()\`. +- Replace old \`PasswordMismatchException\` handlers with \`AuthException\` and a generic 401 response for invalid password login. +- Update any direct password/credential/token calls to use **numeric user IDs** and the new \`(provider, uid)\` credential APIs. \`passwd()\` is a trusted service/admin operation; use \`postPassword()\` for end-user password changes. +- If using \`managed_relations\` in earlier development builds, migrate their reads/writes/validation to application services and use \`user_context_callback\` for refreshed session context. Auth does not automatically persist or remove application-owned association rows. + +## 5. Deploy OAuth changes deliberately + +- Install each optional League OAuth client implementation you use, e.g. \`league/oauth2-google\` or \`league/oauth2-facebook\`, in the application; they are suggestions, not mandatory runtime dependencies. +- Configure an exact registered \`redirectUri\` for every provider. It must use HTTPS, except explicitly configured HTTP on \`localhost\` / loopback for development. Request-derived Host/proxy callback URLs are no longer accepted. +- OAuth uses PKCE S256 and expiring single-use provider/session-bound state. Maintain secure, HttpOnly, SameSite browser sessions. +- A provider identity that has never been seen before does **not** create a local account automatically. Your application must serve the pending-choice page and wire \`getPendingAccount()\`, \`postCreateAccount()\`, \`postExistingAccount()\`, \`postCancelPendingAccount()\`. See [account-choice example](../examples/oauth-account-choice.html). +- Existing signed-in users link OAuth identities only via an authenticated, CSRF-protected \`postLink($provider)\` POST; the frontend navigates to its returned \`authorization_url\`. Normal OAuth login never silently links by email. +- Provider display profile/email values are **hints**, never proof that two accounts belong together. + +## 6. Configure production security and callback delivery + +- **Enable external rate limiting** for login, registration, recovery, reset redemption and credential verification at the gateway/WAF/reverse proxy or middleware. There is intentionally no application-DB rate-limit counter in 0.2.0; see [#2](https://github.com/objectiveweb/auth/issues/2). +- Set \`register_scope\`, \`token\`, \`token_ttl\`, \`token_callback\`, \`invitation_callback\`, \`reset_callback\`, and \`disabled_at\` appropriately. The package cannot deliver email/SMS itself. +- Back up data, expire old auth sessions and clear legacy password-reset links during cutover. Require fresh login; the legacy session payload and credentials cannot be trusted as a v0.2.0 principal. +- Keep user secrets, credential profiles and tokens out of logs, analytics, public APIs and generic error pages. +- Validate application-specific roles, relationships and callbacks on staging before allowing privileged operations. + +## 7. Verify the rollout + +Before merging/upgrading, check: + +- [ ] PHP >=8.2 and correct Composer runtime/integration dependencies; \`composer validate --strict\` passes. +- [ ] A clean install with \`--no-dev\` and the standalone production smoke test passes; PR checks cover this. +- [ ] The target migration schema includes both credential verification columns; indexes and foreign keys validate. +- [ ] Old/new user counts match; preserved IDs and all application foreign keys resolve correctly. +- [ ] Every old password identity maps to exactly one \`local\` credential and bcrypt login works. +- [ ] Old password-reset tokens are invalid; new reset and credential-specific verification tokens are one-time and expire. +- [ ] List/search pagination, role assignment and active-admin safeguards behave as expected. +- [ ] Existing sessions are invalidated; password changes require current password and CSRF. +- [ ] OAuth registered redirect URIs, first-time account choice, existing-account linking, returning-login and link-conflict cases work with **your** providers. +- [ ] External rate limits and secure session cookies are configured and tested. +- [ ] Backups, restore procedure, cutover maintenance window and rollback criteria are agreed. + +**Rollback:** Restore a consistent snapshot of the original 0.1.x database and code together, including application tables and session strategy. Do not simply point 0.1.x code at the 0.2.0 schema, and do not expect new accounts/credentials to survive a rollback unless you build a separate reverse-conversion process. + +For complete API documentation, see [README](../README.md); for the feature summary, see [CHANGELOG](../CHANGELOG.md). From 81a140c92a9249a26a4e7dfbf8b05170d4619ba7 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:43:29 -0300 Subject: [PATCH 194/217] docs(auth): correct Markdown formatting in 0.2 upgrade guide --- docs/UPGRADING-0.2.0.md | 112 ++++++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/docs/UPGRADING-0.2.0.md b/docs/UPGRADING-0.2.0.md index 7f68362..3ae01a6 100644 --- a/docs/UPGRADING-0.2.0.md +++ b/docs/UPGRADING-0.2.0.md @@ -1,34 +1,34 @@ # Upgrading objectiveweb/auth from 0.1.x to 0.2.0 -> **Breaking rewrite — manual migration required.** There is no automatic schema/data converter or compatibility adapter from 0.1.x. Do not deploy v0.2.0 against the old \`ow_auth\` table and assume that existing users will work. Migrate a copy of your application first, and plan a coordinated code + schema rollout. +> **Breaking rewrite — manual migration required.** There is no automatic schema/data converter or compatibility adapter from 0.1.x. Do not deploy v0.2.0 against the old `ow_auth` table and assume that existing users will work. Migrate a copy of your application first, and plan a coordinated code + schema rollout. ## Before you start -- Confirm the new runtime can run **PHP 8.2+** and install \`objectiveweb/db ^0.8.0\`. -- Review every caller of \`Objectiveweb\Auth\`, the old \`Objectiveweb\Auth\AuthController\`, the old HAL-style \`query()\` response and legacy exception classes. -- Inventory existing \`ow_auth\` columns, password hashes, primary IDs, external foreign keys, username/email semantics, sessions, outstanding password reset links and application permission tables. +- Confirm the new runtime can run **PHP 8.2+** and install `objectiveweb/db ^0.8.0`. +- Review every caller of `Objectiveweb\Auth`, the old `Objectiveweb\Auth\AuthController`, the old HAL-style `query()` response and legacy exception classes. +- Inventory existing `ow_auth` columns, password hashes, primary IDs, external foreign keys, username/email semantics, sessions, outstanding password reset links and application permission tables. - Prepare a current **restorable backup** of both the database and application configuration. Use a staging clone and run the migration with writes paused or with a proven incremental catch-up strategy. -- Decide which legacy user IDs must be retained to preserve external FK references. Verify there are no duplicate login identifiers in the new \`(uid, provider)\` namespace. +- Decide which legacy user IDs must be retained to preserve external FK references. Verify there are no duplicate login identifiers in the new `(uid, provider)` namespace. - Plan a rollback point. The old and new user models are not schema-compatible; rolling back code alone after new writes is unsafe. ## 1. Change application code and dependencies | In 0.1.x | In 0.2.0 | Required action | | --- | --- | --- | -| PHP >=5.3, concrete \`Objectiveweb\Auth\` | PHP >=8.2, abstract \`Objectiveweb\Auth\` | Update PHP/dependencies, instantiate an implementation | -| \`new Auth($pdo, $params)\` | \`new DBAuth(new DB($dsn, ...), $params)\` | Replace direct PDO construction with \`objectiveweb/db\` | -| One \`ow_auth\` table, \`username\` stored on user row | \`user\` + \`user_credentials\`, \`(provider, uid)\` identity | Create schema and import old identities | -| \`Auth::get($username)\` / \`update($username, ...)\` by inferred username | \`DBAuth::get($id)\`, \`update($id, ...)\` by user ID (or explicit alternate key where supported) | Resolve the credential first or use retained user IDs | -| \`Auth::query()\` returns \`_embedded\` + page arrays | Returns \`Objectiveweb\DB\Collection\` | Rewrite list controllers and pagination rendering | -| \`Objectiveweb\Auth\AuthController\` (old class) | \`Objectiveweb\Auth\Controller\AuthController\` and \`UserController\` | Update imports, routes, authorization and write payloads | -| \`PasswordMismatchException\` / distinct unknown-user response | \`AuthException('Invalid credentials', 401)\` for password login | Update exception handling without leaking which condition failed | +| PHP >=5.3, concrete `Objectiveweb\Auth` | PHP >=8.2, abstract `Objectiveweb\Auth` | Update PHP/dependencies, instantiate an implementation | +| `new Auth($pdo, $params)` | `new DBAuth(new DB($dsn, ...), $params)` | Replace direct PDO construction with `objectiveweb/db` | +| One `ow_auth` table, `username` stored on user row | `user` + `user_credentials`, `(provider, uid)` identity | Create schema and import old identities | +| `Auth::get($username)` / `update($username, ...)` by inferred username | `DBAuth::get($id)`, `update($id, ...)` by user ID (or explicit alternate key where supported) | Resolve the credential first or use retained user IDs | +| `Auth::query()` returns `_embedded` + page arrays | Returns `Objectiveweb\DB\Collection` | Rewrite list controllers and pagination rendering | +| `Objectiveweb\Auth\AuthController` (old class) | `Objectiveweb\Auth\Controller\AuthController` and `UserController` | Update imports, routes, authorization and write payloads | +| `PasswordMismatchException` / distinct unknown-user response | `AuthException('Invalid credentials', 401)` for password login | Update exception handling without leaking which condition failed | | 0.1.x plaintext password-reset tokens | Expiring, one-use SHA-256 token digests | Invalidate old links and issue new ones | | Legacy session principal format | Sanitized principal with credentials, roles and optional context | Expire old sessions and require a fresh login | -| Implicit application assignments in Auth-managed relations | Application-owned relationships and \`user_context_callback\` | Move write/validation logic to application services | +| Implicit application assignments in Auth-managed relations | Application-owned relationships and `user_context_callback` | Move write/validation logic to application services | Minimal setup for a new application database: -\`\`\`php +```php 'token', 'disabled_at' => 'disabled_at', ]); -\`\`\` +``` Install/upgrade the package when the migrated schema is ready: -\`\`\`sh +```sh composer require 'objectiveweb/auth:^0.2' -\`\`\` +``` -Until v0.2.0 is published, the package cannot be installed from a public release tag; use PR/test environments rather than changing production prematurely. If your application uses the packaged router controllers, add the compatible \`objectiveweb/router\` integration explicitly. The bundled Phinx package and OAuth provider implementations are optional; include them in the **application** deployment/build environment if used. +Until v0.2.0 is published, the package cannot be installed from a public release tag; use PR/test environments rather than changing production prematurely. If your application uses the packaged router controllers, add the compatible `objectiveweb/router` integration explicitly. The bundled Phinx package and OAuth provider implementations are optional; include them in the **application** deployment/build environment if used. ## 2. Provision and inspect the new schema -The bundled Phinx migrations in \`db/\` create the **default logical tables**: +The bundled Phinx migrations in `db/` create the **default logical tables**: | Table | Meaning | | --- | --- | -| \`user\` | User IDs, unique UUID, optional bcrypt password, name/image, password reset token digest/expiry, suspension timestamp, created timestamp | -| \`user_credentials\` | Unique \`(uid, provider)\`, \`user_id\` foreign key, provider profile, last login, token digest, \`token_expires_at\`, \`verified_at\` | -| \`role\` / \`user_roles\` | Global roles and membership | -| \`delegations\` | Optional application-configured authorization relationship | +| `user` | User IDs, unique UUID, optional bcrypt password, name/image, password reset token digest/expiry, suspension timestamp, created timestamp | +| `user_credentials` | Unique `(uid, provider)`, `user_id` foreign key, provider profile, last login, token digest, `token_expires_at`, `verified_at` | +| `role` / `user_roles` | Global roles and membership | +| `delegations` | Optional application-configured authorization relationship | Apply all bundled migrations **in timestamp order**, including -\`20261019210000_auth_credential_verification_migration.php\`. The last migration adds credential-level \`token_expires_at\` and \`verified_at\`; it does **not** migrate legacy users. +`20261019210000_auth_credential_verification_migration.php`. The last migration adds credential-level `token_expires_at` and `verified_at`; it does **not** migrate legacy users. -Point your application's Phinx configuration to the installed package's \`db/\` directory (or copy those migrations into your application's migration plan). The repository's \`test/phinx.php\` is for integration tests, **not** a production deployment configuration. +Point your application's Phinx configuration to the installed package's `db/` directory (or copy those migrations into your application's migration plan). The repository's `test/phinx.php` is for integration tests, **not** a production deployment configuration. -If the application already has conflicting table names or uses prefixes, configure explicit table mappings in \`DBAuth\` and migrate into a compatible schema. Check foreign keys, collations and column sizes on your target engine; CI covers SQLite, PostgreSQL 16, MySQL 8.0 and MySQL 8.4 but cannot validate an arbitrary application's existing schema. +If the application already has conflicting table names or uses prefixes, configure explicit table mappings in `DBAuth` and migrate into a compatible schema. Check foreign keys, collations and column sizes on your target engine; CI covers SQLite, PostgreSQL 16, MySQL 8.0 and MySQL 8.4 but cannot validate an arbitrary application's existing schema. ## 3. Migrate legacy user data explicitly -The 0.1.x default table was \`ow_auth\`, with fields such as \`id\`, \`username\`, \`password\`, \`created\`, \`last_login\` and optionally \`token\`, plus any application-specific fields. +The 0.1.x default table was `ow_auth`, with fields such as `id`, `username`, `password`, `created`, `last_login` and optionally `token`, plus any application-specific fields. A **migration script that you write and test** should apply the following mapping. These are mapping rules, not an executable generic migration: actual schemas and data constraints vary. | Old value | New value | Rule | | --- | --- | --- | -| \`ow_auth.id\` | \`user.id\` | Preserve the numeric ID if other tables reference it, otherwise build and persist an old-to-new ID map | -| \`ow_auth.password\` | \`user.password\` | Retain valid bcrypt hash **as-is**; do not double-hash it, decrypt it or replace it with a plaintext password | -| \`ow_auth.username\` | \`user_credentials.uid\` | Use \`provider = 'local'\`; do not assume that the username is necessarily an email address | -| Old username/password identity | \`user_credentials.user_id\` | Link to exactly one migrated user; enforce uniqueness of \`(uid, provider)\` | -| Existing display name/image | \`user.name\` / \`user.image\` | Map approved application-specific columns explicitly | -| \`ow_auth.created\` | \`user.created\` | Preserve if valid; otherwise select a documented import value | -| New UUID | \`user.uuid\` | Generate a **unique, valid UUID v4** for each migrated user; the old model did not have this column by default | -| \`ow_auth.last_login\` | \`user_credentials.last_login\` | Map only if it represents that credential's login; otherwise leave nullable | -| \`ow_auth.token\` | **do not migrate** | Revoke outstanding legacy password-reset links; set \`user.token\` and \`token_expires_at\` to \`NULL\` | -| \`user_credentials.verified_at\` | Initially \`NULL\` unless independently proven | Never mark legacy email/phone/OAuth ownership verified solely because a matching address was stored | -| Existing roles and relations | \`role\`, \`user_roles\` or application tables | Review grants and target IDs; do not silently grant \`admin\` to a legacy user | +| `ow_auth.id` | `user.id` | Preserve the numeric ID if other tables reference it, otherwise build and persist an old-to-new ID map | +| `ow_auth.password` | `user.password` | Retain valid bcrypt hash **as-is**; do not double-hash it, decrypt it or replace it with a plaintext password | +| `ow_auth.username` | `user_credentials.uid` | Use `provider = 'local'`; do not assume that the username is necessarily an email address | +| Old username/password identity | `user_credentials.user_id` | Link to exactly one migrated user; enforce uniqueness of `(uid, provider)` | +| Existing display name/image | `user.name` / `user.image` | Map approved application-specific columns explicitly | +| `ow_auth.created` | `user.created` | Preserve if valid; otherwise select a documented import value | +| New UUID | `user.uuid` | Generate a **unique, valid UUID v4** for each migrated user; the old model did not have this column by default | +| `ow_auth.last_login` | `user_credentials.last_login` | Map only if it represents that credential's login; otherwise leave nullable | +| `ow_auth.token` | **do not migrate** | Revoke outstanding legacy password-reset links; set `user.token` and `token_expires_at` to `NULL` | +| `user_credentials.verified_at` | Initially `NULL` unless independently proven | Never mark legacy email/phone/OAuth ownership verified solely because a matching address was stored | +| Existing roles and relations | `role`, `user_roles` or application tables | Review grants and target IDs; do not silently grant `admin` to a legacy user | -**Data migration order:** import user rows; verify ID/UUID uniqueness; import linked \`local\` credentials; migrate or remap application foreign keys; import the intended role memberships; migrate any Auth-owned delegations; then run validation queries. Do this in a controlled transaction where the database/schema permits it, and stop deployment on conflicts. +**Data migration order:** import user rows; verify ID/UUID uniqueness; import linked `local` credentials; migrate or remap application foreign keys; import the intended role memberships; migrate any Auth-owned delegations; then run validation queries. Do this in a controlled transaction where the database/schema permits it, and stop deployment on conflicts. -Preserving an existing bcrypt hash allows a password user to sign in without knowing the plaintext. But a legacy username like \`jdoe\` will not automatically work with the new public registration endpoint, which requires an email-formatted UID. Choose and document your application's identity/registration strategy before launch. Password login itself checks configured \`login_providers\` (default \`['local', 'email']\`). +Preserving an existing bcrypt hash allows a password user to sign in without knowing the plaintext. But a legacy username like `jdoe` will not automatically work with the new public registration endpoint, which requires an email-formatted UID. Choose and document your application's identity/registration strategy before launch. Password login itself checks configured `login_providers` (default `['local', 'email']`). -If you preserve numeric \`id\` values, also reset the auto-increment/identity sequence for the migrated user table above the maximum imported ID before any new registration. Migrate and verify application-side FKs before enabling deletes, and confirm the DBAuth default table names or configured aliases resolve to the intended physical tables. +If you preserve numeric `id` values, also reset the auto-increment/identity sequence for the migrated user table above the maximum imported ID before any new registration. Migrate and verify application-side FKs before enabling deletes, and confirm the DBAuth default table names or configured aliases resolve to the intended physical tables. ## 4. Update requests, controllers and authorization -- Replace old \`Objectiveweb\Auth\AuthController\` routes with \`Objectiveweb\Auth\Controller\AuthController\` for authentication and \`UserController\` for admin user management. -- Public registration accepts an email UID, an optional password and safe \`name\`/\`image\` profile fields, not arbitrary \`provider\` or role grants. +- Replace old `Objectiveweb\Auth\AuthController` routes with `Objectiveweb\Auth\Controller\AuthController` for authentication and `UserController` for admin user management. +- Public registration accepts an email UID, an optional password and safe `name`/`image` profile fields, not arbitrary `provider` or role grants. - Privileged management operations require the **admin** role. Seed valid roles before accepting role assignments; the management API rejects unknown roles. -- Management writes and authenticated password changes use **JSON**, the authenticated PHP session and \`X-CSRF-Token\` from \`AuthController::index()\` (\`_csrf\`). Authenticated password changes also require \`current_password\`. -- Replace HAL queries such as \`$result['_embedded']['ow_auth']\` with iteration over a \`Collection\` and \`$collection->total()\` / \`$collection->contentRange()\`. -- Replace old \`PasswordMismatchException\` handlers with \`AuthException\` and a generic 401 response for invalid password login. -- Update any direct password/credential/token calls to use **numeric user IDs** and the new \`(provider, uid)\` credential APIs. \`passwd()\` is a trusted service/admin operation; use \`postPassword()\` for end-user password changes. -- If using \`managed_relations\` in earlier development builds, migrate their reads/writes/validation to application services and use \`user_context_callback\` for refreshed session context. Auth does not automatically persist or remove application-owned association rows. +- Management writes and authenticated password changes use **JSON**, the authenticated PHP session and `X-CSRF-Token` from `AuthController::index()` (`_csrf`). Authenticated password changes also require `current_password`. +- Replace HAL queries such as `$result['_embedded']['ow_auth']` with iteration over a `Collection` and `$collection->total()` / `$collection->contentRange()`. +- Replace old `PasswordMismatchException` handlers with `AuthException` and a generic 401 response for invalid password login. +- Update any direct password/credential/token calls to use **numeric user IDs** and the new `(provider, uid)` credential APIs. `passwd()` is a trusted service/admin operation; use `postPassword()` for end-user password changes. +- If using `managed_relations` in earlier development builds, migrate their reads/writes/validation to application services and use `user_context_callback` for refreshed session context. Auth does not automatically persist or remove application-owned association rows. ## 5. Deploy OAuth changes deliberately -- Install each optional League OAuth client implementation you use, e.g. \`league/oauth2-google\` or \`league/oauth2-facebook\`, in the application; they are suggestions, not mandatory runtime dependencies. -- Configure an exact registered \`redirectUri\` for every provider. It must use HTTPS, except explicitly configured HTTP on \`localhost\` / loopback for development. Request-derived Host/proxy callback URLs are no longer accepted. +- Install each optional League OAuth client implementation you use, e.g. `league/oauth2-google` or `league/oauth2-facebook`, in the application; they are suggestions, not mandatory runtime dependencies. +- Configure an exact registered `redirectUri` for every provider. It must use HTTPS, except explicitly configured HTTP on `localhost` / loopback for development. Request-derived Host/proxy callback URLs are no longer accepted. - OAuth uses PKCE S256 and expiring single-use provider/session-bound state. Maintain secure, HttpOnly, SameSite browser sessions. -- A provider identity that has never been seen before does **not** create a local account automatically. Your application must serve the pending-choice page and wire \`getPendingAccount()\`, \`postCreateAccount()\`, \`postExistingAccount()\`, \`postCancelPendingAccount()\`. See [account-choice example](../examples/oauth-account-choice.html). -- Existing signed-in users link OAuth identities only via an authenticated, CSRF-protected \`postLink($provider)\` POST; the frontend navigates to its returned \`authorization_url\`. Normal OAuth login never silently links by email. +- A provider identity that has never been seen before does **not** create a local account automatically. Your application must serve the pending-choice page and wire `getPendingAccount()`, `postCreateAccount()`, `postExistingAccount()`, `postCancelPendingAccount()`. See [account-choice example](../examples/oauth-account-choice.html). +- Existing signed-in users link OAuth identities only via an authenticated, CSRF-protected `postLink($provider)` POST; the frontend navigates to its returned `authorization_url`. Normal OAuth login never silently links by email. - Provider display profile/email values are **hints**, never proof that two accounts belong together. ## 6. Configure production security and callback delivery - **Enable external rate limiting** for login, registration, recovery, reset redemption and credential verification at the gateway/WAF/reverse proxy or middleware. There is intentionally no application-DB rate-limit counter in 0.2.0; see [#2](https://github.com/objectiveweb/auth/issues/2). -- Set \`register_scope\`, \`token\`, \`token_ttl\`, \`token_callback\`, \`invitation_callback\`, \`reset_callback\`, and \`disabled_at\` appropriately. The package cannot deliver email/SMS itself. +- Set `register_scope`, `token`, `token_ttl`, `token_callback`, `invitation_callback`, `reset_callback`, and `disabled_at` appropriately. The package cannot deliver email/SMS itself. - Back up data, expire old auth sessions and clear legacy password-reset links during cutover. Require fresh login; the legacy session payload and credentials cannot be trusted as a v0.2.0 principal. - Keep user secrets, credential profiles and tokens out of logs, analytics, public APIs and generic error pages. - Validate application-specific roles, relationships and callbacks on staging before allowing privileged operations. @@ -126,11 +126,11 @@ If you preserve numeric \`id\` values, also reset the auto-increment/identity se Before merging/upgrading, check: -- [ ] PHP >=8.2 and correct Composer runtime/integration dependencies; \`composer validate --strict\` passes. -- [ ] A clean install with \`--no-dev\` and the standalone production smoke test passes; PR checks cover this. +- [ ] PHP >=8.2 and correct Composer runtime/integration dependencies; `composer validate --strict` passes. +- [ ] A clean install with `--no-dev` and the standalone production smoke test passes; PR checks cover this. - [ ] The target migration schema includes both credential verification columns; indexes and foreign keys validate. - [ ] Old/new user counts match; preserved IDs and all application foreign keys resolve correctly. -- [ ] Every old password identity maps to exactly one \`local\` credential and bcrypt login works. +- [ ] Every old password identity maps to exactly one `local` credential and bcrypt login works. - [ ] Old password-reset tokens are invalid; new reset and credential-specific verification tokens are one-time and expire. - [ ] List/search pagination, role assignment and active-admin safeguards behave as expected. - [ ] Existing sessions are invalidated; password changes require current password and CSRF. From 6abc637f08b5aa35c0d69ce4ae2e0a02e54138cb Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:44:40 -0300 Subject: [PATCH 195/217] docs(auth): prepare v0.2.0 release notes and operator checklist --- docs/RELEASE-0.2.0.md | 74 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/RELEASE-0.2.0.md diff --git a/docs/RELEASE-0.2.0.md b/docs/RELEASE-0.2.0.md new file mode 100644 index 0000000..65b845e --- /dev/null +++ b/docs/RELEASE-0.2.0.md @@ -0,0 +1,74 @@ +# objectiveweb/auth v0.2.0 — Release notes + +**Status: release candidate — not published.** Prepared for [PR #1](https://github.com/objectiveweb/auth/pull/1). These notes may be used as the GitHub release description **after** the PR is merged, the release checks pass on the intended commit, and the `v0.2.0` tag is created. + +## Summary + +v0.2.0 is a **breaking rewrite** of `objectiveweb/auth`. It introduces a provider-based authentication core, a portable `objectiveweb/db` backend, a complete in-memory implementation, secure OAuth login/linking flows, role-based authorization, reusable management controllers and stronger session/token handling. + +**Do not upgrade an existing 0.1.x application in place without a migration plan.** The former PDO-backed class, tables and controller paths are incompatible with v0.2.0. + +Read the [upgrade guide](UPGRADING-0.2.0.md) and [full changelog](../CHANGELOG.md) before upgrading. + +## Highlights + +- **Provider architecture:** abstract `Objectiveweb\Auth`, database-backed `Objectiveweb\Auth\DBAuth` and `BasicAuth` for small/in-memory deployments. +- **Portable data model:** independent user and provider-credential records; UUID, roles, user-role memberships, optional delegations, session context callbacks and normalized `Collection` queries. +- **Password and credential protection:** generic invalid-login responses, sanitized public users, expiring SHA-256 reset/verification token digests, atomic single-use redemption, CSRF-protected authenticated password changes and account suspension checks. +- **OAuth hardening:** configured HTTPS callback URLs, PKCE S256, bound one-time state, safe provider-profile handling and **explicit account linking** without email-based account takeover. +- **First-time OAuth choice:** unknown OAuth users choose to create an account or sign in with an existing email/password before any local account is created or linked; example frontend included. +- **Admin API:** user lifecycle and credential management, role constraints, safe sorting/pagination and protection of the last active administrator. +- **Validation:** PHP 8.2–8.5; SQLite; PostgreSQL 16 migrations; MySQL 8.0/8.4 migrations and DBAuth scenarios; real League Google/Facebook provider implementations with mocked network; Composer `--no-dev` and isolated consumer installation. + +## Required changes for existing applications + +1. Upgrade to **PHP 8.2+** and `objectiveweb/db ^0.8.0`. The 0.1.x concrete `new Auth($pdo, ...)` API is removed; instantiate `DBAuth(new DB(...))` or `BasicAuth`. +2. Provision the **new user/credentials schema** and manually map legacy `ow_auth` users and their login IDs. There is **no automatic 0.1.x data migration**. +3. Replace legacy `Objectiveweb\Auth\AuthController` routes with `Objectiveweb\Auth\Controller\AuthController` and `UserController`; update role enforcement, JSON/CSRF requirements, and `Collection` response handling. +4. Invalidate legacy plaintext password-reset links and old sessions, then confirm new reset, registration and login flows in staging. +5. Explicitly configure an OAuth `redirectUri` for each provider and integrate the OAuth account-choice and consent-based linking routes if OAuth is enabled. +6. Configure **external authentication rate limiting** at the reverse proxy, WAF, gateway or middleware before making endpoints public. + +### New application installation + +```sh +composer require 'objectiveweb/auth:^0.2' +``` + +> The above command is for **after v0.2.0 is published**. The pre-release CI verifies the candidate through a separate Composer path-based consumer project without falsely assuming Packagist publication. + +```php + 'token', 'disabled_at' => 'disabled_at']); +``` + +Apply the bundled **Phinx migrations** or an equivalent checked schema before constructing an application with persistent users. Provider implementations and the router integration must be included by consuming applications that use those features. + +## Security and deployment notes + +- A matching OAuth email does **not** imply ownership of an existing account; use explicit, authenticated linking. The pending-account choice expires after ten minutes. +- Runtime PHP sessions and secure cookie settings are the consuming application's responsibility; use HttpOnly, Secure and SameSite where appropriate. +- Reset/verification tokens are one-time and should be delivered only out-of-band; provide the application's delivery callbacks. +- `managed_relations` is not part of the generic Auth-owned persistence model; application services own business-specific authorization assignments. +- `register_scope`, protected admin routes, CSRF, gateway throttling and deployed OAuth callback URLs must be reviewed per application. + +## Deferred to later versions + +Rate limiting ([#2](https://github.com/objectiveweb/auth/issues/2)), OAuth2/OIDC server and JWT/JWKS ([#3](https://github.com/objectiveweb/auth/issues/3)), passkeys/TOTP/MFA ([#4](https://github.com/objectiveweb/auth/issues/4)), and refresh-token/device-session management ([#5](https://github.com/objectiveweb/auth/issues/5)) are **not** delivered by v0.2.0. + +## Release operator checklist + +- [ ] Review and approve [PR #1](https://github.com/objectiveweb/auth/pull/1), including this changelog and upgrade guide. +- [ ] Confirm all mandatory CI and release-verification checks pass for the **final** PR head/merge commit: PHP 8.2–8.5, MySQL 8.0/8.4, PostgreSQL 16, OAuth and production installation. +- [ ] Verify package metadata and `composer validate --strict`, and check that the chosen tag points to the exact merged commit. +- [ ] Confirm production applications have migration, backup/rollback, session, callback, OAuth redirect and rate-limit plans. +- [ ] Merge the PR, create the `v0.2.0` tag and publish release notes **only after** those checks; do not imply this document itself performs a release. +- [ ] Confirm Composer/Packagist can resolve the newly published tag (if using Packagist), and validate a clean consumer installation. + +Full reference: [README](../README.md) · [Changelog](../CHANGELOG.md) · [Upgrade guide](UPGRADING-0.2.0.md). From feabae814529690d61a88eb4290aaca47bfecec2 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 15:45:17 -0300 Subject: [PATCH 196/217] docs(auth): link v0.2 release guides and fix SQLite credential schema example --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 9ff4c0b..bbe09f0 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ Authentication library with pluggable providers. +> **v0.2.0 is a breaking upgrade from 0.1.x.** The old PDO-based `Auth` constructor, +> user table and controller paths are not compatible. Read the +> [0.2.0 upgrade guide](docs/UPGRADING-0.2.0.md) before upgrading a live app. + +**Release documentation:** [Changelog](CHANGELOG.md) · +[Upgrade guide (0.1.x → 0.2.0)](docs/UPGRADING-0.2.0.md) · +[0.2.0 release notes and release checklist](docs/RELEASE-0.2.0.md) + + ## Install ```bash @@ -156,6 +165,8 @@ CREATE TABLE user_credentials ( user_id INTEGER NOT NULL, profile TEXT, token VARCHAR(255), + token_expires_at DATETIME, + verified_at DATETIME, last_login DATETIME, created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (uid, provider), From cf17c7242f3049e6cb9505a765e032dfd5a71831 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 17:58:41 -0300 Subject: [PATCH 197/217] refactor(auth): validate credentials without starting a session --- src/Auth.php | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index ec0d2df..2e5bfbd 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -273,7 +273,14 @@ protected function userCanRelationList(int $subjectId, string $resourceType, str * @throws Auth\UserException * @throws Auth\AuthException */ - public function &login($uid, $password) + /** + * Validate local login credentials without changing the authenticated session + * or recording a login. OAuth account linking uses this before committing + * the external identity to the account. + * + * @return array{user: array, provider: string} + */ + public function authenticate($uid, $password): array { $credential = false; $provider = null; @@ -287,9 +294,8 @@ public function &login($uid, $password) break; } - // Use a real bcrypt check for unknown identities and passwordless users - // as well as for known users. Do not distinguish invalid usernames, - // wrong passwords, or suspended accounts in the public response. + // Verify against a dummy hash for unknown or passwordless accounts. + // Invalid usernames, passwords and suspended users share one error. $user = $credential ? $this->get($credential['user_id']) : null; $stored = is_array($user) ? ($user[$this->params['password']] ?? null) : null; $hash = is_string($stored) && $stored !== '' @@ -299,8 +305,16 @@ public function &login($uid, $password) throw new Auth\AuthException('Invalid credentials', 401); } - $user = $this->establish_session($user); - $this->update_credential($user[$this->params['id']], (string) $provider, $uid, null); + return ['user' => $user, 'provider' => (string) $provider]; + } + + public function &login($uid, $password) + { + $authenticated = $this->authenticate($uid, $password); + $user = $this->establish_session($authenticated['user']); + $this->update_credential( + $user[$this->params['id']], $authenticated['provider'], $uid, null + ); return $user; } From 1f3c32aeaf7c12ec9597046d80e0b20506d9e9d6 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 17:58:59 -0300 Subject: [PATCH 198/217] fix(oauth): authenticate only after linking existing account succeeds --- src/Auth/Controller/OAuthController.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Auth/Controller/OAuthController.php b/src/Auth/Controller/OAuthController.php index 1fcf6f7..c0911db 100644 --- a/src/Auth/Controller/OAuthController.php +++ b/src/Auth/Controller/OAuthController.php @@ -208,10 +208,10 @@ function postExistingAccount(array $form): array throw new UserException('OAuth identity is already associated with an account', 409); } - // Invalid passwords leave the pending identity available for retry - // during its limited lifetime. Successful login regenerates the - // session ID; the pending OAuth identity stays in the PHP session. - $user = $this->auth->login(trim($uid), $password); + // Validate ownership without authenticating the browser. A failed + // database link or verification must not create a logged-in session. + $authenticated = $this->auth->authenticate(trim($uid), $password); + $user = $authenticated['user']; $userId = $user[$this->auth->params['id']]; // Do not allow a competing request to attach this OAuth identity to @@ -226,7 +226,7 @@ function postExistingAccount(array $form): array ); $this->auth->mark_credential_verified($userId, $pending['provider'], $pending['uid']); $this->auth->audit('credential.linked', $userId, ['provider' => $pending['provider']]); - return ['status' => 'linked', 'user' => $this->auth->user()]; + return ['status' => 'linked', 'user' => $this->auth->establish_session($this->auth->get($userId))]; } /** From 64ba93fcf13f83159f3c4e185ff4b4dc029a05be Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 17:59:22 -0300 Subject: [PATCH 199/217] test(oauth): linking failure must not establish authenticated session --- test/OAuthControllerTest.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/OAuthControllerTest.php b/test/OAuthControllerTest.php index 815b791..4d48a02 100644 --- a/test/OAuthControllerTest.php +++ b/test/OAuthControllerTest.php @@ -463,6 +463,40 @@ public function testFirstTimeOAuthCanAuthenticateExistingAccountAndLink(): void } + public function testExistingAccountLinkFailureDoesNotAuthenticateBrowser(): void + { + $auth = new class([], ['token' => 'token']) extends OAuthTrackingBasicAuth { + public function mark_credential_verified($userid, string $provider, string $uid): void + { + if ($provider === 'test') { + throw new \RuntimeException('Simulated OAuth credential verification failure'); + } + parent::mark_credential_verified($userid, $provider, $uid); + } + }; + $auth->register('owner@example.com', 'local-secret'); + OAuthTestProvider::$resourceOwner = $this->resourceOwner('pending-oauth-subject'); + $controller = $this->testController($auth); + $controller->get('test', []); + $controller->get('test', ['code' => 'oauth-code', 'state' => 'test-oauth-state']); + $this->withLinkHeaders($auth); + + try { + $controller->postExistingAccount([ + 'uid' => 'owner@example.com', + 'password' => 'local-secret', + ]); + $this->fail('Expected OAuth credential verification to fail'); + } catch (\RuntimeException $e) { + $this->assertSame('Simulated OAuth credential verification failure', $e->getMessage()); + } + + $this->assertFalse($auth->check()); + $this->assertFalse($auth->sessionRegenerated); + $this->assertArrayNotHasKey('ow_auth', $_SESSION); + $this->assertArrayNotHasKey('oauth2pending', $_SESSION); + } + public function testPendingAccountCannotBeCreatedWithoutExplicitChoice(): void { $auth = new BasicAuth([], ['token' => 'token']); From da6ebe2156368ac1e748238ac31c2f653e5627ca Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 17:59:45 -0300 Subject: [PATCH 200/217] docs(auth): consolidate credential validation method documentation --- src/Auth.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Auth.php b/src/Auth.php index 2e5bfbd..bd20b6e 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -267,12 +267,6 @@ protected function userCanRelationList(int $subjectId, string $resourceType, str return []; } - /** - * @param $uid - * @param $password - * @throws Auth\UserException - * @throws Auth\AuthException - */ /** * Validate local login credentials without changing the authenticated session * or recording a login. OAuth account linking uses this before committing From 94fbaf62e62b674d8671cf3550b73ad3aeced316 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 18:06:55 -0300 Subject: [PATCH 201/217] fix(auth): reject tokens expiring during credential redemption --- src/Auth/DBAuth.php | 48 +++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index cacbaca..8c13881 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -665,19 +665,41 @@ public function verify_credential_token(string $provider, string $uid, string $t return false; } - // Consume the matching token atomically. Concurrent attempts cannot - // both mark the same credential verified. - return $this->db->update($this->params['credentials_table'], [ - 'verified_at' => date('Y-m-d H:i:s'), - 'token' => null, - 'token_expires_at' => null, - ], [ - 'user_id' => $credential['user_id'], - 'provider' => $provider, - 'uid' => $uid, - 'token' => $digest, - 'verified_at' => null, - ]) === 1; + // Match the expiry snapshot as well as the token, so concurrent + // rotation or expiry changes invalidate this attempt. Check the clock + // again after the conditional UPDATE and roll back if it expired + // between the initial read and the write. + $expiredDuringRedemption = new \RuntimeException('Credential token expired during redemption'); + try { + return $this->db->transaction(function (DB $db) use ( + $credential, $provider, $uid, $digest, $expires, $expiredDuringRedemption + ): bool { + $updated = $db->update($this->params['credentials_table'], [ + 'verified_at' => date('Y-m-d H:i:s'), + 'token' => null, + 'token_expires_at' => null, + ], [ + 'user_id' => $credential['user_id'], + 'provider' => $provider, + 'uid' => $uid, + 'token' => $digest, + 'token_expires_at' => $expires, + 'verified_at' => null, + ]); + if ($updated !== 1) { + return false; + } + if ($expires <= date('Y-m-d H:i:s')) { + throw $expiredDuringRedemption; + } + return true; + }); + } catch (\RuntimeException $exception) { + if ($exception === $expiredDuringRedemption) { + return false; + } + throw $exception; + } } public function mark_credential_verified($userid, string $provider, string $uid): void From 8059ef0fdd4e47ae59fbdeadf3f1e8007bd5c72f Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 18:08:26 -0300 Subject: [PATCH 202/217] test(auth): reject token expiry races during credential verification --- test/DBAuthSqliteTest.php | 89 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index fbb2e3a..fa755ec 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -9,6 +9,22 @@ use Objectiveweb\DB; use PHPUnit\Framework\TestCase; +class DBAuthExpiryRaceDB extends DB +{ + public ?Closure $beforeVerificationUpdate = null; + + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'auth_credentials' && array_key_exists('verified_at', $data) + && isset($where['token']) && $this->beforeVerificationUpdate !== null) { + $callback = $this->beforeVerificationUpdate; + $this->beforeVerificationUpdate = null; + $callback(); + } + return parent::update($table, $data, $where); + } +} + class DBAuthSqliteTest extends TestCase { private static DB $db; @@ -1116,6 +1132,79 @@ public function testVerificationTokenExpiresAndOnlyOwnerCanIssue(): void $this->assertNull(self::$auth->get_credential('email', 'owner@example.com')['verified_at']); } + /** + * An expiry changed after credential lookup must invalidate the pending + * compare-and-swap rather than marking the account verified. + */ + public function testVerificationRejectsExpiryChangedBetweenReadAndUpdate(): void + { + [$db, $auth, $token] = $this->verificationRaceFixture(); + $db->beforeVerificationUpdate = static function () use ($db): void { + $db->update('auth_credentials', [ + 'token_expires_at' => date('Y-m-d H:i:s', time() - 60), + ], ['provider' => 'email', 'uid' => 'race@example.com']); + }; + + $this->assertFalse($auth->verify_credential_token('email', 'race@example.com', $token)); + $credential = $auth->get_credential('email', 'race@example.com'); + $this->assertNull($credential['verified_at']); + $this->assertSame(hash('sha256', $token), $credential['token']); + } + + /** + * An expiry that passes during the database write must roll back the + * successful compare-and-swap; simply matching the old expiry is not enough. + */ + public function testVerificationRollsBackIfTokenExpiresDuringUpdate(): void + { + [$db, $auth, $token] = $this->verificationRaceFixture(1); + $deadline = $auth->get_credential('email', 'race@example.com')['token_expires_at']; + $db->beforeVerificationUpdate = static function () use ($deadline): void { + while (date('Y-m-d H:i:s') <= $deadline) { + usleep(100000); + } + }; + + $this->assertFalse($auth->verify_credential_token('email', 'race@example.com', $token)); + $credential = $auth->get_credential('email', 'race@example.com'); + $this->assertNull($credential['verified_at']); + $this->assertSame(hash('sha256', $token), $credential['token']); + $this->assertSame($deadline, $credential['token_expires_at']); + } + + /** @return array{DBAuthExpiryRaceDB, DBAuth, string} */ + private function verificationRaceFixture(int $ttl = 3600): array + { + $db = new DBAuthExpiryRaceDB('sqlite::memory:'); + $db->query( + 'CREATE TABLE auth_credentials ( + uid TEXT NOT NULL, + provider TEXT NOT NULL, + user_id INTEGER NOT NULL, + token TEXT NULL, + token_expires_at TEXT NULL, + verified_at TEXT NULL, + PRIMARY KEY(uid, provider) + )' + )->exec(); + + $token = bin2hex(random_bytes(16)); + $db->insert('auth_credentials', [ + 'uid' => 'race@example.com', + 'provider' => 'email', + 'user_id' => 1, + 'token' => hash('sha256', $token), + 'token_expires_at' => date('Y-m-d H:i:s', time() + $ttl), + 'verified_at' => null, + ]); + $auth = new DBAuth($db, [ + 'credentials_table' => 'auth_credentials', + 'roles_table' => null, + 'user_roles_table' => null, + ]); + return [$db, $auth, $token]; + } + public function testCredentialRenameInvalidatesPriorVerification(): void { $owner = self::$auth->register('owner@example.com', 'secret'); From 96eb27f3646b6327d8a2d3f81e9792f0758bc0d2 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 18:12:44 -0300 Subject: [PATCH 203/217] fix(auth): prevent recovery callback errors from leaking reset secrets to logs --- src/Auth/Controller/AuthController.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index 132655e..e98446a 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -236,12 +236,14 @@ function postPassword(array $form) try { call_user_func($this->auth->params['token_callback'], $credential); } catch (\Throwable $exception) { - error_log( - 'Password recovery delivery failed: ' - . $exception::class - . ': ' - . $exception->getMessage() - ); + // Delivery exceptions can contain reset URLs, tokens + // and recipient details. Log only a safe error type + // and an opaque reference for operational correlation. + error_log(sprintf( + 'Password recovery delivery failed [ref=%s]: %s', + bin2hex(random_bytes(8)), + $exception::class + )); } } } From eaa2b801d4e8f2bfa0e0f33b7549b6e26bb28dc4 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 18:13:13 -0300 Subject: [PATCH 204/217] test(auth): ensure recovery delivery logs redact reset tokens and recipient details --- test/AuthControllerTest.php | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 2a30ce4..ff26591 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -366,6 +366,45 @@ public function testPostPasswordForgotFlowHidesDeliveryFailure(): void $this->assertSame([], $result); } + public function testPasswordRecoveryDeliveryLogsNoSensitiveExceptionDetails(): void + { + $recoveryToken = null; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => function (array $credential) use (&$recoveryToken): void { + $recoveryToken = $credential['token']; + throw new \RuntimeException( + 'Failed to deliver to private@example.com: ' + . 'https://app.example/reset?token=' . $recoveryToken + ); + }, + ]); + $auth->register('private@example.com', 'secret', ['provider' => 'email']); + $controller = new AuthController($auth); + + $logFile = tempnam(sys_get_temp_dir(), 'ow-auth-recovery-'); + $this->assertNotFalse($logFile); + $previousLogFile = ini_get('error_log'); + try { + $this->assertNotFalse(ini_set('error_log', $logFile)); + $this->assertSame([], $controller->postPassword(['uid' => 'private@example.com'])); + + $logged = file_get_contents($logFile); + $this->assertIsString($logged); + $this->assertMatchesRegularExpression( + '/Password recovery delivery failed \[ref=[0-9a-f]{16}\]: RuntimeException/', + $logged + ); + $this->assertNotNull($recoveryToken); + $this->assertStringNotContainsString($recoveryToken, $logged); + $this->assertStringNotContainsString('private@example.com', $logged); + $this->assertStringNotContainsString('https://app.example/reset', $logged); + } finally { + ini_set('error_log', (string) $previousLogFile); + unlink($logFile); + } + } + public function testPostPasswordForgotFlowUsesEmailCredential(): void { $auth = new BasicAuth([], ['token' => 'token']); From f898ff9ae75733e64e885278ccaa052bd440a986 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 18:28:42 -0300 Subject: [PATCH 205/217] fix(auth): atomically update user fields and role assignments Run user updates, role creation, and role membership replacement in one DB transaction so failures cannot leave partial changes. Add SQLite regression coverage that injects a membership insert failure and asserts that user fields, role definitions, and assignments roll back. --- src/Auth/DBAuth.php | 27 ++++++++++++++------------ test/DBAuthSqliteTest.php | 40 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 8c13881..262ff3a 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -458,21 +458,24 @@ public function update($user_id, array $data, $key = 'id') throw new \InvalidArgumentException('Role sync requested but role tables are disabled'); } - if (!empty($data)) { - foreach ($data as $k => $v) { - if (is_array($v)) { - $data[$k] = json_encode($v); + // User fields, role definitions and memberships must commit or roll back together. + $this->db->transaction(function () use ($column, $user_id, $key, $data, $syncRoles, $roles): void { + if (!empty($data)) { + foreach ($data as $k => $v) { + if (is_array($v)) { + $data[$k] = json_encode($v); + } } - } - $this->userTable->update([$column => $user_id], $data); - } + $this->userTable->update([$column => $user_id], $data); + } - if ($syncRoles) { - $target = $this->get($user_id, $key); - $this->resolveRoleIds($roles); - $this->syncUserRoles($target[$this->params['id']], $roles); - } + if ($syncRoles) { + $target = $this->get($user_id, $key); + $this->resolveRoleIds($roles); + $this->syncUserRoles($target[$this->params['id']], $roles); + } + }); } public function delete($user_id) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index fa755ec..ed26038 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -778,15 +778,53 @@ public function testUserCanChecksRoles(): void public function testUpdateRolesReplacesAssignments(): void { $user = self::$auth->register('roles@example.com', 'secret', [ + 'name' => 'Before', 'roles' => ['partner'], ]); - self::$auth->update($user['id'], ['roles' => ['operator', 'admin']]); + self::$auth->update($user['id'], ['name' => 'After', 'roles' => ['operator', 'admin']]); $reloaded = self::$auth->get($user['id']); + $this->assertSame('After', $reloaded['name']); $this->assertSame(['admin', 'operator'], $reloaded['roles']); } + public function testUpdateRollsBackUserFieldsRoleDefinitionsAndMembershipsOnFailure(): void + { + $user = self::$auth->register('rollback-roles@example.com', 'secret', [ + 'name' => 'Before', + 'roles' => ['partner'], + ]); + + // Fail only during membership insertion, after the user update, + // new role creation and old membership deletion have run. + self::$db->query( + "CREATE TRIGGER auth_fail_role_assignment + BEFORE INSERT ON auth_user_role + BEGIN + SELECT RAISE(ABORT, 'forced role synchronization failure'); + END" + )->exec(); + + $failure = null; + try { + self::$auth->update($user['id'], [ + 'name' => 'After', + 'roles' => ['operator'], + ]); + } catch (\Throwable $exception) { + $failure = $exception; + } finally { + self::$db->query('DROP TRIGGER auth_fail_role_assignment')->exec(); + } + + $this->assertNotNull($failure, 'A failed role insertion must abort the update'); + $reloaded = self::$auth->get($user['id']); + $this->assertSame('Before', $reloaded['name']); + $this->assertSame(['partner'], $reloaded['roles']); + $this->assertSame(['partner'], self::$auth->get_roles()); + } + public function testGetUsersByRoleReturnsOnlyUsersWithMatchingRole(): void { $admin = self::$auth->register('admin@example.com', 'secret', [ From d14caffd93a8ed33c630134ef5e9197f5dade798 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 19:27:41 -0300 Subject: [PATCH 206/217] fix(auth): restrict relation cleanup to explicitly Auth-owned tables Delete relation rows only for entries marked auth_owned=true, preserving application-owned association data even when used for authorization. Document the opt-in for bundled delegations and add deletion regressions covering both Auth-owned and application-owned relation tables. --- CHANGELOG.md | 2 +- README.md | 7 ++++-- docs/UPGRADING-0.2.0.md | 2 +- src/Auth/DBAuth.php | 6 ++++- test/DBAuthSqliteTest.php | 52 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c618dc8..0127547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol - User-facing password changes require the existing password, a session-bound `X-CSRF-Token`, and a JSON request; public registration is restricted to local credentials and safe profile fields. - OAuth callbacks require a **configured, trusted `redirectUri`** (HTTPS, or explicitly configured HTTP loopback for local development), no longer inferred from Host/proxy request headers. - OAuth provider-resource-owner display names, emails and avatars are optional hints with safe fallbacks; matching email addresses do **not** link accounts. -- User-managed application associations (`managed_relations`) now belong to application services. Use `user_context_callback` to expose current assignments to the authenticated session. +- User-managed application associations (`managed_relations`) now belong to application services. Use `user_context_callback` to expose current assignments to the authenticated session. User deletion cleans only explicitly `auth_owned` authorization relation tables; application-owned relation tables are never implicitly modified. - Project testing/release verification moved from Travis to GitHub Actions; Composer metadata and support URL updated. ### Security fixes diff --git a/README.md b/README.md index bbe09f0..c4916cf 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,8 @@ $auth = new DBAuth($db, [ 'subject_key' => 'user_id', 'target_key' => 'target_user_id', 'ability_key' => 'ability', - 'target_is_user' => true, // Also clean target-user rows on deletion + 'target_is_user' => true, // Also clean target-user rows when Auth owns this relation + 'auth_owned' => true, // Explicit opt-in: Auth may clean this table on user deletion ], 'item' => [ 'table' => 'item_users', // Application-owned relation table @@ -390,6 +391,8 @@ $itemIds = $auth->user_can('manage', 'item'); // Accessible item IDs Each permission relation requires `table`, `subject_key`, `target_key` and either `ability_key` or `role_key` together with `role_abilities` (a role-to-ability map). `eager` may be `true` or a `DB::select()` options array such as `['order' => 'item_id DESC']`. Eager relation rows remain scoped to the current subject user. +**Deletion ownership is opt-in.** `DBAuth::delete()` removes rows from a configured relation table only when `auth_owned => true` is explicitly set (for example, the bundled `delegations` table). When enabled, it cleans rows with the deleted user as `subject_key`, plus `target_key` when `target_is_user => true`. Merely configuring a relation for authorization—even with `target_is_user => true`—does not transfer ownership of application tables to Auth. Never set `auth_owned` on application-owned association tables; use the application's own deletion logic, foreign-key cascades or `deletion_guard_callback` instead. + #### Application-owned user context Use `user_context_callback` for application authorization context that must be available on the authenticated principal (for example, the venue IDs assigned to the current user). It receives a **sanitized** user and must return an array. Auth stores that array under `$auth->user()['context']` at login (including OAuth) and **rebuilds** it from the latest user record whenever `reload()` or `revalidate()` is called. @@ -420,7 +423,7 @@ $venueIds = $auth->user()['context']['venues']; Session context is not implicitly added to `get()` or `query()` results; those remain user-record APIs. `AuthController::index()` returns the authenticated session principal, including its context. If the callback is omitted, Auth does not add a context field. -**Migration from `managed_relations` (breaking change for 0.2.0):** remove that parameter; move `get_managed_relations()` and `sync_managed_relations()` callers into your application's user service; expose venue assignments through application-specific endpoints rather than generic `UserController` create/update. The generic Auth controller now rejects `managed_relations` request fields instead of silently losing assignments, and it no longer includes them in user-detail responses. `DBAuth::delete()` no longer cleans application association tables: arrange cleanup using application transactions/foreign-key cascades and, where necessary, `deletion_guard_callback`. +**Migration from `managed_relations` (breaking change for 0.2.0):** remove that parameter; move `get_managed_relations()` and `sync_managed_relations()` callers into your application's user service; expose venue assignments through application-specific endpoints rather than generic `UserController` create/update. The generic Auth controller now rejects `managed_relations` request fields instead of silently losing assignments, and it no longer includes them in user-detail responses. `DBAuth::delete()` cleans only explicitly `auth_owned` relations and never automatically cleans application association tables: arrange their cleanup using application transactions/foreign-key cascades and, where necessary, `deletion_guard_callback`. ### `BasicAuth` diff --git a/docs/UPGRADING-0.2.0.md b/docs/UPGRADING-0.2.0.md index 3ae01a6..602d9d4 100644 --- a/docs/UPGRADING-0.2.0.md +++ b/docs/UPGRADING-0.2.0.md @@ -103,7 +103,7 @@ If you preserve numeric `id` values, also reset the auto-increment/identity sequ - Replace HAL queries such as `$result['_embedded']['ow_auth']` with iteration over a `Collection` and `$collection->total()` / `$collection->contentRange()`. - Replace old `PasswordMismatchException` handlers with `AuthException` and a generic 401 response for invalid password login. - Update any direct password/credential/token calls to use **numeric user IDs** and the new `(provider, uid)` credential APIs. `passwd()` is a trusted service/admin operation; use `postPassword()` for end-user password changes. -- If using `managed_relations` in earlier development builds, migrate their reads/writes/validation to application services and use `user_context_callback` for refreshed session context. Auth does not automatically persist or remove application-owned association rows. +- If using `managed_relations` in earlier development builds, migrate their reads/writes/validation to application services and use `user_context_callback` for refreshed session context. Auth does not automatically persist or remove application-owned association rows. A configured `relations` entry opts into deletion cleanup only when explicitly marked `auth_owned => true` (for genuinely Auth-owned tables such as bundled `delegations`); do not enable it for application tables. ## 5. Deploy OAuth changes deliberately diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 262ff3a..3c2b0b1 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -506,7 +506,11 @@ public function delete($user_id) ); } foreach ((array) $this->params['relations'] as $relation) { - if (!is_array($relation) || empty($relation['table'])) { + // A relation used for authorization is not necessarily owned by Auth. + // Only explicitly Auth-owned relation tables may be cleaned up here. + if (!is_array($relation) + || ($relation['auth_owned'] ?? false) !== true + || empty($relation['table'])) { continue; } if (!empty($relation['subject_key'])) { diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index ed26038..1e117af 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -122,6 +122,7 @@ public static function setUpBeforeClass(): void 'target_key' => 'target_user_id', 'ability_key' => 'ability', 'target_is_user' => true, + 'auth_owned' => true, ], 'item' => [ 'table' => 'item_users', @@ -1289,15 +1290,66 @@ public function testUserDeletionCleansDelegationsButLeavesApplicationAssociation $actor = self::$auth->register('actor@example.com', 'secret'); $target = self::$auth->register('target@example.com', 'secret'); self::$db->insert('managed_items', ['user_id' => $target['id'], 'item_id' => 3]); + self::$db->insert('item_users', [ + 'user_id' => $target['id'], + 'item_id' => 3, + 'ability' => 'manage', + ]); + // Auth-owned delegation cleanup covers both the subject and target. self::$db->insert('user_delegations', [ 'user_id' => $actor['id'], 'target_user_id' => $target['id'], 'ability' => 'manage', ]); + self::$db->insert('user_delegations', [ + 'user_id' => $target['id'], + 'target_user_id' => $actor['id'], + 'ability' => 'view', + ]); + self::$auth->delete($target['id']); $this->assertSame(0, self::$db->count('user_delegations', [])); $this->assertSame(1, self::$db->count('managed_items', [])); + $this->assertSame(1, self::$db->count('item_users', [])); + } + + public function testUserDeletionDoesNotCleanUnownedRelationsEvenWhenTargetIsUser(): void + { + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + 'relations' => [ + 'user' => [ + 'table' => 'user_delegations', + 'subject_key' => 'user_id', + 'target_key' => 'target_user_id', + 'ability_key' => 'ability', + 'target_is_user' => true, + // No auth_owned flag: application remains responsible. + ], + ], + ]); + + $actor = $auth->register('actor@example.com', 'secret'); + $target = $auth->register('target@example.com', 'secret'); + self::$db->insert('user_delegations', [ + 'user_id' => $actor['id'], + 'target_user_id' => $target['id'], + 'ability' => 'manage', + ]); + self::$db->insert('user_delegations', [ + 'user_id' => $target['id'], + 'target_user_id' => $actor['id'], + 'ability' => 'view', + ]); + + $this->assertTrue($auth->delete($target['id'])); + $this->assertSame(2, self::$db->count('user_delegations', [])); + $this->assertFalse($auth->get_credential('local', 'target@example.com')); } public function testDbAuthSessionContextReloadsApplicationAssignments(): void From 255bb3b8d7aa98a16f51095acf377481072e09b2 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 20:56:52 -0300 Subject: [PATCH 207/217] fix(auth): revalidate session before all user_can decisions Refresh roles and lifecycle status before global, delegated, and resource permissions, invalidating stale sessions for suspended or deleted users. Preserve false/empty-list unauthenticated return semantics and include regression tests for role revocation, suspension, and account deletion. --- CHANGELOG.md | 2 +- README.md | 2 + src/Auth.php | 14 +++---- test/DBAuthSqliteTest.php | 82 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0127547..7005194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol - Local identifiers are represented as credentials (`provider = local`, `uid`); the user table no longer contains the legacy `username` identity column by default. - Password login failures return the same `AuthException` HTTP 401 for unknown identifiers, bad passwords and suspended accounts. - Password reset tokens are cryptographically random, stored only as SHA-256 digests, expire and are consumed atomically; previously issued plaintext legacy tokens do **not** carry forward. -- Session IDs regenerate upon authentication, protected requests can revalidate account status and roles, and public user payloads are sanitized. +- Session IDs regenerate upon authentication; `user_can()` revalidates account status and roles on every call so deleted/suspended accounts and revoked roles cannot retain access through stale sessions. Public user payloads are sanitized. - User-facing password changes require the existing password, a session-bound `X-CSRF-Token`, and a JSON request; public registration is restricted to local credentials and safe profile fields. - OAuth callbacks require a **configured, trusted `redirectUri`** (HTTPS, or explicitly configured HTTP loopback for local development), no longer inferred from Host/proxy request headers. - OAuth provider-resource-owner display names, emails and avatars are optional hints with safe fallbacks; matching email addresses do **not** link accounts. diff --git a/README.md b/README.md index c4916cf..dcda2b6 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,8 @@ $auth->update($admin['id'], ['roles' => ['admin', 'editor']]); $auth->user_can('admin'); // bool ``` +`user_can()` **revalidates the logged-in principal on every permission check**, including role, resource and delegation checks. Role grants/revocations take effect immediately on the next call; suspended or deleted users are logged out and denied (`false`, or `[]` for a resource-list query). This requires a backend read for every call, so cache permission decisions only within a deliberately controlled authorization boundary, never across requests. + Direct `DBAuth::register()` / `update()` can create role definitions when syncing names that do not exist. The **admin UserController API** is intentionally stricter and accepts only *existing* role names: seed the allowed roles before using its role-management endpoints. The public registration controller always ignores role grants, regardless of `register_allow_grants`. Both `roles_table` and `user_roles_table` must be disabled together if roles are not wanted. #### Relations and authorization diff --git a/src/Auth.php b/src/Auth.php index bd20b6e..286e4de 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -189,7 +189,9 @@ public function invite(int|string $userId, bool $reset = false): void public function user_can(string $ability, mixed ...$args): bool|array { if (count($args) === 0) { - if (!$this->check()) { + // Permission decisions must not trust roles or account status cached + // at login. Revalidation also logs out deleted or suspended users. + if (!$this->revalidate()) { return false; } @@ -213,12 +215,10 @@ public function user_can(string $ability, mixed ...$args): bool|array throw new \InvalidArgumentException('Invalid user_can() signature'); } - if (!$this->check()) { - if ($mode === 'resource_list') { - return []; - } - - return false; + // Revalidate relation checks too: a stale session must not retain + // access to associations after the account is disabled or removed. + if (!$this->revalidate()) { + return $mode === 'resource_list' ? [] : false; } $subjectId = $this->currentUserId(); diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 1e117af..0d657a5 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -776,6 +776,88 @@ public function testUserCanChecksRoles(): void $this->assertSame(['admin'], $current['roles']); } + public function testUserCanRevalidatesRolesAfterChangesOutsideSession(): void + { + $user = self::$auth->register('role-refresh@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + self::$auth->login('role-refresh@example.com', 'secret'); + $this->assertTrue(self::$auth->user_can('admin')); + + // Updating a user does not update the existing session by itself. + self::$auth->update($user['id'], ['roles' => ['editor']]); + $this->assertSame(['admin'], self::$auth->user()['roles']); + + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertTrue(self::$auth->user_can('editor')); + $this->assertSame(['editor'], self::$auth->user()['roles']); + $this->assertTrue(self::$auth->check()); + + // Revocations and grants are both visible on the next permission check. + self::$auth->update($user['id'], ['roles' => []]); + $this->assertFalse(self::$auth->user_can('editor')); + $this->assertSame([], self::$auth->user()['roles']); + } + + public function testUserCanRejectsSuspendedAccountForAllCheckModes(): void + { + $user = self::$auth->register('suspended-permissions@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $sessionUser = self::$auth->login('suspended-permissions@example.com', 'secret'); + + self::$db->insert('item_users', [ + 'user_id' => $user['id'], + 'item_id' => 7, + 'ability' => 'manage', + ]); + self::$db->insert('user_delegations', [ + 'user_id' => $user['id'], + 'target_user_id' => 123, + 'ability' => 'manage', + ]); + $this->assertTrue(self::$auth->user_can('admin')); + $this->assertTrue(self::$auth->user_can('manage', 123)); + $this->assertTrue(self::$auth->user_can('manage', 'item', 7)); + $this->assertSame([7], self::$auth->user_can('manage', 'item')); + + self::$auth->update($user['id'], ['disabled_at' => '2026-10-20 12:00:00']); + $sessionKey = self::$auth->params['session_key']; + + // Each restored snapshot represents a different pre-existing session. + $_SESSION[$sessionKey] = $sessionUser; + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertFalse(self::$auth->check()); + + $_SESSION[$sessionKey] = $sessionUser; + $this->assertFalse(self::$auth->user_can('manage', 123)); + $this->assertFalse(self::$auth->check()); + + $_SESSION[$sessionKey] = $sessionUser; + $this->assertFalse(self::$auth->user_can('manage', 'item', 7)); + $this->assertFalse(self::$auth->check()); + + $_SESSION[$sessionKey] = $sessionUser; + $this->assertSame([], self::$auth->user_can('manage', 'item')); + $this->assertFalse(self::$auth->check()); + } + + public function testUserCanRejectsSessionForDeletedUser(): void + { + $user = self::$auth->register('deleted-permissions@example.com', 'secret', [ + 'roles' => ['admin'], + ]); + $sessionUser = self::$auth->login('deleted-permissions@example.com', 'secret'); + + self::$auth->logout(); + self::$auth->delete($user['id']); + + // A separate browser still carries an old session snapshot. + $_SESSION[self::$auth->params['session_key']] = $sessionUser; + $this->assertFalse(self::$auth->user_can('admin')); + $this->assertFalse(self::$auth->check()); + } + public function testUpdateRolesReplacesAssignments(): void { $user = self::$auth->register('roles@example.com', 'secret', [ From aeaaddc0efed3419917f207b2628f6880a456c80 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 21:12:50 -0300 Subject: [PATCH 208/217] fix(auth): reject unexpected user deletion subresources Require a plain DELETE /api/users/{id} for account deletion and return 404 for unknown or incomplete subresource paths rather than deleting users. Cover unsupported paths, valid user deletion and credential-only deletion with regression tests; correct the previous unauthenticated delete test. --- CHANGELOG.md | 2 +- src/Auth/Controller/UserController.php | 7 +++ test/UserControllerTest.php | 83 +++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7005194..bc927af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol - Consume password reset and credential-verification tokens at most once; separate proof for each provider credential. - Prevent OAuth login CSRF, state replay, cross-provider state confusion, unconsented linking, email-based account takeover and cross-account credential reassignment. - Restrict logout redirects to safe same-origin absolute paths and reject attacker-controlled OAuth callback URLs. -- Enforce management CSRF checks, prevent removal of the last active admin and protect the caller from self-deletion/suspension. +- Enforce management CSRF checks, prevent removal of the last active admin and protect the caller from self-deletion/suspension. Reject unknown DELETE subresources with 404 instead of accidentally deleting the user. - Avoid returning passwords, reset tokens or other internal secrets in normal user/session/management payloads. ### Removed / incompatible diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 846c576..47af3ee 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -17,6 +17,7 @@ * GET /api/users/12 * POST /api/users * PUT /api/users/12 + * DELETE /api/users/12 * POST /api/users/12/credentials * PUT /api/users/12/credentials * DELETE /api/users/12/credentials/local/name@example.com @@ -107,6 +108,12 @@ public function delete( return ['ok' => true]; } + // Only the base user endpoint may delete an account. Unknown or + // partially matched subresource paths must never fall through here. + if ($resource !== null || $provider !== null || $uid !== null) { + throw new UserException('Invalid user action', 404); + } + $this->assertNotSelf($userId, 'delete'); $target = $this->auth->get($userId); $this->assertNotFinalActiveAdmin($target); diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index c97af4a..56ccc78 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -285,19 +285,88 @@ public function testCannotSuspendOnlyActiveAdminWhenAnotherAdminIsSuspended(): v $controller->post((int) $activeAdmin['id'], 'suspend'); } - public function testDelete(): void + public function testDeleteRejectsUnknownSubresourcesWithoutDeletingUser(): void { - $this->expectException(\Objectiveweb\Auth\UserException::class); + self::$controller->post([ + 'uid' => 'actor@example.com', + 'password' => 'test', + ]); + $target = self::$controller->post([ + 'uid' => 'target@example.com', + 'password' => 'test', + ]); + self::$auth->login('actor@example.com', 'test'); - $user = self::$controller->post([ - 'uid' => 'vagrant@localhost', + foreach (['unknown', '', 'suspend', 'roles'] as $resource) { + try { + self::$controller->delete((int) $target['id'], $resource); + $this->fail('Expected invalid delete subresource to be rejected'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + $this->assertSame( + (int) $target['id'], + (int) self::$controller->get((int) $target['id'])['id'] + ); + $this->assertNotFalse(self::$auth->get_credential('local', 'target@example.com')); + } + + // Stray nested path parameters without a resource are also invalid. + try { + self::$controller->delete((int) $target['id'], null, 'local', 'target@example.com'); + $this->fail('Expected incomplete subresource path to be rejected'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + $this->assertSame( + (int) $target['id'], + (int) self::$controller->get((int) $target['id'])['id'] + ); + } + + public function testDeleteUserOnlyAtBaseResource(): void + { + self::$controller->post([ + 'uid' => 'actor@example.com', + 'password' => 'test', + ]); + $target = self::$controller->post([ + 'uid' => 'target@example.com', 'password' => 'test', 'name' => 'Test User', ]); + self::$auth->login('actor@example.com', 'test'); + + $this->assertSame(['ok' => true], self::$controller->delete((int) $target['id'])); + $this->assertFalse(self::$auth->get_credential('local', 'target@example.com')); - $deleted = self::$controller->delete((int) $user['id']); - $this->assertTrue($deleted); + $this->expectException(\Objectiveweb\Auth\UserException::class); + $this->expectExceptionCode(404); + self::$controller->get((int) $target['id']); + } - self::$controller->get((int) $user['id']); + public function testDeleteCredentialDoesNotDeleteUser(): void + { + self::$controller->post([ + 'uid' => 'actor@example.com', + 'password' => 'test', + ]); + $target = self::$controller->post([ + 'uid' => 'target@example.com', + 'password' => 'test', + ]); + self::$auth->create_credential((int) $target['id'], 'phone', '+5511999999999'); + self::$auth->login('actor@example.com', 'test'); + + $this->assertSame( + ['ok' => true], + self::$controller->delete((int) $target['id'], 'credentials', 'phone', '%2B5511999999999') + ); + $this->assertFalse(self::$auth->get_credential('phone', '+5511999999999')); + $this->assertNotFalse(self::$auth->get_credential('local', 'target@example.com')); + $this->assertSame( + (int) $target['id'], + (int) self::$controller->get((int) $target['id'])['id'] + ); } } From 237da9cfb19a1c678603976931a942576cc4e370 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Tue, 20 Oct 2026 21:18:42 -0300 Subject: [PATCH 209/217] fix(auth): preserve BasicAuth credential profiles on login Only overwrite the stored BasicAuth credential profile when update_credential receives a non-null value, matching DBAuth's established semantics. Keep null defaults for new credentials and verify that login, null updates, and explicit profile replacements behave correctly. --- CHANGELOG.md | 1 + README.md | 2 +- src/Auth/BasicAuth.php | 21 +++++++++++------ test/BasicAuthTest.php | 52 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc927af..8a316ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol - User-facing password changes require the existing password, a session-bound `X-CSRF-Token`, and a JSON request; public registration is restricted to local credentials and safe profile fields. - OAuth callbacks require a **configured, trusted `redirectUri`** (HTTPS, or explicitly configured HTTP loopback for local development), no longer inferred from Host/proxy request headers. - OAuth provider-resource-owner display names, emails and avatars are optional hints with safe fallbacks; matching email addresses do **not** link accounts. +- `BasicAuth::update_credential()` now preserves an existing provider profile when given `null`, matching `DBAuth` and preventing password login from erasing metadata. - User-managed application associations (`managed_relations`) now belong to application services. Use `user_context_callback` to expose current assignments to the authenticated session. User deletion cleans only explicitly `auth_owned` authorization relation tables; application-owned relation tables are never implicitly modified. - Project testing/release verification moved from Travis to GitHub Actions; Composer metadata and support URL updated. diff --git a/README.md b/README.md index dcda2b6..4054ab6 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ $auth->rename_credential($userId, 'phone', '+5511999999999', 'phone', '+55118888 $auth->delete_credential($userId, 'phone', '+5511888888888'); ``` -`create_credential()` rejects duplicates; `update_credential()` inserts or updates a provider identity and updates its last-login field when configured. `delete_credential()` refuses to remove the user's last credential. Treat raw provider profiles as potentially sensitive; send normalized/sanitized credentials to clients. +`create_credential()` rejects duplicates; `update_credential()` inserts or updates a provider identity and updates its last-login field when configured. Passing `null` as the profile **preserves existing provider metadata** (including during password login) in both `DBAuth` and `BasicAuth`; pass a non-null profile to replace it. `delete_credential()` refuses to remove the user's last credential. Treat raw provider profiles as potentially sensitive; send normalized/sanitized credentials to clients. ##### Independent credential verification diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 95725be..51a53ba 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -393,18 +393,25 @@ public function update_credential($userid, $provider, $uid, $profile = null) } $created = $this->credentials[$provider][$uid]['created'] ?? date('Y-m-d H:i:s'); - $this->credentials[$provider][$uid] = array_merge([ - 'token' => null, - 'token_expires_at' => null, - 'verified_at' => null, - ], $existing ?: [], [ + $updates = [ 'user_id' => $userid, 'provider' => $provider, 'uid' => $uid, - 'profile' => $profile, 'last_login' => date('Y-m-d H:i:s'), 'created' => $created, - ]); + ]; + // A null profile means "leave unchanged", as in DBAuth. Password + // login passes null here and must not erase stored provider metadata. + if ($profile !== null) { + $updates['profile'] = $profile; + } + + $this->credentials[$provider][$uid] = array_merge([ + 'token' => null, + 'token_expires_at' => null, + 'verified_at' => null, + 'profile' => null, + ], $existing ?: [], $updates); return true; } diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index da237f0..f1046e0 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -35,6 +35,58 @@ public function testLoginSuccessAndSessionData(): void $this->assertArrayNotHasKey('password', $user); } + public function testPasswordLoginPreservesExistingCredentialProfile(): void + { + $profile = ['source' => 'import', 'attributes' => ['locale' => 'pt-BR']]; + $user = $this->auth->register('profile@example.com', 'secret', [ + 'profile' => $profile, + ]); + $original = $this->auth->get_credential('local', 'profile@example.com'); + $this->assertSame(json_encode($profile), $original['profile']); + $this->assertNull($original['last_login']); + + $this->auth->login('profile@example.com', 'secret'); + $afterLogin = $this->auth->get_credential('local', 'profile@example.com'); + $this->assertSame($original['profile'], $afterLogin['profile']); + $this->assertSame($original['created'], $afterLogin['created']); + $this->assertNotNull($afterLogin['last_login']); + + $this->auth->logout(); + $this->auth->login('profile@example.com', 'secret'); + $this->assertSame($original['profile'], $this->auth->get_credential('local', 'profile@example.com')['profile']); + + $listed = $this->auth->get_credentials($user['id']); + $this->assertCount(1, $listed); + $this->assertSame($profile, $listed[0]['profile']); + } + + public function testNullCredentialProfileDoesNotReplaceMetadataButNonNullDoes(): void + { + $user = $this->auth->register('update-profile@example.com', 'secret'); + $this->assertNull($this->auth->get_credential('local', 'update-profile@example.com')['profile']); + + $firstProfile = ['source' => 'google', 'avatar' => 'example.png']; + $this->auth->update_credential($user['id'], 'local', 'update-profile@example.com', $firstProfile); + $this->assertSame( + json_encode($firstProfile), + $this->auth->get_credential('local', 'update-profile@example.com')['profile'] + ); + + $this->auth->update_credential($user['id'], 'local', 'update-profile@example.com', null); + $this->assertSame( + json_encode($firstProfile), + $this->auth->get_credential('local', 'update-profile@example.com')['profile'] + ); + + $replacement = ['source' => 'manual']; + $this->auth->update_credential($user['id'], 'local', 'update-profile@example.com', $replacement); + $this->assertSame( + json_encode($replacement), + $this->auth->get_credential('local', 'update-profile@example.com')['profile'] + ); + $this->assertSame($replacement, $this->auth->get_credentials($user['id'])[0]['profile']); + } + public function testLoginInvalidPassword(): void { $this->expectException(AuthException::class); From ce809fee25c0118849bf2d01585f7f12e80cd804 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Wed, 21 Oct 2026 11:08:44 -0300 Subject: [PATCH 210/217] fix(auth): enforce exact MySQL OAuth credential identity matching Use the MySQL 8 utf8mb4_0900_bin collation for credential UID and provider columns in the existing Phinx migration. This prevents case-insensitive or trailing-space-insensitive matching of distinct external provider subjects. Verify column collations and actual credential lookup/uniqueness on MySQL 8.0 and 8.4, and document existing-schema migration considerations. --- README.md | 2 ++ db/20250419210134_auth_migration.php | 14 ++++++++++++-- test/verify_mysql_auth.php | 27 +++++++++++++++++++++++++++ test/verify_mysql_migrations.php | 15 +++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4054ab6..396860c 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,8 @@ complete email verification before treating an email as verified. Explicit provider registration using `DBAuth::register()` is reserved for trusted application code after authenticating the provider's identity. +**OAuth subject matching:** The bundled MySQL 8 migration uses the case-sensitive, `NO PAD` `utf8mb4_0900_bin` collation for both `user_credentials.uid` and `provider`. OAuth subject IDs must match exactly, including case and trailing spaces. Custom schemas must enforce equivalent identity semantics. Editing this migration does **not** update tables already created by Phinx; if you've previously run it, migrate or recreate the credential table before deploying. + #### Users `register()` creates the user and its initial credential transactionally. It returns a **sanitized** user. `get()` returns the provider's **raw** user record, including sensitive fields; sanitize it before exposing it to clients. diff --git a/db/20250419210134_auth_migration.php b/db/20250419210134_auth_migration.php index f8b3134..85e7b81 100644 --- a/db/20250419210134_auth_migration.php +++ b/db/20250419210134_auth_migration.php @@ -49,8 +49,18 @@ public function change() 'primary_key' => ['uid', 'provider'] ]); $user_credentials - ->addColumn('uid', 'string', ['limit' => 255, 'null' => false]) - ->addColumn('provider', 'string', ['limit' => 32, 'null' => false]) + // MySQL 8 needs exact, NO PAD comparisons for OAuth subject IDs. + // Phinx ignores this MySQL-specific option on SQLite/PostgreSQL. + ->addColumn('uid', 'string', [ + 'limit' => 255, + 'null' => false, + 'collation' => 'utf8mb4_0900_bin', + ]) + ->addColumn('provider', 'string', [ + 'limit' => 32, + 'null' => false, + 'collation' => 'utf8mb4_0900_bin', + ]) ->addColumn('user_id', 'integer', ['signed' => false]) ->addColumn('profile', 'text', ['null'=> true]) ->addColumn('token', 'string', ['limit' => 255, 'null' => true]) diff --git a/test/verify_mysql_auth.php b/test/verify_mysql_auth.php index f98c351..2f33165 100644 --- a/test/verify_mysql_auth.php +++ b/test/verify_mysql_auth.php @@ -140,4 +140,31 @@ $assert($auth->get_users_by_role('viewer') === [], 'Deleted user still appears in role lookup'); $assert(count($auth->query()) === 1, 'Deleted user still appears in MySQL query'); +// Provider identity comparisons must remain exact even when the database's +// default collation is case-insensitive. These are distinct OAuth subjects. +$subjects = [ + ['google', 'OAuthSubjectAbC'], + ['google', 'oauthsubjectabc'], + ['google', 'OAuthSubjectAbC '], + ['Google', 'OAuthSubjectAbC'], +]; +$identityUserIds = []; +foreach ($subjects as [$provider, $uid]) { + $user = $auth->register($uid, null, ['provider' => $provider]); + $identityUserIds[] = $user['id']; + $credential = $auth->get_credential($provider, $uid); + $assert( + $credential !== false && $credential['user_id'] == $user['id'], + "MySQL returned another account for {$provider}/{$uid}" + ); +} +$assert( + count(array_unique($identityUserIds)) === count($subjects), + 'Distinct OAuth subjects must never resolve to the same user' +); +$assert( + $auth->get_credential('google', 'OAUTHSUBJECTABC') === false, + 'MySQL incorrectly resolved an OAuth subject using case-insensitive lookup' +); + echo "MySQL 8 DBAuth registration, roles, login, pagination, password reset, credential verification and deletion passed.\n"; diff --git a/test/verify_mysql_migrations.php b/test/verify_mysql_migrations.php index e59cb9d..3e0eace 100644 --- a/test/verify_mysql_migrations.php +++ b/test/verify_mysql_migrations.php @@ -56,6 +56,21 @@ $assert($engineStmt->fetchColumn() === 'InnoDB', "Expected InnoDB table {$table}"); } +// OAuth provider names and subject IDs must use exact comparisons on MySQL, +// regardless of the database/table's default case-insensitive collation. +$collationStmt = $pdo->prepare( + 'SELECT COLUMN_NAME, COLLATION_NAME FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table' +); +$collationStmt->execute(['db' => $database, 'table' => 'user_credentials']); +$collations = $collationStmt->fetchAll(PDO::FETCH_KEY_PAIR); +foreach (['uid', 'provider'] as $column) { + $assert( + ($collations[$column] ?? null) === 'utf8mb4_0900_bin', + "MySQL credential identity column {$column} must use utf8mb4_0900_bin" + ); +} + // Check indexes by indexed column, including whether they enforce uniqueness. $indexStmt = $pdo->prepare( 'SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, SEQ_IN_INDEX From e3799e9902eeaa48e3cf8826fcc0696b56c047da Mon Sep 17 00:00:00 2001 From: objectivebot Date: Thu, 22 Oct 2026 10:47:35 -0300 Subject: [PATCH 211/217] fix(auth): require verified recovery credentials before issuing reset tokens Restrict anonymous password recovery to independently verified credentials for every configured recovery provider, including local. Prefer the first verified matching channel rather than an unverified match, avoid rotating reset tokens for unverified accounts, and keep uniform anonymous responses. Cover BasicAuth and DBAuth behavior in regression tests and document existing-account migration requirements. --- CHANGELOG.md | 1 + README.md | 6 ++- docs/UPGRADING-0.2.0.md | 3 ++ src/Auth/Controller/AuthController.php | 10 +++- test/AuthControllerTest.php | 71 +++++++++++++++++++++++--- test/DBAuthSqliteTest.php | 35 +++++++++++++ 6 files changed, 116 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a316ab..d9dcf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol ### Security fixes - Prevent account enumeration through password-login errors and anonymous password-recovery responses. +- Allow anonymous password recovery only through independently verified credential channels (including local), preventing reset delivery to unverified emails/phone numbers; preserve generic responses and trusted administrator invitation/reset workflows. - Consume password reset and credential-verification tokens at most once; separate proof for each provider credential. - Prevent OAuth login CSRF, state replay, cross-provider state confusion, unconsented linking, email-based account takeover and cross-account credential reassignment. - Restrict logout redirects to safe same-origin absolute paths and reject attacker-controlled OAuth callback URLs. diff --git a/README.md b/README.md index 396860c..0d1b401 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ These options come from both the shared `Auth` contract and `DBAuth`. Set only t | `credentials_table` | `user_credentials` | Logical provider-credential table | | `credentials_last_login` / `credentials_created` | `last_login` / `created` | Credential timestamp columns; `null` disables updates | | `login_providers` | `['local', 'email']` | Ordered provider identities considered for password login | -| `recovery_providers` | `['local', 'email', 'phone']` | Provider identities checked for anonymous recovery | +| `recovery_providers` | `['local', 'email', 'phone']` | Ordered recovery channels; only credentials with `verified_at` are eligible | | `token` | `null` | User-table reset-token field; use `'token'` to enable | | `token_expires_field` / `token_ttl` | `token_expires_at` / `3600` | Reset-token expiry column and lifetime in seconds | | `disabled_at` | `null` | Nullable suspension column; use `'disabled_at'` to enable lifecycle checks | @@ -492,7 +492,9 @@ $auth->passwd_reset($token, 'new-password'); `DBAuth` stores only a SHA-256 digest of the high-entropy reset token and looks it up through the indexed token column. The plaintext token is returned only once for delivery. -Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. **There is no built-in rate limiting in v0.2.0.** Applications must enforce external rate limits at their web server, reverse proxy, gateway, or middleware for login, public registration, recovery, reset-token redemption, and credential verification. Future non-database throttling is tracked in [issue #2](https://github.com/objectiveweb/auth/issues/2). +Anonymous password recovery uses **only independently verified** credentials (`verified_at` is set), including for the default `local`, `email` and `phone` providers. An unverified contact method cannot request an account-wide reset token; if multiple configured providers have the same UID, the first **verified** credential is selected. Password login alone does not verify control of the recovery address. Newly registered local credentials therefore cannot use anonymous recovery until their address is verified through `issue_credential_token()` / `postVerifyCredential()` or other trusted out-of-band proof. Legacy local credentials imported with `verified_at = NULL` likewise cannot recover anonymously; do not bulk-mark them verified without evidence of ownership. An admin-initiated `Auth::invite()` or reset remains a separate trusted operation and must be delivered only via an independently trusted channel. + +Anonymous password-recovery requests intentionally return the same empty success payload whether or not the supplied UID exists or is verified. Recovery callbacks are delivery-only: their return value is not exposed, and delivery failures do not change the anonymous response. **There is no built-in rate limiting in v0.2.0.** Applications must enforce external rate limits at their web server, reverse proxy, gateway, or middleware for login, public registration, recovery, reset-token redemption, and credential verification. Future non-database throttling is tracked in [issue #2](https://github.com/objectiveweb/auth/issues/2). ### Database CI coverage diff --git a/docs/UPGRADING-0.2.0.md b/docs/UPGRADING-0.2.0.md index 602d9d4..3ddcdbd 100644 --- a/docs/UPGRADING-0.2.0.md +++ b/docs/UPGRADING-0.2.0.md @@ -90,6 +90,8 @@ A **migration script that you write and test** should apply the following mappin **Data migration order:** import user rows; verify ID/UUID uniqueness; import linked `local` credentials; migrate or remap application foreign keys; import the intended role memberships; migrate any Auth-owned delegations; then run validation queries. Do this in a controlled transaction where the database/schema permits it, and stop deployment on conflicts. +**Recovery verification during migration:** Anonymous password recovery requires `user_credentials.verified_at` for every recovery provider, including `local`. Imported local credentials with `verified_at = NULL` can still use password login but cannot initiate anonymous recovery until the recovery address is independently verified. Preserve evidence of verified contacts during migration; do not assume that knowing an old password or storing an email address verifies ownership. Provide an out-of-band verification flow for new and legacy accounts, or use a trusted administrator-initiated invitation/reset delivered over an already trusted channel. + Preserving an existing bcrypt hash allows a password user to sign in without knowing the plaintext. But a legacy username like `jdoe` will not automatically work with the new public registration endpoint, which requires an email-formatted UID. Choose and document your application's identity/registration strategy before launch. Password login itself checks configured `login_providers` (default `['local', 'email']`). If you preserve numeric `id` values, also reset the auto-increment/identity sequence for the migrated user table above the maximum imported ID before any new registration. Migrate and verify application-side FKs before enabling deletes, and confirm the DBAuth default table names or configured aliases resolve to the intended physical tables. @@ -132,6 +134,7 @@ Before merging/upgrading, check: - [ ] Old/new user counts match; preserved IDs and all application foreign keys resolve correctly. - [ ] Every old password identity maps to exactly one `local` credential and bcrypt login works. - [ ] Old password-reset tokens are invalid; new reset and credential-specific verification tokens are one-time and expire. +- [ ] Anonymous recovery works only through independently verified credentials; imported local accounts with unverified recovery addresses have a documented verification or trusted-admin recovery process. - [ ] List/search pagination, role assignment and active-admin safeguards behave as expected. - [ ] Existing sessions are invalidated; password changes require current password and CSRF. - [ ] OAuth registered redirect URIs, first-time account choice, existing-account linking, returning-login and link-conflict cases work with **your** providers. diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index e98446a..0b64627 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -215,7 +215,10 @@ function postPassword(array $form) throw new UserException('Invalid request', 400); } - // find user by recovery channels + // Anonymous recovery must prove ownership of the *recovery + // channel*, not just find a credential attached to the account. + // This includes 'local': knowing a local password does not prove + // control over the email/phone identifier used for recovery. $credential = false; $providers = $this->auth->params['recovery_providers']; if (!is_array($providers)) { @@ -224,8 +227,11 @@ function postPassword(array $form) foreach ($providers as $provider) { $candidate = $this->auth->get_credential($provider, $form['uid']); - if ($credential === false && !empty($candidate['user_id'])) { + if (is_array($candidate) + && !empty($candidate['user_id']) + && !empty($candidate['verified_at'])) { $credential = $candidate; + break; } } diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index ff26591..2b03cfc 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -343,7 +343,8 @@ public function testPostPasswordForgotFlowIgnoresCallbackReturnValue(): void 'token_callback' => fn (): array => ['account_exists' => true], ]); $controller = new AuthController($auth); - $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $user = $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'alice@example.com'); $result = $controller->postPassword(['uid' => 'alice@example.com']); @@ -359,7 +360,8 @@ public function testPostPasswordForgotFlowHidesDeliveryFailure(): void }, ]); $controller = new AuthController($auth); - $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $user = $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'alice@example.com'); $result = $controller->postPassword(['uid' => 'alice@example.com']); @@ -379,7 +381,8 @@ public function testPasswordRecoveryDeliveryLogsNoSensitiveExceptionDetails(): v ); }, ]); - $auth->register('private@example.com', 'secret', ['provider' => 'email']); + $user = $auth->register('private@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'private@example.com'); $controller = new AuthController($auth); $logFile = tempnam(sys_get_temp_dir(), 'ow-auth-recovery-'); @@ -410,7 +413,8 @@ public function testPostPasswordForgotFlowUsesEmailCredential(): void $auth = new BasicAuth([], ['token' => 'token']); $controller = new AuthController($auth); - $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $user = $auth->register('alice@example.com', 'secret', ['provider' => 'email']); + $auth->mark_credential_verified($user['id'], 'email', 'alice@example.com'); $result = $controller->postPassword(['uid' => 'alice@example.com']); $this->assertSame([], $result); @@ -421,7 +425,8 @@ public function testPostPasswordForgotFlowUsesPhoneCredential(): void $auth = new BasicAuth([], ['token' => 'token']); $controller = new AuthController($auth); - $auth->register('+5511999999999', 'secret', ['provider' => 'phone']); + $user = $auth->register('+5511999999999', 'secret', ['provider' => 'phone']); + $auth->mark_credential_verified($user['id'], 'phone', '+5511999999999'); $result = $controller->postPassword(['uid' => '+5511999999999']); $this->assertSame([], $result); @@ -432,12 +437,66 @@ public function testPostPasswordForgotFlowUsesLocalCredentialByDefault(): void $auth = new BasicAuth([], ['token' => 'token']); $controller = new AuthController($auth); - $auth->register('local@example.com', 'secret', ['provider' => 'local']); + $user = $auth->register('local@example.com', 'secret', ['provider' => 'local']); + $auth->mark_credential_verified($user['id'], 'local', 'local@example.com'); $result = $controller->postPassword(['uid' => 'local@example.com']); $this->assertSame([], $result); } + public function testAnonymousRecoveryRejectsUnverifiedLocalWithoutRotatingResetToken(): void + { + $deliveries = []; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => static function (array $credential) use (&$deliveries): void { + $deliveries[] = $credential; + }, + ]); + $user = $auth->register('legacy@example.com', 'secret'); + $controller = new AuthController($auth); + + // An admin-issued invitation/reset token must not be replaced by + // anonymous recovery to an unverified local identifier. + $existingToken = $auth->update_token($user['id']); + $existingDigest = $auth->get($user['id'])['token']; + + $this->assertSame([], $controller->postPassword(['uid' => 'legacy@example.com'])); + $this->assertSame([], $controller->postPassword(['uid' => 'missing@example.com'])); + $this->assertSame([], $deliveries); + $this->assertSame($existingDigest, $auth->get($user['id'])['token']); + + // Recovery becomes available only after independent channel proof. + $proof = $auth->issue_credential_token($user['id'], 'local', 'legacy@example.com'); + $this->assertTrue($auth->verify_credential_token('local', 'legacy@example.com', $proof)); + $this->assertSame([], $controller->postPassword(['uid' => 'legacy@example.com'])); + $this->assertCount(1, $deliveries); + $this->assertSame('local', $deliveries[0]['provider']); + $this->assertNotSame($existingToken, $deliveries[0]['token']); + $this->assertTrue(password_verify($deliveries[0]['token'], $auth->get($user['id'])['token'])); + } + + public function testAnonymousRecoverySkipsUnverifiedLocalInFavorOfVerifiedEmail(): void + { + $deliveries = []; + $auth = new BasicAuth([], [ + 'token' => 'token', + 'token_callback' => static function (array $credential) use (&$deliveries): void { + $deliveries[] = $credential; + }, + ]); + $user = $auth->register('shared@example.com', 'secret'); + $auth->create_credential($user['id'], 'email', 'shared@example.com'); + $proof = $auth->issue_credential_token($user['id'], 'email', 'shared@example.com'); + $this->assertTrue($auth->verify_credential_token('email', 'shared@example.com', $proof)); + $this->assertNull($auth->get_credential('local', 'shared@example.com')['verified_at']); + + $controller = new AuthController($auth); + $this->assertSame([], $controller->postPassword(['uid' => 'shared@example.com'])); + $this->assertCount(1, $deliveries); + $this->assertSame('email', $deliveries[0]['provider']); + } + public function testPublicRegistrationCreatesOnlyLocalCredential(): void { $user = $this->controller->postRegister([ diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 0d657a5..bcc05c3 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -671,6 +671,41 @@ public function testDbAuthAuthenticatedPasswordChangeVerifiesCurrentPassword(): } } + public function testAnonymousRecoveryUsesOnlyVerifiedDbAuthCredentials(): void + { + $user = self::$auth->register('db-recovery@example.com', 'secret'); + $controller = new AuthController(self::$auth); + $deliveries = []; + $previousCallback = self::$auth->params['token_callback']; + self::$auth->params['token_callback'] = static function (array $credential) use (&$deliveries): void { + $deliveries[] = $credential; + }; + + try { + $existingToken = self::$auth->update_token($user['id']); + $existingDigest = self::$auth->get($user['id'])['token']; + + $this->assertSame([], $controller->postPassword(['uid' => 'db-recovery@example.com'])); + $this->assertSame([], $controller->postPassword(['uid' => 'unknown@example.com'])); + $this->assertSame([], $deliveries); + $this->assertSame($existingDigest, self::$auth->get($user['id'])['token']); + + $proof = self::$auth->issue_credential_token($user['id'], 'local', 'db-recovery@example.com'); + $this->assertTrue(self::$auth->verify_credential_token('local', 'db-recovery@example.com', $proof)); + + $this->assertSame([], $controller->postPassword(['uid' => 'db-recovery@example.com'])); + $this->assertCount(1, $deliveries); + $this->assertSame('local', $deliveries[0]['provider']); + $this->assertNotSame($existingToken, $deliveries[0]['token']); + $this->assertSame( + hash('sha256', $deliveries[0]['token']), + self::$auth->get($user['id'])['token'] + ); + } finally { + self::$auth->params['token_callback'] = $previousCallback; + } + } + public function testAuthControllerResponsesDoNotExposeUserSecrets(): void { $controller = new AuthController(self::$auth); From 81f10b967b3f3316fdb3c2eea5a9edf8a45db322 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Thu, 22 Oct 2026 18:10:59 -0300 Subject: [PATCH 212/217] fix(auth): revoke outstanding reset tokens on password changes Update password hash, reset-token digest and expiration together in a single DBAuth UPDATE, and apply the same behavior in BasicAuth. Cover direct password changes and authenticated password-change routes with SQLite, in-memory and MySQL regressions. Document reset-link invalidation in the README and changelog. --- CHANGELOG.md | 1 + README.md | 2 ++ src/Auth/BasicAuth.php | 13 ++++++++++++- src/Auth/DBAuth.php | 16 +++++++++++++++- test/AuthControllerTest.php | 13 ++++++++++++- test/BasicAuthTest.php | 24 ++++++++++++++++++++++++ test/DBAuthSqliteTest.php | 37 ++++++++++++++++++++++++++++++++++++- test/verify_mysql_auth.php | 15 +++++++++++++++ 8 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9dcf7c..9475067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol - Prevent account enumeration through password-login errors and anonymous password-recovery responses. - Allow anonymous password recovery only through independently verified credential channels (including local), preventing reset delivery to unverified emails/phone numbers; preserve generic responses and trusted administrator invitation/reset workflows. - Consume password reset and credential-verification tokens at most once; separate proof for each provider credential. +- Revoke outstanding password-reset tokens and expirations whenever a user password is changed, atomically with the password hash update on DBAuth. - Prevent OAuth login CSRF, state replay, cross-provider state confusion, unconsented linking, email-based account takeover and cross-account credential reassignment. - Restrict logout redirects to safe same-origin absolute paths and reject attacker-controlled OAuth callback URLs. - Enforce management CSRF checks, prevent removal of the last active admin and protect the caller from self-deletion/suspension. Reject unknown DELETE subresources with 404 instead of accidentally deleting the user. diff --git a/README.md b/README.md index 0d1b401..097af7c 100644 --- a/README.md +++ b/README.md @@ -490,6 +490,8 @@ $token = $auth->update_token($credential['user_id']); $auth->passwd_reset($token, 'new-password'); ``` +Direct password changes through `passwd($userId, $newPassword)` (including authenticated `AuthController::postPassword()`) revoke any outstanding user password-reset token and its expiry. `DBAuth` changes these fields in one SQL `UPDATE` so a previously issued reset link cannot overwrite the new password. This is separate from credential-verification tokens. + `DBAuth` stores only a SHA-256 digest of the high-entropy reset token and looks it up through the indexed token column. The plaintext token is returned only once for delivery. Anonymous password recovery uses **only independently verified** credentials (`verified_at` is set), including for the default `local`, `email` and `phone` providers. An unverified contact method cannot request an account-wide reset token; if multiple configured providers have the same UID, the first **verified** credential is selected. Password login alone does not verify control of the recovery address. Newly registered local credentials therefore cannot use anonymous recovery until their address is verified through `issue_credential_token()` / `postVerifyCredential()` or other trusted out-of-band proof. Legacy local credentials imported with `verified_at = NULL` likewise cannot recover anonymously; do not bulk-mark them verified without evidence of ownership. An admin-initiated `Auth::invite()` or reset remains a separate trusted operation and must be delivered only via an independently trusted channel. diff --git a/src/Auth/BasicAuth.php b/src/Auth/BasicAuth.php index 51a53ba..d5009ac 100644 --- a/src/Auth/BasicAuth.php +++ b/src/Auth/BasicAuth.php @@ -176,7 +176,18 @@ public function passwd($user_id, $password, $key = 'id') throw new UserException('User not found', 404); } - $this->users[$resolved][$this->params['password']] = self::hash($password); + $changes = [$this->params['password'] => self::hash($password)]; + $tokenField = $this->params['token']; + if (is_string($tokenField) && $tokenField !== '') { + $changes[$tokenField] = null; + $expiresField = $this->params['token_expires_field']; + if (is_string($expiresField) && $expiresField !== '') { + $changes[$expiresField] = null; + } + } + + // Apply password replacement and reset-token revocation together. + $this->users[$resolved] = array_replace($this->users[$resolved], $changes); return true; } diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 3c2b0b1..a1fe868 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -364,9 +364,23 @@ public function register($uid, $password = null, $data = array()) public function passwd($user_id, $password, $key = 'id') { $column = $this->params[$key] ?? $key; + $payload = [$this->params['password'] => self::hash($password)]; + + // Changing a password must also revoke outstanding reset links. + // Commit all fields in one UPDATE so a concurrent token redemption + // cannot later overwrite the newly chosen password. + $tokenField = $this->params['token']; + if (is_string($tokenField) && $tokenField !== '') { + $payload[$tokenField] = null; + $expiresField = $this->params['token_expires_field']; + if (is_string($expiresField) && $expiresField !== '') { + $payload[$expiresField] = null; + } + } + $updated = $this->userTable->update( [$column => $user_id], - [$this->params['password'] => self::hash($password)] + $payload )['updated']; if ($updated !== 1) { diff --git a/test/AuthControllerTest.php b/test/AuthControllerTest.php index 2b03cfc..d9db4ac 100644 --- a/test/AuthControllerTest.php +++ b/test/AuthControllerTest.php @@ -241,7 +241,8 @@ public function testAuthenticatedPasswordChangeRejectsMismatchedConfirmation(): public function testAuthenticatedPasswordChangeSucceedsAndRequiresNewPasswordOnNextLogin(): void { - $this->auth->register('alice@example.com', 'old-secret'); + $user = $this->auth->register('alice@example.com', 'old-secret'); + $resetToken = $this->auth->update_token($user['id']); $this->auth->login('alice@example.com', 'old-secret'); $current = $this->controller->index(); @@ -254,6 +255,16 @@ public function testAuthenticatedPasswordChangeSucceedsAndRequiresNewPasswordOnN 'confirm' => 'new-secret', ])); + $changed = $this->auth->get($user['id']); + $this->assertNull($changed['token']); + $this->assertNull($changed['token_expires_at']); + try { + $this->auth->passwd_reset($resetToken, 'stale-reset-secret'); + $this->fail('Authenticated password change left a reset token active'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + $this->auth->logout(); try { $this->auth->login('alice@example.com', 'old-secret'); diff --git a/test/BasicAuthTest.php b/test/BasicAuthTest.php index f1046e0..54e7fd0 100644 --- a/test/BasicAuthTest.php +++ b/test/BasicAuthTest.php @@ -127,6 +127,30 @@ public function testRegisterAndGetCredential(): void $this->assertSame($user['id'], $credential['user_id']); } + public function testPasswdInvalidatesOutstandingResetToken(): void + { + $credential = $this->auth->get_credential('local', 'alice@example.com'); + $userId = $credential['user_id']; + $token = $this->auth->update_token($userId); + $this->assertNotNull($this->auth->get($userId)['token']); + $this->assertNotNull($this->auth->get($userId)['token_expires_at']); + + $this->assertTrue($this->auth->passwd($userId, 'changed-secret')); + $updated = $this->auth->get($userId); + $this->assertTrue(password_verify('changed-secret', $updated['password'])); + $this->assertNull($updated['token']); + $this->assertNull($updated['token_expires_at']); + + try { + $this->auth->passwd_reset($token, 'stale-reset-secret'); + $this->fail('Password reset token survived a direct password change'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $this->assertSame($userId, $this->auth->login('alice@example.com', 'changed-secret')['id']); + } + public function testPasswordResetFlow(): void { $credential = $this->auth->get_credential('local', 'alice@example.com'); diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index bcc05c3..3048a80 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -296,6 +296,31 @@ public function testPasswd(): void $this->assertSame($user['id'], $logged['id']); } + public function testPasswdInvalidatesOutstandingResetToken(): void + { + $user = self::$auth->register('revoke-reset@example.com', 'original-secret'); + $token = self::$auth->update_token($user['id']); + $before = self::$auth->get($user['id']); + $this->assertSame(hash('sha256', $token), $before['token']); + $this->assertNotNull($before['token_expires_at']); + + $this->assertTrue(self::$auth->passwd($user['id'], 'changed-secret')); + $after = self::$auth->get($user['id']); + $this->assertTrue(password_verify('changed-secret', $after['password'])); + $this->assertNull($after['token']); + $this->assertNull($after['token_expires_at']); + + try { + self::$auth->passwd_reset($token, 'should-not-work'); + $this->fail('A reset link issued before password change was accepted'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } + + $this->assertSame($user['id'], self::$auth->login('revoke-reset@example.com', 'changed-secret')['id']); + $this->assertTrue(password_verify('changed-secret', self::$auth->get($user['id'])['password'])); + } + public function testUpdate(): void { $user = self::$auth->register('alice@example.com', 'secret', ['name' => 'Alice']); @@ -634,6 +659,7 @@ public function testPasswdResetUsesConfiguredDatabasePrefix(): void public function testDbAuthAuthenticatedPasswordChangeVerifiesCurrentPassword(): void { $user = self::$auth->register('db-password-change@example.com', 'old-secret'); + $outstandingReset = self::$auth->update_token($user['id']); self::$auth->login('db-password-change@example.com', 'old-secret'); $controller = new AuthController(self::$auth); @@ -658,7 +684,16 @@ public function testDbAuthAuthenticatedPasswordChangeVerifiesCurrentPassword(): 'password' => 'new-secret', 'confirm' => 'new-secret', ])); - $this->assertTrue(password_verify('new-secret', self::$auth->get($user['id'])['password'])); + $updated = self::$auth->get($user['id']); + $this->assertTrue(password_verify('new-secret', $updated['password'])); + $this->assertNull($updated['token']); + $this->assertNull($updated['token_expires_at']); + try { + self::$auth->passwd_reset($outstandingReset, 'stale-reset-secret'); + $this->fail('Authenticated password change left a reset token active'); + } catch (UserException $exception) { + $this->assertSame(404, $exception->getCode()); + } self::$auth->logout(); $this->assertSame($user['id'], self::$auth->login('db-password-change@example.com', 'new-secret')['id']); diff --git a/test/verify_mysql_auth.php b/test/verify_mysql_auth.php index 2f33165..8e2f355 100644 --- a/test/verify_mysql_auth.php +++ b/test/verify_mysql_auth.php @@ -106,6 +106,21 @@ $auth->login('mysql-admin@example.com', 'replacement-secret'); $auth->logout(); +// Direct password updates must invalidate any previously issued reset link +// in the same SQL statement, without leaving a usable stale token. +$pendingReset = $auth->update_token($admin['id']); +$auth->passwd($admin['id'], 'changed-by-user-secret'); +$stored = $db->select('user', ['id' => $admin['id']], ['limit' => 1])->fetch(); +$assert($stored['token'] === null && $stored['token_expires_at'] === null, + 'Password change did not revoke the outstanding reset token'); +$expectFailure( + static fn () => $auth->passwd_reset($pendingReset, 'stale-reset-secret'), + UserException::class, + 404 +); +$auth->login('mysql-admin@example.com', 'changed-by-user-secret'); +$auth->logout(); + // Each credential has its own token, expiration and verification state. $credential = $auth->create_credential($admin['id'], 'email', 'admin-contact@example.com'); $assert(empty($credential['verified_at']), 'New email credential should be unverified'); From 1229181eba551fc380be5a06003d75e706357b44 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Thu, 22 Oct 2026 20:12:53 -0300 Subject: [PATCH 213/217] fix(auth): serialize final active admin checks and mutations Add DBAuth with_admin_mutation using db->transaction() and a stable role-row write lock, then reload the target and enforce the invariant within the same transaction before deletes, demotions, or suspensions. Protect PUT disabled_at as well as the suspend action. Add tests for all mutation paths and stale-count interleaving, and document the invariant and limits of direct DBAuth writes. --- CHANGELOG.md | 2 +- README.md | 8 ++ src/Auth.php | 10 +++ src/Auth/Controller/UserController.php | 70 ++++++++++----- src/Auth/DBAuth.php | 25 ++++++ test/UserControllerTest.php | 114 +++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9475067..62828fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol - Revoke outstanding password-reset tokens and expirations whenever a user password is changed, atomically with the password hash update on DBAuth. - Prevent OAuth login CSRF, state replay, cross-provider state confusion, unconsented linking, email-based account takeover and cross-account credential reassignment. - Restrict logout redirects to safe same-origin absolute paths and reject attacker-controlled OAuth callback URLs. -- Enforce management CSRF checks, prevent removal of the last active admin and protect the caller from self-deletion/suspension. Reject unknown DELETE subresources with 404 instead of accidentally deleting the user. +- Enforce management CSRF checks; serialize administrator removal, demotion and suspension with a shared role-row lock inside DBAuth transactions to prevent simultaneous requests removing the final active admin (including direct lifecycle-field updates); protect the caller from self-deletion/suspension. Reject unknown DELETE subresources with 404 instead of accidentally deleting the user. - Avoid returning passwords, reset tokens or other internal secrets in normal user/session/management payloads. ### Removed / incompatible diff --git a/README.md b/README.md index 097af7c..98b2246 100644 --- a/README.md +++ b/README.md @@ -682,6 +682,14 @@ The explicit authenticated `postLink()` flow remains distinct from the *anonymou ## User-management API +DBAuth serializes administrator removal, demotion and suspension inside +`$db->transaction()`. It acquires a shared lock on the `admin` role row +before reloading the target and checking the active-administrator count, +including when a `PUT` request changes `disabled_at` directly. +Custom code that bypasses UserController must protect the same invariant; +the lock uses a no-op UPDATE, which may activate role-table UPDATE triggers. + + Register `Objectiveweb\Auth\Controller\UserController` at an application-owned prefix such as `/api/users`. It requires the `admin` role and supports: diff --git a/src/Auth.php b/src/Auth.php index 286e4de..a752cb1 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -438,6 +438,16 @@ abstract public function update($user_id, array $data, $key = 'id'); abstract public function delete($user_id); + /** + * Execute an administrator mutation inside a provider-specific guard. + * Database-backed providers must serialize administrator removals so + * the active-admin invariant is checked against current locked data. + */ + public function with_admin_mutation(callable $mutation): mixed + { + return $mutation(); + } + /** * Generates a new token for the user and update the database * @param $user_id diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 47af3ee..9b7b43b 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -115,16 +115,18 @@ public function delete( } $this->assertNotSelf($userId, 'delete'); - $target = $this->auth->get($userId); - $this->assertNotFinalActiveAdmin($target); - try { - $this->auth->delete($userId); - } catch (UserException $exception) { - throw $exception; - } catch (\Throwable $exception) { - throw new UserException('User has application history; suspend the account instead', 409, $exception); - } - return ['ok' => true]; + return $this->auth->with_admin_mutation(function () use ($userId): array { + $target = $this->auth->get($userId); + $this->assertNotFinalActiveAdmin($target); + try { + $this->auth->delete($userId); + } catch (UserException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new UserException('User has application history; suspend the account instead', 409, $exception); + } + return ['ok' => true]; + }); } private function list(array $params): Collection @@ -186,7 +188,6 @@ private function create(array $data): array private function updateUser(mixed $userId, array $data): array { - $target = $this->auth->get($userId); $this->assertNoApplicationRelations($data); unset($data['uid'], $data[$this->auth->params['password']]); $this->assertWriteFields($data); @@ -194,17 +195,35 @@ private function updateUser(mixed $userId, array $data): array $roleField = $this->auth->params['roles']; if (array_key_exists($roleField, $data)) { $this->assertRolePayload($data[$roleField]); - $roles = $data[$roleField]; - $this->assertKnownRoles($roles); - $this->assertOwnAdminRolePreserved($userId, $roles); - if (in_array('admin', (array) ($target[$roleField] ?? []), true) && !in_array('admin', $roles, true)) { + $this->assertKnownRoles($data[$roleField]); + } + + return $this->auth->with_admin_mutation(function () use ($userId, $data, $roleField): array { + // Reload after obtaining the shared lock: pre-lock reads can be + // stale once a competing administrator mutation has committed. + $target = $this->auth->get($userId); + if (array_key_exists($roleField, $data)) { + $this->assertOwnAdminRolePreserved($userId, $data[$roleField]); + } + + // PUT may change disabled_at directly, bypassing POST /suspend. + // Both demotion and direct suspension must preserve the last admin. + $proposed = array_replace($target, $data); + if ( + in_array('admin', (array) ($target[$roleField] ?? []), true) + && $this->auth->is_active($target) + && ( + !in_array('admin', (array) ($proposed[$roleField] ?? []), true) + || !$this->auth->is_active($proposed) + ) + ) { $this->assertNotFinalActiveAdmin($target); } - } - $this->auth->update($userId, $data); - $this->auth->audit('user.updated', $userId, ['fields' => array_keys($data)]); - return $this->detail($userId); + $this->auth->update($userId, $data); + $this->auth->audit('user.updated', $userId, ['fields' => array_keys($data)]); + return $this->detail($userId); + }); } private function createCredential(mixed $userId, array $data): array @@ -240,11 +259,16 @@ private function setSuspended(mixed $userId, bool $suspended): array } if ($suspended) { $this->assertNotSelf($userId, 'suspend'); - $this->assertNotFinalActiveAdmin($this->auth->get($userId)); } - $this->auth->update($userId, [$field => $suspended ? date('Y-m-d H:i:s') : null]); - $this->auth->audit($suspended ? 'user.suspended' : 'user.activated', $userId); - return $this->detail($userId); + + return $this->auth->with_admin_mutation(function () use ($userId, $field, $suspended): array { + if ($suspended) { + $this->assertNotFinalActiveAdmin($this->auth->get($userId)); + } + $this->auth->update($userId, [$field => $suspended ? date('Y-m-d H:i:s') : null]); + $this->auth->audit($suspended ? 'user.suspended' : 'user.activated', $userId); + return $this->detail($userId); + }); } private function sendToken(mixed $userId, bool $reset): array diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index a1fe868..5cc6e0d 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -492,6 +492,31 @@ public function update($user_id, array $data, $key = 'id') }); } + /** + * Use the same stable role row for every potentially admin-removing + * mutation. The no-op UPDATE takes a row lock in MySQL/PostgreSQL and a + * write lock in SQLite. Unlike FOR UPDATE, it works on all supported + * engines, and DB::update handles configured table prefixes. + * + * The caller must read the target and count active administrators only + * after this lock has been acquired. + */ + public function with_admin_mutation(callable $mutation): mixed + { + return $this->db->transaction(function (DB $db) use ($mutation): mixed { + if ($this->rolesEnabled()) { + $roleField = (string) $this->params['role_name']; + $db->update( + (string) $this->params['roles_table'], + [$roleField => 'admin'], + [$roleField => 'admin'] + ); + } + + return $mutation(); + }); + } + public function delete($user_id) { if ($this->check()) { diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 56ccc78..3ada15a 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -285,6 +285,120 @@ public function testCannotSuspendOnlyActiveAdminWhenAnotherAdminIsSuspended(): v $controller->post((int) $activeAdmin['id'], 'suspend'); } + public function testFinalAdminGuardCoversSuspendDemoteDeleteAndDirectDisable(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $controller = new UserController($auth); + $admin = $auth->register('sole-admin@example.com', 'secret', ['roles' => ['admin']]); + $id = (int) $admin['id']; + + $mutations = [ + static fn () => $controller->post($id, 'suspend'), + static fn () => $controller->put($id, ['roles' => []]), + static fn () => $controller->put($id, ['disabled_at' => '2026-01-01 00:00:00']), + static fn () => $controller->put($id, ['roles' => [], 'disabled_at' => '2026-01-01 00:00:00']), + static fn () => $controller->delete($id), + ]; + foreach ($mutations as $mutate) { + try { + $mutate(); + $this->fail('A mutation removed the final active administrator'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + $stillAdmin = $auth->get($id); + $this->assertTrue($auth->is_active($stillAdmin)); + $this->assertContains('admin', $stillAdmin['roles']); + } + } + + public function testReducingTwoActiveAdminsToOnePreventsFurtherRemoval(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $controller = new UserController($auth); + $first = $auth->register('first-admin@example.com', 'secret', ['roles' => ['admin']]); + $second = $auth->register('second-admin@example.com', 'secret', ['roles' => ['admin']]); + + $this->assertSame(['ok' => true], $controller->delete((int) $first['id'])); + $this->assertCount(1, array_filter( + $auth->get_users_by_role('admin'), + static fn (array $user): bool => $auth->is_active($user) + )); + try { + $controller->post((int) $second['id'], 'suspend'); + $this->fail('A second operation removed the last active admin'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + $this->assertTrue($auth->is_active($auth->get($second['id']))); + } + + /** + * Model a competing transaction committed before the role lock is + * acquired. The count must not be read or cached outside the lock. + */ + public function testAdminGuardReloadsActiveCountAfterAcquiringLock(): void + { + $db = new class('sqlite::memory:') extends DB { + public ?\Closure $beforeRoleLock = null; + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'role' && $this->beforeRoleLock !== null) { + $callback = $this->beforeRoleLock; + $this->beforeRoleLock = null; + $callback(); + } + return parent::update($table, $data, $where); + } + }; + $db->query('CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, name TEXT, password TEXT, disabled_at TEXT)')->exec(); + $db->query('CREATE TABLE user_credentials (uid TEXT NOT NULL, provider TEXT NOT NULL, user_id INTEGER NOT NULL, profile TEXT, token TEXT, token_expires_at TEXT, verified_at TEXT, last_login TEXT, created TEXT, PRIMARY KEY(uid, provider))')->exec(); + $db->query('CREATE TABLE role (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)')->exec(); + $db->query('CREATE TABLE user_roles (user_id INTEGER NOT NULL, role_id INTEGER NOT NULL, PRIMARY KEY(user_id, role_id))')->exec(); + $db->insert('role', ['id' => 1, 'name' => 'admin']); + + $auth = new DBAuth($db, ['disabled_at' => 'disabled_at', 'created' => null]); + $controller = new UserController($auth); + $first = $auth->register('race-first@example.com', 'secret', ['roles' => ['admin']]); + $second = $auth->register('race-second@example.com', 'secret', ['roles' => ['admin']]); + + $db->beforeRoleLock = static function () use ($db, $first): void { + $db->delete('user_roles', ['user_id' => $first['id']]); + }; + try { + $controller->put((int) $second['id'], ['roles' => []]); + $this->fail('Stale admin count bypassed serialization guard'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + + // The simulated concurrent mutation must roll back with the guard. + $this->assertCount(2, $auth->get_users_by_role('admin')); + $this->assertContains('admin', $auth->get($second['id'])['roles']); + } + public function testDeleteRejectsUnknownSubresourcesWithoutDeletingUser(): void { self::$controller->post([ From c319d76091725acc488c3cf3fab66dbbbc4d21e3 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Thu, 22 Oct 2026 20:14:19 -0300 Subject: [PATCH 214/217] test(auth): establish management actor sessions for admin guard regressions Use an authenticated non-target account for self-protection checks in new administrator mutation tests, matching the existing controller tests. --- test/UserControllerTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index 3ada15a..a8f44d3 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -301,6 +301,8 @@ public function testFinalAdminGuardCoversSuspendDemoteDeleteAndDirectDisable(): ]); $controller = new UserController($auth); $admin = $auth->register('sole-admin@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('admin-actor@example.com', 'secret'); + $auth->login('admin-actor@example.com', 'secret'); $id = (int) $admin['id']; $mutations = [ @@ -340,6 +342,8 @@ public function testReducingTwoActiveAdminsToOnePreventsFurtherRemoval(): void $controller = new UserController($auth); $first = $auth->register('first-admin@example.com', 'secret', ['roles' => ['admin']]); $second = $auth->register('second-admin@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('management-actor@example.com', 'secret'); + $auth->login('management-actor@example.com', 'secret'); $this->assertSame(['ok' => true], $controller->delete((int) $first['id'])); $this->assertCount(1, array_filter( @@ -383,6 +387,8 @@ public function update(string $table, array $data, ?array $where = null): int $controller = new UserController($auth); $first = $auth->register('race-first@example.com', 'secret', ['roles' => ['admin']]); $second = $auth->register('race-second@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('race-actor@example.com', 'secret'); + $auth->login('race-actor@example.com', 'secret'); $db->beforeRoleLock = static function () use ($db, $first): void { $db->delete('user_roles', ['user_id' => $first['id']]); From a6239cfe279edc7a27d864989e0374d87c47230c Mon Sep 17 00:00:00 2001 From: objectivebot Date: Fri, 23 Oct 2026 11:39:50 -0300 Subject: [PATCH 215/217] refactor(auth): enforce final-admin invariant in DBAuth update and delete Remove the with_admin_mutation abstraction from Auth, DBAuth and UserController. Use the existing db->transaction() calls in DBAuth update/delete for shared-role locking and fresh active-admin checks. Skip the lock for ordinary user updates and account activation. Protect direct DBAuth mutations, retain provider-neutral controller validation, and extend regression coverage. --- CHANGELOG.md | 2 +- README.md | 15 +++-- src/Auth.php | 10 --- src/Auth/Controller/UserController.php | 82 +++++++++++------------- src/Auth/DBAuth.php | 87 ++++++++++++++++++-------- test/UserControllerTest.php | 67 ++++++++++++++++++++ 6 files changed, 175 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62828fa..51b3de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ All notable changes to `objectiveweb/auth` are documented here. This project fol - Revoke outstanding password-reset tokens and expirations whenever a user password is changed, atomically with the password hash update on DBAuth. - Prevent OAuth login CSRF, state replay, cross-provider state confusion, unconsented linking, email-based account takeover and cross-account credential reassignment. - Restrict logout redirects to safe same-origin absolute paths and reject attacker-controlled OAuth callback URLs. -- Enforce management CSRF checks; serialize administrator removal, demotion and suspension with a shared role-row lock inside DBAuth transactions to prevent simultaneous requests removing the final active admin (including direct lifecycle-field updates); protect the caller from self-deletion/suspension. Reject unknown DELETE subresources with 404 instead of accidentally deleting the user. +- Enforce management CSRF checks; check and serialize administrator removal, demotion and suspension inside DBAuth's existing update/delete transactions so concurrent operations cannot remove the final active admin (including direct DBAuth calls and lifecycle-field updates). Routine profile writes skip the lock; controllers retain self-protection and validation. Reject unknown DELETE subresources with 404 instead of accidentally deleting the user. - Avoid returning passwords, reset tokens or other internal secrets in normal user/session/management payloads. ### Removed / incompatible diff --git a/README.md b/README.md index 98b2246..52dad7b 100644 --- a/README.md +++ b/README.md @@ -682,12 +682,15 @@ The explicit authenticated `postLink()` flow remains distinct from the *anonymou ## User-management API -DBAuth serializes administrator removal, demotion and suspension inside -`$db->transaction()`. It acquires a shared lock on the `admin` role row -before reloading the target and checking the active-administrator count, -including when a `PUT` request changes `disabled_at` directly. -Custom code that bypasses UserController must protect the same invariant; -the lock uses a no-op UPDATE, which may activate role-table UPDATE triggers. +DBAuth enforces the final-active-administrator invariant directly in its +existing `update()` and `delete()` transactions. Operations that may +demote or suspend an administrator, and user deletions, serialize through +a lock on the `admin` role row before counting active administrators. +Ordinary profile updates and account activation do not acquire that lock. +This protection also applies to direct DBAuth calls; application code that +writes to role or user tables without DBAuth must enforce it separately. +The lock is implemented with a no-op UPDATE, which can activate role-table +UPDATE triggers. Register `Objectiveweb\Auth\Controller\UserController` at an application-owned diff --git a/src/Auth.php b/src/Auth.php index a752cb1..286e4de 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -438,16 +438,6 @@ abstract public function update($user_id, array $data, $key = 'id'); abstract public function delete($user_id); - /** - * Execute an administrator mutation inside a provider-specific guard. - * Database-backed providers must serialize administrator removals so - * the active-admin invariant is checked against current locked data. - */ - public function with_admin_mutation(callable $mutation): mixed - { - return $mutation(); - } - /** * Generates a new token for the user and update the database * @param $user_id diff --git a/src/Auth/Controller/UserController.php b/src/Auth/Controller/UserController.php index 9b7b43b..2c735f4 100644 --- a/src/Auth/Controller/UserController.php +++ b/src/Auth/Controller/UserController.php @@ -115,18 +115,18 @@ public function delete( } $this->assertNotSelf($userId, 'delete'); - return $this->auth->with_admin_mutation(function () use ($userId): array { - $target = $this->auth->get($userId); - $this->assertNotFinalActiveAdmin($target); - try { - $this->auth->delete($userId); - } catch (UserException $exception) { - throw $exception; - } catch (\Throwable $exception) { - throw new UserException('User has application history; suspend the account instead', 409, $exception); - } - return ['ok' => true]; - }); + $target = $this->auth->get($userId); + $this->assertNotFinalActiveAdmin($target); + try { + // DBAuth enforces the final-admin invariant again inside its own + // transaction. Controller validation also supports BasicAuth. + $this->auth->delete($userId); + } catch (UserException $exception) { + throw $exception; + } catch (\Throwable $exception) { + throw new UserException('User has application history; suspend the account instead', 409, $exception); + } + return ['ok' => true]; } private function list(array $params): Collection @@ -198,32 +198,28 @@ private function updateUser(mixed $userId, array $data): array $this->assertKnownRoles($data[$roleField]); } - return $this->auth->with_admin_mutation(function () use ($userId, $data, $roleField): array { - // Reload after obtaining the shared lock: pre-lock reads can be - // stale once a competing administrator mutation has committed. - $target = $this->auth->get($userId); - if (array_key_exists($roleField, $data)) { - $this->assertOwnAdminRolePreserved($userId, $data[$roleField]); - } + $target = $this->auth->get($userId); + if (array_key_exists($roleField, $data)) { + $this->assertOwnAdminRolePreserved($userId, $data[$roleField]); + } - // PUT may change disabled_at directly, bypassing POST /suspend. - // Both demotion and direct suspension must preserve the last admin. - $proposed = array_replace($target, $data); - if ( - in_array('admin', (array) ($target[$roleField] ?? []), true) - && $this->auth->is_active($target) - && ( - !in_array('admin', (array) ($proposed[$roleField] ?? []), true) - || !$this->auth->is_active($proposed) - ) - ) { - $this->assertNotFinalActiveAdmin($target); - } + // Retain provider-independent validation. DBAuth rechecks against + // locked, current data in update() before applying a risky mutation. + $proposed = array_replace($target, $data); + if ( + in_array('admin', (array) ($target[$roleField] ?? []), true) + && $this->auth->is_active($target) + && ( + !in_array('admin', (array) ($proposed[$roleField] ?? []), true) + || !$this->auth->is_active($proposed) + ) + ) { + $this->assertNotFinalActiveAdmin($target); + } - $this->auth->update($userId, $data); - $this->auth->audit('user.updated', $userId, ['fields' => array_keys($data)]); - return $this->detail($userId); - }); + $this->auth->update($userId, $data); + $this->auth->audit('user.updated', $userId, ['fields' => array_keys($data)]); + return $this->detail($userId); } private function createCredential(mixed $userId, array $data): array @@ -261,14 +257,12 @@ private function setSuspended(mixed $userId, bool $suspended): array $this->assertNotSelf($userId, 'suspend'); } - return $this->auth->with_admin_mutation(function () use ($userId, $field, $suspended): array { - if ($suspended) { - $this->assertNotFinalActiveAdmin($this->auth->get($userId)); - } - $this->auth->update($userId, [$field => $suspended ? date('Y-m-d H:i:s') : null]); - $this->auth->audit($suspended ? 'user.suspended' : 'user.activated', $userId); - return $this->detail($userId); - }); + if ($suspended) { + $this->assertNotFinalActiveAdmin($this->auth->get($userId)); + } + $this->auth->update($userId, [$field => $suspended ? date('Y-m-d H:i:s') : null]); + $this->auth->audit($suspended ? 'user.suspended' : 'user.activated', $userId); + return $this->detail($userId); } private function sendToken(mixed $userId, bool $reset): array diff --git a/src/Auth/DBAuth.php b/src/Auth/DBAuth.php index 5cc6e0d..da675fa 100644 --- a/src/Auth/DBAuth.php +++ b/src/Auth/DBAuth.php @@ -472,8 +472,44 @@ public function update($user_id, array $data, $key = 'id') throw new \InvalidArgumentException('Role sync requested but role tables are disabled'); } - // User fields, role definitions and memberships must commit or roll back together. - $this->db->transaction(function () use ($column, $user_id, $key, $data, $syncRoles, $roles): void { + // Only updates that might remove an active administrator need the + // shared lock; routine profile edits, promotions and activation don't. + $disabledField = $this->params['disabled_at']; + $removesAdminRole = $syncRoles && !in_array('admin', $roles, true); + $suspendsAccount = is_string($disabledField) && $disabledField !== '' + && !empty($data[$disabledField]); + $needsAdminCheck = $this->rolesEnabled() && ($removesAdminRole || $suspendsAccount); + + // User fields, role definitions and memberships commit or roll back + // together, including the final-active-admin safeguard. + $this->db->transaction(function () use ( + $column, $user_id, $key, $data, $syncRoles, $roles, $needsAdminCheck + ): void { + if ($needsAdminCheck) { + // All admin-removing DBAuth writes lock the same stable row. + // A no-op UPDATE serializes them in MySQL/PostgreSQL, and + // obtains SQLite's write lock without dialect-specific SQL. + $roleName = (string) $this->params['role_name']; + $this->db->update( + (string) $this->params['roles_table'], + [$roleName => 'admin'], + [$roleName => 'admin'] + ); + + // Both the target and count must be loaded after the lock. + $target = $this->get($user_id, $key); + if (in_array('admin', (array) ($target[$this->params['roles']] ?? []), true) + && $this->is_active($target)) { + $activeAdmins = array_filter( + $this->get_users_by_role('admin'), + fn (array $user): bool => $this->is_active($user) + ); + if (count($activeAdmins) <= 1) { + throw new UserException('Cannot remove or suspend the final active administrator', 409); + } + } + } + if (!empty($data)) { foreach ($data as $k => $v) { if (is_array($v)) { @@ -492,31 +528,6 @@ public function update($user_id, array $data, $key = 'id') }); } - /** - * Use the same stable role row for every potentially admin-removing - * mutation. The no-op UPDATE takes a row lock in MySQL/PostgreSQL and a - * write lock in SQLite. Unlike FOR UPDATE, it works on all supported - * engines, and DB::update handles configured table prefixes. - * - * The caller must read the target and count active administrators only - * after this lock has been acquired. - */ - public function with_admin_mutation(callable $mutation): mixed - { - return $this->db->transaction(function (DB $db) use ($mutation): mixed { - if ($this->rolesEnabled()) { - $roleField = (string) $this->params['role_name']; - $db->update( - (string) $this->params['roles_table'], - [$roleField => 'admin'], - [$roleField => 'admin'] - ); - } - - return $mutation(); - }); - } - public function delete($user_id) { if ($this->check()) { @@ -527,6 +538,28 @@ public function delete($user_id) } return $this->db->transaction(function () use ($user_id): bool { + if ($this->rolesEnabled()) { + // Serialize deletion with any demotion/suspension of an admin. + $roleName = (string) $this->params['role_name']; + $this->db->update( + (string) $this->params['roles_table'], + [$roleName => 'admin'], + [$roleName => 'admin'] + ); + + $target = $this->get($user_id); + if (in_array('admin', (array) ($target[$this->params['roles']] ?? []), true) + && $this->is_active($target)) { + $activeAdmins = array_filter( + $this->get_users_by_role('admin'), + fn (array $user): bool => $this->is_active($user) + ); + if (count($activeAdmins) <= 1) { + throw new UserException('Cannot remove or suspend the final active administrator', 409); + } + } + } + $guard = $this->params['deletion_guard_callback']; if (is_callable($guard)) { $result = call_user_func($guard, $user_id); diff --git a/test/UserControllerTest.php b/test/UserControllerTest.php index a8f44d3..1716d2e 100644 --- a/test/UserControllerTest.php +++ b/test/UserControllerTest.php @@ -405,6 +405,73 @@ public function update(string $table, array $data, ?array $where = null): int $this->assertContains('admin', $auth->get($second['id'])['roles']); } + public function testDirectDbAuthCallsCannotRemoveFinalActiveAdmin(): void + { + self::$db->insert('auth_role', ['name' => 'admin']); + $auth = new DBAuth(self::$db, [ + 'table' => 'auth_user', + 'credentials_table' => 'auth_credentials', + 'created' => 'created', + 'token' => 'token', + 'credentials_last_login' => 'last_login', + 'credentials_created' => null, + 'disabled_at' => 'disabled_at', + 'roles_table' => 'auth_role', + 'user_roles_table' => 'auth_user_role', + ]); + $admin = $auth->register('direct-admin@example.com', 'secret', ['roles' => ['admin']]); + $id = (int) $admin['id']; + + foreach ([ + static fn () => $auth->update($id, ['roles' => []]), + static fn () => $auth->update($id, ['disabled_at' => '2026-01-01 00:00:00']), + static fn () => $auth->delete($id), + ] as $mutation) { + try { + $mutation(); + $this->fail('Direct DBAuth mutation removed the final active admin'); + } catch (\Objectiveweb\Auth\UserException $exception) { + $this->assertSame(409, $exception->getCode()); + } + $user = $auth->get($id); + $this->assertTrue($auth->is_active($user)); + $this->assertContains('admin', $user['roles']); + } + } + + public function testOrdinaryDbAuthUpdateSkipsAdminRoleLock(): void + { + $db = new class('sqlite::memory:') extends DB { + public int $roleLockCalls = 0; + public function update(string $table, array $data, ?array $where = null): int + { + if ($table === 'role' && isset($where['name']) + && $where['name'] === 'admin' && ($data['name'] ?? '') === 'admin') { + $this->roleLockCalls++; + } + return parent::update($table, $data, $where); + } + }; + $db->query('CREATE TABLE user (id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT NOT NULL UNIQUE, name TEXT, password TEXT, disabled_at TEXT)')->exec(); + $db->query('CREATE TABLE user_credentials (uid TEXT NOT NULL, provider TEXT NOT NULL, user_id INTEGER NOT NULL, profile TEXT, token TEXT, token_expires_at TEXT, verified_at TEXT, last_login TEXT, created TEXT, PRIMARY KEY(uid, provider))')->exec(); + $db->query('CREATE TABLE role (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE)')->exec(); + $db->query('CREATE TABLE user_roles (user_id INTEGER NOT NULL, role_id INTEGER NOT NULL, PRIMARY KEY(user_id, role_id))')->exec(); + $db->insert('role', ['id' => 1, 'name' => 'admin']); + + $auth = new DBAuth($db, ['disabled_at' => 'disabled_at', 'created' => null]); + $first = $auth->register('ordinary-admin@example.com', 'secret', ['roles' => ['admin']]); + $auth->register('second-ordinary-admin@example.com', 'secret', ['roles' => ['admin']]); + $id = (int) $first['id']; + + $auth->update($id, ['name' => 'Renamed Admin']); + $auth->update($id, ['disabled_at' => null]); + $auth->update($id, ['roles' => ['admin']]); + $this->assertSame(0, $db->roleLockCalls); + + $auth->update($id, ['roles' => []]); + $this->assertSame(1, $db->roleLockCalls); + } + public function testDeleteRejectsUnknownSubresourcesWithoutDeletingUser(): void { self::$controller->post([ From 3c8809fcab2af9f0e64d658d6441c767fb94de61 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Fri, 23 Oct 2026 11:41:23 -0300 Subject: [PATCH 216/217] test(auth): preserve a backup active admin in session revocation tests Existing session revalidation tests must not demote, suspend or delete the only active administrator now that DBAuth enforces the invariant for direct update/delete calls. Keep a separate active administrator so those tests focus on session invalidation. --- test/DBAuthSqliteTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/DBAuthSqliteTest.php b/test/DBAuthSqliteTest.php index 3048a80..d66673d 100644 --- a/test/DBAuthSqliteTest.php +++ b/test/DBAuthSqliteTest.php @@ -848,6 +848,8 @@ public function testUserCanChecksRoles(): void public function testUserCanRevalidatesRolesAfterChangesOutsideSession(): void { + // Preserve an independent active admin while revoking this user's role. + self::$auth->register('role-refresh-backup@example.com', 'secret', ['roles' => ['admin']]); $user = self::$auth->register('role-refresh@example.com', 'secret', [ 'roles' => ['admin'], ]); @@ -871,6 +873,7 @@ public function testUserCanRevalidatesRolesAfterChangesOutsideSession(): void public function testUserCanRejectsSuspendedAccountForAllCheckModes(): void { + self::$auth->register('suspend-backup-admin@example.com', 'secret', ['roles' => ['admin']]); $user = self::$auth->register('suspended-permissions@example.com', 'secret', [ 'roles' => ['admin'], ]); @@ -914,6 +917,7 @@ public function testUserCanRejectsSuspendedAccountForAllCheckModes(): void public function testUserCanRejectsSessionForDeletedUser(): void { + self::$auth->register('delete-backup-admin@example.com', 'secret', ['roles' => ['admin']]); $user = self::$auth->register('deleted-permissions@example.com', 'secret', [ 'roles' => ['admin'], ]); From 74f7e697410ed2cbdeff1866fbd02eb0e5c3f623 Mon Sep 17 00:00:00 2001 From: objectivebot Date: Fri, 23 Oct 2026 11:43:33 -0300 Subject: [PATCH 217/217] test(auth): preserve backup admin in MySQL role replacement smoke test Create a second active admin before exercising role removal so the integration test verifies role syncing without violating the new DBAuth final-admin invariant. Assert that the demoted user loses the role while the backup retains it, and adjust remaining user count. --- test/verify_mysql_auth.php | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/verify_mysql_auth.php b/test/verify_mysql_auth.php index 8e2f355..0103f98 100644 --- a/test/verify_mysql_auth.php +++ b/test/verify_mysql_auth.php @@ -147,13 +147,19 @@ 'Verified credential is missing verified_at' ); -// Roles are synced portably with MySQL's SQL dialect. +// Roles are synced portably with MySQL's SQL dialect. Preserve another +// active admin while demoting the account under test. +$backupAdmin = $auth->register('mysql-backup-admin@example.com', 'backup-secret', [ + 'roles' => ['admin'], +]); $auth->update($admin['id'], ['roles' => ['operator']]); $assert($auth->get($admin['id'])['roles'] === ['operator'], 'Role replacement failed'); -$assert($auth->get_users_by_role('admin') === [], 'Removed role still grants access'); +$remainingAdmins = $auth->get_users_by_role('admin'); +$assert(count($remainingAdmins) === 1 && $remainingAdmins[0]['id'] == $backupAdmin['id'], + 'Removed role still grants access or backup admin was lost'); $auth->delete($viewer['id']); $assert($auth->get_users_by_role('viewer') === [], 'Deleted user still appears in role lookup'); -$assert(count($auth->query()) === 1, 'Deleted user still appears in MySQL query'); +$assert(count($auth->query()) === 2, 'Deleted user still appears in MySQL query'); // Provider identity comparisons must remain exact even when the database's // default collation is case-insensitive. These are distinct OAuth subjects.