diff --git a/src/Auth/Authentication.php b/src/Auth/Authentication.php index e16e9a0a..cd10dd01 100644 --- a/src/Auth/Authentication.php +++ b/src/Auth/Authentication.php @@ -18,6 +18,45 @@ public function getAuthenticateUserId(): mixed return $this->attributes[$this->primary_key]; } + /** + * The name of the column holding the remember-me token. + * + * Override in the model if your column is named differently. The application + * must add this column to its user table: `remember_token VARCHAR(100) NULL`. + * + * @return string + */ + public function getRememberTokenName(): string + { + return 'remember_token'; + } + + /** + * Read the current remember-me token. + * + * @return ?string + */ + public function getRememberToken(): ?string + { + $value = $this->attributes[$this->getRememberTokenName()] ?? null; + + return is_null($value) ? null : (string) $value; + } + + /** + * Store a new remember-me token and persist it. + * + * @param ?string $token + * @return void + */ + public function setRememberToken(?string $token): void + { + $column = $this->getRememberTokenName(); + + $this->attributes[$column] = $token; + $this->update([$column => $token]); + } + /** * Define the additional values * diff --git a/src/Auth/Guards/GuardContract.php b/src/Auth/Guards/GuardContract.php index b2fb846c..8fc61ecd 100644 --- a/src/Auth/Guards/GuardContract.php +++ b/src/Auth/Guards/GuardContract.php @@ -49,12 +49,13 @@ abstract public function guest(): bool; abstract public function logout(): bool; /** - * Logout + * Login * * @param Authentication $user + * @param bool $remember * @return bool */ - abstract public function login(Authentication $user): bool; + abstract public function login(Authentication $user, bool $remember = false): bool; /** * Get authenticated user @@ -67,9 +68,10 @@ abstract public function user(): ?Authentication; * Check if user is authenticated * * @param array $credentials + * @param bool $remember * @return bool */ - abstract public function attempts(array $credentials): bool; + abstract public function attempts(array $credentials, bool $remember = false): bool; /** * Get the guard name diff --git a/src/Auth/Guards/JwtGuard.php b/src/Auth/Guards/JwtGuard.php index ce48bff8..38755586 100644 --- a/src/Auth/Guards/JwtGuard.php +++ b/src/Auth/Guards/JwtGuard.php @@ -51,12 +51,14 @@ public function __construct(array $provider, string $guard) * Check if user is authenticated * * @param array $credentials + * @param bool $remember * @return bool * @throws AuthenticationException * @throws Exception */ - public function attempts(array $credentials): bool + public function attempts(array $credentials, bool $remember = false): bool { + // $remember is ignored: JWT is stateless, remember-me is a session concept. $user = $this->makeLogin($credentials); $this->token = null; @@ -143,11 +145,13 @@ private function getPolicier(): Policier * Make direct login * * @param Authentication $user + * @param bool $remember * @return bool * @throws Exception */ - public function login(Authentication $user): bool + public function login(Authentication $user, bool $remember = false): bool { + // $remember is ignored: JWT is stateless, remember-me is a session concept. $attributes = array_merge( $user->customJwtAttributes(), [ diff --git a/src/Auth/Guards/SessionGuard.php b/src/Auth/Guards/SessionGuard.php index 0096072d..29b357f6 100644 --- a/src/Auth/Guards/SessionGuard.php +++ b/src/Auth/Guards/SessionGuard.php @@ -8,6 +8,7 @@ use Bow\Auth\Exception\AuthenticationException; use Bow\Auth\Traits\LoginUserTrait; use Bow\Security\Hash; +use Bow\Session\Cookie; use Bow\Session\Exception\SessionException; use Bow\Session\Session; @@ -46,10 +47,11 @@ public function __construct(array $provider, string $guard) * Check if user is authenticated * * @param array $credentials + * @param bool $remember * @return bool * @throws AuthenticationException|SessionException */ - public function attempts(array $credentials): bool + public function attempts(array $credentials, bool $remember = false): bool { $user = $this->makeLogin($credentials); @@ -62,6 +64,11 @@ public function attempts(array $credentials): bool if (Hash::check($password, $user->{$fields['password']})) { $this->getSession()->put($this->session_key, $user); + + if ($remember) { + $this->setRememberCookie($user); + } + return true; } @@ -76,7 +83,8 @@ public function attempts(array $credentials): bool */ public function check(): bool { - return $this->getSession()->exists($this->session_key); + return $this->getSession()->exists($this->session_key) + || $this->attemptRememberLogin(); } /** @@ -112,14 +120,19 @@ public function guest(): bool /** * Make direct login * - * @param mixed $user + * @param Authentication $user + * @param bool $remember * @return bool * @throws AuthenticationException|SessionException */ - public function login(Authentication $user): bool + public function login(Authentication $user, bool $remember = false): bool { $this->getSession()->add($this->session_key, $user); + if ($remember) { + $this->setRememberCookie($user); + } + return true; } @@ -131,6 +144,13 @@ public function login(Authentication $user): bool */ public function logout(): bool { + $user = $this->getSession()->get($this->session_key); + + if ($user instanceof Authentication) { + $user->setRememberToken($this->generateRememberToken()); + } + + $this->clearRememberCookie(); $this->getSession()->remove($this->session_key); return true; @@ -161,6 +181,109 @@ public function id(): mixed */ public function user(): ?Authentication { + if (!$this->getSession()->exists($this->session_key)) { + $this->attemptRememberLogin(); + } + return $this->getSession()->get($this->session_key); } + + /** + * Attempt to restore the session from a valid remember-me cookie. + * + * Never throws on malformed input: a bad cookie is simply cleared. + * + * @return bool + * @throws AuthenticationException|SessionException + */ + private function attemptRememberLogin(): bool + { + $cookie = Cookie::get($this->rememberCookieName()); + + if (!is_string($cookie) || !str_contains($cookie, '|')) { + if (!is_null($cookie)) { + $this->clearRememberCookie(); + } + return false; + } + + [$id, $token] = explode('|', $cookie, 2); + + $user = $this->getUserById($id); + + if (is_null($user)) { + $this->clearRememberCookie(); + return false; + } + + $stored = $user->getRememberToken(); + + if (is_null($stored) || !hash_equals($stored, $token)) { + $this->clearRememberCookie(); + return false; + } + + $this->getSession()->put($this->session_key, $user); + + return true; + } + + /** + * Generate a fresh remember token and persist it on the user, then + * write the encrypted remember cookie. + * + * @param Authentication $user + * @return void + */ + private function setRememberCookie(Authentication $user): void + { + $token = $this->generateRememberToken(); + $user->setRememberToken($token); + + Cookie::set( + $this->rememberCookieName(), + $user->getAuthenticateUserId() . '|' . $token, + $this->rememberLifetime() + ); + } + + /** + * Remove the remember cookie. + * + * @return void + */ + private function clearRememberCookie(): void + { + Cookie::remove($this->rememberCookieName()); + } + + /** + * Get the remember cookie name for this guard. + * + * @return string + */ + private function rememberCookieName(): string + { + return 'remember_' . $this->guard; + } + + /** + * Generate a cryptographically strong remember token. + * + * @return string + */ + private function generateRememberToken(): string + { + return bin2hex(random_bytes(30)); + } + + /** + * Get the configured remember-me cookie lifetime in seconds. + * + * @return int + */ + private function rememberLifetime(): int + { + return (int) (config('auth.remember_lifetime') ?? 2592000); + } } diff --git a/src/Auth/README.md b/src/Auth/README.md index 0d2a38f0..ae36254b 100644 --- a/src/Auth/README.md +++ b/src/Auth/README.md @@ -17,3 +17,64 @@ $user = $auth->user(); ``` Enjoy! + +## Remember me + +The session guard supports a persistent "remember me" cookie so users stay authenticated across browser sessions. + +### Required migration + +The framework cannot modify your application's database, so you must add a nullable token column to your user table: + +```sql +ALTER TABLE users ADD COLUMN remember_token VARCHAR(100) NULL; +``` + +The default column name is `remember_token`. Override it on your user model if needed: + +```php +public function getRememberTokenName(): string +{ + return 'my_token_column'; +} +``` + +### Usage + +Pass `true` as the second argument to `attempts()` or `login()`: + +```php +// Authenticate with credentials and remember the user +Auth::attempts(['username' => $username, 'password' => $password], true); + +// Or, for an already-resolved user instance +Auth::login($user, true); +``` + +### Automatic session restore + +When the session has expired, the next call to `Auth::check()` or `Auth::user()` transparently restores the session from the encrypted `remember_` cookie (e.g. `remember_web`). No changes are needed in `AuthMiddleware`. + +### Logout + +`Auth::logout()` regenerates the user's token (invalidating any outstanding remember cookie) and clears the cookie: + +```php +Auth::logout(); +``` + +Because all devices share the same `remember_token` column, logging out on one device invalidates remember-me for that user everywhere. + +### Configuration + +The cookie lifetime is read from `config('auth.remember_lifetime')` in seconds and defaults to 30 days. Add the key to your `config/auth.php` to override it: + +```php +'remember_lifetime' => 2592000, // 30 days (default) +``` + +### Scope and security notes + +- Remember-me applies to the **session guard only**; the JWT guard ignores the flag. +- The token is high-entropy (`bin2hex(random_bytes(30))`) and compared timing-safely with `hash_equals`; the cookie is encrypted. +- A single shared `remember_token` column means logging out invalidates remember-me for that user on all devices. diff --git a/src/Auth/Traits/LoginUserTrait.php b/src/Auth/Traits/LoginUserTrait.php index ba86b883..a3dc777a 100644 --- a/src/Auth/Traits/LoginUserTrait.php +++ b/src/Auth/Traits/LoginUserTrait.php @@ -55,4 +55,17 @@ private function getUserBy(string $key, float|int|string $value): ?Authenticatio return $model::where($key, $value)->first(); } + + /** + * Get a user by primary key value. + * + * @param float|int|string $id + * @return ?Authentication + */ + private function getUserById(float|int|string $id): ?Authentication + { + $model = $this->provider['model']; + + return $this->getUserBy((new $model())->getKey(), $id); + } } diff --git a/src/Session/Cookie.php b/src/Session/Cookie.php index 9b6ba773..ad46ead7 100644 --- a/src/Session/Cookie.php +++ b/src/Session/Cookie.php @@ -35,7 +35,21 @@ public static function isEmpty(): bool public static function get(string $key, mixed $default = null): mixed { if (static::has($key)) { - return Crypto::decrypt($_COOKIE[$key]); + $value = Crypto::decrypt($_COOKIE[$key]); + + // Cookie::set() json-encodes the payload before encrypting, so decode + // here to mirror it (and Cookie::all()). Fall back to the raw value + // when it is not the JSON we wrote — e.g. a tampered cookie decrypts + // to false, or a cookie was set outside the framework. + if (is_string($value)) { + $decoded = json_decode($value, true); + + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + } + + return $value; } if (is_callable($default)) { @@ -95,7 +109,7 @@ public static function remove(string $key): string|bool|null return null; } - if (!static::$is_decrypt[$key]) { + if (!(static::$is_decrypt[$key] ?? false)) { $old = Crypto::decrypt($_COOKIE[$key]); unset(static::$is_decrypt[$key]); @@ -123,14 +137,30 @@ public static function set( ): bool { $data = Crypto::encrypt(json_encode($data)); - return setcookie( - $key, - $data, - time() + $expiration, - config('session.path'), - config('session.domain'), - config('session.secure'), - config('session.httponly') - ); + return setcookie($key, $data, static::options($expiration)); + } + + /** + * Build the setcookie() options array from the session config. + * + * Every value is coerced to its declared type. config('session.domain') is + * null when SESSION_DOMAIN is unset; passing null straight to setcookie() + * is deprecated on PHP 8.x and a fatal TypeError on PHP 9, so cast here. + * + * @param int $expiration + * @return array + */ + private static function options(int $expiration): array + { + // config() with a second argument is a setter, not a getter-with-default, + // so read each value first and apply the fallback in PHP. + return [ + 'expires' => time() + $expiration, + 'path' => (string) (config('session.path') ?? '/'), + 'domain' => (string) (config('session.domain') ?? ''), + 'secure' => (bool) config('session.secure'), + 'httponly' => (bool) (config('session.httponly') ?? true), + 'samesite' => (string) (config('session.samesite') ?? 'Lax'), + ]; } } diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 4126b117..7e8ea351 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -44,12 +44,25 @@ use Carbon\Carbon; use Monolog\Logger; +/* + * Global helper functions. + * + * Each helper is wrapped in `if (!function_exists(...))` so an application may + * override any of them by declaring its own version before this file loads. + * Most helpers are thin shortcuts over a framework class; the section banners + * below mark where each topic begins. + */ + if (!function_exists('app')) { /** - * Application container + * Resolve the service container, or a binding out of it. + * + * With no arguments the container instance itself is returned; with a key + * the matching binding is resolved (using `$setting` as constructor + * parameters when provided). * - * @param ?string $key - * @param array $setting + * @param ?string $key Binding name to resolve, or null for the container + * @param array $setting Parameters passed to makeWith() when resolving * @return mixed */ function app(?string $key = null, array $setting = []): mixed @@ -60,6 +73,7 @@ function app(?string $key = null, array $setting = []): mixed return $capsule; } + // No extra parameters: a plain resolution is enough. if (empty($setting)) { return $capsule->make($key); } @@ -70,12 +84,15 @@ function app(?string $key = null, array $setting = []): mixed if (!function_exists('config')) { /** - * Application configuration + * Read or write application configuration. + * + * No key returns the configuration loader; a key alone reads the value; + * a key with a value writes (and returns) it. * - * @param ?string|null $key - * @param mixed|null $setting + * @param string|null $key Dotted configuration key + * @param mixed $setting Value to set, or null to read * @return Loader|mixed - * @throws + * @throws Exception */ function config(?string $key = null, mixed $setting = null): mixed { @@ -95,7 +112,7 @@ function config(?string $key = null, mixed $setting = null): mixed if (!function_exists('response')) { /** - * Response object instance + * Get the shared Response instance from the container. * * @return Response */ @@ -112,7 +129,7 @@ function response(): Response if (!function_exists('request')) { /** - * Represents the Request class + * Get the shared Request instance from the container. * * @return Request */ @@ -129,10 +146,16 @@ function request(): Request if (!function_exists('db')) { /** - * Allows to connect to another database and return the instance of the DB + * Get the database manager, optionally on another connection. + * + * With no arguments the current connection is returned. When `$cb` is + * given it runs against `$name`, then the previous connection is restored. + * + * Note: registered under the `db` guard but the function is named + * `app_db()`; call it as `app_db(...)`. * - * @param string|null $name - * @param callable|null $cb + * @param string|null $name Connection name to switch to + * @param callable|null $cb Work to run on that connection, then revert * @return DB * @throws ConnectionException */ @@ -163,15 +186,19 @@ function app_db(?string $name = null, ?callable $cb = null): DB if (!function_exists('view')) { /** - * View alias of View::parse + * Render a view template through View::parse(). * - * @param string $template - * @param array|int $data - * @param int $code + * `$data` may be passed as the status code directly (e.g. `view('404', 404)`), + * in which case it is treated as `$code` and the data set is left empty. + * + * @param string $template View name + * @param array|int $data View data, or the HTTP status code + * @param int $code HTTP status code * @return View */ function view(string $template, int|array $data = [], int $code = 200): View { + // Allow the status code to be supplied in the $data slot. if (is_int($data)) { $code = $data; @@ -187,13 +214,14 @@ function view(string $template, int|array $data = [], int $code = 200): View if (!function_exists('table')) { /** - * Table alias of DB::table + * Get a query builder for a table (optionally on another connection). * - * @param string $name - * @param ?string $connexion - * @return Bow\Database\QueryBuilder + * @param string $name Table name + * @param ?string $connexion Connection to switch to first + * @return QueryBuilder * @throws ConnectionException - * @deprecated + * @deprecated Use app_db_table() instead. + * @see app_db_table() */ function table(string $name, ?string $connexion = null): QueryBuilder { @@ -207,12 +235,12 @@ function table(string $name, ?string $connexion = null): QueryBuilder if (!function_exists('app_db_table')) { /** - * Table alias of DB::table + * Get a query builder for a table (optionally on another connection). * - * @param string $name - * @param ?string $connexion - * @return Bow\Database\QueryBuilder - * @throws ConnectionException + * @param string $name Table name + * @param ?string $connexion Connection to switch to first + * @return QueryBuilder + * @throws ConnectionException */ function app_db_table(string $name, ?string $connexion = null): QueryBuilder { @@ -229,7 +257,7 @@ function app_db_table(string $name, ?string $connexion = null): QueryBuilder * Returns the last ID following an INSERT query * on a table whose ID is auto_increment. * - * @param string|null $name + * @param string|null $name Sequence/connection name, if required * @return int */ function get_last_insert_id(?string $name = null): int @@ -240,12 +268,12 @@ function get_last_insert_id(?string $name = null): int if (!function_exists('app_db_select')) { /** - * Launches SELECT SQL Queries + * Run a raw SELECT query. * * app_db_select('SELECT * FROM users'); * - * @param string $sql - * @param array $data + * @param string $sql SQL statement, may contain bindings + * @param array $data Values bound to the statement * @return int|array|stdClass */ function app_db_select(string $sql, array $data = []): array|int|stdClass @@ -256,10 +284,10 @@ function app_db_select(string $sql, array $data = []): array|int|stdClass if (!function_exists('app_db_select_one')) { /** - * Launches SELECT SQL Queries + * Run a raw SELECT query and return a single row. * - * @param string $sql - * @param array $data + * @param string $sql SQL statement, may contain bindings + * @param array $data Values bound to the statement * @return int|array|StdClass */ function app_db_select_one(string $sql, array $data = []): array|int|StdClass @@ -270,11 +298,11 @@ function app_db_select_one(string $sql, array $data = []): array|int|StdClass if (!function_exists('app_db_insert')) { /** - * Launches INSERT SQL Queries + * Run a raw INSERT query. * - * @param string $sql - * @param array $data - * @return int + * @param string $sql SQL statement, may contain bindings + * @param array $data Values bound to the statement + * @return int Number of affected rows */ function app_db_insert(string $sql, array $data = []): int { @@ -284,11 +312,11 @@ function app_db_insert(string $sql, array $data = []): int if (!function_exists('app_db_delete')) { /** - * Launches DELETE type SQL queries + * Run a raw DELETE query. * - * @param string $sql - * @param array $data - * @return int + * @param string $sql SQL statement, may contain bindings + * @param array $data Values bound to the statement + * @return int Number of affected rows */ function app_db_delete(string $sql, array $data = []): int { @@ -298,11 +326,11 @@ function app_db_delete(string $sql, array $data = []): int if (!function_exists('app_db_update')) { /** - * Launches UPDATE SQL Queries + * Run a raw UPDATE query. * - * @param string $sql - * @param array $data - * @return int + * @param string $sql SQL statement, may contain bindings + * @param array $data Values bound to the statement + * @return int Number of affected rows */ function app_db_update(string $sql, array $data = []): int { @@ -312,9 +340,9 @@ function app_db_update(string $sql, array $data = []): int if (!function_exists('app_db_statement')) { /** - * Launches CREATE TABLE, ALTER TABLE, RENAME, DROP TABLE SQL Query + * Run a schema/DDL statement (CREATE, ALTER, RENAME, DROP, ...). * - * @param string $sql + * @param string $sql SQL statement * @return int */ function app_db_statement(string $sql): int @@ -325,9 +353,10 @@ function app_db_statement(string $sql): int if (!function_exists('debug')) { /** - * debug, variable debug function - * it allows you to have a color - * Synthetic data types. + * Dump one or more variables with colourised, typed output. + * + * Accepts any number of arguments; each is sanitised then handed to + * Util::debug(). * * @return void */ @@ -344,7 +373,7 @@ function ($x) { if (!function_exists("sep")) { /** - * Get the PHP OS separator + * Get the OS-specific directory separator. * * @return string */ @@ -356,10 +385,10 @@ function sep(): string if (!function_exists('create_csrf_token')) { /** - * Create a new token + * Create (or fetch) the current CSRF token payload. * - * @param int|null $time - * @return ?array + * @param int|null $time Lifetime in seconds for the generated token + * @return ?array The token data (token, field, expire_at), or null * @throws SessionException */ function create_csrf_token(?int $time = null): ?array @@ -370,10 +399,10 @@ function create_csrf_token(?int $time = null): ?array if (!function_exists('csrf_token')) { /** - * Get the generate token + * Get the current CSRF token string. * * @return string - * @throws HttpException + * @throws HttpException When no token could be generated * @throws SessionException */ function csrf_token(): string @@ -393,10 +422,11 @@ function csrf_token(): string if (!function_exists('csrf_field')) { /** - * Get the input csrf field + * Get the ready-made hidden CSRF input field. * * @return string - * @throws HttpException|SessionException + * @throws HttpException When no token could be generated + * @throws SessionException */ function csrf_field(): string { @@ -415,9 +445,9 @@ function csrf_field(): string if (!function_exists('method_field')) { /** - * Create hidden http method field + * Build a hidden input that spoofs the HTTP method (PUT, PATCH, DELETE). * - * @param string $method + * @param string $method HTTP verb to spoof * @return string */ function method_field(string $method): string @@ -430,7 +460,7 @@ function method_field(string $method): string if (!function_exists('gen_csrf_token')) { /** - * Generate token string + * Generate a fresh, standalone token string (not stored in the session). * * @return string */ @@ -442,10 +472,10 @@ function gen_csrf_token(): string if (!function_exists('verify_csrf')) { /** - * Check the token value + * Verify a submitted CSRF token against the stored one. * - * @param string $token - * @param bool $strict + * @param string $token Token received from the request + * @param bool $strict Also enforce token expiry when true * @return bool * @throws SessionException */ @@ -457,9 +487,9 @@ function verify_csrf(string $token, bool $strict = false): bool if (!function_exists('csrf_time_is_expired')) { /** - * Check if token is expired by time + * Check whether the stored CSRF token has expired. * - * @param string|null $time + * @param string|null $time Reference time, defaults to now * @return bool * @throws SessionException */ @@ -471,11 +501,11 @@ function csrf_time_is_expired(?string $time = null): bool if (!function_exists('response_json')) { /** - * Make json response + * Send a JSON response. * - * @param array|object $data - * @param int $code - * @param array $headers + * @param array|object $data Payload to encode + * @param int $code HTTP status code + * @param array $headers Extra response headers * @return string */ function response_json(array|object $data, int $code = 200, array $headers = []): string @@ -486,11 +516,11 @@ function response_json(array|object $data, int $code = 200, array $headers = []) if (!function_exists('response_download')) { /** - * Download file + * Send a file as a download response. * - * @param string $file - * @param null|string $filename - * @param array $headers + * @param string $file Path to the file on disk + * @param null|string $filename Name presented to the client + * @param array $headers Extra response headers * @return string */ function response_download(string $file, ?string $filename = null, array $headers = []): string @@ -501,7 +531,7 @@ function response_download(string $file, ?string $filename = null, array $header if (!function_exists('set_response_status_code')) { /** - * Set status code + * Set the HTTP response status code. * * @param int $code * @return mixed @@ -514,7 +544,7 @@ function set_response_status_code(int $code): mixed if (!function_exists('sanitize')) { /** - * Sanitize data + * Sanitize a value (numeric values are returned untouched). * * @param mixed $data * @return mixed @@ -531,7 +561,7 @@ function sanitize(mixed $data): mixed if (!function_exists('secure')) { /** - * Secure data with sanitize it + * Sanitize a value in strict/secure mode (numeric values pass through). * * @param mixed $data * @return mixed @@ -548,7 +578,7 @@ function secure(mixed $data): mixed if (!function_exists('set_response_header')) { /** - * Update http headers + * Add a header to the outgoing response. * * @param string $key * @param string $value @@ -562,7 +592,7 @@ function set_response_header(string $key, string $value): void if (!function_exists('get_response_header')) { /** - * Get http header + * Read a header from the incoming request. * * @param string $key * @return string|null @@ -575,9 +605,9 @@ function get_response_header(string $key): ?string if (!function_exists('redirect')) { /** - * Make redirect response + * Get the redirector, optionally redirecting straight to a path. * - * @param string|null $path + * @param string|null $path Target to redirect to, or null for the instance * @return Redirect */ function redirect(?string $path = null): Redirect @@ -594,16 +624,20 @@ function redirect(?string $path = null): Redirect if (!function_exists('url')) { /** - * Build url + * Build an absolute URL from the current request base. * - * @param string|array|null $url - * @param array $parameters + * Passing an array as the first argument is treated as the query string + * parameters (the path is then the current URL). + * + * @param string|array $url Path to append, or query parameters + * @param array $parameters Query string parameters * @return string */ function url(string|array $url = '', array $parameters = []): string { $current = trim(request()->url(), '/'); + // First argument given as parameters: keep the current path. if (is_array($url)) { $parameters = $url; @@ -624,7 +658,7 @@ function url(string|array $url = '', array $parameters = []): string if (!function_exists('pdo')) { /** - * Get database PDO instance + * Get the underlying PDO instance. * * @return PDO */ @@ -636,10 +670,10 @@ function pdo(): PDO if (!function_exists('set_pdo')) { /** - * Set PDO instance + * Replace the underlying PDO instance. * * @param PDO $pdo - * @return PDO + * @return PDO The newly set instance */ function set_pdo(PDO $pdo): PDO { @@ -650,9 +684,8 @@ function set_pdo(PDO $pdo): PDO } if (!function_exists('collect')) { - /** - * Create new Collection instance + * Wrap an array in a Collection. * * @param array $data * @return Collection @@ -695,9 +728,11 @@ function decrypt(string $data): string|bool } } +// ===== Database: transactions ===== + if (!function_exists('app_db_transaction')) { /** - * Start Database transaction + * Begin a database transaction. * * @return void */ @@ -709,7 +744,7 @@ function app_db_transaction(): void if (!function_exists('app_db_transaction_started')) { /** - * Check if database transaction + * Check whether a database transaction is currently open. * * @return bool */ @@ -721,7 +756,7 @@ function app_db_transaction_started(): bool if (!function_exists('app_db_rollback')) { /** - * Stop database transaction + * Roll back the current database transaction. * * @return void */ @@ -733,7 +768,7 @@ function app_db_rollback(): void if (!function_exists('app_db_commit')) { /** - * Commit request after transaction + * Commit the current database transaction. * * @return void */ @@ -745,8 +780,12 @@ function app_db_commit(): void if (!function_exists('event')) { /** - * Event + * Get the event dispatcher, or emit an event. + * + * Called with no arguments it returns the dispatcher; otherwise the first + * argument is the event name and the rest are passed to its listeners. * + * @param mixed ...$args Event name followed by its payload * @return mixed */ function event(): mixed @@ -765,9 +804,11 @@ function event(): mixed if (!function_exists('app_event')) { /** - * Event + * Get the event dispatcher, or emit an event. * + * @param mixed ...$args Event name followed by its payload * @return mixed + * @see event() Identical behaviour; event() is the preferred name. */ function app_event(): mixed { @@ -785,10 +826,10 @@ function app_event(): mixed if (!function_exists('flash')) { /** - * Flash session + * Store a one-request flash message in the session. * - * @param string $key - * @param string $message + * @param string $key Flash key + * @param string $message Message to store * @return mixed * @throws SessionException */ @@ -801,12 +842,13 @@ function flash(string $key, string $message): mixed if (!function_exists('app_flash')) { /** - * Flash session + * Store a one-request flash message in the session. * - * @param string $key - * @param string $message + * @param string $key Flash key + * @param string $message Message to store * @return mixed * @throws SessionException + * @see flash() Identical behaviour; flash() is the preferred name. */ function app_flash(string $key, string $message): mixed { @@ -817,11 +859,14 @@ function app_flash(string $key, string $message): mixed if (!function_exists('email')) { /** - * Send email + * Send an email, or get the mailer instance. * - * @param null|string $view - * @param array $data - * @param callable|null $cb + * With no view the mailer instance is returned; otherwise the view is + * rendered and sent. + * + * @param null|string $view View name for the message body + * @param array $data Data bound to the view + * @param callable|null $cb Builder callback to configure the message * @return MailAdapterInterface|bool */ function email( @@ -839,12 +884,13 @@ function email( if (!function_exists('app_email')) { /** - * Send email + * Send an email, or get the mailer instance. * - * @param null|string $view - * @param array $data - * @param callable|null $cb + * @param null|string $view View name for the message body + * @param array $data Data bound to the view + * @param callable|null $cb Builder callback to configure the message * @return MailAdapterInterface|bool + * @see email() Identical behaviour; email() is the preferred name. */ function app_email( ?string $view = null, @@ -861,12 +907,12 @@ function app_email( if (!function_exists('raw_email')) { /** - * Send raw email + * Send a plain (non-templated) email. * - * @param string $to - * @param string $subject - * @param string $message - * @param array $headers + * @param string $to Recipient address + * @param string $subject Subject line + * @param string $message Message body + * @param array $headers Extra mail headers * @return bool */ function raw_email(string $to, string $subject, string $message, array $headers = []): bool @@ -877,10 +923,10 @@ function raw_email(string $to, string $subject, string $message, array $headers if (!function_exists('session')) { /** - * Session help + * Get the session manager, or read a session value. * - * @param array|string|null $value - * @param mixed $default + * @param string|null $key Key to read, or null for the manager + * @param mixed $default Value returned when the key is absent * @return mixed * @throws SessionException */ @@ -896,11 +942,14 @@ function session(?string $key = null, mixed $default = null): mixed if (!function_exists('cookie')) { /** - * Cooke alias + * Read or write cookies. + * + * No key returns all cookies; a key alone reads one; a key with data + * writes it. * - * @param string|null $key - * @param mixed $data - * @param int $expiration + * @param string|null $key Cookie name + * @param mixed $data Value to write, or null to read + * @param int $expiration Lifetime in seconds when writing * @return string|array|object|null */ function cookie( @@ -922,11 +971,11 @@ function cookie( if (!function_exists('validator')) { /** - * Validate the information on the well-defined criterion + * Validate input against a set of rules. * - * @param array $inputs - * @param array $rules - * @param array $messages + * @param array $inputs Data to validate + * @param array $rules Validation rules keyed by field + * @param array $messages Custom error messages * @return Validate */ function validator(array $inputs, array $rules, array $messages = []): Validate @@ -937,15 +986,21 @@ function validator(array $inputs, array $rules, array $messages = []): Validate if (!function_exists('route')) { /** - * Get Route by name + * Build a URL for a named route. + * + * Named placeholders in the route are filled from `$data`; leftover + * entries become the query string. Passing a bool as `$data` is treated + * as the `$absolute` flag. * - * @param string $name - * @param bool|array $data - * @param bool $absolute + * @param string $name Route name + * @param bool|array $data Placeholder values, or the absolute flag + * @param bool $absolute Prefix with APP_URL when true * @return string + * @throws InvalidArgumentException When the route or a placeholder is missing */ function route(string $name, bool|array $data = [], bool $absolute = false): string { + // Allow route('name', true) to mean "absolute, no parameters". if (is_bool($data)) { $absolute = $data; $data = []; @@ -960,6 +1015,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str ); } + // Substitute :placeholders (optional ones end with "?"). if (preg_match_all('/:([a-zA-Z0-9_]+\??)/', $url, $matches)) { $keys = end($matches); foreach ($keys as $key) { @@ -979,6 +1035,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str } } + // Remaining data becomes the query string. if (count($data) > 0) { $url = $url . '?' . http_build_query($data); } @@ -995,7 +1052,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str if (!function_exists('e')) { /** - * Escape the HTML tags in the chain. + * Escape HTML special characters in a string. * * @param ?string $value * @return string @@ -1008,9 +1065,9 @@ function e(?string $value = null): string if (!function_exists('storage_service')) { /** - * Service loader + * Resolve a remote storage service (FTP, S3, ...). * - * @param string $service + * @param string $service Service name * @return FTPService|S3Service * @throws ServiceConfigurationNotFoundException * @throws ServiceNotFoundException @@ -1023,9 +1080,9 @@ function storage_service(string $service): S3Service|FTPService if (!function_exists('app_storage')) { /** - * Alias on the mount method + * Get a local filesystem disk. * - * @param string $disk + * @param string $disk Disk name * @return DiskFilesystemService * @throws DiskNotFoundException */ @@ -1037,11 +1094,14 @@ function app_storage(string $disk): DiskFilesystemService if (!function_exists('cache')) { /** - * Cache help + * Get the cache instance, or read/write a cache entry. * - * @param ?string $key - * @param mixed $value - * @param ?int $ttl + * No key returns the cache instance; a key alone reads it; a key with a + * value stores it for `$ttl` seconds. + * + * @param ?string $key Cache key + * @param mixed $value Value to store, or null to read + * @param ?int $ttl Time-to-live in seconds when writing * @return mixed * @throws ErrorException */ @@ -1063,9 +1123,9 @@ function cache(?string $key = null, mixed $value = null, ?int $ttl = null): mixe if (!function_exists('redirect_back')) { /** - * Make redirection to back + * Redirect to the previous page. * - * @param int $status + * @param int $status HTTP status code * @return Redirect */ function redirect_back(int $status = 302): Redirect @@ -1076,7 +1136,7 @@ function redirect_back(int $status = 302): Redirect if (!function_exists('app_now')) { /** - * Get the current carbon + * Get the current time as a Carbon instance. * * @return Carbon */ @@ -1088,11 +1148,14 @@ function app_now(): Carbon if (!function_exists('app_hash')) { /** - * Alias on the class Hash. + * Hash a value, or verify one against an existing hash. * - * @param string $data - * @param mixed $hash_value - * @return bool|string + * With `$hash_value` it checks the value against the hash; otherwise it + * returns a new hash. + * + * @param string $data Value to hash or verify + * @param string|null $hash_value Existing hash to verify against + * @return bool|string Boolean when verifying, string when hashing */ function app_hash(string $data, ?string $hash_value = null): bool|string { @@ -1106,12 +1169,13 @@ function app_hash(string $data, ?string $hash_value = null): bool|string if (!function_exists('bow_hash')) { /** - * Alias on the class Hash. + * Hash a value, or verify one against an existing hash. * - * @param string $data - * @param mixed $hash_value + * @param string $data Value to hash or verify + * @param string|null $hash_value Existing hash to verify against * @return bool|string - * @deprecated + * @deprecated Use app_hash() instead. + * @see app_hash() */ function bow_hash(string $data, ?string $hash_value = null): bool|string { @@ -1121,11 +1185,14 @@ function bow_hash(string $data, ?string $hash_value = null): bool|string if (!function_exists('app_trans')) { /** - * Make translation + * Translate a key, or get the translator instance. + * + * No key returns the translator. Passing a bool as `$data` is treated as + * the `$choose` (pluralisation) flag. * - * @param string|null $key - * @param array $data - * @param bool $choose + * @param string|null $key Translation key + * @param array $data Replacement values + * @param bool $choose Pluralisation flag * @return string|Translator */ function app_trans( @@ -1148,12 +1215,13 @@ function app_trans( if (!function_exists('t')) { /** - * Alias of trans + * Translate a key. * - * @param string $key - * @param array $data - * @param bool $choose + * @param string $key Translation key + * @param array $data Replacement values + * @param bool $choose Pluralisation flag * @return string|Translator + * @see app_trans() */ function t( string $key, @@ -1166,12 +1234,13 @@ function t( if (!function_exists('__')) { /** - * Alias of trans + * Translate a key. * - * @param string $key - * @param array $data - * @param bool $choose + * @param string $key Translation key + * @param array $data Replacement values + * @param bool $choose Pluralisation flag * @return string|Translator + * @see app_trans() */ function __( string $key, @@ -1184,10 +1253,12 @@ function __( if (!function_exists('app_env')) { /** - * Gets the app environment variable + * Read an environment variable. * - * @param string $key - * @param mixed $default + * Returns `$default` when the environment has not been loaded yet. + * + * @param string $key Variable name + * @param mixed $default Fallback value * @return ?string */ function app_env(string $key, mixed $default = null): ?string @@ -1208,9 +1279,9 @@ function app_env(string $key, mixed $default = null): ?string if (!function_exists('app_assets')) { /** - * Gets the app assets + * Build a public URL for an asset under the asset prefix. * - * @param string $filename + * @param string $filename Asset path relative to the asset root * @return string */ function app_assets(string $filename): string @@ -1221,12 +1292,14 @@ function app_assets(string $filename): string if (!function_exists('app_abort')) { /** - * Abort bow execution + * Abort the request with an HTTP error. + * + * Falls back to the standard status message when none is given. * - * @param int $code - * @param string $message + * @param int $code HTTP status code + * @param string $message Error message * @return Response - * @throws HttpException + * @throws HttpException Always thrown to interrupt execution */ function app_abort(int $code = 500, string $message = ''): Response { @@ -1240,13 +1313,13 @@ function app_abort(int $code = 500, string $message = ''): Response if (!function_exists('app_abort_if')) { /** - * Abort bow execution if condition is true + * Abort the request only when the given condition is true. * - * @param boolean $boolean - * @param int $code - * @param string $message - * @return Response|null - * @throws HttpException + * @param bool $boolean Condition that triggers the abort + * @param int $code HTTP status code + * @param string $message Error message + * @return Response|null Null when the condition is false + * @throws HttpException When the condition is true */ function app_abort_if( bool $boolean, @@ -1263,7 +1336,7 @@ function app_abort_if( if (!function_exists('app_mode')) { /** - * Get app environment mode + * Get the current application environment (lower-cased APP_ENV). * * @return string */ @@ -1275,7 +1348,7 @@ function app_mode(): string if (!function_exists('app_in_debug')) { /** - * Get app environment mode + * Determine whether debug mode (APP_DEBUG) is enabled. * * @return bool */ @@ -1287,7 +1360,7 @@ function app_in_debug(): bool if (!function_exists('client_locale')) { /** - * Get client request language + * Get the client's preferred request language. * * @return ?string */ @@ -1299,10 +1372,10 @@ function client_locale(): ?string if (!function_exists('old')) { /** - * Get old request value + * Get a value submitted on the previous request. * - * @param string $key - * @param mixed $fullback + * @param string $key Input field name + * @param mixed $fullback Value returned when the field is absent * @return mixed */ function old(string $key, mixed $fullback = null): mixed @@ -1313,12 +1386,13 @@ function old(string $key, mixed $fullback = null): mixed if (!function_exists('auth')) { /** - * Recovery of the guard + * Get the auth manager, or a specific guard. * - * @param string|null $guard + * @param string|null $guard Guard name, or null for the manager * @return GuardContract * @throws AuthenticationException - * @deprecated + * @deprecated Use app_auth() instead. + * @see app_auth() */ function auth(?string $guard = null): GuardContract { @@ -1334,9 +1408,9 @@ function auth(?string $guard = null): GuardContract if (!function_exists('app_auth')) { /** - * Recovery of the guard + * Get the auth manager, or a specific guard. * - * @param string|null $guard + * @param string|null $guard Guard name, or null for the manager * @return GuardContract * @throws AuthenticationException */ @@ -1354,7 +1428,7 @@ function app_auth(?string $guard = null): GuardContract if (!function_exists('logger')) { /** - * Log error message + * Get the application logger. * * @return Logger */ @@ -1366,9 +1440,10 @@ function logger(): Logger if (!function_exists('app_logger')) { /** - * Log error message + * Get the application logger. * * @return Logger + * @see logger() Identical behaviour; logger() is the preferred name. */ function app_logger(): Logger { @@ -1379,10 +1454,10 @@ function app_logger(): Logger if (!function_exists('str_slug')) { /** - * Slugify + * Convert a string into a URL-friendly slug. * - * @param string $str - * @param string $sep + * @param string $str String to slugify + * @param string $sep Word separator * @return string */ function str_slug(string $str, string $sep = '-'): string @@ -1393,7 +1468,7 @@ function str_slug(string $str, string $sep = '-'): string if (!function_exists('str_is_mail')) { /** - * Check if the email is valid + * Check whether a string is a valid email address. * * @param string $email * @return bool @@ -1406,7 +1481,7 @@ function str_is_mail(string $email): bool if (!function_exists('str_uuid')) { /** - * Get str uuid + * Generate a UUID string. * * @return string */ @@ -1418,11 +1493,11 @@ function str_uuid(): string if (!function_exists('str_is_domain')) { /** - * Check if the string is domain + * Check whether a string is a valid domain name. * * @param string $domain * @return bool - * @throws + * @throws Exception */ function str_is_domain(string $domain): bool { @@ -1432,7 +1507,7 @@ function str_is_domain(string $domain): bool if (!function_exists('str_is_slug')) { /** - * Check if string is slug + * Check whether a string is a valid slug. * * @param string $slug * @return string @@ -1445,11 +1520,11 @@ function str_is_slug(string $slug): string if (!function_exists('str_is_alpha')) { /** - * Check if the string is alpha + * Check whether a string contains only alphabetic characters. * * @param string $string * @return bool - * @throws + * @throws Exception */ function str_is_alpha(string $string): bool { @@ -1459,7 +1534,7 @@ function str_is_alpha(string $string): bool if (!function_exists('str_is_lower')) { /** - * Check if the string is lower + * Check whether a string is entirely lower-case. * * @param string $string * @return bool @@ -1472,7 +1547,7 @@ function str_is_lower(string $string): bool if (!function_exists('str_is_upper')) { /** - * Check if the string is upper + * Check whether a string is entirely upper-case. * * @param string $string * @return bool @@ -1485,11 +1560,11 @@ function str_is_upper(string $string): bool if (!function_exists('str_is_alpha_num')) { /** - * Check if string is alphanumeric + * Check whether a string is alphanumeric. * * @param string $slug * @return bool - * @throws + * @throws Exception */ function str_is_alpha_num(string $slug): bool { @@ -1499,7 +1574,7 @@ function str_is_alpha_num(string $slug): bool if (!function_exists('str_shuffle_words')) { /** - * Shuffle words + * Randomly shuffle the words of a string. * * @param string $words * @return string @@ -1512,10 +1587,10 @@ function str_shuffle_words(string $words): string if (!function_exists('str_wordily')) { /** - * Return the array contains the word of the passed string + * Split a string into an array of its words. * - * @param string $words - * @param string $sep + * @param string $words String to split + * @param string $sep Separator to split on * @return array */ function str_wordily(string $words, string $sep = ''): array @@ -1526,7 +1601,7 @@ function str_wordily(string $words, string $sep = ''): array if (!function_exists('str_plural')) { /** - * Transform text to str_plural + * Pluralise a word. * * @param string $slug * @return string @@ -1539,7 +1614,7 @@ function str_plural(string $slug): string if (!function_exists('str_camel')) { /** - * Transform text to camel case + * Convert a string to camelCase. * * @param string $slug * @return string @@ -1552,7 +1627,7 @@ function str_camel(string $slug): string if (!function_exists('str_snake')) { /** - * Transform text to snake case + * Convert a string to snake_case. * * @param string $slug * @return string @@ -1565,10 +1640,10 @@ function str_snake(string $slug): string if (!function_exists('str_contains')) { /** - * Check if string contain another string + * Check whether a string contains another string. * - * @param string $search - * @param string $string + * @param string $search Needle to look for + * @param string $string Haystack to search in * @return bool */ function str_contains(string $search, string $string): bool @@ -1579,7 +1654,7 @@ function str_contains(string $search, string $string): bool if (!function_exists('str_capitalize')) { /** - * Capitalize + * Capitalise a string. * * @param string $slug * @return string @@ -1592,9 +1667,9 @@ function str_capitalize(string $slug): string if (!function_exists('str_random')) { /** - * Random string + * Generate a random string. * - * @param string $string + * @param string $string Length or seed forwarded to Str::random() * @return string */ function str_random(string $string): string @@ -1605,7 +1680,7 @@ function str_random(string $string): string if (!function_exists('str_force_in_utf8')) { /** - * Force output string to utf8 + * Force string output to UTF-8 globally. * * @return void */ @@ -1617,7 +1692,7 @@ function str_force_in_utf8(): void if (!function_exists('str_fix_utf8')) { /** - * Force output string to utf8 + * Repair a malformed UTF-8 string. * * @param string $string * @return string @@ -1630,15 +1705,20 @@ function str_fix_utf8(string $string): string if (!function_exists('app_db_seed')) { /** - * Make programmatic seeding + * Seed data programmatically. * - * @param string $name - * @param array $data - * @return int|array - * @throws ErrorException + * When `$name` is a Model class, `$data` is inserted into its table. + * Otherwise `$name` is resolved to a seeder file whose returned map of + * table => rows is inserted (each row merged with `$data`). + * + * @param string $name Model class name or seeder file name + * @param array $data Rows to insert, or values merged into each row + * @return int|array Affected rows, or one result per seeded table + * @throws ErrorException When the seeder file cannot be found */ function app_db_seed(string $name, array $data = []): int|array { + // A model class: insert straight into its table. if (class_exists($name)) { $instance = app($name); @@ -1658,6 +1738,7 @@ function app_db_seed(string $name, array $data = []): int|array $seeds = array_merge($seeds, []); $collections = []; + // Each entry maps a table (or model class) to its rows. foreach ($seeds as $table => $payload) { if (class_exists($table)) { $instance = app($table); @@ -1677,6 +1758,9 @@ function app_db_seed(string $name, array $data = []): int|array /** * Determine if the given value is "blank". * + * Null, an empty/whitespace string, and an empty Countable are blank; + * numbers and booleans never are. + * * @param mixed $value * @return bool */ @@ -1704,9 +1788,10 @@ function is_blank(mixed $value): bool if (!function_exists("queue")) { /** - * Push the producer on queue + * Push a task onto the queue. * - * @param QueueTask $producer + * @param QueueTask $producer Task to enqueue + * @return void */ function queue(QueueTask $producer): void { diff --git a/tests/Auth/RememberMeTest.php b/tests/Auth/RememberMeTest.php new file mode 100644 index 00000000..eb0e296b --- /dev/null +++ b/tests/Auth/RememberMeTest.php @@ -0,0 +1,250 @@ +insert([ + 'name' => 'Franck', + 'password' => Hash::make('password'), + 'username' => 'papac', + ]); + } + + public static function tearDownAfterClass(): void + { + Database::statement("DROP TABLE IF EXISTS users"); + } + + protected function setUp(): void + { + ob_start(); + $_COOKIE = []; + Database::table('users')->where('username', 'papac')->update(['remember_token' => null]); + } + + protected function tearDown(): void + { + ob_get_clean(); + } + + public function test_remember_token_name_defaults_to_remember_token() + { + $user = UserModelStub::first(); + $this->assertSame('remember_token', $user->getRememberTokenName()); + } + + public function test_set_and_get_remember_token_persists() + { + $user = UserModelStub::first(); + $this->assertNull($user->getRememberToken()); + + $user->setRememberToken('abc123'); + + $this->assertSame('abc123', $user->getRememberToken()); + $fresh = UserModelStub::first(); + $this->assertSame('abc123', $fresh->getRememberToken()); + } + + public function test_remember_token_can_be_cleared_to_null() + { + $user = UserModelStub::first(); + $user->setRememberToken('to-be-cleared'); + $this->assertSame('to-be-cleared', UserModelStub::first()->getRememberToken()); + + $user->setRememberToken(null); + + $this->assertNull($user->getRememberToken()); + $this->assertNull(UserModelStub::first()->getRememberToken()); + } + + public function test_get_user_by_id_returns_the_user() + { + $auth = Auth::guard('web'); + $expected = UserModelStub::first(); + + $method = new \ReflectionMethod($auth, 'getUserById'); + $method->setAccessible(true); + $user = $method->invoke($auth, $expected->getAuthenticateUserId()); + + $this->assertNotNull($user); + $this->assertSame( + $expected->getAuthenticateUserId(), + $user->getAuthenticateUserId() + ); + } + + public function test_get_user_by_id_returns_null_for_unknown_id() + { + $auth = Auth::guard('web'); + + $method = new \ReflectionMethod($auth, 'getUserById'); + $method->setAccessible(true); + + $this->assertNull($method->invoke($auth, 999999)); + } + + public function test_jwt_guard_accepts_but_ignores_remember_flag() + { + $auth = Auth::guard('api'); + + $result = $auth->attempts([ + 'username' => 'papac', + 'password' => 'password', + ], true); + + $this->assertTrue($result); + // The JWT guard must not set any cookie when remember=true. + $this->assertEmpty($_COOKIE); + } + + private function rememberCookieValue(int $id, string $token): string + { + // Mirror Cookie::set()'s encoding (json_encode then Crypto::encrypt) so + // tests exercise the same payload shape attemptRememberLogin() will read. + return Crypto::encrypt(json_encode($id . '|' . $token)); + } + + public function test_attempts_with_remember_persists_token() + { + $auth = Auth::guard('web'); + + $result = $auth->attempts([ + 'username' => 'papac', + 'password' => 'password', + ], true); + + $this->assertTrue($result); + $this->assertNotNull(UserModelStub::first()->getRememberToken()); + } + + public function test_attempts_without_remember_leaves_token_null() + { + $auth = Auth::guard('web'); + + $auth->attempts([ + 'username' => 'papac', + 'password' => 'password', + ], false); + + $this->assertNull(UserModelStub::first()->getRememberToken()); + } + + public function test_check_restores_session_from_valid_remember_cookie() + { + $auth = Auth::guard('web'); + $user = UserModelStub::first(); + $user->setRememberToken('valid-token-123'); + + Session::getInstance()->remove('_auth_web'); + $_COOKIE['remember_web'] = $this->rememberCookieValue( + (int) $user->getAuthenticateUserId(), + 'valid-token-123' + ); + + $this->assertTrue($auth->check()); + $this->assertSame('papac', $auth->user()->username); + } + + public function test_check_rejects_tampered_token_and_clears_cookie() + { + $auth = Auth::guard('web'); + $user = UserModelStub::first(); + $user->setRememberToken('the-real-token'); + + Session::getInstance()->remove('_auth_web'); + $_COOKIE['remember_web'] = $this->rememberCookieValue( + (int) $user->getAuthenticateUserId(), + 'WRONG-token' + ); + + $this->assertFalse($auth->check()); + $this->assertArrayNotHasKey('remember_web', $_COOKIE); + } + + public function test_check_rejects_unknown_user_and_clears_cookie() + { + $auth = Auth::guard('web'); + + Session::getInstance()->remove('_auth_web'); + $_COOKIE['remember_web'] = $this->rememberCookieValue(999999, 'whatever'); + + $this->assertFalse($auth->check()); + $this->assertArrayNotHasKey('remember_web', $_COOKIE); + } + + public function test_check_rejects_malformed_cookie_without_delimiter() + { + $auth = Auth::guard('web'); + + Session::getInstance()->remove('_auth_web'); + // A well-encrypted cookie whose payload has no "|" delimiter. + $_COOKIE['remember_web'] = Crypto::encrypt(json_encode('garbage-no-pipe')); + + $this->assertFalse($auth->check()); + $this->assertArrayNotHasKey('remember_web', $_COOKIE); + } + + public function test_logout_regenerates_token_and_removes_cookie() + { + $auth = Auth::guard('web'); + $user = UserModelStub::first(); + $user->setRememberToken('token-before'); + + Session::getInstance()->remove('_auth_web'); + $_COOKIE['remember_web'] = $this->rememberCookieValue( + (int) $user->getAuthenticateUserId(), + 'token-before' + ); + $this->assertTrue($auth->check()); + + $this->assertTrue($auth->logout()); + + $this->assertArrayNotHasKey('remember_web', $_COOKIE); + $this->assertNotSame('token-before', UserModelStub::first()->getRememberToken()); + } +} diff --git a/tests/Session/CookieTest.php b/tests/Session/CookieTest.php new file mode 100644 index 00000000..fefeea38 --- /dev/null +++ b/tests/Session/CookieTest.php @@ -0,0 +1,96 @@ +setAccessible(true); + + return $method->invoke(null, $expiration); + } + + /** + * The stub session config sets domain to null (SESSION_DOMAIN unset). It + * must reach setcookie() as a string, never null — that null is what would + * deprecate on PHP 8.x and fatally throw on PHP 9. + */ + public function test_null_domain_is_coerced_to_empty_string() + { + $options = $this->options(3600); + + $this->assertSame('', $options['domain']); + $this->assertIsString($options['domain']); + } + + /** + * Every option must carry its declared scalar type so setcookie() never + * receives a null in a non-nullable slot. + */ + public function test_options_are_strictly_typed() + { + $options = $this->options(3600); + + $this->assertIsInt($options['expires']); + $this->assertIsString($options['path']); + $this->assertIsString($options['domain']); + $this->assertIsBool($options['secure']); + $this->assertIsBool($options['httponly']); + $this->assertIsString($options['samesite']); + } + + /** + * Values come from the session config; SameSite falls back to Lax. + */ + public function test_options_reflect_session_config() + { + $options = $this->options(3600); + + $this->assertSame('/', $options['path']); + $this->assertFalse($options['secure']); + // The stub config explicitly sets httponly to false. + $this->assertFalse($options['httponly']); + $this->assertSame('Lax', $options['samesite']); + } + + /** + * Cookie::remove() clears by delegating to set() with a negative lifetime. + * The clearing cookie must therefore carry the same path/domain/samesite + * attributes (browsers require a matching attribute set to overwrite), and + * its expiry must land in the past. + */ + public function test_clearing_cookie_keeps_matching_attributes_and_past_expiry() + { + $live = $this->options(3600); + $clear = $this->options(-1000); + + $this->assertLessThan($live['expires'], $clear['expires']); + $this->assertLessThan(time(), $clear['expires']); + + $this->assertSame($live['path'], $clear['path']); + $this->assertSame($live['domain'], $clear['domain']); + $this->assertSame($live['samesite'], $clear['samesite']); + $this->assertSame($live['secure'], $clear['secure']); + $this->assertSame($live['httponly'], $clear['httponly']); + } +}