Implemented Flashii ID logins.

This commit is contained in:
flash 2024-09-05 22:49:42 +00:00
parent a074aceddd
commit ce7c0f5bd7
11 changed files with 140 additions and 454 deletions

View file

@ -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('<a href="/@%1$s">%1$s</a>', $_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.<br/>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;
}

View file

@ -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 {

View file

@ -1,123 +0,0 @@
<?php
namespace YTKNS;
use Exception;
class UserInviteNotFoundException extends Exception {}
class UserInviteCreationFailedException extends Exception {}
#[\AllowDynamicProperties]
final class UserInvite {
private const TOKEN_LENGTH = 16;
public function getToken(): string {
return $this->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;
}
}
}

View file

@ -1,8 +0,0 @@
<label class="auth-field">
<div class="auth-field-label">
:field_title
</div>
<div class="auth-field-value">
<input type=":field_type" name=":field_name" class="auth-field-value-input" value=":field_value"/>
</div>
</label>

View file

@ -1,11 +0,0 @@
<h1 class="page-title">Log in</h1>
<form action="/auth/login" method="post" class="auth">
:auth_error
:auth_fields
<label class="auth-option">
<input type="checkbox" name="remember" class="auth-option-input" :auth_remember/> Stay logged in
</label>
<div class="auth-buttons">
<input type="submit" value="Log in" class="auth-buttons-button"/>
</div>
</form>

View file

@ -1,17 +0,0 @@
<h1 class="page-title">Log in</h1>
<form action="/auth/fid" method="post" class="auth auth-fid">
:auth_error
<div class="auth-buttons">
<input type="submit" value="Log in with Flashii ID" class="auth-buttons-button auth-buttons-button-fid"/>
<input type="button" value="Log in with YTKNS details" class="auth-buttons-button" onclick="document.getElementById('_login').classList.remove('hidden');this.classList.add('hidden');" />
</div>
</form>
<form action="/auth/login" method="post" class="auth hidden" id="_login">
:auth_fields
<label class="auth-option">
<input type="checkbox" name="remember" class="auth-option-input" :auth_remember/> Stay logged in
</label>
<div class="auth-buttons">
<input type="submit" value="Log in" class="auth-buttons-button"/>
</div>
</form>

View file

@ -1,8 +0,0 @@
<h1 class="page-title">Register</h1>
<form action="/auth/register" method="post" class="auth">
:auth_error
:auth_fields
<div class="auth-buttons">
<input type="submit" value="Create account" class="auth-buttons-button"/>
</div>
</form>

View file

@ -1,7 +1,8 @@
</div>
<div class="footer" id="ytkns-footer">
YTKNS by <a href="//flash.moe">Flashwave</a> 2019-:footer_year |
<a href="#" onclick="alert(['e&', '.m&o', 'h', 's', 'la', '#&f', 's', 't&kn', 'y', '+&&', 'e', '&m'].reverse().join('&').replace(/&/g, '').replace('#', '@'));">Contact</a> |
YTKNS by <a href="//flash.moe">flashwave</a> 2019-:footer_year |
<a href="#" onclick="alert(['e&', '.m&o', 'h', 's', 'la', '#&f', 's', 't&kn', 'y'].reverse().join('&').replace(/&/g, '').replace('#', '@'));">Contact</a> |
<a href="//patchii.net/flash/ytkns" target="_blank" rel="noopener">Source</a> |
Loaded in :footer_took seconds
</div>
</div>:scripts

View file

@ -1,4 +0,0 @@
<form method="post" action="/settings/invites" class="invites-create">
<input type="hidden" name="invite_token" value=":create_token"/>
<input type="submit" value="Create Invite" class="invites-create-button"/>
</form>

View file

@ -1,14 +0,0 @@
<div class="invites-list-item">
<div class="invites-list-item-created">
:invite_created
</div>
<div class="invites-list-item-used">
:invite_used
</div>
<div class="invites-list-item-user">
:invite_user
</div>
<div class="invites-list-item-token">
<code onclick="navigator.clipboard.writeText('https://ytkns.com/auth/register?inv=:invite_token');" title="Click to copy registration link with invite">:invite_token</code>
</div>
</div>

View file

@ -1,22 +0,0 @@
<h1 class="page-title">Invites</h1>
<div class="invites">
:invite_error
:invite_create
<div class="invites-list">
<div class="invites-list-item">
<div class="invites-list-item-created">
Created on
</div>
<div class="invites-list-item-used">
Used on
</div>
<div class="invites-list-item-user">
Used by
</div>
<div class="invites-list-item-token">
Invitation
</div>
</div>
:invite_list
</div>
</div>