diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 16bf7a0..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,4 +0,0 @@ -# These are supported funding model platforms - -open_collective: leaf -github: leafsphp diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ddd79f9 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,34 @@ +name: Lint code + +on: [push] + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: true + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: json, zip, dom, curl, libxml, mbstring + tools: composer:v2 + coverage: none + + - name: Install PHP dependencies + run: composer update --no-interaction --no-progress + + - name: Run Linter + run: composer run lint + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: 'chore: fix styling' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 6ee6474..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Run Tests - -on: ['push', 'pull_request'] - -env: - MYSQL_DATABASE: leaf - DB_USER: root - DB_PASSWORD: root - -jobs: - ci: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest] - php: ['7.4', '8.0', '8.1', '8.2', '8.3'] - - name: PHP ${{ matrix.php }} - ${{ matrix.os }} - - steps: - - name: Checkout - uses: actions/checkout@v2 - - - name: Initialize MySQL - run: sudo systemctl start mysql.service - - - name: Initialize first database - run: | - mysql -e 'CREATE DATABASE ${{ env.MYSQL_DATABASE }};' \ - -u${{ env.DB_USER }} -p${{ env.DB_PASSWORD }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: composer:v2 - coverage: xdebug - - - name: Install PHP dependencies - run: composer update --no-interaction --no-progress - - - name: All Tests - run: composer run-script test diff --git a/.gitignore b/.gitignore index 55e18db..536447e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,8 @@ composer.lock package-lock.json vendor/ test/ +old/ *.tests.php -workflows -!.github/workflows # OS Generated .DS_Store* @@ -18,3 +17,7 @@ Thumbs.db # phpstorm .idea/* + +# Alchemy +.alchemy +.phpunit.result.cache diff --git a/alchemy.config.php b/alchemy.config.php deleted file mode 100644 index 7224127..0000000 --- a/alchemy.config.php +++ /dev/null @@ -1,26 +0,0 @@ - 'pest', - - // php unit options - 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance', - 'xsi:noNamespaceSchemaLocation' => './vendor/phpunit/phpunit/phpunit.xsd', - 'bootstrap' => 'vendor/autoload.php', - 'colors' => true, - - // you can have multiple testsuites - 'testsuites' => [ - 'directory' => './tests' - ], - - // coverage options - 'coverage' => [ - 'processUncoveredFiles' => true, - 'include' => [ - './app' => '.php', - './src' => '.php' - ] - ] -]; diff --git a/alchemy.yml b/alchemy.yml new file mode 100644 index 0000000..1177beb --- /dev/null +++ b/alchemy.yml @@ -0,0 +1,35 @@ +app: + - src + +tests: + engine: pest + parallel: true + paths: + - tests + files: + - '*.test.php' + +lint: + preset: PSR12 + rules: + no_unused_imports: true + not_operator_with_successor_space: false + single_quote: true + +actions: + run: + - lint + - tests + os: + - ubuntu-latest + php: + extensions: json, zip, dom, curl, libxml, mbstring, PDO_PGSQL + versions: + - '8.3' + - '8.2' + - '8.1' + - '8.0' + - '7.4' + events: + - push + - pull_request diff --git a/composer.json b/composer.json index 661f552..e5ce7b8 100644 --- a/composer.json +++ b/composer.json @@ -26,18 +26,19 @@ "Leaf\\": "src" }, "files": [ - "src/functions.php" - ] + "src/functions.php" + ] }, "minimum-stability": "stable", - "prefer-stable": true, + "prefer-stable": true, "require": { "leafs/date": "*", "leafs/password": "*", "leafs/session": "*", "leafs/db": "*", "leafs/form": "*", - "leafs/http": "*" + "leafs/http": "*", + "firebase/php-jwt": "^6.10" }, "config": { "allow-plugins": { @@ -45,10 +46,14 @@ } }, "require-dev": { - "leafs/alchemy": "^1.0", - "pestphp/pest": "^1.0 | ^2.0" + "pestphp/pest": "^1.0 | ^2.0", + "friendsofphp/php-cs-fixer": "^3.64", + "leafs/alchemy": "^2.0" }, "scripts": { - "test": "vendor/bin/pest --colors=always --coverage" + "test": "./vendor/bin/alchemy setup --test", + "alchemy": "./vendor/bin/alchemy setup", + "lint": "./vendor/bin/alchemy setup --lint", + "actions": "./vendor/bin/alchemy setup --actions" } } diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index 8f4b58c..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - ./tests - - - - - ./app - ./src - - - diff --git a/src/Auth.php b/src/Auth.php index b28d247..9d43740 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -2,582 +2,586 @@ namespace Leaf; -use Leaf\Auth\Core; -use Leaf\Helpers\Authentication; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use Leaf\Auth\Config; +use Leaf\Auth\User; use Leaf\Helpers\Password; +use Leaf\Http\Session; /** * Leaf Simple Auth * ------------------------- - * Simple, straightforward authentication. + * Simple, lightweight authentication. * * @author Michael Darko * @since 1.5.0 - * @version 2.0.0 + * @version 3.0.0 */ -class Auth extends Core +class Auth { /** - * Simple user login - * - * @param array $credentials User credentials - * - * @return array|false false or all user info + tokens + session data + * The currently authenticated user + * @var User */ - public static function login(array $credentials) - { - static::leafDbConnect(); + protected $user; - $table = static::$settings['DB_TABLE']; + /** + * Internal instance of Leaf DB + * @var Db + */ + protected $db; - if (static::config('USE_SESSION')) { - static::useSession(); - } + /** + * All errors caught + * @var array + */ + protected $errorsArray = []; - $passKey = static::$settings['PASSWORD_KEY']; - $password = $credentials[$passKey] ?? null; + /** + * Connect leaf auth to the database + * @param array $dbConfig Configuration for leaf db connection + * @return $this + */ + public function connect($dbConfig = []) + { + $this->db = new Db(); + $this->db->connect($dbConfig); - if (isset($credentials[$passKey])) { - unset($credentials[$passKey]); - } else { - static::$settings['AUTH_NO_PASS'] = true; - } + return $this; + } - $user = static::$db->select($table)->where($credentials)->fetchAssoc(); + /** + * Connect to database using environment variables + * + * @param array $pdoOptions Options for PDO connection + * @return $this + */ + public function autoConnect(array $pdoOptions = []) + { + $this->db = new Db(); + $this->db->autoConnect($pdoOptions); - if (!$user) { - static::$errors['auth'] = static::$settings['LOGIN_PARAMS_ERROR']; - return false; - } + return $this; + } - if (static::$settings['AUTH_NO_PASS'] === false) { - $passwordIsValid = false; + /** + * Pass in db connection instance directly + * + * @param \PDO $connection A connection instance of your db + * @return $this; + */ + public function dbConnection(\PDO $connection) + { + $this->db = new Db(); + $this->db->connection($connection); - if (static::$settings['PASSWORD_VERIFY'] !== false && isset($user[$passKey])) { - if (is_callable(static::$settings['PASSWORD_VERIFY'])) { - $passwordIsValid = call_user_func(static::$settings['PASSWORD_VERIFY'], $password, $user[$passKey]); - } else if (static::$settings['PASSWORD_VERIFY'] === Password::MD5) { - $passwordIsValid = md5($password) === $user[$passKey]; - } else { - $passwordIsValid = Password::verify($password, $user[$passKey]); - } - } + return $this; + } - if (!$passwordIsValid) { - static::$errors['password'] = static::$settings['LOGIN_PASSWORD_ERROR']; - return false; - } + /** + * Get/Set Leaf Auth config + * + * @param string|array $config The auth config key or array of config + * @param mixed $value The value if $config is a string + */ + public function config($config, $value = null) + { + if (is_string($config) && $value === null) { + return Config::get($config); } - $token = Authentication::generateSimpleToken( - $user[static::$settings['ID_KEY']], - static::config('TOKEN_SECRET'), - static::config('TOKEN_LIFETIME') + Config::set( + is_string($config) + ? [$config => $value] + : $config ); + } - if (isset($user[static::$settings['ID_KEY']])) { - $userId = $user[static::$settings['ID_KEY']]; + /** + * Sign a user in + * --- + * Verify user credentials and sign them in with token or session + * + * @param array $credentials User credentials + * @return bool + */ + public function login(array $credentials): bool + { + $this->checkDbConnection(); - if (static::$settings['HIDE_ID']) { - unset($user[static::$settings['ID_KEY']]); - } - } + $table = Config::get('db.table'); + $passwordKey = Config::get('password.key'); - if (static::$settings['HIDE_PASSWORD'] && (isset($user[$passKey]) || !$user[$passKey])) { - unset($user[$passKey]); - } + $userPassword = $credentials[$passwordKey] ?? null; - if (!$token) { - static::$errors = array_merge(static::$errors, Authentication::errors()); - return false; + if ($userPassword) { + unset($credentials[$passwordKey]); } - if (static::config('USE_SESSION')) { - if (isset($userId)) { - $user[static::$settings['ID_KEY']] = $userId; + try { + $user = $this->db->select($table)->where($credentials)->first(); + + if (!$user) { + $this->errorsArray['auth'] = Config::get('messages.loginParamsError'); + return false; } + } catch (\Throwable $th) { + throw new \Exception($th->getMessage()); + } - self::setUserToSession($user, $token); + if ($passwordKey !== false) { + $passwordIsValid = (Config::get('password.verify') !== false && isset($user[$passwordKey])) + ? ((is_callable(Config::get('password.verify'))) + ? call_user_func(Config::get('password.verify'), $userPassword, $user[$passwordKey]) + : Password::verify($userPassword, $user[$passwordKey])) + : false; - if (static::config('SESSION_REDIRECT_ON_LOGIN')) { - exit(header('location: ' . static::config('GUARD_HOME'))); + if (!$passwordIsValid) { + $this->errorsArray['password'] = Config::get('messages.loginPasswordError'); + return false; } } - $response['user'] = $user; - $response['token'] = $token; + $this->user = new User($user); - return $response; + return true; } /** - * Simple user registration - * - * @param array $credentials Information for new user - * @param array $uniques Parameters which should be unique + * Register a new user + * --- + * Save a new user to the database * - * @return array|false false or all user info + tokens + session data + * @param array $userData User data + * @return bool */ - public static function register(array $credentials, array $uniques = []) + public function register(array $userData): bool { - static::leafDbConnect(); + $this->checkDbConnection(); - $table = static::$settings['DB_TABLE']; - $passKey = static::$settings['PASSWORD_KEY']; + $table = Config::get('db.table'); + $passwordKey = Config::get('password.key'); + $passwordEncode = Config::get('password.encode'); - if (!isset($credentials[$passKey])) { - static::$settings['AUTH_NO_PASS'] = true; + if ($passwordEncode !== false && $passwordKey !== false) { + $userData[$passwordKey] = (is_callable($passwordEncode)) + ? call_user_func($passwordEncode, $userData[$passwordKey]) + : Password::hash($userData[$passwordKey]); } - if (static::$settings['AUTH_NO_PASS'] === false) { - if (static::$settings['PASSWORD_ENCODE'] !== false) { - if (is_callable(static::$settings['PASSWORD_ENCODE'])) { - $credentials[$passKey] = call_user_func(static::$settings['PASSWORD_ENCODE'], $credentials[$passKey]); - } else if (static::$settings['PASSWORD_ENCODE'] === 'md5') { - $credentials[$passKey] = md5($credentials[$passKey]); - } else { - $credentials[$passKey] = Password::hash($credentials[$passKey]); - } - } + if (Config::get('timestamps')) { + $now = (new Date())->tick()->format(Config::get('timestamps.format')); + $userData['created_at'] = $now; + $userData['updated_at'] = $now; } - if (static::$settings['USE_TIMESTAMPS']) { - $now = (new \Leaf\Date())->tick()->format(static::$settings['TIMESTAMP_FORMAT']); - $credentials['created_at'] = $now; - $credentials['updated_at'] = $now; - } - - if (static::$settings['USE_UUID'] !== false) { - $credentials[static::$settings['ID_KEY']] = static::$settings['USE_UUID']; + if (isset($credentials[Config::get('id.key')])) { + $userData[Config::get('id.key')] = is_callable($userData[Config::get('id.key')]) + ? call_user_func($userData[Config::get('id.key')]) + : $userData[Config::get('id.key')]; } try { - $query = static::$db->insert($table)->params($credentials)->unique($uniques)->execute(); - } catch (\Throwable $th) { - trigger_error($th->getMessage()); - } + $query = $this->db->insert($table)->params($userData)->unique(Config::get('unique'))->execute(); - if (!$query) { - static::$errors = array_merge(static::$errors, static::$db->errors()); - return false; + if (!$query) { + $this->errorsArray = array_merge($this->errorsArray, $this->db->errors()); + return false; + } + } catch (\Throwable $th) { + throw new \Exception($th->getMessage()); } - $user = static::$db->select($table)->where($credentials)->fetchAssoc(); + $user = $this->db->select($table)->where($userData)->first(); if (!$user) { - static::$errors = array_merge(static::$errors, static::$db->errors()); - return false; - } - - $token = Authentication::generateSimpleToken( - $user[static::$settings['ID_KEY']], - static::config('TOKEN_SECRET'), - static::config('TOKEN_LIFETIME') - ); - - if (isset($user[static::$settings['ID_KEY']])) { - $userId = $user[static::$settings['ID_KEY']]; - } - - if (static::$settings['HIDE_ID']) { - unset($user[static::$settings['ID_KEY']]); - } - - if (static::$settings['HIDE_PASSWORD'] && (isset($user[$passKey]) || !$user[$passKey])) { - unset($user[$passKey]); - } - - if (!$token) { - static::$errors = array_merge(static::$errors, Authentication::errors()); + $this->errorsArray = array_merge($this->errorsArray, $this->db->errors()); return false; } - if (static::config('USE_SESSION')) { - if (static::config('SESSION_ON_REGISTER')) { - if (isset($userId)) { - $user[static::$settings['ID_KEY']] = $userId; - } - - self::setUserToSession($user, $token); - - exit(header('location: ' . static::config('GUARD_HOME'))); - } else { - if (static::config('SESSION_REDIRECT_ON_REGISTER')) { - exit(header('location: ' . static::config('GUARD_LOGIN'))); - } - } - } + $this->user = new User($user); - $response['user'] = $user; - $response['token'] = $token; - - return $response; + return true; } /** - * Simple user update - * - * @param array $credentials New information for user - * @param array $uniques Parameters which should be unique + * Update user data + * --- + * Update user data in the database * - * @return array|false all user info + tokens + session data + * @param array $userData User data + * @return bool */ - public static function update(array $credentials, array $uniques = []) + public function update(array $userData): bool { - static::leafDbConnect(); + $this->checkDbConnection(); - $table = static::$settings['DB_TABLE']; + $user = $this->user(); - if (static::config('USE_SESSION')) { - static::useSession(); - } - - $passKey = static::$settings['PASSWORD_KEY']; - $loggedInUser = static::user(); - - if (!$loggedInUser) { - static::$errors['auth'] = 'Not authenticated'; + if (!$user) { return false; } - $where = isset($loggedInUser[static::$settings['ID_KEY']]) ? [static::$settings['ID_KEY'] => $loggedInUser[static::$settings['ID_KEY']]] : $loggedInUser; + $idKey = Config::get('id.key'); + $table = Config::get('db.table'); - if (!isset($credentials[$passKey])) { - static::$settings['AUTH_NO_PASS'] = true; + if (Config::get('timestamps')) { + $userData['updated_at'] = (new Date())->tick()->format(Config::get('timestamps.format')); } - if ( - static::$settings['AUTH_NO_PASS'] === false && - static::$settings['PASSWORD_ENCODE'] !== false - ) { - if (is_callable(static::$settings['PASSWORD_ENCODE'])) { - $credentials[$passKey] = call_user_func(static::$settings['PASSWORD_ENCODE'], $credentials[$passKey]); - } else if (static::$settings['PASSWORD_ENCODE'] === 'md5') { - $credentials[$passKey] = md5($credentials[$passKey]); - } else { - $credentials[$passKey] = Password::hash($credentials[$passKey]); - } - } - - if (static::$settings['USE_TIMESTAMPS']) { - $credentials['updated_at'] = (new \Leaf\Date())->tick()->format(static::$settings['TIMESTAMP_FORMAT']); - } - - if (count($uniques) > 0) { - foreach ($uniques as $unique) { - if (!isset($credentials[$unique])) { - trigger_error("$unique not found in credentials."); + if (count(Config::get('unique')) > 0) { + foreach (Config::get('unique') as $unique) { + if (!isset($userData[$unique])) { + continue; } - $data = static::$db->select($table)->where($unique, $credentials[$unique])->fetchAssoc(); - - $wKeys = array_keys($where); - $wValues = array_values($where); + $data = $this->db->select($table, Config::get('id.key'))->where($unique, $userData[$unique])->first(); - if (isset($data[$wKeys[0]]) && $data[$wKeys[0]] != $wValues[0]) { - static::$errors[$unique] = "$unique already exists"; + if ($data && $data[Config::get('id.key')] !== $this->id()) { + $this->errorsArray[$unique] = "$unique already exists"; } } - if (count(static::$errors) > 0) { + if (count($this->errorsArray) > 0) { return false; } } try { - $query = static::$db->update($table)->params($credentials)->where($where)->execute(); + $query = $this->db->update($table)->params($userData)->where($idKey, $this->user->{$idKey})->execute(); + + if (!$query) { + $this->errorsArray = array_merge($this->errorsArray, $this->db->errors()); + return false; + } } catch (\Throwable $th) { - trigger_error($th->getMessage()); + throw new \Exception($th->getMessage()); } - if (!$query) { - static::$errors = array_merge(static::$errors, static::$db->errors()); - return false; + if (Config::get('session')) { + session_regenerate_id(); } - if (isset($credentials['updated_at'])) { - unset($credentials['updated_at']); + foreach ($userData as $key => $value) { + $this->user->{$key} = $value; } - $user = static::$db->select($table)->where($credentials)->fetchAssoc(); + return true; + } - if (!$user) { - static::$errors = array_merge(static::$errors, static::$db->errors()); - return false; - } + /** + * Update user password + * --- + * Update user password in the database + * + * @param string $oldPassword Old password + * @param string $newPassword New password + * @return bool + */ + public function updatePassword(string $oldPassword, string $newPassword): bool + { + $this->checkDbConnection(); - $token = Authentication::generateSimpleToken( - $user[static::$settings['ID_KEY']], - static::config('TOKEN_SECRET'), - static::config('TOKEN_LIFETIME') - ); + $user = $this->user(); - if (isset($user[static::$settings['ID_KEY']])) { - $userId = $user[static::$settings['ID_KEY']]; + if (!$user) { + return false; } - if (static::$settings['HIDE_ID'] && isset($user[static::$settings['ID_KEY']])) { - unset($user[static::$settings['ID_KEY']]); - } + $passwordKey = Config::get('password.key'); - if (static::$settings['HIDE_PASSWORD'] && (isset($user[$passKey]) || !$user[$passKey])) { - unset($user[$passKey]); - } + if (Config::get('password.verify') !== false && isset($user->{$passwordKey})) { + $passwordIsValid = (is_callable(Config::get('password.verify'))) + ? call_user_func(Config::get('password.verify'), $oldPassword, $user->{$passwordKey}) + : Password::verify($oldPassword, $user->{$passwordKey}); - if (!$token) { - static::$errors = array_merge(static::$errors, Authentication::errors()); - return false; + if (!$passwordIsValid) { + $this->errorsArray['password'] = Config::get('messages.loginPasswordError'); + return false; + } } - if (static::config('USE_SESSION')) { - if (isset($userId)) { - $user[static::$settings['ID_KEY']] = $userId; - } + $newPassword = (Config::get('password.encode') !== false) + ? ((is_callable(Config::get('password.encode'))) + ? call_user_func(Config::get('password.encode'), $newPassword) + : Password::hash($newPassword)) + : $newPassword; - static::$session->set('AUTH_USER', $user); - static::$session->set('HAS_SESSION', true); + try { + $query = $this->db->update(Config::get('db.table')) + ->params([$passwordKey => $newPassword]) + ->where(Config::get('id.key'), $this->id()) + ->execute(); - if (static::config('SAVE_SESSION_JWT')) { - static::$session->set('AUTH_TOKEN', $token); + if (!$query) { + $this->errorsArray = array_merge($this->errorsArray, $this->db->errors()); + return false; } - - return $user; + } catch (\Throwable $th) { + throw new \Exception($th->getMessage()); } - $response['user'] = $user; - $response['token'] = $token; - - return $response; - } + $this->user->{$passwordKey} = $newPassword; - /** - * Manually start an auth session - */ - public static function useSession() - { - static::config('USE_SESSION', true); - static::$session = Auth\Session::init(static::config('SESSION_COOKIE_PARAMS')); + return true; } /** - * Throw a 'use session' warning + * Sign a user out + * --- + * Sign out the currently authenticated user + * + * @param string|array|callable|null $redirectUrl Redirect to this url after logout + * @return bool */ - protected static function sessionCheck() + public function logout($action = null): bool { - if (!static::config('USE_SESSION')) { - trigger_error('Turn on USE_SESSION to use this feature.'); - } - - if (!static::$session) { - static::useSession(); + if (Config::get('session')) { + Session::unset('auth'); } - } - /** - * A simple auth guard: 'guest' pages can't be viewed when logged in, - * 'auth' pages can't be viewed without authentication - * - * @param string $type The type of guard/guard options - */ - public static function guard(string $type) - { - static::sessionCheck(); + $this->user = null; - if ($type === 'guest' && static::status()) { - exit(header('location: ' . static::config('GUARD_HOME'), true, 302)); + if (is_callable($action)) { + $action($this); + return true; } - if ($type === 'auth' && !static::status()) { - exit(header('location: ' . static::config('GUARD_LOGIN'), true, 302)); + if ($action) { + response()->redirect($action); + exit; } + + return true; } /** - * Check session status + * Get the id of the currently authenticated user + * @return string|int */ - public static function status() + public function id() { - static::sessionCheck(); - static::expireSession(); - - return static::$session->get('AUTH_USER') ?? false; + return Config::get('session') + ? $this->getFromSession('auth.id') + : ($this->user ? $this->user->id() : ($this->parseToken()['user.id'] ?? null)); } /** - * Return the user id encoded in token or session + * Get the currently authenticated user + * @return User|null */ - public static function id() + public function user() { - static::leafDbConnect(); + if (Config::get('session')) { + $userId = $this->getFromSession('auth.id'); - if (static::config('USE_SESSION')) { - if (static::expireSession()) { + if (!$userId) { return null; } - - return static::$session->get('AUTH_USER')[static::$settings['ID_KEY']] ?? null; } - $payload = static::validateToken(static::config('TOKEN_SECRET')); - - return $payload->user_id ?? null; - } - - /** - * Get the current user data from token - * - * @param array $hidden Fields to hide from user array - */ - public static function user(array $hidden = []) - { - $table = static::$settings['DB_TABLE']; + if ($this->user) { + return $this->user; + } - if (!static::id()) { - if (static::config('USE_SESSION')) { - return static::$session->get('AUTH_USER'); - } + $userId = $this->id(); + if (!$userId) { return null; } - $user = static::$db->select($table)->where(static::$settings['ID_KEY'], static::id())->fetchAssoc(); + $idKey = Config::get('id.key'); + $table = Config::get('db.table'); - if (count($hidden) > 0) { - foreach ($hidden as $item) { - if (isset($user[$item]) || !$user[$item]) { - unset($user[$item]); - } + try { + $user = $this->db->select($table)->where($idKey, $userId)->first(); + + if (!$user) { + $this->errorsArray = $this->db->errors(); + return null; } + } catch (\Throwable $th) { + throw new \Exception($th->getMessage()); } - return $user; + return $this->user = new User( + $user + ); } /** - * End a session - * - * @param string $location A route to redirect to after logout + * Get data generated on user login + * @return object|null */ - public static function logout(?string $location = null) + public function data() { - static::sessionCheck(); + $user = $this->user(); - static::$session->destroy(); - - if (is_string($location)) { - \Leaf\Http\Headers::status(302); - $route = static::config($location) ?? $location; - - exit(header("location: $route")); + if (!$user) { + return null; } + + return $user->getAuthInfo(); } /** - * @return bool + * Get generated access tokens + * @return array|null */ - private static function expireSession(): bool + public function tokens() { - self::sessionCheck(); + $user = $this->user(); + + if (!$user) { + return null; + } - $sessionTtl = static::$session->get('SESSION_TTL'); + return $user->tokens(); + } - if (!$sessionTtl) { - return false; + /** + * Register auth middleware for your Leaf apps + * @param string $middleware The middleware to register + * @param callable $callback The callback to run if middleware fails + */ + public function middleware(string $middleware, callable $callback) + { + if (!class_exists(\Leaf\App::class)) { + throw new \Exception('This feature is only available for Leaf apps'); } - $isSessionExpired = time() > $sessionTtl; + if ($middleware === 'auth.required') { + return app()->registerMiddleware('auth.required', function () use ($callback) { + if (!$this->user()) { + $callback(); + } + }); + } - if ($isSessionExpired) { - static::$session->unset('AUTH_USER'); - static::$session->unset('HAS_SESSION'); - static::$session->unset('AUTH_TOKEN'); - static::$session->unset('SESSION_STARTED_AT'); - static::$session->unset('SESSION_LAST_ACTIVITY'); - static::$session->unset('SESSION_TTL'); + if ($middleware === 'auth.guest') { + return app()->registerMiddleware('auth.guest', function () use ($callback) { + if ($this->user()) { + $callback(); + } + }); } - return $isSessionExpired; + app()->registerMiddleware($middleware, $callback); } /** - * Session last active + * Parse the current user's token */ - public static function lastActive() + public function parseToken() { - static::sessionCheck(); + $bearerToken = $this->getTokenFromRequest(); + + if ($bearerToken === null) { + return null; + } - return time() - static::$session->get('SESSION_LAST_ACTIVITY'); + return (array) JWT::decode( + $bearerToken, + new Key(Config::get('token.secret'), 'HS256') + ); } /** - * Refresh session + * Return the current db instance * - * @param bool $clearData Remove existing session data + * @return Db */ - public static function refresh(bool $clearData = true) + public function db() { - static::sessionCheck(); - - $success = static::$session->regenerate($clearData); - - static::$session->set('SESSION_STARTED_AT', time()); - static::$session->set('SESSION_LAST_ACTIVITY', time()); - static::setSessionTtl(); - - return $success; + return $this->db; } - /** - * Check how long a session has been going on - */ - public static function length() + protected function checkDbConnection(): void { - static::sessionCheck(); + if (!$this->db && function_exists('db')) { + if (db()->connection() instanceof \PDO || db()->autoConnect()) { + $this->db = db(); + } + } - return time() - static::$session->get('SESSION_STARTED_AT'); + if (!$this->db) { + throw new \Exception('You need to connect to your database first'); + } } - /** - * @param array $user - * @param string $token - * - * @return void - */ - private static function setUserToSession(array $user, string $token): void + protected function getFromSession($value) { - session_regenerate_id(); + if ($this->checkAndExpireSession()) { + return null; + } - static::$session->set('AUTH_USER', $user); - static::$session->set('HAS_SESSION', true); - static::setSessionTtl(); + return Session::get($value); + } - if (static::config('SAVE_SESSION_JWT')) { - static::$session->set('AUTH_TOKEN', $token); + protected function sessionCheck() + { + if (!Config::get('session')) { + throw new \Exception('Turn on sessions to use this feature.'); } } - /** - * @return void - */ - private static function setSessionTtl(): void + protected function checkAndExpireSession(): bool { - $sessionLifetime = static::config('SESSION_LIFETIME'); + $sessionTtl = Session::get('auth.ttl'); - if ($sessionLifetime === 0) { - return; + if (!$sessionTtl) { + return false; } - if (is_int($sessionLifetime)) { - static::$session->set('SESSION_TTL', time() + $sessionLifetime); - return; + $isSessionExpired = time() > $sessionTtl; + + if ($isSessionExpired) { + Session::unset('auth'); } - $sessionLifetimeInTime = strtotime($sessionLifetime); + return $isSessionExpired; + } - if (!$sessionLifetimeInTime) { - throw new \Exception('Provided string could not be converted to time'); + protected function getTokenFromRequest() + { + $headers = null; + + if (isset($_SERVER['Authorization'])) { + $headers = trim($_SERVER['Authorization']); + } elseif (isset($_SERVER['HTTP_AUTHORIZATION'])) { + $headers = trim($_SERVER['HTTP_AUTHORIZATION']); + } elseif (function_exists('apache_request_headers')) { + $requestHeaders = apache_request_headers(); + // Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care about capitalization for Authorization) + $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders)); + + if (isset($requestHeaders['Authorization'])) { + $headers = trim($requestHeaders['Authorization']); + } + } + + if (!empty($headers)) { + if (preg_match('/Bearer\s(\S+)/', $headers, $matches)) { + return $matches[1]; + } } - static::$session->set('SESSION_TTL', $sessionLifetimeInTime); + $this->errorsArray['token'] = 'Access token not found'; + + return null; + } + + protected function getTokenFromSession() + { + return Session::get('auth.token'); + } + + /** + * Return all errors caught + */ + public function errors(): array + { + return $this->errorsArray; } } diff --git a/src/Auth/Config.php b/src/Auth/Config.php new file mode 100644 index 0000000..dfa9e04 --- /dev/null +++ b/src/Auth/Config.php @@ -0,0 +1,71 @@ + 'id', + 'db.table' => 'users', + + 'timestamps' => true, + 'timestamps.format' => 'YYYY-MM-DD HH:mm:ss', + + 'password.encode' => null, + 'password.verify' => null, + 'password.key' => 'password', + + 'unique' => ['email', 'username'], + 'hidden' => ['field.id', 'field.password'], + + 'session' => false, + 'session.lifetime' => 60 * 60 * 24, + 'session.cookie' => ['secure' => true, 'httponly' => true, 'samesite' => 'lax'], + + 'token.lifetime' => null, + 'token.secret' => '@_leaf$0Secret!', + + 'messages.loginParamsError' => 'Incorrect credentials!', + 'messages.loginPasswordError' => 'Password is incorrect!', + ]; + + /** + * Set Leaf Auth config + */ + public static function set($config): void + { + static::$config = array_merge(static::$config, $config); + } + + /** + * Overwrite Leaf Auth config + */ + public static function overwrite($config): void + { + static::$config = $config; + } + + /** + * Get Leaf Auth config + */ + public static function get($key = null) + { + if ($key) { + return static::$config[$key] ?? null; + } + + return static::$config; + } +} diff --git a/src/Auth/Core.php b/src/Auth/Core.php deleted file mode 100644 index 28f587b..0000000 --- a/src/Auth/Core.php +++ /dev/null @@ -1,201 +0,0 @@ - 'users', - 'AUTH_NO_PASS' => false, - 'USE_TIMESTAMPS' => true, - 'TIMESTAMP_FORMAT' => 'c', - 'PASSWORD_ENCODE' => null, - 'PASSWORD_VERIFY' => null, - 'PASSWORD_KEY' => 'password', - 'HIDE_ID' => true, - 'ID_KEY' => 'id', - 'USE_UUID' => false, - 'HIDE_PASSWORD' => true, - 'LOGIN_PARAMS_ERROR' => 'Incorrect credentials!', - 'LOGIN_PASSWORD_ERROR' => 'Password is incorrect!', - 'USE_SESSION' => false, - 'SESSION_ON_REGISTER' => false, - 'GUARD_LOGIN' => '/auth/login', - 'GUARD_REGISTER' => '/auth/register', - 'GUARD_HOME' => '/home', - 'GUARD_LOGOUT' => '/auth/logout', - 'SAVE_SESSION_JWT' => false, - 'TOKEN_LIFETIME' => null, - 'TOKEN_SECRET' => '@_leaf$0Secret!', - 'SESSION_REDIRECT_ON_LOGIN' => true, - 'SESSION_LIFETIME' => self::TIMESTAMP_OF_ONE_DAY, - 'SESSION_COOKIE_PARAMS' => ['secure' => true, 'httponly' => true, 'samesite' => 'lax'], - ]; - - /** - * @var \Leaf\Db - */ - protected static $db; - - /** - * @var \Leaf\Http\Session - */ - protected static $session; - - /** - * Connect leaf auth to the database - * - * @param string|array $host Host Name or full config - * @param string $dbname Database name - * @param string $user Database username - * @param string $password Database password - * @param string $dbtype Type of database: mysql, postgres, sqlite, ... - * @param array $pdoOptions Options for PDO connection - */ - public static function connect( - $host, - string $dbname, - string $user, - string $password, - string $dbtype, - array $pdoOptions = [] - ) { - $db = new \Leaf\Db(); - $db->connect($host, $dbname, $user, $password, $dbtype, $pdoOptions); - static::$db = $db; - } - - /** - * Connect to database using environment variables - * - * @param array $pdoOptions Options for PDO connection - */ - public static function autoConnect(array $pdoOptions = []) - { - $db = new \Leaf\Db(); - $db->autoConnect($pdoOptions); - static::$db = $db; - } - - /** - * Pass in db connetion instance directly - * - * @param \PDO $connection A connection instance of your db - */ - public static function dbConnection(\PDO $connection) - { - $db = new \Leaf\Db(); - $db->connection($connection); - static::$db = $db; - } - - /** - * Auto connect to leaf db - */ - protected static function leafDbConnect() - { - if (!static::$db && function_exists('db')) { - if (db()->connection() instanceof \PDO || db()->autoConnect()) { - static::$db = db(); - } - } - - if (!static::$db) { - trigger_error('You need to connect to your database first'); - } - } - - /** - * Set auth config - * - * @param string|array $config The auth config key or array of config - * @param mixed $value The value if $config is a string - */ - public static function config($config, $value = null) - { - if (is_array($config)) { - foreach ($config as $key => $configValue) { - static::config($key, $configValue); - } - } else { - if ($value === null) { - return static::$settings[$config] ?? null; - } - - static::$settings[$config] = $value; - } - } - - /** - * Validate Json Web Token - * - * @param string $token The token validate - * @param string $secretKey The secret key used to encode token - */ - public static function validateUserToken(string $token, ?string $secretKey = null) - { - $payload = Authentication::validate($token, $secretKey ?? static::config("TOKEN_SECRET")); - if ($payload) return $payload; - - static::$errors = array_merge(static::$errors, Authentication::errors()); - - return null; - } - - /** - * Validate Bearer Token - * - * @param string $secretKey The secret key used to encode token - */ - public static function validateToken(?string $secretKey = null) - { - $payload = Authentication::validateToken($secretKey ?? static::config("TOKEN_SECRET")); - if ($payload) return $payload; - - static::$errors = array_merge(static::$errors, Authentication::errors()); - - return null; - } - - /** - * Get Bearer token - */ - public static function getBearerToken() - { - $token = Authentication::getBearerToken(); - if ($token) return $token; - - static::$errors = array_merge(static::$errors, Authentication::errors()); - - return null; - } - - /** - * Get all authentication errors as associative array - */ - public static function errors(): array - { - return static::$errors; - } -} diff --git a/src/Auth/Session.php b/src/Auth/Session.php deleted file mode 100644 index e8da0d7..0000000 --- a/src/Auth/Session.php +++ /dev/null @@ -1,39 +0,0 @@ -get("SESSION_STARTED_AT")) { - static::$session->set("SESSION_STARTED_AT", time()); - } - - static::$session->set("SESSION_LAST_ACTIVITY", time()); - - return static::$session; - } -} diff --git a/src/Auth/User.php b/src/Auth/User.php new file mode 100644 index 0000000..785420e --- /dev/null +++ b/src/Auth/User.php @@ -0,0 +1,224 @@ +data = $data; + + $sessionLifetime = Config::get('token.lifetime'); + + if (Config::get('session')) { + $sessionLifetime = Config::get('session.lifetime'); + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_set_cookie_params(Config::get('session.cookie')); + session_start(); + } + + session_regenerate_id(); + + if (!Session::has('auth.startedAt')) { + Session::set('auth.startedAt', time()); + } + + Session::set('auth.lastActivity', time()); + Session::set('auth.id', $this->id()); + Session::set('auth.user', $this->get()); + + if ($sessionLifetime !== 0 && $sessionLifetime !== null) { + if (!is_int($sessionLifetime)) { + $sessionLifetime = strtotime($sessionLifetime); + + if (!$sessionLifetime) { + throw new \Exception('Invalid session lifetime'); + } + } else { + $sessionLifetime = time() + $sessionLifetime; + } + + Session::set('auth.ttl', $sessionLifetime); + } + } + + $this->tokens['access'] = $this->generateToken($sessionLifetime); + $this->tokens['refresh'] = $this->generateToken($sessionLifetime + 259200); + } + + /** + * Return the id of current user + * @return string|int + */ + public function id() + { + return $this->data['id'] ?? null; + } + + /** + * Get auth information to be sent to the client + * @return object + */ + public function getAuthInfo(): object + { + $dataToReturn = (object) [ + 'user' => $this->get(), + 'accessToken' => $this->tokens['access'], + 'refreshToken' => $this->tokens['refresh'], + ]; + + if (count($this->roles ?? [])) { + $dataToReturn->roles = $this->roles; + } + + if (count($this->permissions ?? [])) { + $dataToReturn->permissions = $this->permissions; + } + + return $dataToReturn; + } + + /** + * Return generated tokens + * @return array + */ + public function tokens(): array + { + return $this->tokens; + } + + /** + * Generate a new JWT for the user + * @return string + */ + public function generateToken($tokenLifetime): string + { + $userIdKey = Config::get('id.key'); + $secretPhrase = Config::get('token.secret'); + + // no fallback because we need the user id + $userId = $this->data[$userIdKey]; + + $payload = [ + 'user.id' => $userId, + 'iat' => time(), + 'exp' => $tokenLifetime, + 'iss' => $_SERVER['HTTP_HOST'] ?? 'localhost', + ]; + + $token = JWT::encode($payload, $secretPhrase, 'HS256'); + + return $token; + } + + public function get() + { + $userData = $this->data; + + $idKey = Config::get('id.key'); + $hidden = Config::get('hidden'); + $passwordKey = Config::get('password.key'); + + if (count($hidden) > 0) { + foreach ($hidden as $item) { + if (isset($userData[$item])) { + unset($userData[$item]); + } + + if ($item === 'field.id' && isset($userData[$idKey])) { + unset($userData[$idKey]); + } + + if ($item === 'field.password' && isset($userData[$passwordKey])) { + unset($userData[$passwordKey]); + } + } + } + + return $userData; + } + + public function __toString() + { + return json_encode($this->get()); + } + + public function __get($name) + { + // using data instead of get() here because + // we want people to be able to user()->get hidden fields + // since it's expected to be used within the app + return $this->data[$name] ?? null; + } + + public function __set($name, $value) + { + $this->data[$name] = $value; + } + + public function __isset($name) + { + return isset($this->data[$name]); + } + + public function __unset($name) + { + unset($this->data[$name]); + } + + /** + * Get a "user to many" table relation + * + * + * auth()->user()->orders()->all(); + * auth()->user()->transactions()->where('amount', '>', 100)->get(); + * auth()->user()->notes()->where('title', 'like', '%important%')->get(); + * auth()->user()->posts()->where('published', true)->all(); + * + * + * @param mixed $method The table to relate to + * @param mixed $args + * @throws \Exception + * @return Db + */ + public function __call($method, $args) + { + if (!class_exists('Leaf\App')) { + throw new \Exception('Relations are only available in Leaf apps.'); + } + + return auth() + ->db() + ->select($method) + ->where('user_id', $this->id()); + } +} diff --git a/src/Auth/UsesRoles.php b/src/Auth/UsesRoles.php new file mode 100644 index 0000000..3650afa --- /dev/null +++ b/src/Auth/UsesRoles.php @@ -0,0 +1,125 @@ +permissions = array_merge( + $this->permissions, + is_array($permission) ? $permission : [$permission] + ); + } + + /** + * Assign new role to user + * @param string|array $role The role to assign + */ + public function assignRoles($role): void + { + // will need to verify roles here + + $this->roles = array_merge( + $this->roles, + is_array($role) ? $role : [$role] + ); + + // persist via storage contract + + foreach ($this->roles as $role) { + $this->grantPermissions($this->getRolePermissions($role)); + } + + // persist via storage contract + } + + /** + * Check if user has a permission + * @param string|array $permission The permission(s) to check + * @return bool + */ + public function can($permission): bool + { + if (is_array($permission)) { + return count(array_intersect($permission, $this->permissions)) === count($permission); + } + + return in_array($permission, $this->permissions); + } + + /** + * Check if user has a role + * @param string|array $role The role(s) to check + * @return bool + */ + public function is($role): bool + { + if (is_array($role)) { + return count(array_intersect($role, $this->roles)) === count($role); + } + + return in_array($role, $this->roles); + } + + /** + * Revoke a permission from a user + * @param string|array $permission The permission(s) to revoke + */ + public function revokePermissions($permission): void + { + // persist via storage contract + $this->permissions = array_diff( + $this->permissions, + is_array($permission) ? $permission : [$permission] + ); + } + + /** + * Remove a role from a user + * @param string|array $role The role(s) to revoke + */ + public function removeRoles($role): void + { + // persist via storage contract + $this->roles = array_diff( + $this->roles, + is_array($role) ? $role : [$role] + ); + } + + /** + * Get the permissions for a role + * @param string $role + * @return array + */ + protected function getRolePermissions($role): array + { + // get permissions from storage contract + return []; + } +} diff --git a/src/Helpers/Authentication.php b/src/Helpers/Authentication.php deleted file mode 100755 index 5df9384..0000000 --- a/src/Helpers/Authentication.php +++ /dev/null @@ -1,134 +0,0 @@ - - * @since v1.2.0 - */ -class Authentication -{ - /** - * Any errors caught - */ - protected static $errorsArray = []; - - /** - * Quickly generate a JWT encoding a user id - * - * @param string $userId The user id to encode - * @param string $secretPhrase The user id to encode - * @param int $expiresAt Token lifetime - * - * @return string The generated token - */ - public static function generateSimpleToken(string $userId, string $secretPhrase, int $expiresAt = null): string - { - $payload = [ - 'iat' => time(), - 'iss' => 'localhost', - 'exp' => time() + ($expiresAt ?? (60 * 60 * 24)), - 'user_id' => $userId - ]; - - return self::generateToken($payload, $secretPhrase); - } - - /** - * Create a JWT with your own payload - * - * @param array $payload The JWT payload - * @param string $secretPhrase The user id to encode - * - * @return string The generated token - */ - public static function generateToken(array $payload, string $secretPhrase): string - { - return JWT::encode($payload, $secretPhrase); - } - - /** - * Get Authorization Headers - */ - public static function getAuthorizationHeader() - { - $headers = null; - - if (isset($_SERVER['Authorization'])) { - $headers = trim($_SERVER["Authorization"]); - } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { - $headers = trim($_SERVER["HTTP_AUTHORIZATION"]); - } else if (function_exists('apache_request_headers')) { - $requestHeaders = apache_request_headers(); - // Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care about capitalization for Authorization) - $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders)); - - if (isset($requestHeaders['Authorization'])) { - $headers = trim($requestHeaders['Authorization']); - } - } - - return $headers; - } - - /** - * get access token from header - */ - public static function getBearerToken() - { - $headers = self::getAuthorizationHeader(); - - if (!empty($headers)) { - if (preg_match('/Bearer\s(\S+)/', $headers, $matches)) { - return $matches[1]; - } - - self::$errorsArray["token"] = "Access token not found"; - return null; - } - - self::$errorsArray["token"] = "Access token not found"; - return null; - } - - /** - * Validate and decode access token in header - */ - public static function validateToken($secretPhrase) - { - $bearerToken = self::getBearerToken(); - if ($bearerToken === null) return null; - - return self::validate($bearerToken, $secretPhrase); - } - - /** - * Validate access token - * - * @param string $token Access token to validate and decode - */ - public static function validate($token, $secretPhrase) - { - try { - $payload = JWT::decode($token, $secretPhrase, ['HS256']); - if ($payload !== null) return $payload; - } catch (\DomainException $exception) { - self::$errorsArray["token"] = "Malformed token"; - } - - self::$errorsArray = array_merge(self::$errorsArray, JWT::errors()); - return null; - } - - /** - * Get all authentication errors as associative array - */ - public static function errors() - { - return self::$errorsArray; - } -} diff --git a/src/Helpers/JWT.php b/src/Helpers/JWT.php deleted file mode 100755 index 8fe1da5..0000000 --- a/src/Helpers/JWT.php +++ /dev/null @@ -1,371 +0,0 @@ - - * @author Anant Narayanan - * @license http://opensource.org/licenses/BSD-3-Clause 3-clause BSD - * @link https://github.com/firebase/php-jwt - */ -class JWT -{ - /** - * Errors caught - */ - protected static $errorsArray = []; - /** - * When checking nbf, iat or expiration times, - * we want to provide some extra leeway time to - * account for clock skew. - */ - public static $leeway = 0; - /** - * Allow the current timestamp to be specified. - * Useful for fixing a value within unit testing. - * - * Will default to PHP time() value if null. - */ - public static $timestamp = null; - public static $supported_algs = array( - 'HS256' => array('hash_hmac', 'SHA256'), - 'HS512' => array('hash_hmac', 'SHA512'), - 'HS384' => array('hash_hmac', 'SHA384'), - 'RS256' => array('openssl', 'SHA256'), - 'RS384' => array('openssl', 'SHA384'), - 'RS512' => array('openssl', 'SHA512'), - ); - /** - * Decodes a JWT string into a PHP object. - * - * @param string $jwt The JWT - * @param string|array $key The key, or map of keys. - * If the algorithm used is asymmetric, this is the public key - * @param array $allowed_algs List of supported verification algorithms - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * - * @return object The JWT's payload as a PHP object - * - * - * @uses jsonDecode - * @uses urlsafeB64Decode - */ - public static function decode($jwt, $key, array $allowed_algs = array()) - { - $timestamp = is_null(static::$timestamp) ? time() : static::$timestamp; - if (empty($key)) { - return static::saveErr("Key may not be empty"); - } - $tks = explode('.', $jwt); - if (count($tks) != 3) { - return static::saveErr("Wrong number of segments"); - } - list($headb64, $bodyb64, $cryptob64) = $tks; - if (null === ($header = static::jsonDecode(static::urlsafeB64Decode($headb64)))) { - return static::saveErr("Invalid header encoding"); - } - if (null === $payload = static::jsonDecode(static::urlsafeB64Decode($bodyb64))) { - return static::saveErr("Invalid claims encoding"); - } - if (false === ($sig = static::urlsafeB64Decode($cryptob64))) { - return static::saveErr("Invalid signature encoding"); - } - if (empty($header->alg)) { - return static::saveErr("Empty algorithm"); - } - if (empty(static::$supported_algs[$header->alg])) { - return static::saveErr("Algorithm not supported"); - } - if (!in_array($header->alg, $allowed_algs)) { - return static::saveErr("Algorithm not allowed"); - } - if (is_array($key) || $key instanceof \ArrayAccess) { - if (isset($header->kid)) { - if (!isset($key[$header->kid])) { - return static::saveErr("'kid' invalid, unable to lookup correct key"); - } - $key = $key[$header->kid]; - } else { - return static::saveErr("'kid' empty, unable to lookup correct key"); - } - } - // Check the signature - if (!static::verify("$headb64.$bodyb64", $sig, $key, $header->alg)) { - return static::saveErr("Signature verification failed"); - } - // Check if the nbf if it is defined. This is the time that the - // token can actually be used. If it's not yet that time, abort. - if (isset($payload->nbf) && $payload->nbf > ($timestamp + static::$leeway)) { - return static::saveErr( - "Cannot handle token prior to " . date(\DateTime::ISO8601, $payload->nbf) - ); - } - // Check that this token has been created before "now". This prevents - // using tokens that have been created for later use (and haven't - // correctly used the nbf claim). - if (isset($payload->iat) && $payload->iat > ($timestamp + static::$leeway)) { - return static::saveErr( - "Cannot handle token prior to " . date(\DateTime::ISO8601, $payload->iat) - ); - } - // Check if this token has expired. - if (isset($payload->exp) && ($timestamp - static::$leeway) >= $payload->exp) { - return static::saveErr("Expired token"); - } - return $payload; - } - - /** - * Converts and signs a PHP object or array into a JWT string. - * - * @param object|array $payload PHP object or array - * @param string $key The secret key. - * If the algorithm used is asymmetric, this is the private key - * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * @param mixed $keyId - * @param array $head An array with header elements to attach - * - * @return string A signed JWT - * - * @uses jsonEncode - * @uses urlsafeB64Encode - */ - public static function encode($payload, $key, $alg = 'HS256', $keyId = null, $head = null) - { - $header = array('typ' => 'JWT', 'alg' => $alg); - if ($keyId !== null) { - $header['kid'] = $keyId; - } - if (isset($head) && is_array($head)) { - $header = array_merge($head, $header); - } - $segments = array(); - $segments[] = static::urlsafeB64Encode(static::jsonEncode($header)); - $segments[] = static::urlsafeB64Encode(static::jsonEncode($payload)); - $signing_input = implode('.', $segments); - $signature = static::sign($signing_input, $key, $alg); - $segments[] = static::urlsafeB64Encode($signature); - return implode('.', $segments); - } - - /** - * Sign a string with a given key and algorithm. - * - * @param string $msg The message to sign - * @param string|resource $key The secret key - * @param string $alg The signing algorithm. - * Supported algorithms are 'HS256', 'HS384', 'HS512' and 'RS256' - * - * @return string An encrypted message - * - * @throws \DomainException Unsupported algorithm was specified - */ - public static function sign($msg, $key, $alg = 'HS256') - { - if (empty(static::$supported_algs[$alg])) { - throw new \DomainException('Algorithm not supported'); - } - list($function, $algorithm) = static::$supported_algs[$alg]; - switch ($function) { - case 'hash_hmac': - return hash_hmac($algorithm, $msg, $key, true); - case 'openssl': - $signature = ''; - $success = openssl_sign($msg, $signature, $key, $algorithm); - if (!$success) { - throw new \DomainException("OpenSSL unable to sign data"); - } else { - return $signature; - } - } - } - - /** - * Verify a signature with the message, key and method. Not all methods - * are symmetric, so we must have a separate verify and sign method. - * - * @param string $msg The original message (header and body) - * @param string $signature The original signature - * @param string|resource $key For HS*, a string key works. for RS*, must be a resource of an openssl public key - * @param string $alg The algorithm - * - * @return bool - * - * @throws \DomainException Invalid Algorithm or OpenSSL failure - */ - private static function verify($msg, $signature, $key, $alg) - { - if (empty(static::$supported_algs[$alg])) { - throw new \DomainException('Algorithm not supported'); - } - list($function, $algorithm) = static::$supported_algs[$alg]; - switch ($function) { - case 'openssl': - $success = openssl_verify($msg, $signature, $key, $algorithm); - if ($success === 1) { - return true; - } elseif ($success === 0) { - return false; - } - // returns 1 on success, 0 on failure, -1 on error. - throw new \DomainException( - 'OpenSSL error: ' . openssl_error_string() - ); - case 'hash_hmac': - default: - $hash = hash_hmac($algorithm, $msg, $key, true); - if (function_exists('hash_equals')) { - return hash_equals($signature, $hash); - } - $len = min(static::safeStrlen($signature), static::safeStrlen($hash)); - $status = 0; - for ($i = 0; $i < $len; $i++) { - $status |= (ord($signature[$i]) ^ ord($hash[$i])); - } - $status |= (static::safeStrlen($signature) ^ static::safeStrlen($hash)); - return ($status === 0); - } - } - - /** - * Decode a JSON string into a PHP object. - * - * @param string $input JSON string - * - * @return object Object representation of JSON string - * - * @throws \DomainException Provided string was invalid JSON - */ - public static function jsonDecode($input) - { - if (version_compare(PHP_VERSION, '5.4.0', '>=') && !(defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) { - /** In PHP >=5.4.0, json_decode() accepts an options parameter, that allows you - * to specify that large ints (like Steam Transaction IDs) should be treated as - * strings, rather than the PHP default behaviour of converting them to floats. - */ - $obj = json_decode($input, false, 512, JSON_BIGINT_AS_STRING); - } else { - /** Not all servers will support that, however, so for older versions we must - * manually detect large ints in the JSON string and quote them (thus converting - *them to strings) before decoding, hence the preg_replace() call. - */ - $max_int_length = strlen((string) PHP_INT_MAX) - 1; - $json_without_bigints = preg_replace('/:\s*(-?\d{' . $max_int_length . ',})/', ': "$1"', $input); - $obj = json_decode($json_without_bigints); - } - if (function_exists('json_last_error') && $errno = json_last_error()) { - static::handleJsonError($errno); - } elseif ($obj === null && $input !== 'null') { - throw new \DomainException('Null result with non-null input'); - } - return $obj; - } - - /** - * Encode a PHP object into a JSON string. - * - * @param object|array $input A PHP object or array - * - * @return string JSON representation of the PHP object or array - * - * @throws \DomainException Provided object could not be encoded to valid JSON - */ - public static function jsonEncode($input) - { - $json = json_encode($input); - if (function_exists('json_last_error') && $errno = json_last_error()) { - static::handleJsonError($errno); - } elseif ($json === 'null' && $input !== null) { - throw new \DomainException('Null result with non-null input'); - } - return $json; - } - - /** - * Decode a string with URL-safe Base64. - * - * @param string $input A Base64 encoded string - * - * @return string A decoded string - */ - public static function urlsafeB64Decode($input) - { - $remainder = strlen($input) % 4; - if ($remainder) { - $padlen = 4 - $remainder; - $input .= str_repeat('=', $padlen); - } - return base64_decode(strtr($input, '-_', '+/')); - } - - /** - * Encode a string with URL-safe Base64. - * - * @param string $input The string you want encoded - * - * @return string The base64 encode of what you passed in - */ - public static function urlsafeB64Encode($input) - { - return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); - } - - /** - * Helper method to create a JSON error. - * - * @param int $errno An error number from json_last_error() - * - * @return void - */ - private static function handleJsonError($errno) - { - $messages = array( - JSON_ERROR_DEPTH => 'Maximum stack depth exceeded', - JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON', - JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', - JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', - JSON_ERROR_UTF8 => 'Malformed UTF-8 characters' //PHP >= 5.3.3 - ); - throw new \DomainException( - isset($messages[$errno]) - ? $messages[$errno] - : 'Unknown JSON error: ' . $errno - ); - } - - /** - * Get the number of bytes in cryptographic strings. - * - * @param string - * - * @return int - */ - private static function safeStrlen($str) - { - if (function_exists('mb_strlen')) { - return mb_strlen($str, '8bit'); - } - return strlen($str); - } - - protected static function saveErr($err, $key = "token") - { - self::$errorsArray[$key] = $err; - return null; - } - - /** - * Return all errors found - */ - public static function errors() - { - return static::$errorsArray; - } -} diff --git a/src/functions.php b/src/functions.php index 555fcbf..a88c508 100644 --- a/src/functions.php +++ b/src/functions.php @@ -3,7 +3,7 @@ if (!function_exists('auth') && class_exists('Leaf\App')) { /** * Return the leaf auth object - * + * * @return Leaf\Auth */ function auth() @@ -17,35 +17,3 @@ function auth() return \Leaf\Config::get('auth'); } } - -if (!function_exists('guard') && function_exists('auth')) { - /** - * Run an auth guard - * - * @param string $guard The auth guard to run - */ - function guard(string $guard) - { - return auth()->guard($guard); - } -} - -if (!function_exists('hasAuth') && function_exists('auth')) { - /** - * Find out if there's an active sesion - */ - function hasAuth(): bool - { - return !!sessionUser(); - } -} - -if (!function_exists('sessionUser') && function_exists('auth')) { - /** - * Get the currently logged in user - */ - function sessionUser() - { - return \Leaf\Http\Session::get('AUTH_USER'); - } -} diff --git a/tests/AuthSessionTest.php b/tests/AuthSessionTest.php deleted file mode 100644 index 54224ef..0000000 --- a/tests/AuthSessionTest.php +++ /dev/null @@ -1,165 +0,0 @@ - 'login-user', 'password' => 'login-pass']); - - $session = new \Leaf\Http\Session(false); - $user = $session->get('AUTH_USER'); - - expect($user['username'])->toBe('login-user'); -}); - -test('login should set session ttl', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig()); - - $timeBeforeLogin = time(); - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - $session = new \Leaf\Http\Session(false); - $sessionTtl = $session->get('SESSION_TTL'); - - expect($sessionTtl > $timeBeforeLogin)->toBeTrue(); -}); - -test('login should set regenerate session id', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig()); - - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - $originalSessionId = session_id(); - - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - expect(session_id())->not()->toBe($originalSessionId); -}); - -test('login should set secure session cookie params', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig()); - - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - $cookieParams = session_get_cookie_params(); - - expect($cookieParams['secure'])->toBeTrue(); - expect($cookieParams['httponly'])->toBeTrue(); - expect($cookieParams['samesite'])->toBe('lax'); -}); - -test('register should set session ttl on login', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig()); - - $timeBeforeLogin = time(); - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - $session = new \Leaf\Http\Session(false); - $sessionTtl = $session->get('SESSION_TTL'); - - expect($sessionTtl > $timeBeforeLogin)->toBeTrue(); -}); - -test('Session should expire when fetching user, and then login is possible again', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig(['SESSION_LIFETIME' => 2])); - - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - $user = $auth::user(); - expect($user)->not()->toBeNull(); - expect($user['username'])->toBe('login-user'); - - sleep(1); - expect($auth::user())->not()->toBeNull(); - - sleep(2); - expect($auth::user())->toBeNull(); - - $userAfterReLogin = $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - expect($userAfterReLogin)->not()->toBeNull(); - expect($userAfterReLogin['user']['username'])->toBe('login-user'); -}); - -test('Session should not expire when fetching user if session lifetime is 0', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig(['SESSION_LIFETIME' => 0])); - - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - $user = $auth::user(); - expect($user)->not()->toBeNull(); - expect($user['username'])->toBe('login-user'); - - sleep(2); - expect($auth::user())->not()->toBeNull(); -}); - -test('Session should expire when fetching user id', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig(['SESSION_LIFETIME' => 2])); - - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - expect($auth::id())->not()->toBeNull(); - - sleep(1); - expect($auth::id())->not()->toBeNull(); - - sleep(2); - expect($auth::id())->toBeNull(); -}); - -test('Session should expire when fetching status', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig(['SESSION_LIFETIME' => 2])); - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - expect($auth::status())->not()->toBeNull(); - - sleep(1); - expect($auth::status())->not()->toBeNull(); - - sleep(2); - expect($auth::status())->toBeFalse(); -}); - -test('Session lifetime should set correct session ttl when string is configured instead of timestamp', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig(['SESSION_LIFETIME' => '1 day'])); - $auth::login(['username' => 'login-user', 'password' => 'login-pass']); - - expect($auth::status())->not()->toBeNull(); - - $timestampOneDay = 60 * 60 * 24; - $session = new \Leaf\Http\Session(false); - $sessionTtl = $session->get('SESSION_TTL'); - - expect($sessionTtl)->toBe(time() + $timestampOneDay); -}); - -test('Login should throw error when lifetime string is invalid', function () { - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig(['SESSION_LIFETIME' => 'invalid string'])); - - expect(fn() => $auth::login(['username' => 'login-user', 'password' => 'login-pass'])) - ->toThrow(Exception::class, 'Provided string could not be converted to time'); -}); diff --git a/tests/AuthTest.php b/tests/AuthTest.php deleted file mode 100644 index a16a3d9..0000000 --- a/tests/AuthTest.php +++ /dev/null @@ -1,17 +0,0 @@ - false])); - $response = $auth::register(['username' => 'test-user', 'password' => 'test-password']); - - expect($response['user']['username'])->toBe('test-user'); -}); diff --git a/tests/Pest.php b/tests/Pest.php index dac93b2..06ecfcd 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,119 +1,65 @@ 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' +]]]); -// uses(Tests\TestCase::class)->in('Feature'); - -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ - -expect()->extend('toBeOne', function () { - return $this->toBe(1); -}); - -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ +function getDatabaseConnection(): array +{ + return [ + 'dbtype' => 'pgsql', + 'port' => '5432', + 'host' => 'ep-autumn-block-a28alwsy.eu-central-1.aws.neon.tech', + 'username' => 'sandbox_owner', + 'password' => 'WH1qpBIf7LYc', + 'dbname' => 'sandbox', + ]; +} -function createUsersTable() +function dbInstance(): \Leaf\Db { $db = new \Leaf\Db(); - $db->connect(...getConnectionConfig()); + $db->connect(getDatabaseConnection()); - $db->createTableIfNotExists( - 'users', - [ - 'id' => 'int NOT NULL AUTO_INCREMENT', - 'username' => 'varchar(255)', - 'password' => 'varchar(255)', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', - 'PRIMARY KEY' => '(id)', - ] - )->execute(); + return $db; } -function haveRegisteredUser(string $username, string $password): array +function authInstance(): \Leaf\Auth { - \Leaf\Auth\Core::connect(...getConnectionConfig('mysql')); - $auth = new \Leaf\Auth(); - $auth::config(getAuthConfig(['USE_SESSION' => false])); + $auth->dbConnection(dbInstance()->connection()); - return $auth::register(['username' => $username, 'password' => $password]); + return $auth; } -function deleteUser(string $username) +function deleteUser(string $username, $table = 'users') { $db = new \Leaf\Db(); - $db->connect(...getConnectionConfig()); + $db->connect(getDatabaseConnection()); - $db->delete('users')->where('username', '=', $username)->execute(); + $db->delete($table)->where('username', $username)->execute(); } -function getConnectionConfig(?string $dbType = null): array +function createTableForUsers($table = 'users'): void { - $config = ['localhost', 'leaf', 'root', 'root']; - - if ($dbType) { - $config[] = $dbType; + $db = dbInstance(); + + try { + $db + ->query("CREATE TABLE IF NOT EXISTS $table ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + permissions JSONB, + roles JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )") + ->execute(); + } catch (\Throwable $th) { + throw new \Exception('Failed to create table for users: ' . $th->getMessage()); } - - return $config; -} - -function getAuthConfig(array $settingsReplacement = []): array -{ - $settings = [ - 'DB_TABLE' => 'users', - 'AUTH_NO_PASS' => false, - 'USE_TIMESTAMPS' => false, - 'TIMESTAMP_FORMAT' => 'c', - 'PASSWORD_ENCODE' => null, - 'PASSWORD_VERIFY' => null, - 'PASSWORD_KEY' => 'password', - 'HIDE_ID' => true, - 'ID_KEY' => 'id', - 'USE_UUID' => false, - 'HIDE_PASSWORD' => true, - 'LOGIN_PARAMS_ERROR' => 'Incorrect credentials!', - 'LOGIN_PASSWORD_ERROR' => 'Password is incorrect!', - 'USE_SESSION' => true, - 'SESSION_ON_REGISTER' => false, - 'GUARD_LOGIN' => '/auth/login', - 'GUARD_REGISTER' => '/auth/register', - 'GUARD_HOME' => '/home', - 'GUARD_LOGOUT' => '/auth/logout', - 'SAVE_SESSION_JWT' => false, - 'TOKEN_LIFETIME' => null, - 'TOKEN_SECRET' => '@_leaf$0Secret!', - 'SESSION_REDIRECT_ON_LOGIN' => false, - 'SESSION_LIFETIME' => 60 * 60 * 24, - ]; - - return array_replace($settings, $settingsReplacement); } diff --git a/tests/db.test.php b/tests/db.test.php new file mode 100644 index 0000000..e69de29 diff --git a/tests/login.test.php b/tests/login.test.php new file mode 100644 index 0000000..954826a --- /dev/null +++ b/tests/login.test.php @@ -0,0 +1,107 @@ +insert('users') + ->params([ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => password_hash('password', PASSWORD_BCRYPT) + ]) + ->execute(); + } catch (\Throwable $th) { + throw $th; + } +}); + +afterAll(function () { + dbInstance()->delete('users')->execute(); +}); + +test('user can login', function () { + $auth = authInstance(); + + $testUser = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($testUser); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($testUser['username']); +}); + +test('login generates tokens on success', function () { + $auth = authInstance(); + + $testUser = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($testUser); + + expect($success)->toBeTrue(); + expect($auth->data())->not()->toBeNull(); + expect($auth->data()->accessToken)->toBeString(); + expect($auth->data()->refreshToken)->toBeString(); +}); + +test('login fails with incorrect password', function () { + $auth = authInstance(); + + $userData = [ + 'username' => 'test-user', + 'password' => 'wrong-password' + ]; + + $success = $auth->login($userData); + + $loginPasswordError = $auth->config('messages.loginPasswordError'); + + expect($success)->toBeFalse(); + expect($auth->user())->toBeNull(); + expect($auth->errors()['password'] ?? null)->toBe($loginPasswordError); +}); + +test('login should fail if user does not exist', function () { + $auth = authInstance(); + + $userData = [ + 'username' => 'non-existent-user', + 'password' => 'password' + ]; + + $success = $auth->login($userData); + + $loginParamsError = $auth->config('messages.loginParamsError'); + + expect($success)->toBeFalse(); + expect($auth->user())->toBeNull(); + expect($auth->errors()['auth'] ?? null)->toBe($loginParamsError); +}); + +test('login should work without password is password.key is false', function () { + $auth = authInstance(); + + $auth->config('password.key', false); + + $userData = [ + 'username' => 'test-user' + ]; + + $success = $auth->login($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); + + $auth->config('password.key', 'password'); +}); diff --git a/tests/register.test.php b/tests/register.test.php new file mode 100644 index 0000000..630335f --- /dev/null +++ b/tests/register.test.php @@ -0,0 +1,101 @@ +delete('users')->execute(); +}); + +afterEach(function () { + dbInstance()->delete('users')->execute(); +}); + +test('user can register an account', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->register($userData); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); +}); + +test('user can login after registering', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $registerSuccess = $auth->register($userData); + + expect($registerSuccess)->toBeTrue(); + + $loginSuccess = $auth->login($userData); + + expect($loginSuccess)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); +}); + +test('user can only sign up once', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $auth->config([ + 'unique' => ['email', 'username'] + ]); + + $registerSuccess = $auth->register($userData); + + expect($registerSuccess)->toBeTrue(); + + $registerAgain = $auth->register($userData); + + expect($registerAgain)->toBeFalse(); + + expect($auth->errors())->toBe([ + 'email' => 'email already exists', + 'username' => 'username already exists', + ]); +}); + +test('register passwords are encrypted', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $auth->config([ + 'hidden' => [] + ]); + + $registerSuccess = $auth->register($userData); + + expect($registerSuccess)->toBeTrue(); + expect($auth->user()->password)->not()->toBe($userData['password']); + expect(password_verify($userData['password'], $auth->user()->password))->toBeTrue(); +}); diff --git a/tests/session.test.php b/tests/session.test.php new file mode 100644 index 0000000..55a3c2c --- /dev/null +++ b/tests/session.test.php @@ -0,0 +1,205 @@ +delete('users')->execute(); +}); + +afterEach(function () { + if (session_status() === PHP_SESSION_ACTIVE) { + $_SESSION = []; + session_destroy(); + } +}); + +afterAll(function () { + dbInstance()->delete('users')->execute(); +}); + +test('register should create a new session when session => true', function () { + $auth = authInstance(); + $auth->config(['session' => true]); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->register($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); + + expect(session_status())->toBe(PHP_SESSION_ACTIVE); + expect($_SESSION['auth']['user']['username'] ?? null)->toBe($userData['username']); +}); + +test('register should not create a new session when session => false', function () { + $auth = authInstance(); + $auth->config(['session' => false]); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user2', + 'email' => 'test-user2@example.com', + 'password' => 'password' + ]; + + $success = $auth->register($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + + expect(session_status())->toBe(PHP_SESSION_NONE); + expect($_SESSION['auth']['user']['username'] ?? null)->toBeNull(); +}); + +test('login should create session when session => true', function () { + $auth = authInstance(); + $auth->config(['session' => true]); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); + + expect(session_status())->toBe(PHP_SESSION_ACTIVE); + expect($_SESSION['auth']['user']['username'] ?? null)->toBe($userData['username']); +}); + +test('session should create auth.ttl when session.lifetime is not 0', function () { + $auth = authInstance(); + $auth->config(['session' => true, 'session.lifetime' => 2]); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $timeBeforeLogin = time(); + + $success = $auth->login($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); + + expect(session_status())->toBe(PHP_SESSION_ACTIVE); + expect($_SESSION['auth']['user']['username'] ?? null)->toBe($userData['username']); + + expect($_SESSION['auth']['ttl'])->toBeGreaterThan($timeBeforeLogin); +}); + +test('session should not create auth.ttl when session.lifetime is 0', function () { + $auth = authInstance(); + $auth->config(['session' => true, 'session.lifetime' => 0]); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); + + expect(session_status())->toBe(PHP_SESSION_ACTIVE); + expect($_SESSION['auth']['user']['username'] ?? null)->toBe($userData['username']); + + expect($_SESSION['auth']['ttl'] ?? null)->toBeNull(); +}); + +test('session should expire after session.lifetime', function () { + $auth = authInstance(); + $auth->config(['session' => true, 'session.lifetime' => 2]); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); + + expect(session_status())->toBe(PHP_SESSION_ACTIVE); + expect($_SESSION['auth']['user']['username'] ?? null)->toBe($userData['username']); + + sleep(3); + + expect($auth->id())->toBeNull(); + expect($auth->user())->toBeNull(); +}); + +test('login should regenerate session id when session => true and session is already active', function () { + $auth = authInstance(); + $auth->config(['session' => true]); + $auth->config(['db.table' => 'users']); + + session_start(); + + $sessionId = session_id(); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($userData); + + $newSessionId = session_id(); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($userData['username']); + + expect(session_status())->toBe(PHP_SESSION_ACTIVE); + expect($_SESSION['auth']['user']['username'] ?? null)->toBe($userData['username']); + + expect($newSessionId)->not()->toBe($sessionId); +}); + +test('logout should remove auth info from session when session => true', function () { + $auth = authInstance(); + $auth->config(['session' => true]); + $auth->config(['db.table' => 'users']); + + $userData = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($userData); + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($_SESSION['auth']['user']['username'] ?? null)->toBe($userData['username']); + + $auth->logout(); + + expect($auth->user())->toBeNull(); + expect($_SESSION['auth']['user']['username'] ?? null)->toBeNull(); +}); diff --git a/tests/table.test.php b/tests/table.test.php new file mode 100644 index 0000000..ff235cd --- /dev/null +++ b/tests/table.test.php @@ -0,0 +1,93 @@ +delete('myusers')->execute(); +}); + +test('register should save user in user defined table', function () { + $auth = authInstance(); + $auth->config(['session' => false, 'db.table' => 'myusers']); + + $success = $auth->register([ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + expect($auth->user()->username)->toBe('test-user'); +}); + +test('login should work with user defined table', function () { + $auth = authInstance(); + $auth->config(['session' => false, 'db.table' => 'myusers']); + + $success = $auth->login([ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + expect($auth->user()->username)->toBe('test-user'); +}); + +test('update should work with user defined table', function () { + $auth = authInstance(); + $auth->config(['session' => true, 'db.table' => 'myusers', 'session.lifetime' => '1 day']); + + $success = $auth->login([ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + $response = $auth->update([ + 'username' => 'test-user55', + 'email' => 'test-user55@example.com', + ]); + + if (!$response) { + $this->fail(json_encode($auth->errors())); + } + + expect($response['user']['username'])->toBe('test-user55'); + expect($response['user']['email'])->toBe('test-user55@example.com'); +})->skip(); + +test('user table can use uuid as id', function () { + createUsersTable('uuid_users', true); + + $auth = authInstance(); + $auth->config(['session' => false, 'db.table' => 'uuid_users']); + + $response = $auth->register([ + 'id' => '123e4567-e89b-12d3-a456-426614174000', + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password', + ]); + + if (!$response) { + $this->fail(json_encode($auth->errors())); + } + + expect($response['user']['username'])->toBe('test-user'); +})->skip(); diff --git a/tests/update.test.php b/tests/update.test.php new file mode 100644 index 0000000..76b2286 --- /dev/null +++ b/tests/update.test.php @@ -0,0 +1,135 @@ +config(['db.table' => 'users']); + + $auth->register([ + 'username' => 'test-user-1', + 'email' => 'test-user-1@example.com', + 'password' => 'password', + ]); + + $auth->register([ + 'username' => 'test-user-2', + 'email' => 'test-user-2@example.com', + 'password' => 'password', + ]); + + sleep(1); +}); + +afterAll(function () { + dbInstance()->delete('users')->execute(); +}); + +test('update should update user data', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $success = $auth->login([ + 'username' => 'test-user-1', + 'password' => 'password' + ]); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + $updateData = [ + 'username' => 'test-user-3', + ]; + + $updateSuccess = $auth->update($updateData); + + if (!$updateSuccess) { + $this->fail(json_encode($auth->errors())); + } + + expect($auth->user()->username)->toBe($updateData['username']); +}); + +test('update should fail if user already exists', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $success = $auth->login([ + 'username' => 'test-user-3', + 'password' => 'password' + ]); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + $updateData = [ + 'username' => 'test-user-2', + ]; + + $updateSuccess = $auth->update($updateData); + + expect($updateSuccess)->toBeFalse(); + expect($auth->errors()['username'])->toBe('username already exists'); +}); + +test('updatePassword should update user password', function () { + $auth = authInstance(); + $auth->config(['unique' => ['username']]); + $auth->config(['db.table' => 'users']); + + $success = $auth->login([ + 'username' => 'test-user-2', + 'password' => 'password' + ]); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + $oldPassword = 'password'; + $newPassword = 'new-password'; + + $updateSuccess = $auth->updatePassword($oldPassword, $newPassword); + + if (!$updateSuccess) { + $this->fail(json_encode($auth->errors())); + } + + $loginSuccess = $auth->login([ + 'username' => 'test-user-2', + 'password' => 'new-password' + ]); + + expect($loginSuccess)->toBeTrue(); + expect($auth->user()->{$auth->config('password.key')})->not()->toBe($oldPassword); +}); + +test('update should regenerate session id if session => true', function () { + $auth = authInstance(); + $auth->config(['session' => true]); + $auth->config(['db.table' => 'users']); + + $success = $auth->login([ + 'username' => 'test-user-2', + 'password' => 'new-password' + ]); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + $updateData = [ + 'username' => 'test-user-5', + ]; + + $initialSessionId = session_id(); + + $updateSuccess = $auth->update($updateData); + + if (!$updateSuccess) { + $this->fail(json_encode($auth->errors())); + } + + expect($auth->user()->username)->toBe($updateData['username']); + expect($initialSessionId)->not()->toBe(session_id()); +}); diff --git a/tests/user.test.php b/tests/user.test.php new file mode 100644 index 0000000..4086518 --- /dev/null +++ b/tests/user.test.php @@ -0,0 +1,71 @@ +delete('users')->execute(); + + try { + dbInstance() + ->insert('users') + ->params([ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => password_hash('password', PASSWORD_BCRYPT) + ]) + ->execute(); + } catch (\Throwable $th) { + throw $th; + } +}); + +afterAll(function () { + dbInstance()->delete('users')->execute(); +}); + +test('auth user is instance of Leaf\Auth\User', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $testUser = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($testUser); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($testUser['username']); +}); + +test('logout can use logout callback to run custom action', function () { + $auth = authInstance(); + $auth->config(['db.table' => 'users']); + + $testUser = [ + 'username' => 'test-user', + 'email' => 'test-user@example.com', + 'password' => 'password' + ]; + + $success = $auth->login($testUser); + + if (!$success) { + $this->fail(json_encode($auth->errors())); + } + + expect($success)->toBeTrue(); + expect($auth->user())->toBeInstanceOf(\Leaf\Auth\User::class); + expect($auth->user()->username)->toBe($testUser['username']); + + $auth->logout(function ($auth) use ($testUser) { + expect($auth)->toBeInstanceOf(\Leaf\Auth::class); + }); + + expect($auth->user())->toBeNull(); +});