diff --git a/auth/Auth.php b/auth/Auth.php new file mode 100644 index 0000000..7aa2e7a --- /dev/null +++ b/auth/Auth.php @@ -0,0 +1,319 @@ +provider = $provider; + $this->session = $session; + $this->config = array_merge([ + 'cookie_name' => self::REMEMBER_COOKIE, + 'cookie_lifetime' => self::REMEMBER_DURATION, + 'cookie_path' => '/', + 'cookie_domain' => '', + 'cookie_secure' => false, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Lax' + ], $config); + + $this->initializeFromSession(); + } + + /** + * Attempt to authenticate user with credentials + */ + public function attempt(array $credentials, bool $remember = false): bool + { + $identifier = $credentials['email'] ?? $credentials['username'] ?? null; + $password = $credentials['password'] ?? null; + + if (!$identifier || !$password) { + return false; + } + + $user = $this->provider->findByCredentials($identifier); + + if (!$user || !$this->provider->verifyPassword($user, $password)) { + return false; + } + + $this->login($user, $remember); + return true; + } + + /** + * Login a user instance + */ + public function login(User $user, bool $remember = false): void + { + $this->user = $user; + $this->session->set(self::SESSION_KEY, $user->getId()); + $this->session->regenerate(); + + if ($remember) { + $this->createRememberToken($user); + } + } + + /** + * Login using user ID + */ + public function loginById(int|string $id, bool $remember = false): bool + { + $user = $this->provider->findById($id); + + if (!$user) { + return false; + } + + $this->login($user, $remember); + return true; + } + + /** + * Logout the current user + */ + public function logout(): void + { + if ($this->user && $this->user->rememberToken) { + $this->provider->updateRememberToken($this->user, null); + } + + $this->user = null; + $this->session->remove(self::SESSION_KEY); + $this->session->regenerate(); + $this->clearRememberCookie(); + } + + /** + * Check if user is authenticated + */ + public function check(): bool + { + return $this->user() !== null; + } + + /** + * Check if user is guest (not authenticated) + */ + public function guest(): bool + { + return !$this->check(); + } + + /** + * Get the currently authenticated user + */ + public function user(): ?User + { + if ($this->user) { + return $this->user; + } + + // Try to load from session + $userId = $this->session->get(self::SESSION_KEY); + if ($userId) { + $this->user = $this->provider->findById($userId); + return $this->user; + } + + // Try to load from remember cookie + $this->user = $this->getUserFromRememberCookie(); + if ($this->user) { + $this->session->set(self::SESSION_KEY, $this->user->getId()); + } + + return $this->user; + } + + /** + * Get user ID + */ + public function id(): int|string|null + { + return $this->user()?->getId(); + } + + /** + * Register a new user + */ + public function register(array $data, bool $login = true): User + { + // Hash password before storing + if (isset($data['password'])) { + $data['password'] = $this->hashPassword($data['password']); + } + + $user = $this->provider->create($data); + + if ($login) { + $this->login($user); + } + + return $user; + } + + /** + * Validate user credentials without logging in + */ + public function validate(array $credentials): bool + { + $identifier = $credentials['email'] ?? $credentials['username'] ?? null; + $password = $credentials['password'] ?? null; + + if (!$identifier || !$password) { + return false; + } + + $user = $this->provider->findByCredentials($identifier); + + return $user && $this->provider->verifyPassword($user, $password); + } + + /** + * Hash a password + */ + public function hashPassword(string $password): string + { + return password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]); + } + + /** + * Verify a password against hash + */ + public function verifyPassword(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Initialize user from session + */ + private function initializeFromSession(): void + { + if (!$this->session->isStarted()) { + $this->session->start(); + } + + $this->user(); + } + + /** + * Create remember token for user + */ + private function createRememberToken(User $user): void + { + $token = $this->generateRememberToken(); + $hashedToken = hash('sha256', $token); + + $this->provider->updateRememberToken($user, $hashedToken); + $user->rememberToken = $hashedToken; + + $this->setRememberCookie($user->getId() . '|' . $token); + } + + /** + * Generate a random remember token + */ + private function generateRememberToken(): string + { + return bin2hex(random_bytes(32)); + } + + /** + * Set remember cookie + */ + private function setRememberCookie(string $value): void + { + setcookie( + $this->config['cookie_name'], + $value, + [ + 'expires' => time() + $this->config['cookie_lifetime'], + 'path' => $this->config['cookie_path'], + 'domain' => $this->config['cookie_domain'], + 'secure' => $this->config['cookie_secure'], + 'httponly' => $this->config['cookie_httponly'], + 'samesite' => $this->config['cookie_samesite'] + ] + ); + } + + /** + * Clear remember cookie + */ + private function clearRememberCookie(): void + { + setcookie( + $this->config['cookie_name'], + '', + [ + 'expires' => time() - 3600, + 'path' => $this->config['cookie_path'], + 'domain' => $this->config['cookie_domain'], + 'secure' => $this->config['cookie_secure'], + 'httponly' => $this->config['cookie_httponly'], + 'samesite' => $this->config['cookie_samesite'] + ] + ); + } + + /** + * Get user from remember cookie + */ + private function getUserFromRememberCookie(): ?User + { + $cookie = $_COOKIE[$this->config['cookie_name']] ?? null; + + if (!$cookie || !str_contains($cookie, '|')) { + return null; + } + + [$id, $token] = explode('|', $cookie, 2); + $hashedToken = hash('sha256', $token); + + $user = $this->provider->findById($id); + + if (!$user || $user->rememberToken !== $hashedToken) { + $this->clearRememberCookie(); + return null; + } + + return $user; + } + + /** + * Refresh user instance from provider + */ + public function refresh(): void + { + if ($this->user) { + $this->user = $this->provider->findById($this->user->getId()); + } + } + + /** + * Set the current user + */ + public function setUser(User $user): void + { + $this->user = $user; + $this->session->set(self::SESSION_KEY, $user->getId()); + } +} \ No newline at end of file diff --git a/auth/AuthMiddleware.php b/auth/AuthMiddleware.php new file mode 100644 index 0000000..c383b20 --- /dev/null +++ b/auth/AuthMiddleware.php @@ -0,0 +1,206 @@ +auth = $auth; + } + + /** + * Require authentication + */ + public function requireAuth(): callable + { + return function(Context $context, callable $next) { + if ($this->auth->guest()) { + // Check if request expects JSON + if ($context->request->expectsJson()) { + $context->json(['error' => 'Unauthenticated'], 401); + return; + } + + // Redirect to login page + $context->redirect('/login'); + return; + } + + // Add user to context + $context->set('user', $this->auth->user()); + $next(); + }; + } + + /** + * Require guest (not authenticated) + */ + public function requireGuest(): callable + { + return function(Context $context, callable $next) { + if ($this->auth->check()) { + // Check if request expects JSON + if ($context->request->expectsJson()) { + $context->json(['error' => 'Already authenticated'], 403); + return; + } + + // Redirect to home or dashboard + $context->redirect('/'); + return; + } + + $next(); + }; + } + + /** + * Optional authentication (sets user if authenticated) + */ + public function optional(): callable + { + return function(Context $context, callable $next) { + if ($this->auth->check()) { + $context->set('user', $this->auth->user()); + } + $next(); + }; + } + + /** + * Check if user has specific role + */ + public function requireRole(string|array $roles): callable + { + $roles = is_array($roles) ? $roles : [$roles]; + + return function(Context $context, callable $next) use ($roles) { + if ($this->auth->guest()) { + if ($context->request->expectsJson()) { + $context->json(['error' => 'Unauthenticated'], 401); + return; + } + $context->redirect('/login'); + return; + } + + $user = $this->auth->user(); + $userRole = $user->getAttribute('role') ?? $user->role ?? null; + + if (!in_array($userRole, $roles)) { + if ($context->request->expectsJson()) { + $context->json(['error' => 'Insufficient permissions'], 403); + return; + } + $context->error(403, 'Forbidden'); + return; + } + + $context->set('user', $user); + $next(); + }; + } + + /** + * Rate limiting per user + */ + public function rateLimit(int $maxAttempts = 60, int $decayMinutes = 1): callable + { + return function(Context $context, callable $next) use ($maxAttempts, $decayMinutes) { + if ($this->auth->guest()) { + $identifier = $context->request->ip(); + } else { + $identifier = 'user:' . $this->auth->id(); + } + + $key = 'rate_limit:' . $identifier . ':' . $context->request->path; + $attempts = $context->session->get($key, 0); + $resetTime = $context->session->get($key . ':reset', 0); + + // Reset counter if decay time has passed + if (time() > $resetTime) { + $attempts = 0; + $context->session->set($key . ':reset', time() + ($decayMinutes * 60)); + } + + if ($attempts >= $maxAttempts) { + $retryAfter = $resetTime - time(); + $context->response->header('X-RateLimit-Limit', (string)$maxAttempts); + $context->response->header('X-RateLimit-Remaining', '0'); + $context->response->header('Retry-After', (string)$retryAfter); + + if ($context->request->expectsJson()) { + $context->json([ + 'error' => 'Too many requests', + 'retry_after' => $retryAfter + ], 429); + return; + } + + $context->error(429, 'Too Many Requests'); + return; + } + + // Increment attempts + $context->session->set($key, $attempts + 1); + + // Add rate limit headers + $context->response->header('X-RateLimit-Limit', (string)$maxAttempts); + $context->response->header('X-RateLimit-Remaining', (string)($maxAttempts - $attempts - 1)); + + $next(); + }; + } + + /** + * CSRF protection + */ + public function verifyCsrf(): callable + { + return function(Context $context, callable $next) { + // Skip CSRF for safe methods + if (in_array($context->request->method, ['GET', 'HEAD', 'OPTIONS'])) { + $next(); + return; + } + + $token = $context->request->input('_token') + ?? $context->request->header('X-CSRF-TOKEN') + ?? $context->request->header('X-XSRF-TOKEN'); + + if (!$context->session->validateToken($token)) { + if ($context->request->expectsJson()) { + $context->json(['error' => 'CSRF token mismatch'], 419); + return; + } + $context->error(419, 'CSRF token mismatch'); + return; + } + + $next(); + }; + } + + /** + * Remember user from cookie + */ + public function remember(): callable + { + return function(Context $context, callable $next) { + // Auth class already handles remember cookies in constructor + // This middleware can be used to refresh the remember token if needed + + if ($this->auth->check()) { + $context->set('user', $this->auth->user()); + } + + $next(); + }; + } +} \ No newline at end of file diff --git a/auth/User.php b/auth/User.php new file mode 100644 index 0000000..d34e5d9 --- /dev/null +++ b/auth/User.php @@ -0,0 +1,83 @@ + $value) { + if (property_exists($this, $key)) { + $this->$key = $value; + } else { + $this->attributes[$key] = $value; + } + } + } + + /** + * Get user identifier (email or username) + */ + public function getIdentifier(): string + { + return $this->email ?: $this->username; + } + + /** + * Get user ID + */ + public function getId(): int|string + { + return $this->id; + } + + /** + * Get custom attribute + */ + public function getAttribute(string $key): mixed + { + return $this->attributes[$key] ?? null; + } + + /** + * Set custom attribute + */ + public function setAttribute(string $key, mixed $value): void + { + $this->attributes[$key] = $value; + } + + /** + * Convert to array + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'username' => $this->username, + 'email' => $this->email, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ] + $this->attributes; + } + + /** + * Convert to safe array (without sensitive data) + */ + public function toSafeArray(): array + { + $data = $this->toArray(); + unset($data['password'], $data['remember_token']); + return $data; + } +} \ No newline at end of file diff --git a/auth/UserProviderInterface.php b/auth/UserProviderInterface.php new file mode 100644 index 0000000..25ecef5 --- /dev/null +++ b/auth/UserProviderInterface.php @@ -0,0 +1,52 @@ +filePath = $filePath; + $this->loadUsers(); + } + + /** + * Load users from JSON file + */ + private function loadUsers(): void + { + if (!file_exists($this->filePath)) { + $this->users = []; + $this->saveUsers(); + return; + } + + $content = file_get_contents($this->filePath); + $data = json_decode($content, true) ?? []; + + $this->users = []; + foreach ($data as $userData) { + $this->users[$userData['id']] = new User($userData); + } + } + + /** + * Save users to JSON file + */ + private function saveUsers(): void + { + $data = []; + foreach ($this->users as $user) { + $userData = [ + 'id' => $user->id, + 'username' => $user->username, + 'email' => $user->email, + 'password' => $user->password, + 'remember_token' => $user->rememberToken, + 'created_at' => $user->createdAt, + 'updated_at' => $user->updatedAt, + ] + $user->attributes; + $data[] = $userData; + } + + file_put_contents($this->filePath, json_encode($data, JSON_PRETTY_PRINT)); + } + + /** + * Generate next user ID + */ + private function generateId(): int + { + if (empty($this->users)) { + return 1; + } + return max(array_keys($this->users)) + 1; + } + + public function findById(int|string $id): ?User + { + return $this->users[$id] ?? null; + } + + public function findByEmail(string $email): ?User + { + foreach ($this->users as $user) { + if ($user->email === $email) { + return $user; + } + } + return null; + } + + public function findByUsername(string $username): ?User + { + foreach ($this->users as $user) { + if ($user->username === $username) { + return $user; + } + } + return null; + } + + public function findByCredentials(string $identifier): ?User + { + // Check if identifier is email + if (filter_var($identifier, FILTER_VALIDATE_EMAIL)) { + return $this->findByEmail($identifier); + } + + // Otherwise treat as username + return $this->findByUsername($identifier); + } + + public function findByRememberToken(string $token): ?User + { + foreach ($this->users as $user) { + if ($user->rememberToken === $token) { + return $user; + } + } + return null; + } + + public function create(array $data): User + { + $data['id'] = $data['id'] ?? $this->generateId(); + $data['created_at'] = $data['created_at'] ?? date('Y-m-d H:i:s'); + $data['updated_at'] = $data['updated_at'] ?? date('Y-m-d H:i:s'); + + $user = new User($data); + $this->users[$user->id] = $user; + $this->saveUsers(); + + return $user; + } + + public function update(User $user, array $data): bool + { + if (!isset($this->users[$user->id])) { + return false; + } + + foreach ($data as $key => $value) { + if (property_exists($user, $key)) { + $user->$key = $value; + } else { + $user->attributes[$key] = $value; + } + } + + $user->updatedAt = date('Y-m-d H:i:s'); + $this->users[$user->id] = $user; + $this->saveUsers(); + + return true; + } + + public function updateRememberToken(User $user, ?string $token): bool + { + if (!isset($this->users[$user->id])) { + return false; + } + + $user->rememberToken = $token; + $this->users[$user->id] = $user; + $this->saveUsers(); + + return true; + } + + public function verifyPassword(User $user, string $password): bool + { + return password_verify($password, $user->password); + } +} \ No newline at end of file diff --git a/auth/providers/MysqlUserProvider.php b/auth/providers/MysqlUserProvider.php new file mode 100644 index 0000000..e8a86d1 --- /dev/null +++ b/auth/providers/MysqlUserProvider.php @@ -0,0 +1,205 @@ +table = $table; + + $dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $config['host'] ?? 'localhost', + $config['port'] ?? 3306, + $config['database'], + $config['charset'] ?? 'utf8mb4' + ); + + try { + $this->db = new PDO( + $dsn, + $config['username'], + $config['password'], + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4" + ] + ); + $this->createTable(); + } catch (PDOException $e) { + throw new Exception("Failed to connect to MySQL database: " . $e->getMessage()); + } + } + + /** + * Create users table if it doesn't exist + */ + private function createTable(): void + { + $sql = "CREATE TABLE IF NOT EXISTS {$this->table} ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) UNIQUE, + email VARCHAR(255) UNIQUE, + password VARCHAR(255) NOT NULL, + remember_token VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + attributes JSON, + INDEX idx_email (email), + INDEX idx_username (username), + INDEX idx_remember_token (remember_token) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"; + + $this->db->exec($sql); + } + + /** + * Map database row to User object + */ + private function mapToUser(array $row): User + { + $attributes = isset($row['attributes']) ? json_decode($row['attributes'], true) ?? [] : []; + + return new User([ + 'id' => $row['id'], + 'username' => $row['username'], + 'email' => $row['email'], + 'password' => $row['password'], + 'rememberToken' => $row['remember_token'], + 'createdAt' => $row['created_at'], + 'updatedAt' => $row['updated_at'], + 'attributes' => $attributes + ]); + } + + public function findById(int|string $id): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE id = :id"); + $stmt->execute(['id' => $id]); + + $row = $stmt->fetch(); + return $row ? $this->mapToUser($row) : null; + } + + public function findByEmail(string $email): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE email = :email"); + $stmt->execute(['email' => $email]); + + $row = $stmt->fetch(); + return $row ? $this->mapToUser($row) : null; + } + + public function findByUsername(string $username): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE username = :username"); + $stmt->execute(['username' => $username]); + + $row = $stmt->fetch(); + return $row ? $this->mapToUser($row) : null; + } + + public function findByCredentials(string $identifier): ?User + { + $stmt = $this->db->prepare( + "SELECT * FROM {$this->table} WHERE email = :identifier OR username = :identifier" + ); + $stmt->execute(['identifier' => $identifier]); + + $row = $stmt->fetch(); + return $row ? $this->mapToUser($row) : null; + } + + public function findByRememberToken(string $token): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE remember_token = :token"); + $stmt->execute(['token' => $token]); + + $row = $stmt->fetch(); + return $row ? $this->mapToUser($row) : null; + } + + public function create(array $data): User + { + $attributes = $data['attributes'] ?? []; + unset($data['attributes']); + + $stmt = $this->db->prepare( + "INSERT INTO {$this->table} (username, email, password, remember_token, attributes) + VALUES (:username, :email, :password, :remember_token, :attributes)" + ); + + $stmt->execute([ + 'username' => $data['username'] ?? null, + 'email' => $data['email'] ?? null, + 'password' => $data['password'], + 'remember_token' => $data['remember_token'] ?? null, + 'attributes' => json_encode($attributes) + ]); + + $data['id'] = $this->db->lastInsertId(); + $data['attributes'] = $attributes; + + // Fetch created/updated timestamps + $user = $this->findById($data['id']); + return $user; + } + + public function update(User $user, array $data): bool + { + $attributes = array_merge($user->attributes, $data['attributes'] ?? []); + unset($data['attributes']); + + $fields = []; + $params = ['id' => $user->id]; + + foreach (['username', 'email', 'password'] as $field) { + if (isset($data[$field])) { + $fields[] = "$field = :$field"; + $params[$field] = $data[$field]; + $user->$field = $data[$field]; + } + } + + if (!empty($attributes)) { + $fields[] = "attributes = :attributes"; + $params['attributes'] = json_encode($attributes); + $user->attributes = $attributes; + } + + if (empty($fields)) { + return true; + } + + $sql = "UPDATE {$this->table} SET " . implode(', ', $fields) . " WHERE id = :id"; + $stmt = $this->db->prepare($sql); + + return $stmt->execute($params); + } + + public function updateRememberToken(User $user, ?string $token): bool + { + $stmt = $this->db->prepare( + "UPDATE {$this->table} SET remember_token = :token WHERE id = :id" + ); + + return $stmt->execute([ + 'id' => $user->id, + 'token' => $token + ]); + } + + public function verifyPassword(User $user, string $password): bool + { + return password_verify($password, $user->password); + } +} \ No newline at end of file diff --git a/auth/providers/SqliteUserProvider.php b/auth/providers/SqliteUserProvider.php new file mode 100644 index 0000000..0faf24a --- /dev/null +++ b/auth/providers/SqliteUserProvider.php @@ -0,0 +1,190 @@ +table = $table; + + try { + $this->db = new PDO("sqlite:$databasePath"); + $this->db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->createTable(); + } catch (PDOException $e) { + throw new Exception("Failed to connect to SQLite database: " . $e->getMessage()); + } + } + + /** + * Create users table if it doesn't exist + */ + private function createTable(): void + { + $sql = "CREATE TABLE IF NOT EXISTS {$this->table} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(255) UNIQUE, + email VARCHAR(255) UNIQUE, + password VARCHAR(255) NOT NULL, + remember_token VARCHAR(255), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + attributes TEXT + )"; + + $this->db->exec($sql); + } + + /** + * Map database row to User object + */ + private function mapToUser(array $row): User + { + $attributes = isset($row['attributes']) ? json_decode($row['attributes'], true) ?? [] : []; + + return new User([ + 'id' => $row['id'], + 'username' => $row['username'], + 'email' => $row['email'], + 'password' => $row['password'], + 'rememberToken' => $row['remember_token'], + 'createdAt' => $row['created_at'], + 'updatedAt' => $row['updated_at'], + 'attributes' => $attributes + ]); + } + + public function findById(int|string $id): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE id = :id"); + $stmt->execute(['id' => $id]); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? $this->mapToUser($row) : null; + } + + public function findByEmail(string $email): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE email = :email"); + $stmt->execute(['email' => $email]); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? $this->mapToUser($row) : null; + } + + public function findByUsername(string $username): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE username = :username"); + $stmt->execute(['username' => $username]); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? $this->mapToUser($row) : null; + } + + public function findByCredentials(string $identifier): ?User + { + $stmt = $this->db->prepare( + "SELECT * FROM {$this->table} WHERE email = :identifier OR username = :identifier" + ); + $stmt->execute(['identifier' => $identifier]); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? $this->mapToUser($row) : null; + } + + public function findByRememberToken(string $token): ?User + { + $stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE remember_token = :token"); + $stmt->execute(['token' => $token]); + + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return $row ? $this->mapToUser($row) : null; + } + + public function create(array $data): User + { + $attributes = $data['attributes'] ?? []; + unset($data['attributes']); + + $stmt = $this->db->prepare( + "INSERT INTO {$this->table} (username, email, password, remember_token, attributes, created_at, updated_at) + VALUES (:username, :email, :password, :remember_token, :attributes, :created_at, :updated_at)" + ); + + $stmt->execute([ + 'username' => $data['username'] ?? null, + 'email' => $data['email'] ?? null, + 'password' => $data['password'], + 'remember_token' => $data['remember_token'] ?? null, + 'attributes' => json_encode($attributes), + 'created_at' => $data['created_at'] ?? date('Y-m-d H:i:s'), + 'updated_at' => $data['updated_at'] ?? date('Y-m-d H:i:s') + ]); + + $data['id'] = $this->db->lastInsertId(); + $data['attributes'] = $attributes; + + return new User($data); + } + + public function update(User $user, array $data): bool + { + $attributes = array_merge($user->attributes, $data['attributes'] ?? []); + unset($data['attributes']); + + $fields = []; + $params = ['id' => $user->id]; + + foreach (['username', 'email', 'password'] as $field) { + if (isset($data[$field])) { + $fields[] = "$field = :$field"; + $params[$field] = $data[$field]; + $user->$field = $data[$field]; + } + } + + if (!empty($attributes)) { + $fields[] = "attributes = :attributes"; + $params['attributes'] = json_encode($attributes); + $user->attributes = $attributes; + } + + if (empty($fields)) { + return true; + } + + $fields[] = "updated_at = :updated_at"; + $params['updated_at'] = date('Y-m-d H:i:s'); + + $sql = "UPDATE {$this->table} SET " . implode(', ', $fields) . " WHERE id = :id"; + $stmt = $this->db->prepare($sql); + + return $stmt->execute($params); + } + + public function updateRememberToken(User $user, ?string $token): bool + { + $stmt = $this->db->prepare( + "UPDATE {$this->table} SET remember_token = :token, updated_at = :updated_at WHERE id = :id" + ); + + return $stmt->execute([ + 'id' => $user->id, + 'token' => $token, + 'updated_at' => date('Y-m-d H:i:s') + ]); + } + + public function verifyPassword(User $user, string $password): bool + { + return password_verify($password, $user->password); + } +} \ No newline at end of file diff --git a/example-auth.php b/example-auth.php new file mode 100644 index 0000000..f9fc06c --- /dev/null +++ b/example-auth.php @@ -0,0 +1,210 @@ + 'localhost', +// 'database' => 'myapp', +// 'username' => 'root', +// 'password' => 'password' +// ]); + +// Create web application +$app = new Web(debug: true); + +// Initialize auth system +$auth = new Auth($userProvider, new Session()); +$authMiddleware = new AuthMiddleware($auth); + +// Make auth available in all routes +$app->use(function($context, $next) use ($auth) { + $context->set('auth', $auth); + $next(); +}); + +// Public routes +$app->get('/', function($context) { + $user = $context->get('auth')->user(); + $context->html("

Welcome " . ($user ? $user->username : 'Guest') . "

"); +}); + +// Registration page +$app->get('/register', function($context) { + $csrfToken = $context->session->token(); + $context->html(<< + +
+
+
+
+ + + HTML); +}); + +// Registration handler +$app->post('/register', function($context) use ($auth) { + // Validate input + $validator = $context->validate($context->request->all(), [ + 'username' => 'required|alphaNum|min:3|max:20', + 'email' => 'required|email', + 'password' => 'required|min:6|confirmed' + ]); + + // Check if username/email already exists + if ($auth->provider->findByUsername($context->request->input('username'))) { + $context->error(400, 'Username already taken'); + return; + } + + if ($auth->provider->findByEmail($context->request->input('email'))) { + $context->error(400, 'Email already registered'); + return; + } + + // Register user + $user = $auth->register([ + 'username' => $context->request->input('username'), + 'email' => $context->request->input('email'), + 'password' => $context->request->input('password') + ]); + + $context->redirect('/dashboard'); +})->use($authMiddleware->verifyCsrf()); + +// Login page +$app->get('/login', function($context) { + $csrfToken = $context->session->token(); + $context->html(<< + +
+
+
+ + + HTML); +})->use($authMiddleware->requireGuest()); + +// Login handler +$app->post('/login', function($context) use ($auth) { + $credentials = [ + 'username' => $context->request->input('username'), + 'password' => $context->request->input('password') + ]; + + $remember = $context->request->input('remember') === 'on'; + + if ($auth->attempt($credentials, $remember)) { + $context->redirect('/dashboard'); + } else { + $context->error(401, 'Invalid credentials'); + } +})->use($authMiddleware->verifyCsrf()) + ->use($authMiddleware->requireGuest()); + +// Logout +$app->post('/logout', function($context) use ($auth) { + $auth->logout(); + $context->redirect('/'); +})->use($authMiddleware->verifyCsrf()); + +// Protected routes +$app->group('/dashboard', function($app) use ($authMiddleware) { + // Apply auth middleware to all routes in this group + $app->use($authMiddleware->requireAuth()); + + $app->get('', function($context) { + $user = $context->get('user'); + $csrfToken = $context->session->token(); + $context->html(<<Dashboard +

Welcome, {$user->username}!

+

Email: {$user->email}

+
+ + +
+ HTML); + }); + + $app->get('/profile', function($context) { + $user = $context->get('user'); + $context->json($user->toSafeArray()); + }); + + $app->post('/update-profile', function($context) use ($auth) { + $user = $context->get('user'); + + $validator = $context->validate($context->request->all(), [ + 'email' => 'email', + 'username' => 'alphaNum|min:3|max:20' + ]); + + $data = $context->request->only(['email', 'username']); + + if (!empty($data)) { + $auth->provider->update($user, $data); + $auth->refresh(); + } + + $context->json(['success' => true, 'user' => $auth->user()->toSafeArray()]); + })->use($authMiddleware->verifyCsrf()); +}); + +// Admin only routes +$app->group('/admin', function($app) use ($authMiddleware) { + // Require admin role + $app->use($authMiddleware->requireRole('admin')); + + $app->get('', function($context) { + $context->html('

Admin Panel

'); + }); +}); + +// API routes with rate limiting +$app->group('/api', function($app) use ($authMiddleware, $auth) { + // Optional auth for API + $app->use($authMiddleware->optional()); + + // Rate limiting: 60 requests per minute + $app->use($authMiddleware->rateLimit(60, 1)); + + $app->post('/login', function($context) use ($auth) { + $credentials = $context->request->only(['username', 'password']); + + if ($auth->validate($credentials)) { + $auth->attempt($credentials); + return ['success' => true, 'user' => $auth->user()->toSafeArray()]; + } + + return $context->json(['error' => 'Invalid credentials'], 401); + }); + + $app->get('/me', function($context) use ($auth) { + if ($auth->guest()) { + return $context->json(['error' => 'Unauthenticated'], 401); + } + + return $auth->user()->toSafeArray(); + }); +}); + +// Run the application +$app->run(); \ No newline at end of file