From ce7c0f5bd772578e49fcc330ee735b861ac5e526 Mon Sep 17 00:00:00 2001 From: flashwave Date: Thu, 5 Sep 2024 22:49:42 +0000 Subject: [PATCH] Implemented Flashii ID logins. --- public/index.php | 325 ++++++++++--------------- src/User.php | 57 ++--- src/UserInvite.php | 123 ---------- templates/auth/field.html | 8 - templates/auth/login.html | 11 - templates/auth/login2.html | 17 -- templates/auth/register.html | 8 - templates/footer.html | 5 +- templates/settings/invites-create.html | 4 - templates/settings/invites-item.html | 14 -- templates/settings/invites.html | 22 -- 11 files changed, 140 insertions(+), 454 deletions(-) delete mode 100644 src/UserInvite.php delete mode 100644 templates/auth/field.html delete mode 100644 templates/auth/login.html delete mode 100644 templates/auth/login2.html delete mode 100644 templates/auth/register.html delete mode 100644 templates/settings/invites-create.html delete mode 100644 templates/settings/invites-item.html delete mode 100644 templates/settings/invites.html diff --git a/public/index.php b/public/index.php index 8309b26..44cc8b2 100644 --- a/public/index.php +++ b/public/index.php @@ -5,6 +5,18 @@ use Exception; require_once __DIR__ . '/../startup.php'; +if(!function_exists('base64uri_encode')) { + function base64uri_encode(string $string): string { + return rtrim(strtr(base64_encode($string), '+/', '-_'), '='); + } +} + +if(!function_exists('base64uri_decode')) { + function base64uri_decode(string $string, bool $strict = false): string|false { + return base64_decode(str_pad(strtr($string, '-_', '+/'), strlen($string) % 4, '=', STR_PAD_RIGHT)); + } +} + function page_url(string $path, array $params = []): string { if(isset($params['p'])) unset($params['p']); @@ -55,14 +67,10 @@ function html_header(array $vars = []): string { ['text' => 'Settings', 'link' => page_url('/settings')], ]; - if(Config::get('user.invite_only', Config::TYPE_BOOL)) - $userMenu[] = ['text' => 'Invites', 'link' => page_url('/settings/invites')]; - $userMenu[] = ['text' => 'Log out', 'link' => page_url('/auth/logout', ['s' => UserSession::instance()->getSmallToken()])]; } else { $userMenu = [ ['text' => 'Log in', 'link' => page_url('/auth/login')], - ['text' => 'Register', 'link' => page_url('/auth/register')], ]; } @@ -861,7 +869,7 @@ if(preg_match('#^/uploads/([a-zA-Z0-9-_]{16}).json$#', $reqPath, $matches)) { if($reqPath === '/settings') { if(!UserSession::hasInstance()) { - http_response_code(404); + http_response_code(403); echo html_information('You must be logged in to access this page.'); return; } @@ -872,62 +880,6 @@ if($reqPath === '/settings') { return; } -if($reqPath === '/settings/invites') { - if(!UserSession::hasInstance()) { - http_response_code(404); - echo html_information('You must be logged in to access this page.'); - return; - } - - $currentUser = UserSession::instance()->getUser(); - $createdInvites = UserInvite::fromCreator($currentUser); - $createToken = $currentUser->getId() !== 1 /*&& count($createdInvites) >= 5*/ ? '' : UserSession::instance()->getSmallToken(11); - - if($reqMethod === 'POST') { - if(empty($createToken)) { - $inviteError = 'You\'ve reached the maximum amount of invites you can generate.'; - } elseif($createToken !== filter_input(INPUT_POST, 'invite_token')) { - $inviteError = 'Cannot create invite.'; - } else { - try { - $createdInvites[] = $createdInvite = UserInvite::create($currentUser); - $inviteError = $createdInvite->getToken(); - } catch(UserInviteCreationFailedException $ex) { - $inviteError = 'Invite creation failed.'; - } - } - } - - if(isset($inviteError)) - $inviteError = Template::renderRaw('error', [ - 'error_text' => $inviteError, - ]); - - if(!empty($createToken)) - $inviteCreate = Template::renderRaw('settings/invites-create', [ - 'create_token' => $createToken, - ]); - - $invitesItems = []; - foreach($createdInvites as $inviteItem) { - $invitesItems[] = [ - 'invite_created' => date('c', $inviteItem->getCreated()), - 'invite_used' => (($_used = $inviteItem->getUsed()) === null ? 'Unused' : date('c', $_used)), - 'invite_user' => (($_user = $inviteItem->getUser()) === null ? 'Unused' : sprintf('%1$s', $_user->getUsername())), - 'invite_token' => $inviteItem->getToken(), - ]; - } - - echo html_header(['title' => 'Invites - YTKNS']); - Template::render('settings/invites', [ - 'invite_error' => $inviteError ?? '', - 'invite_create' => $inviteCreate ?? '', - 'invite_list' => Template::renderSet('settings/invites-item', $invitesItems), - ]); - echo html_footer(); - return; -} - if($reqPath === '/auth/login') { if(YTKNS_MAINTENANCE) { http_response_code(503); @@ -941,161 +893,130 @@ if($reqPath === '/auth/login') { return; } - if($reqMethod === 'POST') { - $loginUsername = filter_input(INPUT_POST, 'username'); - $loginPassword = filter_input(INPUT_POST, 'password'); - $loginRemember = !empty(filter_input(INPUT_POST, 'remember')); + if(filter_has_var(INPUT_GET, 'state')) { + $state = base64uri_decode((string)filter_input(INPUT_GET, 'state')); + if($state === false || strlen($state) !== 60) { + http_response_code(400); + echo html_information('Provided state is invalid.'); + return; + } - if(empty($loginUsername) || empty($loginPassword)) { - $authError = 'Username or password missing.'; - } else { + $signature = hash_hmac('sha256', substr($state, 32), YTKNS_OA2_STATE_SECRET, true); + if(!hash_equals($signature, substr($state, 0, 32))) { + http_response_code(403); + echo html_information('Request verification failed.'); + return; + } + + $state = unpack('a32hash/Jtime/a20verifier', $state); + if($state === false) { + http_response_code(500); + echo html_information('State unpack failed.'); + return; + } + + if($state['time'] < strtotime('-15 minutes') || $state['time'] > strtotime('+15 minutes')) { + http_response_code(403); + echo html_information('Authorisation request timestamp is no longer valid.'); + return; + } + + if(filter_has_var(INPUT_GET, 'error')) { + $error = (string)filter_input(INPUT_GET, 'error'); + $text = (string)filter_input(INPUT_GET, 'error_description'); + if($text === '') + $text = match($error) { + 'access_denied' => 'You rejected the authorisation request.', + 'invalid_request' => 'YTKNS sent an invalid request to the authorisation server, please report this!', + 'invalid_scope' => 'YTKNS sent an invalid request to the authorisation server, please report this!', + 'server_error' => 'Something went wrong on the authorisation server, please try again later.', + default => sprintf('An unexpected error occurred: %s', $error), + }; + + http_response_code(400); + echo html_information(htmlspecialchars($text)); + return; + } + + if(filter_has_var(INPUT_GET, 'code')) { try { - $loginUser = User::forLogin($loginUsername); - } catch(\Exception $ex) {} + $postData = sprintf( + 'grant_type=authorization_code&code=%s&code_verifier=%s', + rawurlencode((string)filter_input(INPUT_GET, 'code')), + rawurldecode($state['verifier']) + ); + $authz = sprintf('Basic %s', base64_encode(sprintf('%s:%s', YTKNS_OA2_CLIENT_ID, YTKNS_OA2_CLIENT_SECRET))); - if(empty($loginUser) || empty($loginUser->password) || !password_verify($loginPassword, $loginUser->password)) { - $authError = 'Username or password was invalid.'; - } else { - $session = UserSession::create($loginUser, $loginRemember); - $session->setInstance(); - setcookie('ytkns_login', $session->getToken(), $session->getExpires(), '/', '.' . Config::get('domain.main'), false, true); - echo html_information('You are now logged in!', 'Welcome', '/'); - return; - } - } - } + $tokenInfo = json_decode(file_get_contents('https://api.flashii.net/oauth2/token', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", [ + sprintf('Authorization: %s', $authz), + 'Content-Type: application/x-www-form-urlencoded', + sprintf('Content-Length: %d', strlen($postData)), + 'User-Agent: YTKNS', + ]), + 'content' => $postData, + ], + ]))); - if(isset($authError)) - $authError = Template::renderRaw('error', [ - 'error_text' => $authError, - ]); + if(isset($tokenInfo->access_token)) { + $fUserInfo = json_decode(file_get_contents('https://api.flashii.net/v1/me', false, stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => implode("\r\n", [ + sprintf('Authorization: Bearer %s', $tokenInfo->access_token), + 'User-Agent: YTKNS', + ]), + ], + ]))); - $authFields = [ - [ - 'field_title' => 'Username or E-Mail Address', - 'field_type' => 'text', - 'field_name' => 'username', - 'field_value' => ($loginUsername ?? ''), - ], - [ - 'field_title' => 'Password', - 'field_type' => 'password', - 'field_name' => 'password', - 'field_value' => '', - ], - ]; - - echo html_header(['title' => 'Log in - YTKNS']); - Template::render('auth/login' . (isset($_GET['new']) ? '2' : ''), [ - 'auth_error' => $authError ?? '', - 'auth_fields' => Template::renderSet('auth/field', $authFields), - 'auth_remember' => ($loginRemember ?? false) ? ' checked' : '', - ]); - echo html_footer(); - return; -} - -if($reqPath === '/auth/register') { - if(YTKNS_MAINTENANCE) { - http_response_code(503); - echo html_information('You cannot register during maintenance.'); - return; - } - - if(UserSession::hasInstance()) { - http_response_code(404); - echo html_information('You are logged in already.'); - return; - } - - $inviteOnly = Config::get('user.invite_only', Config::TYPE_BOOL); - - if($reqMethod === 'POST') { - $registerUsername = filter_input(INPUT_POST, 'username'); - $registerPassword = filter_input(INPUT_POST, 'password'); - $registerEMail = filter_input(INPUT_POST, 'email'); - $registerInvite = filter_input(INPUT_POST, 'invite'); - - if(empty($registerUsername) || empty($registerPassword) || empty($registerEMail) || ($inviteOnly && empty($registerInvite))) { - $authError = 'You must fill in all fields.'; - } else { - if($inviteOnly) { - try { - $userInvite = UserInvite::byToken($registerInvite); - - if($userInvite->isUsed()) { - $authError = 'Invalid invite token.'; + if(empty($fUserInfo->id)) { + http_response_code(500); + echo html_information('Authentication failed.'); + return; } - } catch(UserInviteNotFoundException $ex) { - $authError = 'Invalid invite token.'; - } - } - if(!isset($authError)) { - try { - $createdUser = User::create( - $registerUsername, - $registerPassword, - $registerEMail - ); + try { + $userInfo = User::byRemoteId($fUserInfo->id); + $loginMessage = 'You are now logged in!'; + } catch(UserNotFoundException) { + try { + $userInfo = User::create($fUserInfo->id, $fUserInfo->name); + } catch(\PDOException) { + $userInfo = User::create($fUserInfo->id, sprintf('%s_%04d', $fUserInfo->name, random_int(0, 9999))); + } - if(isset($userInvite)) - $userInvite->markUsed($createdUser); - } catch(UserCreationInvalidNameException $ex) { - $authError = 'Your username contains invalid characters or is too short or long.
Must be between 1 and 20 characters and may only contains alphanumeric characters as well as - and _.'; - } catch(UserCreationInvalidPasswordException $ex) { - $authError = 'Your password must have at least 6 unique characters.'; - } catch(UserCreationInvalidMailException $ex) { - $authError = 'Your e-mail address isn\'t real.'; - } catch(UserCreationFailedException $ex) { - $authError = 'Failed to create user.'; + $loginMessage = 'Your account been created!'; + } + + // leaving session bumping off for now, the implementation needs to be better for that + $session = UserSession::create($userInfo, false); + $session->setInstance(); + setcookie('ytkns_login', $session->getToken(), $session->getExpires(), '/', '.' . Config::get('domain.main'), false, true); + echo html_information($loginMessage, 'Welcome', '/'); + return; } + } catch(\Exception $ex) { + http_response_code(500); + echo html_information('Authorisation request failed, please try again.'); } + return; } - } elseif($reqMethod === 'GET') { - $registerInvite = filter_input(INPUT_GET, 'inv', FILTER_SANITIZE_STRING); } - $authFields = [ - [ - 'field_title' => 'Username', - 'field_type' => 'text', - 'field_name' => 'username', - 'field_value' => ($registerUsername ?? ''), - ], - [ - 'field_title' => 'Password', - 'field_type' => 'password', - 'field_name' => 'password', - 'field_value' => '', - ], - [ - 'field_title' => 'E-Mail Address', - 'field_type' => 'email', - 'field_name' => 'email', - 'field_value' => ($registerEMail ?? ''), - ], - ]; + $verifier = random_bytes(20); + $time = pack('J', time()); + $signature = hash_hmac('sha256', $time . $verifier, YTKNS_OA2_STATE_SECRET, true); + $state = base64uri_encode($signature . $time . $verifier); - if($inviteOnly) - $authFields[] = [ - 'field_title' => 'Invitation', - 'field_type' => 'password', - 'field_name' => 'invite', - 'field_value' => ($registerInvite ?? ''), - ]; - - if(isset($authError)) - $authError = Template::renderRaw('error', [ - 'error_text' => $authError, - ]); - - echo html_header(['title' => 'Register - YTKNS']); - Template::render('auth/register', [ - 'auth_error' => $authError ?? '', - 'auth_fields' => Template::renderSet('auth/field', $authFields), - ]); - echo html_footer(); + header(sprintf( + 'Location: https://id.flashii.net/oauth2/authorise?response_type=code&scope=identify&code_challenge_method=S256&client_id=%s&state=%s&code_challenge=%s&redirect_uri=%s', + rawurlencode(YTKNS_OA2_CLIENT_ID), + rawurlencode($state), + rawurlencode(base64uri_encode(hash('sha256', $verifier, true))), + rawurlencode('https://ytkns.com/auth/login'), + )); return; } diff --git a/src/User.php b/src/User.php index c23ec13..94d8088 100644 --- a/src/User.php +++ b/src/User.php @@ -5,9 +5,6 @@ use Exception; class UserNotFoundException extends Exception {} class UserCreationFailedException extends Exception {} -class UserCreationInvalidNameException extends Exception {} -class UserCreationInvalidPasswordException extends Exception {} -class UserCreationInvalidMailException extends Exception {} #[\AllowDynamicProperties] class User { @@ -21,13 +18,17 @@ class User { $this->user_id = $userId; } + public function getRemoteId(): string { + return (string)($this->remote_id ?? ''); + } + public function getUsername(): string { return $this->username ?? ''; } public static function byId(int $userId): self { $getUser = DB::prepare(' - SELECT `user_id`, `username`, `email`, `password`, `user_created` + SELECT `user_id`, `remote_id`, `username`, `user_created` FROM `ytkns_users` WHERE `user_id` = :user '); @@ -41,15 +42,13 @@ class User { return $user; } - public static function forLogin(string $usernameOrEmail): self { + public static function byRemoteId(string $remoteId): self { $getUser = DB::prepare(' - SELECT `user_id`, `username`, `email`, `password`, `user_created` + SELECT `user_id`, `remote_id`, `username`, `user_created` FROM `ytkns_users` - WHERE LOWER(`username`) = LOWER(:username) - OR LOWER(`email`) = LOWER(:email) + WHERE `remote_id` = :remote '); - $getUser->bindValue('username', $usernameOrEmail); - $getUser->bindValue('email', $usernameOrEmail); + $getUser->bindValue('remote', $remoteId); $getUser->execute(); $user = $getUser->fetchObject(self::class); @@ -61,7 +60,7 @@ class User { public static function forProfile(string $username): self { $getUser = DB::prepare(' - SELECT `user_id`, `username`, `email`, `password`, `user_created` + SELECT `user_id`, `remote_id`, `username`, `user_created` FROM `ytkns_users` WHERE LOWER(`username`) = LOWER(:username) '); @@ -75,44 +74,16 @@ class User { return $user; } - public static function validatePassword(string $password): bool { - $chars = []; - $length = mb_strlen($password); - - for($i = 0; $i < $length; $i++) { - $current = mb_substr($password, $i, 1); - - if(!in_array($current, $chars, true)) - $chars[] = $current; - } - - return count($chars) >= 6; - } - - public static function hashPassword(string $password): string { - return password_hash($password, PASSWORD_ARGON2ID); - } - - public static function create(string $userName, string $password, string $email): self { - if(!preg_match('#^([a-zA-Z0-9-_]{1,20})$#', $userName)) - throw new UserCreationInvalidNameException; - if(!filter_var($email, FILTER_VALIDATE_EMAIL)) - throw new UserCreationInvalidMailException; - if(!self::validatePassword($password)) - throw new UserCreationInvalidPasswordException; - - $password = self::hashPassword($password); - + public static function create(string $remoteId, string $userName): self { $createUser = DB::prepare(' INSERT INTO `ytkns_users` ( - `username`, `email`, `password` + `remote_id`, `username` ) VALUES ( - :username, :email, :password + :remote, :username ) '); + $createUser->bindValue('remote', $remoteId); $createUser->bindValue('username', $userName); - $createUser->bindValue('email', $email); - $createUser->bindValue('password', $password); $userId = $createUser->execute() ? (int)DB::lastInsertId() : 0; try { diff --git a/src/UserInvite.php b/src/UserInvite.php deleted file mode 100644 index 7d1dc71..0000000 --- a/src/UserInvite.php +++ /dev/null @@ -1,123 +0,0 @@ -invite_token; - } - - public function getCreatorId(): int { - return $this->created_by ?? 0; - } - public function getCreator(): User { - return User::byId($this->getCreatedById()); - } - - public function getUserId(): ?int { - return $this->used_by ?? null; - } - public function getUser(): ?User { - $userId = $this->getUserId(); - - if($userId === null) - return null; - - try { - return User::byId($this->getUserId()); - } catch(UserNotFoundException $ex) { - return null; - } - } - - public function getCreated(): int { - return $this->invite_created ?? 0; - } - - public function getUsed(): ?int { - return $this->invite_used ?? null; - } - - public function isUsed(): bool { - return !empty($this->used_by) || !empty($this->invite_used); - } - - public function markUsed(User $user): void { - $markUsed = DB::prepare(' - UPDATE `ytkns_users_invites` - SET `used_by` = :user - WHERE `invite_token` = UNHEX(:token) - '); - $markUsed->bindValue('user', $user->getId()); - $markUsed->bindValue('token', $this->getToken()); - $markUsed->execute(); - } - - public static function fromCreator(User $creator): array { - $getInvites = DB::prepare(' - SELECT `created_by`, `used_by`, - LOWER(HEX(`invite_token`)) AS `invite_token`, - UNIX_TIMESTAMP(`invite_created`) AS `invite_created`, - UNIX_TIMESTAMP(`invite_used`) AS `invite_used` - FROM `ytkns_users_invites` - WHERE `created_by` = :creator - '); - $getInvites->bindValue('creator', $creator->getId()); - $getInvites->execute(); - $invites = []; - - while($invite = $getInvites->fetchObject(self::class)) - $invites[] = $invite; - - return $invites; - } - - public static function byToken(string $token, bool $isHex = true): self { - $getInvite = DB::prepare(sprintf(' - SELECT `created_by`, `used_by`, - LOWER(HEX(`invite_token`)) AS `invite_token`, - UNIX_TIMESTAMP(`invite_created`) AS `invite_created`, - UNIX_TIMESTAMP(`invite_used`) AS `invite_used` - FROM `ytkns_users_invites` - WHERE `invite_token` = %s(:token) - ', $isHex ? 'UNHEX' : '')); - $getInvite->bindValue('token', $token); - $invite = $getInvite->execute() ? $getInvite->fetchObject(self::class) : false; - - if(!$invite) - throw new UserInviteNotFoundException; - - return $invite; - } - - public static function generateToken(): string { - return bin2hex(random_bytes(self::TOKEN_LENGTH)); - } - - public static function create(User $creator): self { - $inviteToken = self::generateToken(); - $insertInvite = DB::prepare(' - INSERT INTO `ytkns_users_invites` ( - `invite_token`, `created_by` - ) VALUES ( - UNHEX(:token), :creator - ) - '); - $insertInvite->bindValue('token', $inviteToken); - $insertInvite->bindValue('creator', $creator->getId()); - $insertInvite->execute(); - - try { - return self::byToken($inviteToken); - } catch(UserInviteNotFoundException $ex) { - throw new UserInviteCreationFailedException; - } - } -} diff --git a/templates/auth/field.html b/templates/auth/field.html deleted file mode 100644 index de57e19..0000000 --- a/templates/auth/field.html +++ /dev/null @@ -1,8 +0,0 @@ - \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html deleted file mode 100644 index a6f2e95..0000000 --- a/templates/auth/login.html +++ /dev/null @@ -1,11 +0,0 @@ -

Log in

-
- :auth_error - :auth_fields - -
- -
-
diff --git a/templates/auth/login2.html b/templates/auth/login2.html deleted file mode 100644 index 277bb0e..0000000 --- a/templates/auth/login2.html +++ /dev/null @@ -1,17 +0,0 @@ -

Log in

-
- :auth_error -
- - -
-
- diff --git a/templates/auth/register.html b/templates/auth/register.html deleted file mode 100644 index a662ee7..0000000 --- a/templates/auth/register.html +++ /dev/null @@ -1,8 +0,0 @@ -

Register

-
- :auth_error - :auth_fields -
- -
-
diff --git a/templates/footer.html b/templates/footer.html index c7fbe80..89641a9 100644 --- a/templates/footer.html +++ b/templates/footer.html @@ -1,7 +1,8 @@ :scripts diff --git a/templates/settings/invites-create.html b/templates/settings/invites-create.html deleted file mode 100644 index 87c6fcf..0000000 --- a/templates/settings/invites-create.html +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
\ No newline at end of file diff --git a/templates/settings/invites-item.html b/templates/settings/invites-item.html deleted file mode 100644 index 0fc377f..0000000 --- a/templates/settings/invites-item.html +++ /dev/null @@ -1,14 +0,0 @@ -
-
- :invite_created -
-
- :invite_used -
-
- :invite_user -
-
- :invite_token -
-
\ No newline at end of file diff --git a/templates/settings/invites.html b/templates/settings/invites.html deleted file mode 100644 index 9cbc070..0000000 --- a/templates/settings/invites.html +++ /dev/null @@ -1,22 +0,0 @@ -

Invites

-
- :invite_error - :invite_create -
-
-
- Created on -
-
- Used on -
-
- Used by -
-
- Invitation -
-
- :invite_list -
-