Merge pull request #10670 from appwrite/revert-auth

Revert "Merge pull request #10468 from appwrite/feat-apps-module-dl"
This commit is contained in:
Jake Barnby 2025-10-21 02:58:23 +00:00 committed by GitHub
commit f150cffa17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1573 additions and 868 deletions

View file

@ -1,5 +1,6 @@
<?php
use Appwrite\Auth\Auth;
use Utopia\Database\Database;
use Utopia\Database\Helpers\ID;
@ -172,7 +173,7 @@ return [
'size' => 256,
'signed' => true,
'required' => false,
'default' => 'argon2',
'default' => Auth::DEFAULT_ALGO,
'array' => false,
'filters' => [],
],
@ -183,7 +184,7 @@ return [
'size' => 65535,
'signed' => true,
'required' => false,
'default' => ['type' => 'argon2', 'memoryCost' => 2048, 'timeCost' => 4, 'threads' => 3],
'default' => Auth::DEFAULT_ALGO_OPTIONS,
'array' => false,
'filters' => ['json'],
],
@ -1114,9 +1115,9 @@ return [
[
'$id' => ID::custom('expire'),
'type' => Database::VAR_DATETIME,
'format' => '',
'size' => 0,
'required' => false,
'format' => '',
'signed' => false,
'default' => null,
'array' => false,

View file

@ -4,6 +4,7 @@
* Initializes console project document.
*/
use Appwrite\Auth\Auth;
use Appwrite\Network\Platform;
use Utopia\Database\Helpers\ID;
use Utopia\System\System;
@ -37,7 +38,7 @@ $console = [
'mockNumbers' => [],
'invites' => System::getEnv('_APP_CONSOLE_INVITES', 'enabled') === 'enabled',
'limit' => (System::getEnv('_APP_CONSOLE_WHITELIST_ROOT', 'enabled') === 'enabled') ? 1 : 0, // limit signup to 1 user
'duration' => TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG, // 1 Year in seconds
'sessionAlerts' => System::getEnv('_APP_CONSOLE_SESSION_ALERTS', 'disabled') === 'enabled',
'invalidateSessions' => true
],

View file

@ -1,5 +1,6 @@
<?php
use Appwrite\Auth\Auth;
$member = [
'global',
@ -91,7 +92,7 @@ $admins = [
];
return [
USER_ROLE_GUESTS => [
Auth::USER_ROLE_GUESTS => [
'label' => 'Guests',
'scopes' => [
'global',
@ -111,23 +112,23 @@ return [
'execution.write',
],
],
USER_ROLE_USERS => [
Auth::USER_ROLE_USERS => [
'label' => 'Users',
'scopes' => \array_merge($member),
],
USER_ROLE_ADMIN => [
Auth::USER_ROLE_ADMIN => [
'label' => 'Admin',
'scopes' => \array_merge($admins),
],
USER_ROLE_DEVELOPER => [
Auth::USER_ROLE_DEVELOPER => [
'label' => 'Developer',
'scopes' => \array_merge($admins),
],
USER_ROLE_OWNER => [
Auth::USER_ROLE_OWNER => [
'label' => 'Owner',
'scopes' => \array_merge($member, $admins),
],
USER_ROLE_APPS => [
Auth::USER_ROLE_APPS => [
'label' => 'Applications',
'scopes' => ['global', 'health.read', 'graphql'],
],

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
<?php
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Validator\MockNumber;
use Appwrite\Event\Delete;
use Appwrite\Event\Mail;
@ -117,7 +118,7 @@ App::post('/v1/projects')
'maxSessions' => APP_LIMIT_USER_SESSIONS_DEFAULT,
'passwordHistory' => 0,
'passwordDictionary' => false,
'duration' => TOKEN_EXPIRATION_LOGIN_LONG,
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG,
'personalDataCheck' => false,
'mockNumbers' => [],
'sessionAlerts' => false,

View file

@ -28,9 +28,6 @@ use MaxMind\Db\Reader;
use Utopia\Abuse\Abuse;
use Utopia\App;
use Utopia\Audit\Audit;
use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@ -474,9 +471,10 @@ App::post('/v1/teams/:teamId/memberships')
->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true)
->param('roles', [], function (Document $project) {
if ($project->getId() === 'console') {
;
$roles = array_keys(Config::getParam('roles', []));
$roles = array_filter($roles, function ($role) {
return !in_array($role, [USER_ROLE_APPS, USER_ROLE_GUESTS, USER_ROLE_USERS]);
array_filter($roles, function ($role) {
return !in_array($role, [Auth::USER_ROLE_APPS, Auth::USER_ROLE_GUESTS, Auth::USER_ROLE_USERS]);
});
return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE);
}
@ -495,9 +493,7 @@ App::post('/v1/teams/:teamId/memberships')
->inject('timelimit')
->inject('queueForStatsUsage')
->inject('plan')
->inject('proofForPassword')
->inject('proofForToken')
->action(function (string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan, Password $proofForPassword, Token $proofForToken) {
->action(function (string $teamId, string $email, string $userId, string $phone, array $roles, string $url, string $name, Response $response, Document $project, Document $user, Database $dbForProject, Locale $locale, Mail $queueForMails, Messaging $queueForMessaging, Event $queueForEvents, callable $timelimit, StatsUsage $queueForStatsUsage, array $plan) {
$isAppUser = Auth::isAppUser(Authorization::getRoles());
$isPrivilegedUser = Auth::isPrivilegedUser(Authorization::getRoles());
@ -570,7 +566,6 @@ App::post('/v1/teams/:teamId/memberships')
try {
$userId = ID::unique();
$hash = $proofForPassword->hash($proofForPassword->generate());
$invitee = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([
'$id' => $userId,
'$permissions' => [
@ -584,9 +579,9 @@ App::post('/v1/teams/:teamId/memberships')
'emailVerification' => false,
'status' => true,
// TODO: Set password empty?
'password' => $hash,
'hash' => $proofForPassword->getHash()->getName(),
'hashOptions' => $proofForPassword->getHash()->getOptions(),
'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS),
'hash' => Auth::DEFAULT_ALGO,
'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS,
/**
* Set the password update time to 0 for users created using
* team invite and OAuth to allow password updates without an
@ -618,7 +613,7 @@ App::post('/v1/teams/:teamId/memberships')
Query::equal('teamInternalId', [$team->getSequence()]),
]);
$secret = $proofForToken->generate();
$secret = Auth::tokenGenerator();
if ($membership->isEmpty()) {
$membershipId = ID::unique();
$membership = new Document([
@ -638,7 +633,7 @@ App::post('/v1/teams/:teamId/memberships')
'invited' => DateTime::now(),
'joined' => ($isPrivilegedUser || $isAppUser) ? DateTime::now() : null,
'confirm' => ($isPrivilegedUser || $isAppUser),
'secret' => $proofForToken->hash($secret),
'secret' => Auth::hash($secret),
'search' => implode(' ', [$membershipId, $invitee->getId()])
]);
@ -651,7 +646,7 @@ App::post('/v1/teams/:teamId/memberships')
}
} elseif ($membership->getAttribute('confirm') === false) {
$membership->setAttribute('secret', $proofForToken->hash($secret));
$membership->setAttribute('secret', Auth::hash($secret));
$membership->setAttribute('invited', DateTime::now());
if ($isPrivilegedUser || $isAppUser) {
@ -1075,7 +1070,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId')
if ($project->getId() === 'console') {
$roles = array_keys(Config::getParam('roles', []));
array_filter($roles, function ($role) {
return !in_array($role, [USER_ROLE_APPS, USER_ROLE_GUESTS, USER_ROLE_USERS]);
return !in_array($role, [Auth::USER_ROLE_APPS, Auth::USER_ROLE_GUESTS, Auth::USER_ROLE_USERS]);
});
return new ArrayList(new WhiteList($roles), APP_LIMIT_ARRAY_PARAMS_SIZE);
}
@ -1190,9 +1185,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
->inject('project')
->inject('geodb')
->inject('queueForEvents')
->inject('store')
->inject('proofForToken')
->action(function (string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) {
->action(function (string $teamId, string $membershipId, string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Reader $geodb, Event $queueForEvents) {
$protocol = $request->getProtocol();
$membership = $dbForProject->getDocument('memberships', $membershipId);
@ -1211,7 +1204,7 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
throw new Exception(Exception::TEAM_MEMBERSHIP_MISMATCH);
}
if (!$proofForToken->verify($secret, $membership->getAttribute('secret'))) {
if (Auth::hash($secret) !== $membership->getAttribute('secret')) {
throw new Exception(Exception::TEAM_INVALID_SECRET);
}
@ -1245,9 +1238,9 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$authDuration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$authDuration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::addSeconds(new \DateTime(), $authDuration);
$secret = $proofForToken->generate();
$secret = Auth::tokenGenerator();
$session = new Document(array_merge([
'$id' => ID::unique(),
'$permissions' => [
@ -1257,9 +1250,9 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
],
'userId' => $user->getId(),
'userInternalId' => $user->getSequence(),
'provider' => SESSION_PROVIDER_EMAIL,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => $user->getAttribute('email'),
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => ['email'],
@ -1271,19 +1264,14 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
Authorization::setRole(Role::user($userId)->toString());
$encoded = $store
->setProperty('id', $user->getId())
->setProperty('secret', $secret)
->encode();
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([$store->getKey() => $encoded]));
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
}
$response
->addCookie(
name: $store->getKey() . '_legacy',
value: $encoded,
name: Auth::$cookieName . '_legacy',
value: Auth::encodeSession($user->getId(), $secret),
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
domain: Config::getParam('cookieDomain'),
@ -1291,8 +1279,8 @@ App::patch('/v1/teams/:teamId/memberships/:membershipId/status')
httponly: true
)
->addCookie(
name: $store->getKey(),
value: $encoded,
name: Auth::$cookieName,
value: Auth::encodeSession($user->getId(), $secret),
expire: (new \DateTime($expire))->getTimestamp(),
path: '/',
domain: Config::getParam('cookieDomain'),

View file

@ -1,6 +1,7 @@
<?php
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\MFA\Type;
use Appwrite\Auth\MFA\Type\TOTP;
use Appwrite\Auth\Validator\Password;
@ -31,18 +32,6 @@ use Appwrite\Utopia\Response;
use MaxMind\Db\Reader;
use Utopia\App;
use Utopia\Audit\Audit;
use Utopia\Auth\Hash;
use Utopia\Auth\Hashes\Argon2;
use Utopia\Auth\Hashes\Bcrypt;
use Utopia\Auth\Hashes\MD5;
use Utopia\Auth\Hashes\PHPass;
use Utopia\Auth\Hashes\Plaintext;
use Utopia\Auth\Hashes\Scrypt;
use Utopia\Auth\Hashes\ScryptModified;
use Utopia\Auth\Hashes\Sha;
use Utopia\Auth\Proofs\Password as ProofsPassword;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Config\Config;
use Utopia\Database\Database;
use Utopia\Database\DateTime;
@ -71,9 +60,10 @@ use Utopia\Validator\Text;
use Utopia\Validator\WhiteList;
/** TODO: Remove function when we move to using utopia/platform */
function createUser(Hash $hash, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
function createUser(string $hash, mixed $hashOptions, string $userId, ?string $email, ?string $password, ?string $phone, string $name, Document $project, Database $dbForProject, Hooks $hooks): Document
{
$plaintextPassword = $password;
$hashOptionsObject = (\is_string($hashOptions)) ? \json_decode($hashOptions, true) : $hashOptions; // Cast to JSON array
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
if (!empty($email)) {
@ -107,18 +97,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
}
}
$hashedPassword = null;
if (!empty($password)) {
if ($hash instanceof Plaintext) { // Password was never hashed, hash it with the default hash
$defaultHash = new ProofsPassword();
$hashedPassword = $defaultHash->hash($password);
$hash = $defaultHash->getHash();
} else {
$hashedPassword = $password;
}
}
$password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null;
$user = new Document([
'$id' => $userId,
'$permissions' => [
@ -132,11 +111,11 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
'phoneVerification' => false,
'status' => true,
'labels' => [],
'password' => $hashedPassword,
'passwordHistory' => is_null($hashedPassword) || $passwordHistory === 0 ? [] : [$hashedPassword],
'passwordUpdate' => (!empty($hashedPassword)) ? DateTime::now() : null,
'hash' => $hash->getName(),
'hashOptions' => $hash->getOptions(),
'password' => $password,
'passwordHistory' => is_null($password) || $passwordHistory === 0 ? [] : [$password],
'passwordUpdate' => (!empty($password)) ? DateTime::now() : null,
'hash' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO : $hash,
'hashOptions' => $hash === 'plaintext' ? Auth::DEFAULT_ALGO_OPTIONS : $hashOptionsObject + ['type' => $hash],
'registration' => DateTime::now(),
'reset' => false,
'name' => $name,
@ -147,7 +126,7 @@ function createUser(Hash $hash, string $userId, ?string $email, ?string $passwor
'search' => implode(' ', [$userId, $email, $phone, $name]),
]);
if ($hash instanceof Plaintext) {
if ($hash === 'plaintext') {
$hooks->trigger('passwordValidator', [$dbForProject, $project, $plaintextPassword, &$user, true]);
}
@ -238,9 +217,7 @@ App::post('/v1/users')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, ?string $email, ?string $phone, ?string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$plaintext = new Plaintext();
$user = createUser($plaintext, $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
$user = createUser('plaintext', '{}', $userId, $email, $password, $phone, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($user, Response::MODEL_USER);
@ -274,10 +251,7 @@ App::post('/v1/users/bcrypt')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$bcrypt = new Bcrypt();
$bcrypt->setCost(8); // Default cost
$user = createUser($bcrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$user = createUser('bcrypt', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -312,9 +286,7 @@ App::post('/v1/users/md5')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$md5 = new MD5();
$user = createUser($md5, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$user = createUser('md5', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -349,13 +321,7 @@ App::post('/v1/users/argon2')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$argon2 = new Argon2();
$argon2
->setMemoryCost(2048)
->setTimeCost(4)
->setThreads(3);
$user = createUser($argon2, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$user = createUser('argon2', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -391,12 +357,13 @@ App::post('/v1/users/sha')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordVersion, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$sha = new Sha();
$options = '{}';
if (!empty($passwordVersion)) {
$sha->setVersion($passwordVersion);
$options = '{"version":"' . $passwordVersion . '"}';
}
$user = createUser($sha, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$user = createUser('sha', $options, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -431,9 +398,7 @@ App::post('/v1/users/phpass')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$phpass = new PHPass();
$user = createUser($phpass, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$user = createUser('phpass', '{}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -473,15 +438,15 @@ App::post('/v1/users/scrypt')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordSalt, int $passwordCpu, int $passwordMemory, int $passwordParallel, int $passwordLength, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$scrypt = new Scrypt();
$scrypt
->setSalt($passwordSalt)
->setCpuCost($passwordCpu)
->setMemoryCost($passwordMemory)
->setParallelCost($passwordParallel)
->setLength($passwordLength);
$options = [
'salt' => $passwordSalt,
'costCpu' => $passwordCpu,
'costMemory' => $passwordMemory,
'costParallel' => $passwordParallel,
'length' => $passwordLength
];
$user = createUser($scrypt, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$user = createUser('scrypt', \json_encode($options), $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -519,13 +484,7 @@ App::post('/v1/users/scrypt-modified')
->inject('dbForProject')
->inject('hooks')
->action(function (string $userId, string $email, string $password, string $passwordSalt, string $passwordSaltSeparator, string $passwordSignerKey, string $name, Response $response, Document $project, Database $dbForProject, Hooks $hooks) {
$scryptModified = new ScryptModified();
$scryptModified
->setSalt($passwordSalt)
->setSaltSeparator($passwordSaltSeparator)
->setSignerKey($passwordSignerKey);
$user = createUser($scryptModified, $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$user = createUser('scryptMod', '{"signerKey":"' . $passwordSignerKey . '","saltSeparator":"' . $passwordSaltSeparator . '","salt":"' . $passwordSalt . '"}', $userId, $email, $password, null, $name, $project, $dbForProject, $hooks);
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -1110,6 +1069,7 @@ App::get('/v1/users/identities')
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
}
if (!empty($search)) {
$queries[] = Query::search('search', $search);
}
@ -1376,21 +1336,12 @@ App::patch('/v1/users/:userId/password')
$hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]);
// Create Argon2 hasher with default settings
$hasher = new Argon2();
$hasher
->setMemoryCost(2048)
->setTimeCost(4)
->setThreads(3);
$newPassword = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS);
$newPassword = $hasher->hash($password);
$hash = ProofsPassword::createHash($user->getAttribute('hash'), $user->getAttribute('hashOptions'));
$historyLimit = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
$history = $user->getAttribute('passwordHistory', []);
if ($historyLimit > 0) {
$validator = new PasswordHistory($history, $hash);
$validator = new PasswordHistory($history, $user->getAttribute('hash'), $user->getAttribute('hashOptions'));
if (!$validator->isValid($password)) {
throw new Exception(Exception::USER_PASSWORD_RECENTLY_USED);
}
@ -1403,8 +1354,8 @@ App::patch('/v1/users/:userId/password')
->setAttribute('password', $newPassword)
->setAttribute('passwordHistory', $history)
->setAttribute('passwordUpdate', DateTime::now())
->setAttribute('hash', $hasher->getName())
->setAttribute('hashOptions', $hasher->getOptions());
->setAttribute('hash', Auth::DEFAULT_ALGO)
->setAttribute('hashOptions', Auth::DEFAULT_ALGO_OPTIONS);
$user = $dbForProject->updateDocument('users', $user->getId(), $user);
@ -2217,19 +2168,17 @@ App::post('/v1/users/:userId/sessions')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->inject('store')
->inject('proofForToken')
->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents, Store $store, Token $proofForToken) {
->action(function (string $userId, Request $request, Response $response, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
$user = $dbForProject->getDocument('users', $userId);
if ($user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$secret = $proofForToken->generate();
$secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$session = new Document(array_merge(
@ -2237,8 +2186,8 @@ App::post('/v1/users/:userId/sessions')
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getSequence(),
'provider' => SESSION_PROVIDER_SERVER,
'secret' => $proofForToken->hash($secret), // One way hash encryption to protect DB leak
'provider' => Auth::SESSION_PROVIDER_SERVER,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'factors' => ['server'],
'ip' => $request->getIP(),
@ -2262,13 +2211,8 @@ App::post('/v1/users/:userId/sessions')
$dbForProject->purgeCachedDocument('users', $user->getId());
$encoded = $store
->setProperty('id', $user->getId())
->setProperty('secret', $secret)
->encode();
$session
->setAttribute('secret', $encoded)
->setAttribute('secret', Auth::encodeSession($user->getId(), $secret))
->setAttribute('countryName', $countryName);
$queueForEvents
@ -2303,7 +2247,7 @@ App::post('/v1/users/:userId/tokens')
))
->param('userId', '', new UID(), 'User ID.')
->param('length', 6, new Range(4, 128), 'Token length in characters. The default length is 6 characters', true)
->param('expire', TOKEN_EXPIRATION_GENERIC, new Range(60, TOKEN_EXPIRATION_LOGIN_LONG), 'Token expiration period in seconds. The default expiration is 15 minutes.', true)
->param('expire', Auth::TOKEN_EXPIRATION_GENERIC, new Range(60, Auth::TOKEN_EXPIRATION_LOGIN_LONG), 'Token expiration period in seconds. The default expiration is 15 minutes.', true)
->inject('request')
->inject('response')
->inject('dbForProject')
@ -2315,17 +2259,15 @@ App::post('/v1/users/:userId/tokens')
throw new Exception(Exception::USER_NOT_FOUND);
}
$proofForToken = new Token($length);
$proofForToken->setHash(new Sha());
$secret = $proofForToken->generate();
$secret = Auth::tokenGenerator($length);
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $expire));
$token = new Document([
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getSequence(),
'type' => TOKEN_TYPE_GENERIC,
'secret' => $proofForToken->hash($secret),
'type' => Auth::TOKEN_TYPE_GENERIC,
'secret' => Auth::hash($secret),
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP()

View file

@ -1185,29 +1185,17 @@ App::error()
$trace = $error->getTrace();
if (php_sapi_name() === 'cli') {
$logLevel = $code >= 500 || $code == 0 ? 'error' : 'warning';
$logPrefix = $code >= 500 || $code == 0 ? '[Error]' : '[Warning]';
Console::{$logLevel}($logPrefix . ' Timestamp: ' . date('c', time()));
Console::error('[Error] Timestamp: ' . date('c', time()));
if ($route) {
Console::{$logLevel}($logPrefix . ' Status Code: ' . $code);
Console::{$logLevel}($logPrefix . ' URL: ' . $route->getMethod() . ' ' . $route->getPath());
Console::error('[Error] Method: ' . $route->getMethod());
Console::error('[Error] URL: ' . $route->getPath());
}
Console::{$logLevel}($logPrefix . ' Type: ' . get_class($error));
Console::{$logLevel}($logPrefix . ' Message: ' . $message);
Console::{$logLevel}($logPrefix . ' File: ' . $file);
Console::{$logLevel}($logPrefix . ' Line: ' . $line);
Console::{$logLevel}($logPrefix . ' Trace:');
foreach ($trace as $index => $entry) {
$traceFile = $entry['file'] ?? 'unknown';
$traceLine = $entry['line'] ?? 0;
$traceFunction = $entry['function'] ?? 'unknown';
$traceClass = $entry['class'] ?? '';
$traceType = $entry['type'] ?? '';
Console::{$logLevel}(" #{$index} {$traceFile}({$traceLine}): {$traceClass}{$traceType}{$traceFunction}()");
}
Console::{$logLevel}('');
Console::error('[Error] Type: ' . get_class($error));
Console::error('[Error] Message: ' . $message);
Console::error('[Error] File: ' . $file);
Console::error('[Error] Line: ' . $line);
}
switch ($class) {

View file

@ -222,94 +222,39 @@ App::init()
->action(function (App $utopia, Request $request, Database $dbForPlatform, Database $dbForProject, Audit $queueForAudits, Document $project, Document $user, ?Document $session, array $servers, string $mode, Document $team, ?Key $apiKey) {
$route = $utopia->getRoute();
/**
* Handle user authentication and session validation.
*
* This function follows a series of steps to determine the appropriate user session
* based on cookies, headers, and JWT tokens.
*
* Process:
*
* Project & Role Validation:
* 1. Check if the project is empty. If so, throw an exception.
* 2. Get the roles configuration.
* 3. Determine the role for the user based on the user document.
* 4. Get the scopes for the role.
*
* API Key Authentication:
* 5. If there is an API key:
* - Verify no user session exists simultaneously
* - Check if key is expired
* - Set role and scopes from API key
* - Handle special app role case
* - For standard keys, update last accessed time
*
* User Activity:
* 6. If the project is not the console and user is not admin:
* - Update user's last activity timestamp
*
* Access Control:
* 7. Get the method from the route
* 8. Validate namespace permissions
* 9. Validate scope permissions
* 10. Check if user is blocked
*
* Security Checks:
* 11. Verify password status (check if reset required)
* 12. Validate MFA requirements:
* - Check if MFA is enabled
* - Verify email status
* - Verify phone status
* - Verify authenticator status
* 13. Handle Multi-Factor Authentication:
* - Check remaining required factors
* - Validate factor completion
* - Throw exception if factors incomplete
*/
// Step 1: Check if project is empty
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
// Step 2: Get roles configuration
$roles = Config::getParam('roles', []);
// Step 3: Determine role for user
// TODO get scopes from the identity instead of the user roles config. The identity will containn the scopes the user authorized for the access token.
$role = $user->isEmpty()
? Role::guests()->toString()
: Role::users()->toString();
// Step 4: Get scopes for the role
$scopes = $roles[$role]['scopes'];
// Step 5: API Key Authentication
// API Key authentication
if (!empty($apiKey)) {
// Verify no user session exists simultaneously
if (!$user->isEmpty()) {
throw new Exception(Exception::USER_API_KEY_AND_SESSION_SET);
}
// Check if key is expired
if ($apiKey->isExpired()) {
throw new Exception(Exception::PROJECT_KEY_EXPIRED);
}
// Set role and scopes from API key
$role = $apiKey->getRole();
$scopes = $apiKey->getScopes();
// Handle special app role case
if ($apiKey->getRole() === USER_ROLE_APPS) {
if ($apiKey->getRole() === Auth::USER_ROLE_APPS) {
// Disable authorization checks for API keys
Authorization::setDefaultStatus(false);
$user = new Document([
'$id' => '',
'status' => true,
'type' => ACTIVITY_TYPE_APP,
'type' => Auth::ACTIVITY_TYPE_APP,
'email' => 'app.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => $apiKey->getName(),
@ -318,7 +263,6 @@ App::init()
$queueForAudits->setUser($user);
}
// For standard keys, update last accessed time
if ($apiKey->getType() === API_KEY_STANDARD) {
$dbKey = $project->find(
key: 'secret',
@ -388,7 +332,7 @@ App::init()
Authorization::setRole($authRole);
}
// Step 6: Update project and user last activity
// Update project last activity
if (!$project->isEmpty() && $project->getId() !== 'console') {
$accessedAt = $project->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_PROJECT_ACCESS)) > $accessedAt) {
@ -397,6 +341,7 @@ App::init()
}
}
// Update user last activity
if (!empty($user->getId())) {
$accessedAt = $user->getAttribute('accessedAt', 0);
if (DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -APP_USER_ACCESS)) > $accessedAt) {
@ -410,7 +355,6 @@ App::init()
}
}
// Steps 7-9: Access Control - Method, Namespace and Scope Validation
/**
* @var ?Method $method
*/
@ -434,23 +378,21 @@ App::init()
}
}
// Step 9: Validate scope permissions
// Do now allow access if scope is not allowed
$allowed = (array)$route->getLabel('scope', 'none');
if (empty(\array_intersect($allowed, $scopes))) {
throw new Exception(Exception::GENERAL_UNAUTHORIZED_SCOPE, $user->getAttribute('email', 'User') . ' (role: ' . \strtolower($roles[$role]['label']) . ') missing scopes (' . \json_encode($allowed) . ')');
}
// Step 10: Check if user is blocked
// Do not allow access to blocked accounts
if (false === $user->getAttribute('status')) { // Account is blocked
throw new Exception(Exception::USER_BLOCKED);
}
// Step 11: Verify password status
if ($user->getAttribute('reset')) {
throw new Exception(Exception::USER_PASSWORD_RESET_REQUIRED);
}
// Step 12: Validate MFA requirements
$mfaEnabled = $user->getAttribute('mfa', false);
$hasVerifiedEmail = $user->getAttribute('emailVerification', false);
$hasVerifiedPhone = $user->getAttribute('phoneVerification', false);
@ -458,7 +400,6 @@ App::init()
$hasMoreFactors = $hasVerifiedEmail || $hasVerifiedPhone || $hasVerifiedAuthenticator;
$minimumFactors = ($mfaEnabled && $hasMoreFactors) ? 2 : 1;
// Step 13: Handle Multi-Factor Authentication
if (!in_array('mfa', $route->getGroups())) {
if ($session && \count($session->getAttribute('factors', [])) < $minimumFactors) {
throw new Exception(Exception::USER_MORE_FACTORS_REQUIRED);
@ -585,7 +526,7 @@ App::init()
if (!$user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
$userClone->setAttribute('type', Auth::ACTIVITY_TYPE_USER);
$queueForAudits->setUser($userClone);
}
@ -825,7 +766,7 @@ App::shutdown()
if (!$user->isEmpty()) {
$userClone = clone $user;
// $user doesn't support `type` and can cause unintended effects.
$userClone->setAttribute('type', ACTIVITY_TYPE_USER);
$userClone->setAttribute('type', Auth::ACTIVITY_TYPE_USER);
$queueForAudits->setUser($userClone);
} elseif ($queueForAudits->getUser() === null || $queueForAudits->getUser()->isEmpty()) {
/**
@ -839,7 +780,7 @@ App::shutdown()
$user = new Document([
'$id' => '',
'status' => true,
'type' => ACTIVITY_TYPE_GUEST,
'type' => Auth::ACTIVITY_TYPE_GUEST,
'email' => 'guest.' . $project->getId() . '@service.' . $request->getHostname(),
'password' => '',
'name' => 'Guest',

View file

@ -20,7 +20,7 @@ App::init()
$lastUpdate = $session->getAttribute('mfaUpdatedAt');
if (!empty($lastUpdate)) {
$now = DateTime::now();
$maxAllowedDate = DateTime::addSeconds(new \DateTime($lastUpdate), MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge
$maxAllowedDate = DateTime::addSeconds(new \DateTime($lastUpdate), Auth::MFA_RECENT_DURATION); // Maximum date until session is considered safe before asking for another challenge
$isSessionFresh = DateTime::formatTz($maxAllowedDate) >= DateTime::formatTz($now);
}

View file

@ -92,72 +92,6 @@ const APP_VCS_GITHUB_USERNAME = 'Appwrite';
const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io';
const APP_BRANDED_EMAIL_BASE_TEMPLATE = 'email-base-styled';
// User Roles
const USER_ROLE_ANY = 'any';
const USER_ROLE_GUESTS = 'guests';
const USER_ROLE_USERS = 'users';
const USER_ROLE_ADMIN = 'admin';
const USER_ROLE_DEVELOPER = 'developer';
const USER_ROLE_OWNER = 'owner';
const USER_ROLE_APPS = 'apps';
const USER_ROLE_SYSTEM = 'system';
/**
* Token Expiration times.
*/
const TOKEN_EXPIRATION_LOGIN_LONG = 31536000; /* 1 year */
const TOKEN_EXPIRATION_LOGIN_SHORT = 3600; /* 1 hour */
const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */
const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */
const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */
const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */
/**
* Token Lengths.
*/
const TOKEN_LENGTH_MAGIC_URL = 64;
const TOKEN_LENGTH_VERIFICATION = 256;
const TOKEN_LENGTH_RECOVERY = 256;
const TOKEN_LENGTH_OAUTH2 = 64;
const TOKEN_LENGTH_SESSION = 256;
/**
* Token Types.
*/
const TOKEN_TYPE_LOGIN = 1; // Deprecated
const TOKEN_TYPE_VERIFICATION = 2;
const TOKEN_TYPE_RECOVERY = 3;
const TOKEN_TYPE_INVITE = 4;
const TOKEN_TYPE_MAGIC_URL = 5;
const TOKEN_TYPE_PHONE = 6;
const TOKEN_TYPE_OAUTH2 = 7;
const TOKEN_TYPE_GENERIC = 8;
const TOKEN_TYPE_EMAIL = 9; // OTP
/**
* Session Providers.
*/
const SESSION_PROVIDER_EMAIL = 'email';
const SESSION_PROVIDER_ANONYMOUS = 'anonymous';
const SESSION_PROVIDER_MAGIC_URL = 'magic-url';
const SESSION_PROVIDER_PHONE = 'phone';
const SESSION_PROVIDER_OAUTH2 = 'oauth2';
const SESSION_PROVIDER_TOKEN = 'token';
const SESSION_PROVIDER_SERVER = 'server';
/**
* Activity associated with user or the app.
*/
const ACTIVITY_TYPE_APP = 'app';
const ACTIVITY_TYPE_USER = 'user';
const ACTIVITY_TYPE_GUEST = 'guest';
/**
* MFA
*/
const MFA_RECENT_DURATION = 1800; // 30 mins
// Database Reconnect
const DATABASE_RECONNECT_SLEEP = 2;
const DATABASE_RECONNECT_MAX_ATTEMPTS = 10;

View file

@ -24,16 +24,9 @@ use Appwrite\GraphQL\Schema;
use Appwrite\Network\Platform;
use Appwrite\Network\Validator\Origin;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Executor\Executor;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\App;
use Utopia\Auth\Hashes\Argon2;
use Utopia\Auth\Hashes\Sha;
use Utopia\Auth\Proofs\Code;
use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
@ -233,93 +226,72 @@ App::setResource('platforms', function (Request $request, Document $console, Doc
];
}, ['request', 'console', 'project', 'dbForPlatform']);
App::setResource('user', function (string $mode, Document $project, Document $console, Request $request, Response $response, Database $dbForProject, Database $dbForPlatform, Store $store, Token $proofForToken) {
App::setResource('user', function ($mode, $project, $console, $request, $response, $dbForProject, $dbForPlatform) {
/** @var Appwrite\Utopia\Request $request */
/** @var Appwrite\Utopia\Response $response */
/** @var Utopia\Database\Document $project */
/** @var Utopia\Database\Database $dbForProject */
/** @var Utopia\Database\Database $dbForPlatform */
/** @var string $mode */
/** @var Utopia\Auth\Store $store */
/**
* Handles user authentication and session validation.
*
* This function follows a series of steps to determine the appropriate user session
* based on cookies, headers, and JWT tokens.
*
* Process:
* 1. Checks the cookie based on mode:
* - If in admin mode, uses console project id for key.
* - Otherwise, sets the key using the project ID
* 2. If no cookie is found, attempts to retrieve the fallback header `x-fallback-cookies`.
* - If this method is used, returns the header: `X-Debug-Fallback: true`.
* 3. Fetches the user document from the appropriate database based on the mode.
* 4. If the user document is empty or the session key cannot be verified, sets an empty user document.
* 5. Regardless of the results from steps 1-4, attempts to fetch the JWT token.
* 6. If the JWT user has a valid session ID, updates the user variable with the user from `projectDB`,
* overwriting the previous value.
*/
Authorization::setDefaultStatus(true);
$store->setKey('a_session_' . $project->getId());
Auth::setCookieName('a_session_' . $project->getId());
if (APP_MODE_ADMIN === $mode) {
$store->setKey('a_session_' . $console->getId());
Auth::setCookieName('a_session_' . $console->getId());
}
$store->decode(
$session = Auth::decodeSession(
$request->getCookie(
$store->getKey(), // Get sessions
$request->getCookie($store->getKey() . '_legacy', '')
Auth::$cookieName, // Get sessions
$request->getCookie(Auth::$cookieName . '_legacy', '')
)
);
// Get session from header for SSR clients
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
if (empty($session['id']) && empty($session['secret'])) {
$sessionHeader = $request->getHeader('x-appwrite-session', '');
if (!empty($sessionHeader)) {
$store->decode($sessionHeader);
$session = Auth::decodeSession($sessionHeader);
}
}
// Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
if ($response) { // if in http context - add debug header
if ($response) {
$response->addHeader('X-Debug-Fallback', 'false');
}
if (empty($store->getProperty('id', '')) && empty($store->getProperty('secret', ''))) {
if (empty($session['id']) && empty($session['secret'])) {
if ($response) {
$response->addHeader('X-Debug-Fallback', 'true');
}
$fallback = $request->getHeader('x-fallback-cookies', '');
$fallback = \json_decode($fallback, true);
$store->decode(((is_array($fallback) && isset($fallback[$store->getKey()])) ? $fallback[$store->getKey()] : ''));
$session = Auth::decodeSession(((isset($fallback[Auth::$cookieName])) ? $fallback[Auth::$cookieName] : ''));
}
Auth::$unique = $session['id'] ?? '';
Auth::$secret = $session['secret'] ?? '';
$user = new Document([]);
if (APP_MODE_ADMIN === $mode) {
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
} else {
if ($project->isEmpty()) {
$user = new Document([]);
} else {
if (!empty($store->getProperty('id', ''))) {
if ($project->getId() === 'console') {
$user = $dbForPlatform->getDocument('users', $store->getProperty('id', ''));
} else {
$user = $dbForProject->getDocument('users', $store->getProperty('id', ''));
}
if (!empty(Auth::$unique)) {
if ($mode === APP_MODE_ADMIN) {
$user = $dbForPlatform->getDocument('users', Auth::$unique);
} elseif (!$project->isEmpty()) {
if ($project->getId() === 'console') {
$user = $dbForPlatform->getDocument('users', Auth::$unique);
} else {
$user = $dbForProject->getDocument('users', Auth::$unique);
}
}
}
if (
$user->isEmpty() // Check a document has been found in the DB
|| !Auth::sessionVerify($user->getAttribute('sessions', []), $store->getProperty('secret', ''), $proofForToken)
|| !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret)
) { // Validate user has valid login token
$user = new Document([]);
}
@ -364,7 +336,7 @@ App::setResource('user', function (string $mode, Document $project, Document $co
$dbForPlatform->setMetadata('user', $user->getId());
return $user;
}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform', 'store', 'proofForToken']);
}, ['mode', 'project', 'console', 'request', 'response', 'dbForProject', 'dbForPlatform']);
App::setResource('project', function ($dbForPlatform, $request, $console) {
/** @var Appwrite\Utopia\Request $request */
@ -382,13 +354,13 @@ App::setResource('project', function ($dbForPlatform, $request, $console) {
return $project;
}, ['dbForPlatform', 'request', 'console']);
App::setResource('session', function (Document $user, Store $store, Token $proofForToken) {
App::setResource('session', function (Document $user) {
if ($user->isEmpty()) {
return;
}
$sessions = $user->getAttribute('sessions', []);
$sessionId = Auth::sessionVerify($user->getAttribute('sessions'), $store->getProperty('secret', ''), $proofForToken);
$sessionId = Auth::sessionVerify($user->getAttribute('sessions'), Auth::$secret);
if (!$sessionId) {
return;
@ -401,7 +373,7 @@ App::setResource('session', function (Document $user, Store $store, Token $proof
}
return;
}, ['user', 'store', 'proofForToken']);
}, ['user']);
App::setResource('console', function () {
return new Document(Config::getParam('console'));
@ -975,37 +947,6 @@ App::setResource('apiKey', function (Request $request, Document $project): ?Key
return Key::decode($project, $key);
}, ['request', 'project']);
App::setResource('store', function (): Store {
return new Store();
});
App::setResource('proofForPassword', function (): Password {
$hash = new Argon2();
$hash
->setMemoryCost(2048)
->setTimeCost(4)
->setThreads(3);
$password = new Password();
$password
->setHash($hash);
return $password;
});
App::setResource('proofForToken', function (): Token {
$token = new Token();
$token->setHash(new Sha());
return $token;
});
App::setResource('proofForCode', function (): Code {
$code = new Code();
$code->setHash(new Sha());
return $code;
});
App::setResource('executor', fn () => new Executor());
App::setResource('resourceToken', function ($project, $dbForProject, $request) {

View file

@ -16,9 +16,6 @@ use Swoole\Timer;
use Utopia\Abuse\Abuse;
use Utopia\Abuse\Adapters\TimeLimit\Redis as TimeLimitRedis;
use Utopia\App;
use Utopia\Auth\Hashes\Sha;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\Cache\Adapter\Pool as CachePool;
use Utopia\Cache\Adapter\Sharding;
use Utopia\Cache\Cache;
@ -681,24 +678,15 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Payload is not valid.');
}
$store = new Store();
$session = Auth::decodeSession($message['data']['session']);
Auth::$unique = $session['id'] ?? '';
Auth::$secret = $session['secret'] ?? '';
$store->decode($message['data']['session']);
$user = $database->getDocument('users', $store->getProperty('id', ''));
/**
* TODO:
* Moving forward, we should try to use our dependency injection container
* to inject the proof for token.
* This way we will have one source of truth for the proof for token.
*/
$proofForToken = new Token();
$proofForToken->setHash(new Sha());
$user = $database->getDocument('users', Auth::$unique);
if (
empty($user->getId()) // Check a document has been found in the DB
|| !Auth::sessionVerify($user->getAttribute('sessions', []), $store->getProperty('secret', ''), $proofForToken) // Validate user has valid login token
|| !Auth::sessionVerify($user->getAttribute('sessions', []), Auth::$secret) // Validate user has valid login token
) {
// cookie not valid
throw new Exception(Exception::REALTIME_MESSAGE_FORMAT_INVALID, 'Session is not valid.');

View file

@ -46,7 +46,6 @@
"ext-sockets": "*",
"appwrite/php-runtimes": "0.19.*",
"appwrite/php-clamav": "2.0.*",
"utopia-php/auth": "0.4.*",
"utopia-php/abuse": "1.*",
"utopia-php/analytics": "0.10.*",
"utopia-php/audit": "1.*",

83
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3e8df036b4cb47d2eae34be382e04800",
"content-hash": "407c1717bfef580d733ff2bbb232ec8a",
"packages": [
{
"name": "adhocore/jwt",
@ -3592,61 +3592,6 @@
},
"time": "2025-10-20T07:14:26+00:00"
},
{
"name": "utopia-php/auth",
"version": "0.4.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/auth.git",
"reference": "02415e1a89cdbc14e3e16a7856ecf7f868869449"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/auth/zipball/02415e1a89cdbc14e3e16a7856ecf7f868869449",
"reference": "02415e1a89cdbc14e3e16a7856ecf7f868869449",
"shasum": ""
},
"require": {
"ext-hash": "*",
"ext-scrypt": "*",
"ext-sodium": "*",
"php": ">=8.0"
},
"require-dev": {
"laravel/pint": "1.2.*",
"phpstan/phpstan": "1.9.x-dev",
"phpunit/phpunit": "^9.3",
"vimeo/psalm": "4.0.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Utopia\\Auth\\": "src/Auth"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Utopia PHP",
"email": "team@appwrite.io"
}
],
"description": "A simple PHP authentication library",
"keywords": [
"Authentication",
"auth",
"php",
"security"
],
"support": {
"issues": "https://github.com/utopia-php/auth/issues",
"source": "https://github.com/utopia-php/auth/tree/0.4.0"
},
"time": "2025-04-29T19:29:28+00:00"
},
{
"name": "utopia-php/cache",
"version": "0.13.1",
@ -3847,16 +3792,16 @@
},
{
"name": "utopia-php/database",
"version": "3.0.1",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/database.git",
"reference": "da0d583e1590e37515edfa338d8684c01833455f"
"reference": "b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/database/zipball/da0d583e1590e37515edfa338d8684c01833455f",
"reference": "da0d583e1590e37515edfa338d8684c01833455f",
"url": "https://api.github.com/repos/utopia-php/database/zipball/b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e",
"reference": "b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e",
"shasum": ""
},
"require": {
@ -3866,7 +3811,7 @@
"php": ">=8.1",
"utopia-php/cache": "0.13.*",
"utopia-php/framework": "0.33.*",
"utopia-php/mongo": "0.10.*",
"utopia-php/mongo": "0.11.*",
"utopia-php/pools": "0.8.*"
},
"require-dev": {
@ -3899,9 +3844,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/database/issues",
"source": "https://github.com/utopia-php/database/tree/3.0.0"
"source": "https://github.com/utopia-php/database/tree/3.0.2"
},
"time": "2025-10-20T05:51:31+00:00"
"time": "2025-10-20T23:58:56+00:00"
},
{
"name": "utopia-php/detector",
@ -4457,16 +4402,16 @@
},
{
"name": "utopia-php/mongo",
"version": "0.10.0",
"version": "0.11.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/mongo.git",
"reference": "ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18"
"reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/utopia-php/mongo/zipball/ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18",
"reference": "ecfad6aad2e2e3fe5899ac2ebf1009a21b4d6b18",
"url": "https://api.github.com/repos/utopia-php/mongo/zipball/34bc0cda8ea368cde68702a6fffe2c3ac625398e",
"reference": "34bc0cda8ea368cde68702a6fffe2c3ac625398e",
"shasum": ""
},
"require": {
@ -4512,9 +4457,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/mongo/issues",
"source": "https://github.com/utopia-php/mongo/tree/0.10.0"
"source": "https://github.com/utopia-php/mongo/tree/0.11.0"
},
"time": "2025-10-02T04:50:07+00:00"
"time": "2025-10-20T11:11:23+00:00"
},
{
"name": "utopia-php/orchestration",

View file

@ -2,8 +2,13 @@
namespace Appwrite\Auth;
use Utopia\Auth\Proof;
use Utopia\Auth\Proofs\Token;
use Appwrite\Auth\Hash\Argon2;
use Appwrite\Auth\Hash\Bcrypt;
use Appwrite\Auth\Hash\Md5;
use Appwrite\Auth\Hash\Phpass;
use Appwrite\Auth\Hash\Scrypt;
use Appwrite\Auth\Hash\Scryptmodified;
use Appwrite\Auth\Hash\Sha;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\Role;
@ -12,45 +17,186 @@ use Utopia\Database\Validator\Roles;
class Auth
{
public const SUPPORTED_ALGOS = [
'argon2',
'bcrypt',
'md5',
'sha',
'phpass',
'scrypt',
'scryptMod',
'plaintext'
];
public const DEFAULT_ALGO = 'argon2';
public const DEFAULT_ALGO_OPTIONS = ['type' => 'argon2', 'memoryCost' => 2048, 'timeCost' => 4, 'threads' => 3];
/**
* User Roles.
*/
public const USER_ROLE_ANY = 'any';
public const USER_ROLE_GUESTS = 'guests';
public const USER_ROLE_USERS = 'users';
public const USER_ROLE_ADMIN = 'admin';
public const USER_ROLE_DEVELOPER = 'developer';
public const USER_ROLE_OWNER = 'owner';
public const USER_ROLE_APPS = 'apps';
public const USER_ROLE_SYSTEM = 'system';
/**
* Activity associated with user or the app.
*/
public const ACTIVITY_TYPE_APP = 'app';
public const ACTIVITY_TYPE_USER = 'user';
public const ACTIVITY_TYPE_GUEST = 'guest';
/**
* Token Types.
*/
public const TOKEN_TYPE_LOGIN = 1; // Deprecated
public const TOKEN_TYPE_VERIFICATION = 2;
public const TOKEN_TYPE_RECOVERY = 3;
public const TOKEN_TYPE_INVITE = 4;
public const TOKEN_TYPE_MAGIC_URL = 5;
public const TOKEN_TYPE_PHONE = 6;
public const TOKEN_TYPE_OAUTH2 = 7;
public const TOKEN_TYPE_GENERIC = 8;
public const TOKEN_TYPE_EMAIL = 9; // OTP
/**
* Session Providers.
*/
public const SESSION_PROVIDER_EMAIL = 'email';
public const SESSION_PROVIDER_ANONYMOUS = 'anonymous';
public const SESSION_PROVIDER_MAGIC_URL = 'magic-url';
public const SESSION_PROVIDER_PHONE = 'phone';
public const SESSION_PROVIDER_OAUTH2 = 'oauth2';
public const SESSION_PROVIDER_TOKEN = 'token';
public const SESSION_PROVIDER_SERVER = 'server';
/**
* Token Expiration times.
*/
public const TOKEN_EXPIRATION_LOGIN_LONG = 31536000; /* 1 year */
public const TOKEN_EXPIRATION_LOGIN_SHORT = 3600; /* 1 hour */
public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */
public const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */
public const TOKEN_EXPIRATION_OTP = 60 * 15; /* 15 minutes */
public const TOKEN_EXPIRATION_GENERIC = 60 * 15; /* 15 minutes */
/**
* Token Lengths.
*/
public const TOKEN_LENGTH_MAGIC_URL = 64;
public const TOKEN_LENGTH_VERIFICATION = 256;
public const TOKEN_LENGTH_RECOVERY = 256;
public const TOKEN_LENGTH_OAUTH2 = 64;
public const TOKEN_LENGTH_SESSION = 256;
/**
* MFA
*/
public const MFA_RECENT_DURATION = 1800; // 30 mins
/**
* @var string
*/
public static $cookieName = 'a_session';
/**
* @var string
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
*/
public static $cookieNamePreview = 'a_jwt_console';
/**
* Token type to session provider mapping.
* User Unique ID.
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param int $type
* @var string
*/
public static $unique = '';
/**
* User Secret Key.
*
* @var string
*/
public static $secret = '';
/**
* Set Cookie Name.
*
* @param $string
*
* @return string
*/
public static function setCookieName($string)
{
return self::$cookieName = $string;
}
/**
* Encode Session.
*
* @param string $id
* @param string $secret
*
* @return string
*/
public static function encodeSession($id, $secret)
{
return \base64_encode(\json_encode([
'id' => $id,
'secret' => $secret,
]));
}
/**
* Token type to session provider mapping.
*/
public static function getSessionProviderByTokenType(int $type): string
{
switch ($type) {
case TOKEN_TYPE_VERIFICATION:
case TOKEN_TYPE_RECOVERY:
case TOKEN_TYPE_INVITE:
return SESSION_PROVIDER_EMAIL;
case TOKEN_TYPE_MAGIC_URL:
return SESSION_PROVIDER_MAGIC_URL;
case TOKEN_TYPE_PHONE:
return SESSION_PROVIDER_PHONE;
case TOKEN_TYPE_OAUTH2:
return SESSION_PROVIDER_OAUTH2;
case Auth::TOKEN_TYPE_VERIFICATION:
case Auth::TOKEN_TYPE_RECOVERY:
case Auth::TOKEN_TYPE_INVITE:
return Auth::SESSION_PROVIDER_EMAIL;
case Auth::TOKEN_TYPE_MAGIC_URL:
return Auth::SESSION_PROVIDER_MAGIC_URL;
case Auth::TOKEN_TYPE_PHONE:
return Auth::SESSION_PROVIDER_PHONE;
case Auth::TOKEN_TYPE_OAUTH2:
return Auth::SESSION_PROVIDER_OAUTH2;
default:
return SESSION_PROVIDER_TOKEN;
return Auth::SESSION_PROVIDER_TOKEN;
}
}
/**
* Decode Session.
*
* @param string $session
*
* @return array
*
* @throws \Exception
*/
public static function decodeSession($session)
{
$session = \json_decode(\base64_decode($session), true);
$default = ['id' => null, 'secret' => ''];
if (!\is_array($session)) {
return $default;
}
return \array_merge($default, $session);
}
/**
* Encode.
*
* One-way encryption
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param $string
*
* @return string
@ -60,12 +206,124 @@ class Auth
return \hash('sha256', $string);
}
/**
* Password Hash.
*
* One way string hashing for user passwords
*
* @param string $string
* @param string $algo hashing algorithm to use
* @param array $options algo-specific options
*
* @return bool|string|null
*/
public static function passwordHash(string $string, string $algo, array $options = [])
{
// Plain text not supported, just an alias. Switch to recommended algo
if ($algo === 'plaintext') {
$algo = Auth::DEFAULT_ALGO;
$options = Auth::DEFAULT_ALGO_OPTIONS;
}
if (!\in_array($algo, Auth::SUPPORTED_ALGOS)) {
throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.');
}
switch ($algo) {
case 'argon2':
$hasher = new Argon2($options);
return $hasher->hash($string);
case 'bcrypt':
$hasher = new Bcrypt($options);
return $hasher->hash($string);
case 'md5':
$hasher = new Md5($options);
return $hasher->hash($string);
case 'sha':
$hasher = new Sha($options);
return $hasher->hash($string);
case 'phpass':
$hasher = new Phpass($options);
return $hasher->hash($string);
case 'scrypt':
$hasher = new Scrypt($options);
return $hasher->hash($string);
case 'scryptMod':
$hasher = new Scryptmodified($options);
return $hasher->hash($string);
default:
throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.');
}
}
/**
* Password verify.
*
* @param string $plain
* @param string $hash
* @param string $algo hashing algorithm used to hash
* @param array $options algo-specific options
*
* @return bool
*/
public static function passwordVerify(string $plain, string $hash, string $algo, array $options = [])
{
// Plain text not supported, just an alias. Switch to recommended algo
if ($algo === 'plaintext') {
$algo = Auth::DEFAULT_ALGO;
$options = Auth::DEFAULT_ALGO_OPTIONS;
}
if (!\in_array($algo, Auth::SUPPORTED_ALGOS)) {
throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.');
}
switch ($algo) {
case 'argon2':
$hasher = new Argon2($options);
return $hasher->verify($plain, $hash);
case 'bcrypt':
$hasher = new Bcrypt($options);
return $hasher->verify($plain, $hash);
case 'md5':
$hasher = new Md5($options);
return $hasher->verify($plain, $hash);
case 'sha':
$hasher = new Sha($options);
return $hasher->verify($plain, $hash);
case 'phpass':
$hasher = new Phpass($options);
return $hasher->verify($plain, $hash);
case 'scrypt':
$hasher = new Scrypt($options);
return $hasher->verify($plain, $hash);
case 'scryptMod':
$hasher = new Scryptmodified($options);
return $hasher->verify($plain, $hash);
default:
throw new \Exception('Hashing algorithm \'' . $algo . '\' is not supported.');
}
}
/**
* Password Generator.
*
* Generate random password string
*
* @param int $length
*
* @return string
*/
public static function passwordGenerator(int $length = 20): string
{
return \bin2hex(\random_bytes($length));
}
/**
* Token Generator.
*
* Generate random password string
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param int $length Length of returned token
*
* @return string
@ -82,17 +340,36 @@ class Auth
return substr($token, 0, $length);
}
/**
* Code Generator.
*
* Generate random code string
*
* @param int $length
*
* @return string
*/
public static function codeGenerator(int $length = 6): string
{
$value = '';
for ($i = 0; $i < $length; $i++) {
$value .= random_int(0, 9);
}
return $value;
}
/**
* Verify token and check that its not expired.
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param array<Document> $tokens
* @param int $type Type of token to verify, if null will verify any type
* @param string $secret
*
* @return false|Document
*/
public static function tokenVerify(array $tokens, int $type = null, string $secret, Proof $proofForToken): false|Document
public static function tokenVerify(array $tokens, int $type = null, string $secret): false|Document
{
foreach ($tokens as $token) {
if (
@ -100,7 +377,7 @@ class Auth
$token->isSet('expire') &&
$token->isSet('type') &&
($type === null || $token->getAttribute('type') === $type) &&
$proofForToken->verify($secret, $token->getAttribute('secret')) &&
$token->getAttribute('secret') === self::hash($secret) &&
DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now())
) {
return $token;
@ -113,19 +390,18 @@ class Auth
/**
* Verify session and check that its not expired.
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param array<Document> $sessions
* @param string $secret
*
* @return bool|string
*/
public static function sessionVerify(array $sessions, string $secret, Token $proofForToken)
public static function sessionVerify(array $sessions, string $secret)
{
foreach ($sessions as $session) {
if (
$session->isSet('secret') &&
$session->isSet('provider') &&
$proofForToken->verify($secret, $session->getAttribute('secret')) &&
$session->getAttribute('secret') === self::hash($secret) &&
DateTime::formatTz(DateTime::format(new \DateTime($session->getAttribute('expire')))) >= DateTime::formatTz(DateTime::now())
) {
return $session->getId();
@ -138,7 +414,6 @@ class Auth
/**
* Is Privileged User?
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param array<string> $roles
*
* @return bool
@ -146,9 +421,9 @@ class Auth
public static function isPrivilegedUser(array $roles): bool
{
if (
in_array(USER_ROLE_OWNER, $roles) ||
in_array(USER_ROLE_DEVELOPER, $roles) ||
in_array(USER_ROLE_ADMIN, $roles)
in_array(self::USER_ROLE_OWNER, $roles) ||
in_array(self::USER_ROLE_DEVELOPER, $roles) ||
in_array(self::USER_ROLE_ADMIN, $roles)
) {
return true;
}
@ -159,14 +434,13 @@ class Auth
/**
* Is App User?
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param array<string> $roles
*
* @return bool
*/
public static function isAppUser(array $roles): bool
{
if (in_array(USER_ROLE_APPS, $roles)) {
if (in_array(self::USER_ROLE_APPS, $roles)) {
return true;
}
@ -176,7 +450,6 @@ class Auth
/**
* Returns all roles for a user.
*
* @deprecated We plan to deprecate this class in the future. Use Utopia Auth when possible.
* @param Document $user
* @return array<string>
*/
@ -227,4 +500,16 @@ class Auth
return $roles;
}
/**
* Check if user is anonymous.
*
* @param Document $user
* @return bool
*/
public static function isAnonymousUser(Document $user): bool
{
return is_null($user->getAttribute('email'))
&& is_null($user->getAttribute('phone'));
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Appwrite\Auth;
abstract class Hash
{
/**
* @var array $options Hashing-algo specific options
*/
protected array $options = [];
/**
* @param array $options Hashing-algo specific options
*/
public function __construct(array $options = [])
{
$this->setOptions($options);
}
/**
* Set hashing algo options
*
* @param array $options Hashing-algo specific options
*/
public function setOptions(array $options): self
{
$this->options = \array_merge([], $this->getDefaultOptions(), $options);
return $this;
}
/**
* Get hashing algo options
*
* @return array $options Hashing-algo specific options
*/
public function getOptions(): array
{
return $this->options;
}
/**
* @param string $password Input password to hash
*
* @return string hash
*/
abstract public function hash(string $password): string;
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
abstract public function verify(string $password, string $hash): bool;
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
abstract public function getDefaultOptions(): array;
}

View file

@ -0,0 +1,47 @@
<?php
namespace Appwrite\Auth\Hash;
use Appwrite\Auth\Hash;
/*
* Argon2 accepted options:
* int threads
* int time_cost
* int memory_cost
*
* Reference: https://www.php.net/manual/en/function.password-hash.php#example-983
*/
class Argon2 extends Hash
{
/**
* @param string $password Input password to hash
*
* @return string hash
*/
public function hash(string $password): string
{
return \password_hash($password, PASSWORD_ARGON2ID, $this->getOptions());
}
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
public function verify(string $password, string $hash): bool
{
return \password_verify($password, $hash);
}
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
public function getDefaultOptions(): array
{
return ['memory_cost' => 65536, 'time_cost' => 4, 'threads' => 3];
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace Appwrite\Auth\Hash;
use Appwrite\Auth\Hash;
/*
* Bcrypt accepted options:
* int cost
* string? salt; auto-generated if empty
*
* Reference: https://www.php.net/manual/en/password.constants.php
*/
class Bcrypt extends Hash
{
/**
* @param string $password Input password to hash
*
* @return string hash
*/
public function hash(string $password): string
{
return \password_hash($password, PASSWORD_BCRYPT, $this->getOptions());
}
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
public function verify(string $password, string $hash): bool
{
return \password_verify($password, $hash);
}
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
public function getDefaultOptions(): array
{
return [ 'cost' => 8 ];
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Appwrite\Auth\Hash;
use Appwrite\Auth\Hash;
/*
* MD5 does not accept any options.
*
* Reference: https://www.php.net/manual/en/function.md5.php
*/
class Md5 extends Hash
{
/**
* @param string $password Input password to hash
*
* @return string hash
*/
public function hash(string $password): string
{
return \md5($password);
}
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
public function verify(string $password, string $hash): bool
{
return $this->hash($password) === $hash;
}
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
public function getDefaultOptions(): array
{
return [];
}
}

View file

@ -0,0 +1,290 @@
<?php
/**
* Portable PHP password hashing framework.
* source Version 0.5 / genuine.
* Written by Solar Designer <solar at openwall.com> in 2004-2017 and placed in
* the public domain. Revised in subsequent years, still public domain.
* There's absolutely no warranty.
* The homepage URL for the source framework is: http://www.openwall.com/phpass/
* Please be sure to update the Version line if you edit this file in any way.
* It is suggested that you leave the main version number intact, but indicate
* your project name (after the slash) and add your own revision information.
* Please do not change the "private" password hashing method implemented in
* here, thereby making your hashes incompatible. However, if you must, please
* change the hash type identifier (the "$P$") to something different.
* Obviously, since this code is in the public domain, the above are not
* requirements (there can be none), but merely suggestions.
*
* @author Solar Designer <solar@openwall.com>
* @copyright Copyright (C) 2017 All rights reserved.
* @license http://www.opensource.org/licenses/mit-license.html MIT License; see LICENSE.txt
*/
namespace Appwrite\Auth\Hash;
use Appwrite\Auth\Hash;
/*
* PHPass accepted options:
* int iteration_count_log2; The Logarithmic cost value used when generating hash values indicating the number of rounds used to generate hashes
* string portable_hashes
* string random_state; The cached random state
*
* Reference: https://github.com/photodude/phpass
*/
class Phpass extends Hash
{
/**
* Alphabet used in itoa64 conversions.
*
* @var string
* @since 0.1.0
*/
protected string $itoa64 = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
public function getDefaultOptions(): array
{
$randomState = \microtime();
if (\function_exists('getmypid')) {
$randomState .= getmypid();
}
return ['iteration_count_log2' => 8, 'portable_hashes' => false, 'random_state' => $randomState];
}
/**
* @param string $password Input password to hash
*
* @return string hash
*/
public function hash(string $password): string
{
$options = $this->getDefaultOptions();
$random = '';
if (CRYPT_BLOWFISH === 1 && !$options['portable_hashes']) {
$random = $this->getRandomBytes(16, $options);
$hash = crypt($password, $this->gensaltBlowfish($random, $options));
if (strlen($hash) === 60) {
return $hash;
}
}
if (strlen($random) < 6) {
$random = $this->getRandomBytes(6, $options);
}
$hash = $this->cryptPrivate($password, $this->gensaltPrivate($random, $options));
if (strlen($hash) === 34) {
return $hash;
}
/**
* Returning '*' on error is safe here, but would _not_ be safe
* in a crypt(3)-like function used _both_ for generating new
* hashes and for validating passwords against existing hashes.
*/
return '*';
}
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
public function verify(string $password, string $hash): bool
{
$verificationHash = $this->cryptPrivate($password, $hash);
if ($verificationHash[0] === '*') {
$verificationHash = crypt($password, $hash);
}
/**
* This is not constant-time. In order to keep the code simple,
* for timing safety we currently rely on the salts being
* unpredictable, which they are at least in the non-fallback
* cases (that is, when we use /dev/urandom and bcrypt).
*/
return $hash === $verificationHash;
}
/**
* @param int $count
*
* @return String $output
* @since 0.1.0
* @throws Exception Thows an Exception if the $count parameter is not a positive integer.
*/
protected function getRandomBytes(int $count, array $options): string
{
if (!is_int($count) || $count < 1) {
throw new \Exception('Argument count must be a positive integer');
}
$output = '';
if (@is_readable('/dev/urandom') && ($fh = @fopen('/dev/urandom', 'rb'))) {
$output = fread($fh, $count);
fclose($fh);
}
if (strlen($output) < $count) {
$output = '';
for ($i = 0; $i < $count; $i += 16) {
$options['iteration_count_log2'] = md5(microtime() . $options['iteration_count_log2']);
$output .= md5($options['iteration_count_log2'], true);
}
$output = substr($output, 0, $count);
}
return $output;
}
/**
* @param String $input
* @param int $count
*
* @return String $output
* @since 0.1.0
* @throws Exception Thows an Exception if the $count parameter is not a positive integer.
*/
protected function encode64($input, $count)
{
if (!is_int($count) || $count < 1) {
throw new \Exception('Argument count must be a positive integer');
}
$output = '';
$i = 0;
do {
$value = ord($input[$i++]);
$output .= $this->itoa64[$value & 0x3f];
if ($i < $count) {
$value |= ord($input[$i]) << 8;
}
$output .= $this->itoa64[($value >> 6) & 0x3f];
if ($i++ >= $count) {
break;
}
if ($i < $count) {
$value |= ord($input[$i]) << 16;
}
$output .= $this->itoa64[($value >> 12) & 0x3f];
if ($i++ >= $count) {
break;
}
$output .= $this->itoa64[($value >> 18) & 0x3f];
} while ($i < $count);
return $output;
}
/**
* @param String $input
*
* @return String $output
* @since 0.1.0
*/
private function gensaltPrivate($input, $options)
{
$output = '$P$';
$output .= $this->itoa64[min($options['iteration_count_log2'] + ((PHP_VERSION >= '5') ? 5 : 3), 30)];
$output .= $this->encode64($input, 6);
return $output;
}
/**
* @param String $password
* @param String $setting
*
* @return String $output
* @since 0.1.0
*/
private function cryptPrivate($password, $setting)
{
$output = '*0';
if (substr($setting, 0, 2) === $output) {
$output = '*1';
}
$id = substr($setting, 0, 3);
// We use "$P$", phpBB3 uses "$H$" for the same thing
if ($id !== '$P$' && $id !== '$H$') {
return $output;
}
$count_log2 = strpos($this->itoa64, $setting[3]);
if ($count_log2 < 7 || $count_log2 > 30) {
return $output;
}
$count = 1 << $count_log2;
$salt = substr($setting, 4, 8);
if (strlen($salt) !== 8) {
return $output;
}
/**
* We were kind of forced to use MD5 here since it's the only
* cryptographic primitive that was available in all versions of PHP
* in use. To implement our own low-level crypto in PHP
* would have result in much worse performance and
* consequently in lower iteration counts and hashes that are
* quicker to crack (by non-PHP code).
*/
$hash = md5($salt . $password, true);
do {
$hash = md5($hash . $password, true);
} while (--$count);
$output = substr($setting, 0, 12);
$output .= $this->encode64($hash, 16);
return $output;
}
/**
* @param String $input
*
* @return String $output
* @since 0.1.0
*/
private function gensaltBlowfish($input, $options)
{
/**
* This one needs to use a different order of characters and a
* different encoding scheme from the one in encode64() above.
* We care because the last character in our encoded string will
* only represent 2 bits. While two known implementations of
* bcrypt will happily accept and correct a salt string which
* has the 4 unused bits set to non-zero, we do not want to take
* chances and we also do not want to waste an additional byte
* of entropy.
*/
$itoa64 = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
$output = '$2a$';
$output .= chr(ord('0') + intval($options['iteration_count_log2'] / 10));
$output .= chr(ord('0') + $options['iteration_count_log2'] % 10);
$output .= '$';
$i = 0;
do {
$c1 = ord($input[$i++]);
$output .= $itoa64[$c1 >> 2];
$c1 = ($c1 & 0x03) << 4;
if ($i >= 16) {
$output .= $itoa64[$c1];
break;
}
$c2 = ord($input[$i++]);
$c1 |= $c2 >> 4;
$output .= $itoa64[$c1];
$c1 = ($c2 & 0x0f) << 2;
$c2 = ord($input[$i++]);
$c1 |= $c2 >> 6;
$output .= $itoa64[$c1];
$output .= $itoa64[$c2 & 0x3f];
} while (1);
return $output;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace Appwrite\Auth\Hash;
use Appwrite\Auth\Hash;
/*
* Scrypt accepted options:
* string? salt; auto-generated if empty
* int costCpu
* int costMemory
* int costParallel
* int length
*
* Reference: https://github.com/DomBlack/php-scrypt/blob/master/scrypt.php#L112-L116
*/
class Scrypt extends Hash
{
/**
* @param string $password Input password to hash
*
* @return string hash
*/
public function hash(string $password): string
{
$options = $this->getOptions();
return \scrypt($password, $options['salt'], $options['costCpu'], $options['costMemory'], $options['costParallel'], $options['length']);
}
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
public function verify(string $password, string $hash): bool
{
return $hash === $this->hash($password);
}
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
public function getDefaultOptions(): array
{
return [ 'costCpu' => 8, 'costMemory' => 14, 'costParallel' => 1, 'length' => 64 ];
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace Appwrite\Auth\Hash;
use Appwrite\Auth\Hash;
/*
* This is Scrypt hash with some additional steps added by Google.
*
* string salt
* string saltSeparator
* strin signerKey
*
* Reference: https://github.com/DomBlack/php-scrypt/blob/master/scrypt.php#L112-L116
*/
class Scryptmodified extends Hash
{
/**
* @param string $password Input password to hash
*
* @return string hash
*/
public function hash(string $password): string
{
$options = $this->getOptions();
$derivedKeyBytes = $this->generateDerivedKey($password);
$signerKeyBytes = \base64_decode($options['signerKey']);
$hashedPassword = $this->hashKeys($signerKeyBytes, $derivedKeyBytes);
return \base64_encode($hashedPassword);
}
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
public function verify(string $password, string $hash): bool
{
return $this->hash($password) === $hash;
}
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
public function getDefaultOptions(): array
{
return [ ];
}
private function generateDerivedKey(string $password)
{
$options = $this->getOptions();
$saltBytes = \base64_decode($options['salt']);
$saltSeparatorBytes = \base64_decode($options['saltSeparator']);
$password = mb_convert_encoding($password, 'UTF-8');
$derivedKey = \scrypt($password, $saltBytes . $saltSeparatorBytes, 16384, 8, 1, 64);
$derivedKey = \hex2bin($derivedKey);
return $derivedKey;
}
private function hashKeys($signerKeyBytes, $derivedKeyBytes): string
{
$key = \substr($derivedKeyBytes, 0, 32);
$iv = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
$hash = \openssl_encrypt($signerKeyBytes, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv);
return $hash;
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Appwrite\Auth\Hash;
use Appwrite\Auth\Hash;
/*
* SHA accepted options:
* string? version. Allowed:
* - Version 1: sha1
* - Version 2: sha224, sha256, sha384, sha512/224, sha512/256, sha512
* - Version 3: sha3-224, sha3-256, sha3-384, sha3-512
*
* Reference: https://www.php.net/manual/en/function.hash-algos.php
*/
class Sha extends Hash
{
/**
* @param string $password Input password to hash
*
* @return string hash
*/
public function hash(string $password): string
{
$algo = $this->getOptions()['version'];
return \hash($algo, $password);
}
/**
* @param string $password Input password to validate
* @param string $hash Hash to verify password against
*
* @return boolean true if password matches hash
*/
public function verify(string $password, string $hash): bool
{
return $this->hash($password) === $hash;
}
/**
* Get default options for specific hashing algo
*
* @return array options named array
*/
public function getDefaultOptions(): array
{
return [ 'version' => 'sha3-512' ];
}
}

View file

@ -110,16 +110,16 @@ class Key
$secret = $key;
}
$role = USER_ROLE_APPS;
$role = Auth::USER_ROLE_APPS;
$roles = Config::getParam('roles', []);
$scopes = $roles[USER_ROLE_APPS]['scopes'] ?? [];
$scopes = $roles[Auth::USER_ROLE_APPS]['scopes'] ?? [];
$expired = false;
$guestKey = new Key(
$project->getId(),
$type,
USER_ROLE_GUESTS,
$roles[USER_ROLE_GUESTS]['scopes'] ?? [],
Auth::USER_ROLE_GUESTS,
$roles[Auth::USER_ROLE_GUESTS]['scopes'] ?? [],
'UNKNOWN'
);

View file

@ -2,8 +2,8 @@
namespace Appwrite\Auth\MFA;
use Appwrite\Auth\Auth;
use OTPHP\OTP;
use Utopia\Auth\Proofs\Token;
abstract class Type
{
@ -51,10 +51,9 @@ abstract class Type
public static function generateBackupCodes(int $length = 10, int $total = 6): array
{
$backups = [];
$token = new Token($length);
for ($i = 0; $i < $total; $i++) {
$backups[] = $token->generate();
$backups[] = Auth::tokenGenerator($length);
}
return $backups;

View file

@ -2,7 +2,7 @@
namespace Appwrite\Auth\Validator;
use Utopia\Auth\Hash;
use Appwrite\Auth\Auth;
/**
* Password.
@ -12,14 +12,16 @@ use Utopia\Auth\Hash;
class PasswordHistory extends Password
{
protected array $history;
protected Hash $hash;
protected string $algo;
protected array $algoOptions;
public function __construct(array $history, Hash $hash)
public function __construct(array $history, string $algo, array $algoOptions = [])
{
parent::__construct();
$this->history = $history;
$this->hash = $hash;
$this->algo = $algo;
$this->algoOptions = $algoOptions;
}
/**
@ -44,7 +46,7 @@ class PasswordHistory extends Password
public function isValid($value): bool
{
foreach ($this->history as $hash) {
if (!empty($hash) && $this->hash->verify($value, $hash)) {
if (!empty($hash) && Auth::passwordVerify($value, $hash, $this->algo, $this->algoOptions)) {
return false;
}
}

View file

@ -2,6 +2,7 @@
namespace Appwrite\Migration\Version;
use Appwrite\Auth\Auth;
use Appwrite\Migration\Migration;
use Utopia\CLI\Console;
use Utopia\Config\Config;
@ -117,7 +118,7 @@ class V16 extends Migration
* Set default authDuration
*/
$document->setAttribute('auths', array_merge($document->getAttribute('auths', []), [
'duration' => TOKEN_EXPIRATION_LOGIN_LONG
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG
]));
/**

View file

@ -2,8 +2,8 @@
namespace Appwrite\Migration\Version;
use Appwrite\Auth\Auth;
use Appwrite\Migration\Migration;
use Utopia\Auth\Proofs\Password;
use Utopia\CLI\Console;
use Utopia\Database\Database;
use Utopia\Database\Document;
@ -270,7 +270,7 @@ class V17 extends Migration
* Set hashOptions type
*/
$document->setAttribute('hashOptions', array_merge($document->getAttribute('hashOptions', []), [
'type' => $document->getAttribute('hash', (new Password())->getHash()->getName())
'type' => $document->getAttribute('hash', Auth::DEFAULT_ALGO)
]));
break;
}

View file

@ -2,6 +2,7 @@
namespace Appwrite\Migration\Version;
use Appwrite\Auth\Auth;
use Appwrite\Migration\Migration;
use Exception;
use PDOException;
@ -631,15 +632,15 @@ class V20 extends Migration
}
break;
case 'sessions':
$duration = $this->project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$duration = $this->project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::addSeconds(new \DateTime(), $duration);
$document->setAttribute('expire', $expire);
$factors = match ($document->getAttribute('provider')) {
SESSION_PROVIDER_EMAIL => ['password'],
SESSION_PROVIDER_PHONE => ['phone'],
SESSION_PROVIDER_ANONYMOUS => ['anonymous'],
SESSION_PROVIDER_TOKEN => ['token'],
Auth::SESSION_PROVIDER_EMAIL => ['password'],
Auth::SESSION_PROVIDER_PHONE => ['phone'],
Auth::SESSION_PROVIDER_ANONYMOUS => ['anonymous'],
Auth::SESSION_PROVIDER_TOKEN => ['token'],
default => ['email'],
};

View file

@ -18,8 +18,6 @@ use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Executor\Executor;
use MaxMind\Db\Reader;
use Utopia\Auth\Proofs\Token;
use Utopia\Auth\Store;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Database\Database;
@ -94,8 +92,6 @@ class Create extends Base
->inject('queueForStatsUsage')
->inject('queueForFunctions')
->inject('geodb')
->inject('store')
->inject('proofForToken')
->inject('executor')
->callback($this->action(...));
}
@ -118,8 +114,6 @@ class Create extends Base
StatsUsage $queueForStatsUsage,
Func $queueForFunctions,
Reader $geodb,
Store $store,
Token $proofForToken,
Executor $executor
) {
$async = \strval($async) === 'true' || \strval($async) === '1';
@ -204,7 +198,7 @@ class Create extends Base
foreach ($sessions as $session) {
/** @var Utopia\Database\Document $session */
if ($proofForToken->verify($store->getProperty('secret', ''), $session->getAttribute('secret'))) { // Find most recent active session for user ID and JWT headers
if ($session->getAttribute('secret') == Auth::hash(Auth::$secret)) { // If current session delete the cookies too
$current = $session;
}
}

View file

@ -2,11 +2,10 @@
namespace Appwrite\Platform\Tasks;
use Appwrite\Auth\Auth;
use Appwrite\Docker\Compose;
use Appwrite\Docker\Env;
use Appwrite\Utopia\View;
use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\CLI\Console;
use Utopia\Config\Config;
use Utopia\Platform\Action;
@ -150,8 +149,6 @@ class Install extends Action
$input = [];
$password = new Password();
$token = new Token();
foreach ($vars as $var) {
if (!empty($var['filter']) && ($interactive !== 'Y' || !Console::isInteractive())) {
if ($data && $var['default'] !== null) {
@ -160,12 +157,12 @@ class Install extends Action
}
if ($var['filter'] === 'token') {
$input[$var['name']] = $token->generate();
$input[$var['name']] = Auth::tokenGenerator();
continue;
}
if ($var['filter'] === 'password') {
$input[$var['name']] = $password->generate();
$input[$var['name']] = Auth::passwordGenerator();
continue;
}
}

View file

@ -2,6 +2,7 @@
namespace Appwrite\Platform\Workers;
use Appwrite\Auth\Auth;
use Exception;
use Throwable;
use Utopia\Audit\Audit;
@ -84,7 +85,7 @@ class Audits extends Action
$userName = $user->getAttribute('name', '');
$userEmail = $user->getAttribute('email', '');
$userType = $user->getAttribute('type', ACTIVITY_TYPE_USER);
$userType = $user->getAttribute('type', Auth::ACTIVITY_TYPE_USER);
// Create event data
$eventData = [

View file

@ -2,6 +2,7 @@
namespace Appwrite\Platform\Workers;
use Appwrite\Auth\Auth;
use Appwrite\Certificates\Adapter as CertificatesAdapter;
use Appwrite\Deletes\Identities;
use Appwrite\Deletes\Targets;
@ -707,7 +708,7 @@ class Deletes extends Action
private function deleteExpiredSessions(Document $project, callable $getProjectDB): void
{
$dbForProject = $getProjectDB($project);
$duration = $project->getAttribute('auths', [])['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG;
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$expired = DateTime::addSeconds(new \DateTime(), -1 * $duration);
// Delete Sessions

View file

@ -105,7 +105,7 @@ class Project extends Model
->addRule('authDuration', [
'type' => self::TYPE_INTEGER,
'description' => 'Session duration in seconds.',
'default' => TOKEN_EXPIRATION_LOGIN_LONG,
'default' => Auth::TOKEN_EXPIRATION_LOGIN_LONG,
'example' => 60,
])
->addRule('authLimit', [
@ -372,7 +372,7 @@ class Project extends Model
$auth = Config::getParam('auth', []);
$document->setAttribute('authLimit', $authValues['limit'] ?? 0);
$document->setAttribute('authDuration', $authValues['duration'] ?? TOKEN_EXPIRATION_LOGIN_LONG);
$document->setAttribute('authDuration', $authValues['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG);
$document->setAttribute('authSessionsLimit', $authValues['maxSessions'] ?? APP_LIMIT_USER_SESSIONS_DEFAULT);
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);

View file

@ -169,7 +169,6 @@ trait ProjectCustom
$project = [
'$id' => $project['body']['$id'],
'name' => $project['body']['name'],
'teamId' => $team['body']['$id'],
'apiKey' => $key['body']['secret'],
'devKey' => $devKey['body']['secret'],
'webhookId' => $webhook['body']['$id'],

View file

@ -45,6 +45,7 @@ class AccountConsoleClientTest extends Scope
$this->assertEquals($response['headers']['status-code'], 201);
$session = $response['cookies']['a_session_' . $this->getProject()['$id']];
// create team
$team = $this->client->call(Client::METHOD_POST, '/teams', [
'origin' => 'http://localhost',
@ -55,7 +56,6 @@ class AccountConsoleClientTest extends Scope
'teamId' => 'unique()',
'name' => 'myteam'
]);
$this->assertEquals($team['headers']['status-code'], 201);
$teamId = $team['body']['$id'];

View file

@ -2,6 +2,7 @@
namespace Tests\E2E\Services\Projects;
use Appwrite\Auth\Auth;
use Appwrite\Extend\Exception;
use Appwrite\Tests\Async;
use Tests\E2E\Client;
@ -865,7 +866,7 @@ class ProjectsConsoleClientTest extends Scope
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year
$this->assertEquals(Auth::TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year
/**
* Test for SUCCESS
@ -1008,7 +1009,7 @@ class ProjectsConsoleClientTest extends Scope
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'duration' => TOKEN_EXPIRATION_LOGIN_LONG,
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG,
]);
$this->assertEquals(200, $response['headers']['status-code']);
@ -1021,7 +1022,7 @@ class ProjectsConsoleClientTest extends Scope
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertEquals(TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year
$this->assertEquals(Auth::TOKEN_EXPIRATION_LOGIN_LONG, $response['body']['authDuration']); // 1 Year
return ['projectId' => $projectId];
}

View file

@ -4,7 +4,6 @@ namespace Tests\Unit\Auth;
use Appwrite\Auth\Auth;
use PHPUnit\Framework\TestCase;
use Utopia\Auth\Proofs\Token;
use Utopia\Database\DateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
@ -23,25 +22,203 @@ class AuthTest extends TestCase
Authorization::setRole(Role::any()->toString());
}
public function testCookieName(): void
{
$name = 'cookie-name';
$this->assertEquals(Auth::setCookieName($name), $name);
$this->assertEquals(Auth::$cookieName, $name);
}
public function testEncodeDecodeSession(): void
{
$id = 'id';
$secret = 'secret';
$session = 'eyJpZCI6ImlkIiwic2VjcmV0Ijoic2VjcmV0In0=';
$this->assertEquals(Auth::encodeSession($id, $secret), $session);
$this->assertEquals(Auth::decodeSession($session), ['id' => $id, 'secret' => $secret]);
}
public function testHash(): void
{
$secret = 'secret';
$this->assertEquals(Auth::hash($secret), '2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b');
}
public function testPassword(): void
{
/*
General tests, using pre-defined hashes generated by online tools
*/
// Bcrypt - Version Y
$plain = 'secret';
$hash = '$2y$08$PDbMtV18J1KOBI9tIYabBuyUwBrtXPGhLxCy9pWP6xkldVOKLrLKy';
$generatedHash = Auth::passwordHash($plain, 'bcrypt');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt'));
// Bcrypt - Version A
$plain = 'test123';
$hash = '$2a$12$3f2ZaARQ1AmhtQWx2nmQpuXcWfTj1YV2/Hl54e8uKxIzJe3IfwLiu';
$generatedHash = Auth::passwordHash($plain, 'bcrypt');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt'));
// Bcrypt - Cost 5
$plain = 'hello-world';
$hash = '$2a$05$IjrtSz6SN7UJ6Sh3l.b5jODEvEG2LMJTPAHIaLWRvlWx7if3VMkFO';
$generatedHash = Auth::passwordHash($plain, 'bcrypt');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt'));
// Bcrypt - Cost 15
$plain = 'super-secret-password';
$hash = '$2a$15$DS0ZzbsFZYumH/E4Qj5oeOHnBcM3nCCsCA2m4Goigat/0iMVQC4Na';
$generatedHash = Auth::passwordHash($plain, 'bcrypt');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt'));
// MD5 - Short
$plain = 'appwrite';
$hash = '144fa7eaa4904e8ee120651997f70dcc';
$generatedHash = Auth::passwordHash($plain, 'md5');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'md5'));
// MD5 - Long
$plain = 'AppwriteIsAwesomeBackendAsAServiceThatIsAlsoOpenSourced';
$hash = '8410e96cf7ac64e0b84c3f8517a82616';
$generatedHash = Auth::passwordHash($plain, 'md5');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md5'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'md5'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'md5'));
// PHPass
$plain = 'pass123';
$hash = '$P$BVKPmJBZuLch27D4oiMRTEykGLQ9tX0';
$generatedHash = Auth::passwordHash($plain, 'phpass');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'phpass'));
// SHA
$plain = 'developersAreAwesome!';
$hash = '2455118438cb125354b89bb5888346e9bd23355462c40df393fab514bf2220b5a08e4e2d7b85d7327595a450d0ac965cc6661152a46a157c66d681bed20a4735';
$generatedHash = Auth::passwordHash($plain, 'sha');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'sha'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'sha'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'sha'));
// Argon2
$plain = 'safe-argon-password';
$hash = '$argon2id$v=19$m=2048,t=3,p=4$MWc5NWRmc2QxZzU2$41mp7rSgBZ49YxLbbxIac7aRaxfp5/e1G45ckwnK0g8';
$generatedHash = Auth::passwordHash($plain, 'argon2');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'argon2'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'argon2'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'argon2'));
// Scrypt
$plain = 'some-scrypt-password';
$hash = 'b448ad7ba88b653b5b56b8053a06806724932d0751988bc9cd0ef7ff059e8ba8a020e1913b7069a650d3f99a1559aba0221f2c277826919513a054e76e339028';
$generatedHash = Auth::passwordHash($plain, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2]);
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2]));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2]));
$this->assertEquals(false, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-wrong-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2]));
$this->assertEquals(false, Auth::passwordVerify($plain, $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 10, 'costParallel' => 2]));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'scrypt', [ 'salt' => 'some-salt', 'length' => 64, 'costCpu' => 16384, 'costMemory' => 12, 'costParallel' => 2]));
// ScryptModified tested are in provider-specific tests below
/*
Provider-specific tests, ensuring functionality of specific use-cases
*/
// Provider #1 (Database)
$plain = 'example-password';
$hash = '$2a$10$3bIGRWUes86CICsuchGLj.e.BqdCdg2/1Ud9LvBhJr0j7Dze8PBdS';
$generatedHash = Auth::passwordHash($plain, 'bcrypt');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'bcrypt'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'bcrypt'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'bcrypt'));
// Provider #2 (Blog)
$plain = 'your-password';
$hash = '$P$BkiNDJTpAWXtpaMhEUhUdrv7M0I1g6.';
$generatedHash = Auth::passwordHash($plain, 'phpass');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'phpass'));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'phpass'));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'phpass'));
// Provider #2 (Google)
$plain = 'users-password';
$hash = 'EPKgfALpS9Tvgr/y1ki7ubY4AEGJeWL3teakrnmOacN4XGiyD00lkzEHgqCQ71wGxoi/zb7Y9a4orOtvMV3/Jw==';
$salt = '56dFqW+kswqktw==';
$saltSeparator = 'Bw==';
$signerKey = 'XyEKE9RcTDeLEsL/RjwPDBv/RqDl8fb3gpYEOQaPihbxf1ZAtSOHCjuAAa7Q3oHpCYhXSN9tizHgVOwn6krflQ==';
$options = [ 'salt' => $salt, 'saltSeparator' => $saltSeparator, 'signerKey' => $signerKey ];
$generatedHash = Auth::passwordHash($plain, 'scryptMod', $options);
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'scryptMod', $options));
$this->assertEquals(true, Auth::passwordVerify($plain, $hash, 'scryptMod', $options));
$this->assertEquals(false, Auth::passwordVerify('wrongPassword', $hash, 'scryptMod', $options));
}
public function testUnknownAlgo()
{
$this->expectExceptionMessage('Hashing algorithm \'md8\' is not supported.');
// Bcrypt - Cost 5
$plain = 'whatIsMd8?!?';
$generatedHash = Auth::passwordHash($plain, 'md8');
$this->assertEquals(true, Auth::passwordVerify($plain, $generatedHash, 'md8'));
}
public function testPasswordGenerator(): void
{
$this->assertEquals(\mb_strlen(Auth::passwordGenerator()), 40);
$this->assertEquals(\mb_strlen(Auth::passwordGenerator(5)), 10);
}
public function testTokenGenerator(): void
{
$this->assertEquals(\strlen(Auth::tokenGenerator()), 256);
$this->assertEquals(\strlen(Auth::tokenGenerator(5)), 5);
}
public function testCodeGenerator(): void
{
$this->assertEquals(6, \strlen(Auth::codeGenerator()));
$this->assertEquals(\mb_strlen(Auth::codeGenerator(256)), 256);
$this->assertEquals(\mb_strlen(Auth::codeGenerator(10)), 10);
$this->assertTrue(is_numeric(Auth::codeGenerator(5)));
}
public function testSessionVerify(): void
{
$proofForToken = new Token();
$expireTime1 = 60 * 60 * 24;
$secret = 'secret1';
$hash = $proofForToken->hash($secret);
$hash = Auth::hash($secret);
$tokens1 = [
new Document([
'$id' => ID::custom('token1'),
'secret' => $hash,
'provider' => SESSION_PROVIDER_EMAIL,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1),
]),
new Document([
'$id' => ID::custom('token2'),
'secret' => 'secret2',
'provider' => SESSION_PROVIDER_EMAIL,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
'expire' => DateTime::addSeconds(new \DateTime(), $expireTime1),
]),
@ -53,40 +230,39 @@ class AuthTest extends TestCase
new Document([ // Correct secret and type time, wrong expire time
'$id' => ID::custom('token1'),
'secret' => $hash,
'provider' => SESSION_PROVIDER_EMAIL,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2),
]),
new Document([
'$id' => ID::custom('token2'),
'secret' => 'secret2',
'provider' => SESSION_PROVIDER_EMAIL,
'provider' => Auth::SESSION_PROVIDER_EMAIL,
'providerUid' => 'test@example.com',
'expire' => DateTime::addSeconds(new \DateTime(), $expireTime2),
]),
];
$this->assertEquals(Auth::sessionVerify($tokens1, $secret, $proofForToken), 'token1');
$this->assertEquals(Auth::sessionVerify($tokens1, 'false-secret', $proofForToken), false);
$this->assertEquals(Auth::sessionVerify($tokens2, $secret, $proofForToken), false);
$this->assertEquals(Auth::sessionVerify($tokens2, 'false-secret', $proofForToken), false);
$this->assertEquals(Auth::sessionVerify($tokens1, $secret), 'token1');
$this->assertEquals(Auth::sessionVerify($tokens1, 'false-secret'), false);
$this->assertEquals(Auth::sessionVerify($tokens2, $secret), false);
$this->assertEquals(Auth::sessionVerify($tokens2, 'false-secret'), false);
}
public function testTokenVerify(): void
{
$proofForToken = new Token();
$secret = 'secret1';
$hash = $proofForToken->hash($secret);
$hash = Auth::hash($secret);
$tokens1 = [
new Document([
'$id' => ID::custom('token1'),
'type' => TOKEN_TYPE_RECOVERY,
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)),
'secret' => $hash,
]),
new Document([
'$id' => ID::custom('token2'),
'type' => TOKEN_TYPE_RECOVERY,
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)),
'secret' => 'secret2',
]),
@ -95,13 +271,13 @@ class AuthTest extends TestCase
$tokens2 = [
new Document([ // Correct secret and type time, wrong expire time
'$id' => ID::custom('token1'),
'type' => TOKEN_TYPE_RECOVERY,
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)),
'secret' => $hash,
]),
new Document([
'$id' => ID::custom('token2'),
'type' => TOKEN_TYPE_RECOVERY,
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)),
'secret' => 'secret2',
]),
@ -110,25 +286,25 @@ class AuthTest extends TestCase
$tokens3 = [ // Correct secret and expire time, wrong type
new Document([
'$id' => ID::custom('token1'),
'type' => TOKEN_TYPE_INVITE,
'type' => Auth::TOKEN_TYPE_INVITE,
'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), 60 * 60 * 24)),
'secret' => $hash,
]),
new Document([
'$id' => ID::custom('token2'),
'type' => TOKEN_TYPE_RECOVERY,
'type' => Auth::TOKEN_TYPE_RECOVERY,
'expire' => DateTime::formatTz(DateTime::addSeconds(new \DateTime(), -60 * 60 * 24)),
'secret' => 'secret2',
]),
];
$this->assertEquals(Auth::tokenVerify($tokens1, TOKEN_TYPE_RECOVERY, $secret, $proofForToken), $tokens1[0]);
$this->assertEquals(Auth::tokenVerify($tokens1, null, $secret, $proofForToken), $tokens1[0]);
$this->assertEquals(Auth::tokenVerify($tokens1, TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false);
$this->assertEquals(Auth::tokenVerify($tokens2, TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false);
$this->assertEquals(Auth::tokenVerify($tokens2, TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false);
$this->assertEquals(Auth::tokenVerify($tokens3, TOKEN_TYPE_RECOVERY, $secret, $proofForToken), false);
$this->assertEquals(Auth::tokenVerify($tokens3, TOKEN_TYPE_RECOVERY, 'false-secret', $proofForToken), false);
$this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, $secret), $tokens1[0]);
$this->assertEquals(Auth::tokenVerify($tokens1, null, $secret), $tokens1[0]);
$this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false);
$this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, $secret), false);
$this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false);
$this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, $secret), false);
$this->assertEquals(Auth::tokenVerify($tokens3, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false);
}
public function testIsPrivilegedUser(): void
@ -136,16 +312,16 @@ class AuthTest extends TestCase
$this->assertEquals(false, Auth::isPrivilegedUser([]));
$this->assertEquals(false, Auth::isPrivilegedUser([Role::guests()->toString()]));
$this->assertEquals(false, Auth::isPrivilegedUser([Role::users()->toString()]));
$this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_ADMIN]));
$this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_DEVELOPER]));
$this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_OWNER]));
$this->assertEquals(false, Auth::isPrivilegedUser([USER_ROLE_APPS]));
$this->assertEquals(false, Auth::isPrivilegedUser([USER_ROLE_SYSTEM]));
$this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_ADMIN]));
$this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_DEVELOPER]));
$this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER]));
$this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS]));
$this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_SYSTEM]));
$this->assertEquals(false, Auth::isPrivilegedUser([USER_ROLE_APPS, USER_ROLE_APPS]));
$this->assertEquals(false, Auth::isPrivilegedUser([USER_ROLE_APPS, Role::guests()->toString()]));
$this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_OWNER, Role::guests()->toString()]));
$this->assertEquals(true, Auth::isPrivilegedUser([USER_ROLE_OWNER, USER_ROLE_ADMIN, USER_ROLE_DEVELOPER]));
$this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS, Auth::USER_ROLE_APPS]));
$this->assertEquals(false, Auth::isPrivilegedUser([Auth::USER_ROLE_APPS, Role::guests()->toString()]));
$this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER, Role::guests()->toString()]));
$this->assertEquals(true, Auth::isPrivilegedUser([Auth::USER_ROLE_OWNER, Auth::USER_ROLE_ADMIN, Auth::USER_ROLE_DEVELOPER]));
}
public function testIsAppUser(): void
@ -153,16 +329,16 @@ class AuthTest extends TestCase
$this->assertEquals(false, Auth::isAppUser([]));
$this->assertEquals(false, Auth::isAppUser([Role::guests()->toString()]));
$this->assertEquals(false, Auth::isAppUser([Role::users()->toString()]));
$this->assertEquals(false, Auth::isAppUser([USER_ROLE_ADMIN]));
$this->assertEquals(false, Auth::isAppUser([USER_ROLE_DEVELOPER]));
$this->assertEquals(false, Auth::isAppUser([USER_ROLE_OWNER]));
$this->assertEquals(true, Auth::isAppUser([USER_ROLE_APPS]));
$this->assertEquals(false, Auth::isAppUser([USER_ROLE_SYSTEM]));
$this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_ADMIN]));
$this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_DEVELOPER]));
$this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER]));
$this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS]));
$this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_SYSTEM]));
$this->assertEquals(true, Auth::isAppUser([USER_ROLE_APPS, USER_ROLE_APPS]));
$this->assertEquals(true, Auth::isAppUser([USER_ROLE_APPS, Role::guests()->toString()]));
$this->assertEquals(false, Auth::isAppUser([USER_ROLE_OWNER, Role::guests()->toString()]));
$this->assertEquals(false, Auth::isAppUser([USER_ROLE_OWNER, USER_ROLE_ADMIN, USER_ROLE_DEVELOPER]));
$this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS, Auth::USER_ROLE_APPS]));
$this->assertEquals(true, Auth::isAppUser([Auth::USER_ROLE_APPS, Role::guests()->toString()]));
$this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER, Role::guests()->toString()]));
$this->assertEquals(false, Auth::isAppUser([Auth::USER_ROLE_OWNER, Auth::USER_ROLE_ADMIN, Auth::USER_ROLE_DEVELOPER]));
}
public function testGuestRoles(): void
@ -242,7 +418,7 @@ class AuthTest extends TestCase
public function testPrivilegedUserRoles(): void
{
Authorization::setRole(USER_ROLE_OWNER);
Authorization::setRole(Auth::USER_ROLE_OWNER);
$user = new Document([
'$id' => ID::custom('123'),
'emailVerification' => true,
@ -286,7 +462,7 @@ class AuthTest extends TestCase
public function testAppUserRoles(): void
{
Authorization::setRole(USER_ROLE_APPS);
Authorization::setRole(Auth::USER_ROLE_APPS);
$user = new Document([
'$id' => ID::custom('123'),
'memberships' => [

View file

@ -3,6 +3,7 @@
namespace Tests\Unit\Auth;
use Ahc\Jwt\JWT;
use Appwrite\Auth\Auth;
use Appwrite\Auth\Key;
use PHPUnit\Framework\TestCase;
use Utopia\Config\Config;
@ -20,7 +21,7 @@ class KeyTest extends TestCase
'collections.read',
'documents.read',
];
$roleScopes = Config::getParam('roles', [])[USER_ROLE_APPS]['scopes'];
$roleScopes = Config::getParam('roles', [])[Auth::USER_ROLE_APPS]['scopes'];
$key = static::generateKey($projectId, $usage, $scopes);
$project = new Document(['$id' => $projectId,]);
@ -28,7 +29,7 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(USER_ROLE_APPS, $decoded->getRole());
$this->assertEquals(Auth::USER_ROLE_APPS, $decoded->getRole());
$this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes());
}

View file

@ -59,7 +59,7 @@ class MessagingChannelsTest extends TestCase
'confirm' => true,
'roles' => [
empty($index % 2)
? USER_ROLE_ADMIN
? Auth::USER_ROLE_ADMIN
: 'member',
]
]
@ -294,7 +294,7 @@ class MessagingChannelsTest extends TestCase
}
$role = empty($index % 2)
? USER_ROLE_ADMIN
? Auth::USER_ROLE_ADMIN
: 'member';
$permissions = [

View file

@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase;
use ReflectionMethod;
use Utopia\Database\Document;
class MigrationTest extends TestCase
abstract class MigrationTest extends TestCase
{
/**
* @var Migration