1
0
Fork 0
mirror of synced 2024-07-06 07:00:56 +12:00

Merge branch '1.5.x' into fix-limit-failed-webhook-attempts

This commit is contained in:
Khushboo Verma 2024-01-18 15:21:55 +05:30
commit e9aab8d9ee
46 changed files with 2511 additions and 1994 deletions

View file

@ -7,21 +7,21 @@ return [
'name' => 'Email/Password', 'name' => 'Email/Password',
'key' => 'emailPassword', 'key' => 'emailPassword',
'icon' => '/images/users/email.png', 'icon' => '/images/users/email.png',
'docs' => 'https://appwrite.io/docs/client/account?sdk=web-default#accountCreateEmailSession', 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreateEmailPasswordSession',
'enabled' => true, 'enabled' => true,
], ],
'magic-url' => [ 'magic-url' => [
'name' => 'Magic URL', 'name' => 'Magic URL',
'key' => 'usersAuthMagicURL', 'key' => 'usersAuthMagicURL',
'icon' => '/images/users/magic-url.png', 'icon' => '/images/users/magic-url.png',
'docs' => 'https://appwrite.io/docs/client/account?sdk=web-default#accountCreateMagicURLSession', 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreateMagicURLToken',
'enabled' => true, 'enabled' => true,
], ],
'anonymous' => [ 'anonymous' => [
'name' => 'Anonymous', 'name' => 'Anonymous',
'key' => 'anonymous', 'key' => 'anonymous',
'icon' => '/images/users/anonymous.png', 'icon' => '/images/users/anonymous.png',
'docs' => 'https://appwrite.io/docs/client/account?sdk=web-default#accountCreateAnonymousSession', 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreateAnonymousSession',
'enabled' => true, 'enabled' => true,
], ],
'invites' => [ 'invites' => [
@ -42,7 +42,7 @@ return [
'name' => 'Phone', 'name' => 'Phone',
'key' => 'phone', 'key' => 'phone',
'icon' => '/images/users/phone.png', 'icon' => '/images/users/phone.png',
'docs' => 'https://appwrite.io/docs/client/account?sdk=web-default#accountCreatePhoneSession', 'docs' => 'https://appwrite.io/docs/references/cloud/client-web/account#accountCreatePhoneToken',
'enabled' => true, 'enabled' => true,
], ],
]; ];

View file

@ -58,6 +58,14 @@ return [
'$description' => 'This event triggers when a user\'s target is deleted.', '$description' => 'This event triggers when a user\'s target is deleted.',
], ],
], ],
'tokens' => [
'$model' => Response::MODEL_TOKEN,
'$resource' => true,
'$description' => 'This event triggers on any user\'s token event.',
'create' => [
'$description' => 'This event triggers when a user\'s token is created.',
],
],
'create' => [ 'create' => [
'$description' => 'This event triggers when a user is created.' '$description' => 'This event triggers when a user is created.'
], ],

View file

@ -8,7 +8,9 @@ $member = [
'home', 'home',
'console', 'console',
'graphql', 'graphql',
'account', 'sessions.write',
'accounts.read',
'accounts.write',
'teams.read', 'teams.read',
'teams.write', 'teams.write',
'documents.read', 'documents.read',
@ -31,6 +33,7 @@ $member = [
$admins = [ $admins = [
'global', 'global',
'graphql', 'graphql',
'sessions.write',
'teams.read', 'teams.read',
'teams.write', 'teams.write',
'documents.read', 'documents.read',
@ -85,6 +88,7 @@ return [
'home', 'home',
'console', 'console',
'graphql', 'graphql',
'sessions.write',
'documents.read', 'documents.read',
'documents.write', 'documents.write',
'files.read', 'files.read',

View file

@ -1,6 +1,15 @@
<?php <?php
return [ // List of publicly visible scopes return [ // List of publicly visible scopes
'accounts.read' => [
'description' => 'Access to read your active user account',
],
'accounts.write' => [
'description' => 'Access to create, update, and delete your active user account',
],
'sessions.write' => [
'description' => 'Access to create, update, and delete user sessions',
],
'users.read' => [ 'users.read' => [
'description' => 'Access to read your project\'s users', 'description' => 'Access to read your project\'s users',
], ],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -55,7 +55,7 @@ App::post('/v1/account')
->desc('Create account') ->desc('Create account')
->groups(['api', 'account', 'auth']) ->groups(['api', 'account', 'auth'])
->label('event', 'users.[userId].create') ->label('event', 'users.[userId].create')
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('auth.type', 'emailPassword') ->label('auth.type', 'emailPassword')
->label('audits.event', 'user.create') ->label('audits.event', 'user.create')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
@ -69,7 +69,7 @@ App::post('/v1/account')
->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER) ->label('sdk.response.model', Response::MODEL_USER)
->label('abuse-limit', 10) ->label('abuse-limit', 10)
->param('userId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
->param('email', '', new Email(), 'User email.') ->param('email', '', new Email(), 'User email.')
->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary']) ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary'])
->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true)
@ -183,10 +183,10 @@ App::post('/v1/account')
App::post('/v1/account/sessions/email') App::post('/v1/account/sessions/email')
->alias('/v1/account/sessions') ->alias('/v1/account/sessions')
->desc('Create email session') ->desc('Create email password session')
->groups(['api', 'account', 'auth', 'session']) ->groups(['api', 'account', 'auth', 'session'])
->label('event', 'users.[userId].sessions.[sessionId].create') ->label('event', 'users.[userId].sessions.[sessionId].create')
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('auth.type', 'emailPassword') ->label('auth.type', 'emailPassword')
->label('audits.event', 'session.create') ->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
@ -195,8 +195,8 @@ App::post('/v1/account/sessions/email')
->label('usage.params', ['provider:email']) ->label('usage.params', ['provider:email'])
->label('sdk.auth', []) ->label('sdk.auth', [])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'createEmailSession') ->label('sdk.method', ['createEmailPasswordSession', 'createEmailSession'])
->label('sdk.description', '/docs/references/account/create-session-email.md') ->label('sdk.description', '/docs/references/account/create-session-email-password.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED) ->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_SESSION) ->label('sdk.response.model', Response::MODEL_SESSION)
@ -229,6 +229,10 @@ App::post('/v1/account/sessions/email')
throw new Exception(Exception::USER_BLOCKED); // User is in status blocked throw new Exception(Exception::USER_BLOCKED); // User is in status blocked
} }
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
$user->setAttributes($profile->getArrayCopy()); $user->setAttributes($profile->getArrayCopy());
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
@ -236,7 +240,7 @@ App::post('/v1/account/sessions/email')
$detector = new Detector($request->getUserAgent('UNKNOWN')); $detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP()); $record = $geodb->get($request->getIP());
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$secret = Auth::tokenGenerator(); $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$session = new Document(array_merge( $session = new Document(array_merge(
[ [
'$id' => ID::unique(), '$id' => ID::unique(),
@ -273,7 +277,6 @@ App::post('/v1/account/sessions/email')
Permission::delete(Role::user($user->getId())), Permission::delete(Role::user($user->getId())),
])); ]));
if (!Config::getParam('domainVerification')) { if (!Config::getParam('domainVerification')) {
$response $response
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])) ->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]))
@ -292,6 +295,7 @@ App::post('/v1/account/sessions/email')
->setAttribute('current', true) ->setAttribute('current', true)
->setAttribute('countryName', $countryName) ->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire) ->setAttribute('expire', $expire)
->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '')
; ;
$queueForEvents $queueForEvents
@ -306,8 +310,9 @@ App::get('/v1/account/sessions/oauth2/:provider')
->desc('Create OAuth2 session') ->desc('Create OAuth2 session')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('error', __DIR__ . '/../../views/general/error.phtml') ->label('error', __DIR__ . '/../../views/general/error.phtml')
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('sdk.auth', []) ->label('sdk.auth', [])
->label('sdk.hideServer', true)
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'createOAuth2Session') ->label('sdk.method', 'createOAuth2Session')
->label('sdk.description', '/docs/references/account/create-session-oauth2.md') ->label('sdk.description', '/docs/references/account/create-session-oauth2.md')
@ -319,11 +324,13 @@ App::get('/v1/account/sessions/oauth2/:provider')
->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn($node) => (!$node['mock'])))) . '.') ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'OAuth2 Provider. Currently, supported providers are: ' . \implode(', ', \array_keys(\array_filter(Config::getParam('oAuthProviders'), fn($node) => (!$node['mock'])))) . '.')
->param('success', '', fn($clients) => new Host($clients), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients']) ->param('success', '', fn($clients) => new Host($clients), 'URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients'])
->param('failure', '', fn($clients) => new Host($clients), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients']) ->param('failure', '', fn($clients) => new Host($clients), 'URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project\'s platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients'])
->param('token', false, new Boolean(true), 'Include token credentials in the final redirect, useful for server-side integrations, or when cookies are not available.', true)
->param('scopes', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true) ->param('scopes', [], new ArrayList(new Text(APP_LIMIT_ARRAY_ELEMENT_SIZE), APP_LIMIT_ARRAY_PARAMS_SIZE), 'A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long.', true)
->inject('request') ->inject('request')
->inject('response') ->inject('response')
->inject('project') ->inject('project')
->action(function (string $provider, string $success, string $failure, array $scopes, Request $request, Response $response, Document $project) use ($oauthDefaultSuccess, $oauthDefaultFailure) { ->action(function (string $provider, string $success, string $failure, mixed $token, array $scopes, Request $request, Response $response, Document $project) use ($oauthDefaultSuccess, $oauthDefaultFailure) {
$token = in_array($token, ['true', true], true);
$protocol = $request->getProtocol(); $protocol = $request->getProtocol();
@ -360,7 +367,11 @@ App::get('/v1/account/sessions/oauth2/:provider')
$failure = $protocol . '://' . $request->getHostname() . $oauthDefaultFailure; $failure = $protocol . '://' . $request->getHostname() . $oauthDefaultFailure;
} }
$oauth2 = new $className($appId, $appSecret, $callback, ['success' => $success, 'failure' => $failure], $scopes); $oauth2 = new $className($appId, $appSecret, $callback, [
'success' => $success,
'failure' => $failure,
'token' => $token,
], $scopes);
$response $response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
@ -747,12 +758,63 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$dbForProject->updateDocument('identities', $identity->getId(), $identity); $dbForProject->updateDocument('identities', $identity->getId(), $identity);
} }
// Create session token, verify user account and update OAuth2 ID and Access Token if (empty($user->getAttribute('email'))) {
$user->setAttribute('email', $oauth2->getUserEmail($accessToken));
}
if (empty($user->getAttribute('name'))) {
$user->setAttribute('name', $oauth2->getUserName($accessToken));
}
$user->setAttribute('status', true);
$dbForProject->updateDocument('users', $user->getId(), $user);
Authorization::setRole(Role::user($user->getId())->toString());
$state['success'] = URLParser::parse($state['success']);
$query = URLParser::parseQuery($state['success']['query']);
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
// If the `token` param is set, we will return the token in the query string
if ($state['token']) {
$secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_OAUTH2);
$token = new Document([
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'type' => Auth::TOKEN_TYPE_OAUTH2,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
]);
Authorization::setRole(Role::user($user->getId())->toString());
$token = $dbForProject->createDocument('tokens', $token
->setAttribute('$permissions', [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]));
$queueForEvents
->setEvent('users.[userId].tokens.[tokenId].create')
->setParam('userId', $user->getId())
->setParam('tokenId', $token->getId())
;
$query['secret'] = $secret;
$query['userId'] = $user->getId();
// If the `token` param is not set, we persist the session in a cookie
} else {
$detector = new Detector($request->getUserAgent('UNKNOWN')); $detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP()); $record = $geodb->get($request->getIP());
$secret = Auth::tokenGenerator(); $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$session = new Document(array_merge([ $session = new Document(array_merge([
'$id' => ID::unique(), '$id' => ID::unique(),
@ -769,59 +831,45 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
], $detector->getOS(), $detector->getClient(), $detector->getDevice())); ], $detector->getOS(), $detector->getClient(), $detector->getDevice()));
if (empty($user->getAttribute('email'))) {
$user->setAttribute('email', $oauth2->getUserEmail($accessToken));
}
if (empty($user->getAttribute('name'))) {
$user->setAttribute('name', $oauth2->getUserName($accessToken));
}
$user
->setAttribute('status', true)
;
Authorization::setRole(Role::user($user->getId())->toString());
$dbForProject->updateDocument('users', $user->getId(), $user);
$session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [ $session = $dbForProject->createDocument('sessions', $session->setAttribute('$permissions', [
Permission::read(Role::user($user->getId())), Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())), Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())), Permission::delete(Role::user($user->getId())),
])); ]));
$dbForProject->deleteCachedDocument('users', $user->getId());
$session->setAttribute('expire', $expire); $session->setAttribute('expire', $expire);
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
}
$queueForEvents $queueForEvents
->setParam('userId', $user->getId()) ->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId()) ->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION)) ->setPayload($response->output($session, Response::MODEL_SESSION))
; ;
if (!Config::getParam('domainVerification')) { // TODO: Remove this deprecated, undocumented workaround
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); if ($state['success']['path'] == $oauthDefaultSuccess) {
}
// Add keys for non-web platforms - TODO - add verification phase to aviod session sniffing
if (parse_url($state['success'], PHP_URL_PATH) === $oauthDefaultSuccess) {
$state['success'] = URLParser::parse($state['success']);
$query = URLParser::parseQuery($state['success']['query']);
$query['project'] = $project->getId(); $query['project'] = $project->getId();
$query['domain'] = Config::getParam('cookieDomain'); $query['domain'] = Config::getParam('cookieDomain');
$query['key'] = Auth::$cookieName; $query['key'] = Auth::$cookieName;
$query['secret'] = Auth::encodeSession($user->getId(), $secret); $query['secret'] = $secret;
}
$response
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'));
}
$dbForProject->deleteCachedDocument('users', $user->getId());
$state['success']['query'] = URLParser::unparseQuery($query); $state['success']['query'] = URLParser::unparseQuery($query);
$state['success'] = URLParser::unparse($state['success']); $state['success'] = URLParser::unparse($state['success']);
}
$response $response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') ->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->addHeader('Pragma', 'no-cache') ->addHeader('Pragma', 'no-cache')
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->redirect($state['success']) ->redirect($state['success'])
; ;
}); });
@ -829,7 +877,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
App::get('/v1/account/identities') App::get('/v1/account/identities')
->desc('List Identities') ->desc('List Identities')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read') ->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
@ -880,7 +928,7 @@ App::get('/v1/account/identities')
App::delete('/v1/account/identities/:identityId') App::delete('/v1/account/identities/:identityId')
->desc('Delete identity') ->desc('Delete identity')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('event', 'users.[userId].identities.[identityId].delete') ->label('event', 'users.[userId].identities.[identityId].delete')
->label('audits.event', 'identity.delete') ->label('audits.event', 'identity.delete')
->label('audits.resource', 'identity/{request.$identityId}') ->label('audits.resource', 'identity/{request.$identityId}')
@ -914,24 +962,25 @@ App::delete('/v1/account/identities/:identityId')
return $response->noContent(); return $response->noContent();
}); });
App::post('/v1/account/sessions/magic-url') App::post('/v1/account/tokens/magic-url')
->desc('Create magic URL session') ->alias('/v1/account/sessions/magic-url')
->desc('Create magic URL token')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('auth.type', 'magic-url') ->label('auth.type', 'magic-url')
->label('audits.event', 'session.create') ->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}') ->label('audits.userId', '{response.userId}')
->label('sdk.auth', []) ->label('sdk.auth', [])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'createMagicURLSession') ->label('sdk.method', ['createMagicURLToken', 'createMagicURLSession'])
->label('sdk.description', '/docs/references/account/create-magic-url-session.md') ->label('sdk.description', '/docs/references/account/create-token-magic-url.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED) ->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_TOKEN) ->label('sdk.response.model', Response::MODEL_TOKEN)
->label('abuse-limit', 10) ->label('abuse-limit', 10)
->label('abuse-key', 'url:{url},email:{param-email}') ->label('abuse-key', 'url:{url},email:{param-email}')
->param('userId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
->param('email', '', new Email(), 'User email.') ->param('email', '', new Email(), 'User email.')
->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients']) ->param('url', '', fn($clients) => new Host($clients), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['clients'])
->param('securityPhrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of authentication flow.', true) ->param('securityPhrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of authentication flow.', true)
@ -1009,7 +1058,7 @@ App::post('/v1/account/sessions/magic-url')
Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); Authorization::skip(fn () => $dbForProject->createDocument('users', $user));
} }
$loginSecret = Auth::tokenGenerator(32); $tokenSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_MAGIC_URL);
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM)); $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM));
$token = new Document([ $token = new Document([
@ -1017,7 +1066,7 @@ App::post('/v1/account/sessions/magic-url')
'userId' => $user->getId(), 'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(), 'userInternalId' => $user->getInternalId(),
'type' => Auth::TOKEN_TYPE_MAGIC_URL, 'type' => Auth::TOKEN_TYPE_MAGIC_URL,
'secret' => Auth::hash($loginSecret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($tokenSecret), // One way hash encryption to protect DB leak
'expire' => $expire, 'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(), 'ip' => $request->getIP(),
@ -1039,7 +1088,7 @@ App::post('/v1/account/sessions/magic-url')
} }
$url = Template::parseURL($url); $url = Template::parseURL($url);
$url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['userId' => $user->getId(), 'secret' => $loginSecret, 'expire' => $expire, 'project' => $project->getId()]); $url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['userId' => $user->getId(), 'secret' => $tokenSecret, 'expire' => $expire, 'project' => $project->getId()]);
$url = Template::unParseURL($url); $url = Template::unParseURL($url);
$body = $locale->getText("emails.magicSession.body"); $body = $locale->getText("emails.magicSession.body");
@ -1138,13 +1187,13 @@ App::post('/v1/account/sessions/magic-url')
$queueForEvents->setPayload( $queueForEvents->setPayload(
$response->output( $response->output(
$token->setAttribute('secret', $loginSecret), $token->setAttribute('secret', $tokenSecret),
Response::MODEL_TOKEN Response::MODEL_TOKEN
) )
); );
// Hide secret for clients // Hide secret for clients
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $loginSecret : ''); $token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $tokenSecret : '');
if (!empty($securityPhrase)) { if (!empty($securityPhrase)) {
$token->setAttribute('securityPhrase', $securityPhrase); $token->setAttribute('securityPhrase', $securityPhrase);
@ -1156,48 +1205,21 @@ App::post('/v1/account/sessions/magic-url')
; ;
}); });
App::put('/v1/account/sessions/magic-url') $createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
->desc('Create magic URL session (confirmation)') $roles = Authorization::getRoles();
->groups(['api', 'account', 'session']) $isPrivilegedUser = Auth::isPrivilegedUser($roles);
->label('scope', 'public') $isAppUser = Auth::isAppUser($roles);
->label('event', 'users.[userId].sessions.[sessionId].create')
->label('audits.event', 'session.update')
->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('usage.params', ['provider:magic-url'])
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', 'updateMagicURLSession')
->label('sdk.description', '/docs/references/account/update-magic-url-session.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_SESSION)
->label('abuse-limit', 10)
->label('abuse-key', 'url:{url},userId:{param-userId}')
->param('userId', '', new CustomId(), 'User ID.')
->param('secret', '', new Text(256), 'Valid verification token.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
/** @var Utopia\Database\Document $user */ /** @var Utopia\Database\Document $user */
$userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId)); $userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId));
if ($userFromRequest->isEmpty()) { if ($userFromRequest->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND); throw new Exception(Exception::USER_INVALID_TOKEN);
} }
$token = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), Auth::TOKEN_TYPE_MAGIC_URL, $secret); $verifiedToken = Auth::tokenVerify($userFromRequest->getAttribute('tokens', []), null, $secret);
if (!$token) { if (!$verifiedToken) {
throw new Exception(Exception::USER_INVALID_TOKEN); throw new Exception(Exception::USER_INVALID_TOKEN);
} }
@ -1206,7 +1228,7 @@ App::put('/v1/account/sessions/magic-url')
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN')); $detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP()); $record = $geodb->get($request->getIP());
$secret = Auth::tokenGenerator(); $sessionSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$session = new Document(array_merge( $session = new Document(array_merge(
@ -1214,8 +1236,8 @@ App::put('/v1/account/sessions/magic-url')
'$id' => ID::unique(), '$id' => ID::unique(),
'userId' => $user->getId(), 'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(), 'userInternalId' => $user->getInternalId(),
'provider' => Auth::SESSION_PROVIDER_MAGIC_URL, 'provider' => Auth::getSessionProviderByTokenType($verifiedToken->getAttribute('type')),
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak 'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'), 'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(), 'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--', 'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
@ -1235,17 +1257,16 @@ App::put('/v1/account/sessions/magic-url')
])); ]));
$dbForProject->deleteCachedDocument('users', $user->getId()); $dbForProject->deleteCachedDocument('users', $user->getId());
Authorization::skip(fn () => $dbForProject->deleteDocument('tokens', $verifiedToken->getId()));
$tokens = $user->getAttribute('tokens', []);
/**
* We act like we're updating and validating
* the recovery token but actually we don't need it anymore.
*/
$dbForProject->deleteDocument('tokens', $token);
$dbForProject->deleteCachedDocument('users', $user->getId()); $dbForProject->deleteCachedDocument('users', $user->getId());
if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_MAGIC_URL) {
$user->setAttribute('emailVerification', true); $user->setAttribute('emailVerification', true);
}
if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_PHONE) {
$user->setAttribute('phoneVerification', true);
}
try { try {
$dbForProject->updateDocument('users', $user->getId(), $user); $dbForProject->updateDocument('users', $user->getId(), $user);
@ -1258,14 +1279,14 @@ App::put('/v1/account/sessions/magic-url')
->setParam('sessionId', $session->getId()); ->setParam('sessionId', $session->getId());
if (!Config::getParam('domainVerification')) { if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)])); $response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $sessionSecret)]));
} }
$protocol = $request->getProtocol(); $protocol = $request->getProtocol();
$response $response
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null) ->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite')) ->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $sessionSecret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED); ->setStatusCode(Response::STATUS_CODE_CREATED);
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown')); $countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
@ -1273,23 +1294,87 @@ App::put('/v1/account/sessions/magic-url')
$session $session
->setAttribute('current', true) ->setAttribute('current', true)
->setAttribute('countryName', $countryName) ->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire); ->setAttribute('expire', $expire)
->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $sessionSecret) : '')
;
$response->dynamic($session, Response::MODEL_SESSION); $response->dynamic($session, Response::MODEL_SESSION);
}); };
App::post('/v1/account/sessions/phone') App::put('/v1/account/sessions/magic-url')
->desc('Create phone session') ->alias('/v1/account/sessions/phone')
->desc('Create session (deprecated)')
->label('event', 'users.[userId].sessions.[sessionId].create')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', ['updateMagicURLSession', 'updatePhoneSession'])
->label('sdk.description', '/docs/references/account/create-session.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_SESSION)
->label('abuse-limit', 10)
->label('abuse-key', 'ip:{ip},userId:{param-userId}')
->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
->param('secret', '', new Text(256), 'Valid verification token.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->action($createSession);
App::post('/v1/account/sessions/token')
->desc('Create session')
->label('event', 'users.[userId].sessions.[sessionId].create')
->groups(['api', 'account'])
->label('scope', 'sessions.write')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', 'createSession')
->label('sdk.description', '/docs/references/account/create-session.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_SESSION)
->label('abuse-limit', 10)
->label('abuse-key', 'ip:{ip},userId:{param-userId}')
->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
->param('secret', '', new Text(256), 'Secret of a token generated by login methods. For example, the `createMagicURLToken` or `createPhoneToken` methods.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->action($createSession);
App::post('/v1/account/tokens/phone')
->alias('/v1/account/sessions/phone')
->desc('Create phone token')
->groups(['api', 'account'])
->label('scope', 'sessions.write')
->label('auth.type', 'phone') ->label('auth.type', 'phone')
->label('audits.event', 'session.create') ->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}') ->label('audits.userId', '{response.userId}')
->label('sdk.auth', []) ->label('sdk.auth', [])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'createPhoneSession') ->label('sdk.method', ['createPhoneToken', 'createPhoneSession'])
->label('sdk.description', '/docs/references/account/create-phone-session.md') ->label('sdk.description', '/docs/references/account/create-token-phone.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED) ->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_TOKEN) ->label('sdk.response.model', Response::MODEL_TOKEN)
@ -1435,7 +1520,7 @@ App::post('/v1/account/sessions/phone')
); );
// Hide secret for clients // Hide secret for clients
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : ''); $token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '');
$response $response
->setStatusCode(Response::STATUS_CODE_CREATED) ->setStatusCode(Response::STATUS_CODE_CREATED)
@ -1443,127 +1528,11 @@ App::post('/v1/account/sessions/phone')
; ;
}); });
App::put('/v1/account/sessions/phone')
->desc('Create phone session (confirmation)')
->groups(['api', 'account', 'session'])
->label('scope', 'public')
->label('event', 'users.[userId].sessions.[sessionId].create')
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', 'updatePhoneSession')
->label('sdk.description', '/docs/references/account/update-phone-session.md')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_SESSION)
->label('abuse-limit', 10)
->label('abuse-key', 'url:{url},userId:{param-userId}')
->param('userId', '', new CustomId(), 'User ID.')
->param('secret', '', new Text(256), 'Valid verification token.')
->inject('request')
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->action(function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
if ($userFromRequest->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$token = Auth::phoneTokenVerify($userFromRequest->getAttribute('tokens', []), $secret);
if (!$token) {
throw new Exception(Exception::USER_INVALID_TOKEN);
}
$user->setAttributes($userFromRequest->getArrayCopy());
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$secret = Auth::tokenGenerator();
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$session = new Document(array_merge(
[
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'provider' => Auth::SESSION_PROVIDER_PHONE,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
],
$detector->getOS(),
$detector->getClient(),
$detector->getDevice()
));
Authorization::setRole(Role::user($user->getId())->toString());
$session = $dbForProject->createDocument('sessions', $session
->setAttribute('$permissions', [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]));
$dbForProject->deleteCachedDocument('users', $user->getId());
/**
* We act like we're updating and validating
* the recovery token but actually we don't need it anymore.
*/
$dbForProject->deleteDocument('tokens', $token);
$dbForProject->deleteCachedDocument('users', $user->getId());
$user->setAttribute('phoneVerification', true);
$dbForProject->updateDocument('users', $user->getId(), $user);
if (false === $user) {
throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Failed saving user to DB');
}
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
;
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
}
$protocol = $request->getProtocol();
$response
->addCookie(Auth::$cookieName . '_legacy', Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), (new \DateTime($expire))->getTimestamp(), '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
->setStatusCode(Response::STATUS_CODE_CREATED)
;
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
$session
->setAttribute('current', true)
->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire)
;
$response->dynamic($session, Response::MODEL_SESSION);
});
App::post('/v1/account/sessions/anonymous') App::post('/v1/account/sessions/anonymous')
->desc('Create anonymous session') ->desc('Create anonymous session')
->groups(['api', 'account', 'auth', 'session']) ->groups(['api', 'account', 'auth', 'session'])
->label('event', 'users.[userId].sessions.[sessionId].create') ->label('event', 'users.[userId].sessions.[sessionId].create')
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('auth.type', 'anonymous') ->label('auth.type', 'anonymous')
->label('audits.event', 'session.create') ->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
@ -1590,6 +1559,9 @@ App::post('/v1/account/sessions/anonymous')
->action(function (Request $request, Response $response, Locale $locale, Document $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents) { ->action(function (Request $request, Response $response, Locale $locale, Document $user, Document $project, Database $dbForProject, Reader $geodb, Event $queueForEvents) {
$protocol = $request->getProtocol(); $protocol = $request->getProtocol();
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
if ('console' === $project->getId()) { if ('console' === $project->getId()) {
throw new Exception(Exception::USER_ANONYMOUS_CONSOLE_PROHIBITED, 'Failed to create anonymous user'); throw new Exception(Exception::USER_ANONYMOUS_CONSOLE_PROHIBITED, 'Failed to create anonymous user');
@ -1641,7 +1613,7 @@ App::post('/v1/account/sessions/anonymous')
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG; $duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN')); $detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP()); $record = $geodb->get($request->getIP());
$secret = Auth::tokenGenerator(); $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration)); $expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$session = new Document(array_merge( $session = new Document(array_merge(
@ -1691,6 +1663,7 @@ App::post('/v1/account/sessions/anonymous')
->setAttribute('current', true) ->setAttribute('current', true)
->setAttribute('countryName', $countryName) ->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire) ->setAttribute('expire', $expire)
->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '')
; ;
$response->dynamic($session, Response::MODEL_SESSION); $response->dynamic($session, Response::MODEL_SESSION);
@ -1699,9 +1672,9 @@ App::post('/v1/account/sessions/anonymous')
App::post('/v1/account/jwt') App::post('/v1/account/jwt')
->desc('Create JWT') ->desc('Create JWT')
->groups(['api', 'account', 'auth']) ->groups(['api', 'account', 'auth'])
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('auth.type', 'jwt') ->label('auth.type', 'jwt')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION]) ->label('sdk.auth', [])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
->label('sdk.method', 'createJWT') ->label('sdk.method', 'createJWT')
->label('sdk.description', '/docs/references/account/create-jwt.md') ->label('sdk.description', '/docs/references/account/create-jwt.md')
@ -1821,7 +1794,7 @@ App::post('/v1/account/targets/push')
App::get('/v1/account') App::get('/v1/account')
->desc('Get account') ->desc('Get account')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read') ->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
@ -1842,7 +1815,7 @@ App::get('/v1/account')
App::get('/v1/account/prefs') App::get('/v1/account/prefs')
->desc('Get account preferences') ->desc('Get account preferences')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read') ->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
@ -1865,7 +1838,7 @@ App::get('/v1/account/prefs')
App::get('/v1/account/sessions') App::get('/v1/account/sessions')
->desc('List sessions') ->desc('List sessions')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read') ->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
@ -1904,7 +1877,7 @@ App::get('/v1/account/sessions')
App::get('/v1/account/logs') App::get('/v1/account/logs')
->desc('List logs') ->desc('List logs')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read') ->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
@ -1965,7 +1938,7 @@ App::get('/v1/account/logs')
App::get('/v1/account/sessions/:sessionId') App::get('/v1/account/sessions/:sessionId')
->desc('Get session') ->desc('Get session')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read') ->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT]) ->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account') ->label('sdk.namespace', 'account')
@ -2011,7 +1984,7 @@ App::patch('/v1/account/name')
->desc('Update name') ->desc('Update name')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('event', 'users.[userId].update.name') ->label('event', 'users.[userId].update.name')
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('audits.event', 'user.update') ->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update') ->label('usage.metric', 'users.{scope}.requests.update')
@ -2045,7 +2018,7 @@ App::patch('/v1/account/password')
->desc('Update password') ->desc('Update password')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('event', 'users.[userId].update.password') ->label('event', 'users.[userId].update.password')
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('audits.event', 'user.update') ->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
->label('audits.userId', '{response.$id}') ->label('audits.userId', '{response.$id}')
@ -2112,7 +2085,7 @@ App::patch('/v1/account/email')
->desc('Update email') ->desc('Update email')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('event', 'users.[userId].update.email') ->label('event', 'users.[userId].update.email')
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('audits.event', 'user.update') ->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update') ->label('usage.metric', 'users.{scope}.requests.update')
@ -2200,7 +2173,7 @@ App::patch('/v1/account/phone')
->desc('Update phone') ->desc('Update phone')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('event', 'users.[userId].update.phone') ->label('event', 'users.[userId].update.phone')
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('audits.event', 'user.update') ->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update') ->label('usage.metric', 'users.{scope}.requests.update')
@ -2278,7 +2251,7 @@ App::patch('/v1/account/prefs')
->desc('Update preferences') ->desc('Update preferences')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('event', 'users.[userId].update.prefs') ->label('event', 'users.[userId].update.prefs')
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('audits.event', 'user.update') ->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update') ->label('usage.metric', 'users.{scope}.requests.update')
@ -2312,7 +2285,7 @@ App::patch('/v1/account/status')
->desc('Update status') ->desc('Update status')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('event', 'users.[userId].update.status') ->label('event', 'users.[userId].update.status')
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('audits.event', 'user.update') ->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}') ->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.delete') ->label('usage.metric', 'users.{scope}.requests.delete')
@ -2355,7 +2328,7 @@ App::patch('/v1/account/status')
App::delete('/v1/account/sessions/:sessionId') App::delete('/v1/account/sessions/:sessionId')
->desc('Delete session') ->desc('Delete session')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('event', 'users.[userId].sessions.[sessionId].delete') ->label('event', 'users.[userId].sessions.[sessionId].delete')
->label('audits.event', 'session.delete') ->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{user.$id}') ->label('audits.resource', 'user/{user.$id}')
@ -2431,7 +2404,7 @@ App::delete('/v1/account/sessions/:sessionId')
App::patch('/v1/account/sessions/:sessionId') App::patch('/v1/account/sessions/:sessionId')
->desc('Update OAuth session (refresh tokens)') ->desc('Update OAuth session (refresh tokens)')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('event', 'users.[userId].sessions.[sessionId].update') ->label('event', 'users.[userId].sessions.[sessionId].update')
->label('audits.event', 'session.update') ->label('audits.event', 'session.update')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
@ -2517,7 +2490,7 @@ App::patch('/v1/account/sessions/:sessionId')
App::delete('/v1/account/sessions') App::delete('/v1/account/sessions')
->desc('Delete sessions') ->desc('Delete sessions')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('event', 'users.[userId].sessions.[sessionId].delete') ->label('event', 'users.[userId].sessions.[sessionId].delete')
->label('audits.event', 'session.delete') ->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{user.$id}') ->label('audits.resource', 'user/{user.$id}')
@ -2578,7 +2551,7 @@ App::delete('/v1/account/sessions')
App::post('/v1/account/recovery') App::post('/v1/account/recovery')
->desc('Create password recovery') ->desc('Create password recovery')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('event', 'users.[userId].recovery.[tokenId].create') ->label('event', 'users.[userId].recovery.[tokenId].create')
->label('audits.event', 'recovery.create') ->label('audits.event', 'recovery.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
@ -2631,7 +2604,7 @@ App::post('/v1/account/recovery')
$expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_RECOVERY); $expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_RECOVERY);
$secret = Auth::tokenGenerator(); $secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_RECOVERY);
$recovery = new Document([ $recovery = new Document([
'$id' => ID::unique(), '$id' => ID::unique(),
'userId' => $profile->getId(), 'userId' => $profile->getId(),
@ -2756,7 +2729,7 @@ App::post('/v1/account/recovery')
App::put('/v1/account/recovery') App::put('/v1/account/recovery')
->desc('Create password recovery (confirmation)') ->desc('Create password recovery (confirmation)')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'public') ->label('scope', 'sessions.write')
->label('event', 'users.[userId].recovery.[tokenId].update') ->label('event', 'users.[userId].recovery.[tokenId].update')
->label('audits.event', 'recovery.update') ->label('audits.event', 'recovery.update')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
@ -2787,9 +2760,9 @@ App::put('/v1/account/recovery')
} }
$tokens = $profile->getAttribute('tokens', []); $tokens = $profile->getAttribute('tokens', []);
$recovery = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_RECOVERY, $secret); $verifiedToken = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_RECOVERY, $secret);
if (!$recovery) { if (!$verifiedToken) {
throw new Exception(Exception::USER_INVALID_TOKEN); throw new Exception(Exception::USER_INVALID_TOKEN);
} }
@ -2819,13 +2792,13 @@ App::put('/v1/account/recovery')
$user->setAttributes($profile->getArrayCopy()); $user->setAttributes($profile->getArrayCopy());
$recoveryDocument = $dbForProject->getDocument('tokens', $recovery); $recoveryDocument = $dbForProject->getDocument('tokens', $verifiedToken->getId());
/** /**
* We act like we're updating and validating * We act like we're updating and validating
* the recovery token but actually we don't need it anymore. * the recovery token but actually we don't need it anymore.
*/ */
$dbForProject->deleteDocument('tokens', $recovery); $dbForProject->deleteDocument('tokens', $verifiedToken->getId());
$dbForProject->deleteCachedDocument('users', $profile->getId()); $dbForProject->deleteCachedDocument('users', $profile->getId());
$queueForEvents $queueForEvents
@ -2839,7 +2812,7 @@ App::put('/v1/account/recovery')
App::post('/v1/account/verification') App::post('/v1/account/verification')
->desc('Create email verification') ->desc('Create email verification')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('event', 'users.[userId].verification.[tokenId].create') ->label('event', 'users.[userId].verification.[tokenId].create')
->label('audits.event', 'verification.create') ->label('audits.event', 'verification.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
@ -2875,7 +2848,7 @@ App::post('/v1/account/verification')
$roles = Authorization::getRoles(); $roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles); $isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles); $isAppUser = Auth::isAppUser($roles);
$verificationSecret = Auth::tokenGenerator(); $verificationSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_VERIFICATION);
$expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM); $expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM);
$verification = new Document([ $verification = new Document([
@ -3029,9 +3002,9 @@ App::put('/v1/account/verification')
} }
$tokens = $profile->getAttribute('tokens', []); $tokens = $profile->getAttribute('tokens', []);
$verification = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_VERIFICATION, $secret); $verifiedToken = Auth::tokenVerify($tokens, Auth::TOKEN_TYPE_VERIFICATION, $secret);
if (!$verification) { if (!$verifiedToken) {
throw new Exception(Exception::USER_INVALID_TOKEN); throw new Exception(Exception::USER_INVALID_TOKEN);
} }
@ -3041,13 +3014,13 @@ App::put('/v1/account/verification')
$user->setAttributes($profile->getArrayCopy()); $user->setAttributes($profile->getArrayCopy());
$verificationDocument = $dbForProject->getDocument('tokens', $verification); $verificationDocument = $dbForProject->getDocument('tokens', $verifiedToken->getId());
/** /**
* We act like we're updating and validating * We act like we're updating and validating
* the verification token but actually we don't need it anymore. * the verification token but actually we don't need it anymore.
*/ */
$dbForProject->deleteDocument('tokens', $verification); $dbForProject->deleteDocument('tokens', $verifiedToken->getId());
$dbForProject->deleteCachedDocument('users', $profile->getId()); $dbForProject->deleteCachedDocument('users', $profile->getId());
$queueForEvents $queueForEvents
@ -3061,7 +3034,7 @@ App::put('/v1/account/verification')
App::post('/v1/account/verification/phone') App::post('/v1/account/verification/phone')
->desc('Create phone verification') ->desc('Create phone verification')
->groups(['api', 'account']) ->groups(['api', 'account'])
->label('scope', 'account') ->label('scope', 'accounts.write')
->label('event', 'users.[userId].verification.[tokenId].create') ->label('event', 'users.[userId].verification.[tokenId].create')
->label('audits.event', 'verification.create') ->label('audits.event', 'verification.create')
->label('audits.resource', 'user/{response.userId}') ->label('audits.resource', 'user/{response.userId}')
@ -3202,9 +3175,9 @@ App::put('/v1/account/verification/phone')
throw new Exception(Exception::USER_NOT_FOUND); throw new Exception(Exception::USER_NOT_FOUND);
} }
$verification = Auth::phoneTokenVerify($user->getAttribute('tokens', []), $secret); $verifiedToken = Auth::tokenVerify($user->getAttribute('tokens', []), Auth::TOKEN_TYPE_PHONE, $secret);
if (!$verification) { if (!$verifiedToken) {
throw new Exception(Exception::USER_INVALID_TOKEN); throw new Exception(Exception::USER_INVALID_TOKEN);
} }
@ -3214,12 +3187,12 @@ App::put('/v1/account/verification/phone')
$user->setAttributes($profile->getArrayCopy()); $user->setAttributes($profile->getArrayCopy());
$verificationDocument = $dbForProject->getDocument('tokens', $verification); $verificationDocument = $dbForProject->getDocument('tokens', $verifiedToken->getId());
/** /**
* We act like we're updating and validating the verification token but actually we don't need it anymore. * We act like we're updating and validating the verification token but actually we don't need it anymore.
*/ */
$dbForProject->deleteDocument('tokens', $verification); $dbForProject->deleteDocument('tokens', $verifiedToken->getId());
$dbForProject->deleteCachedDocument('users', $profile->getId()); $dbForProject->deleteCachedDocument('users', $profile->getId());
$queueForEvents $queueForEvents
@ -3243,7 +3216,6 @@ App::put('/v1/account/targets/:targetId/push')
->label('sdk.response.code', Response::STATUS_CODE_OK) ->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON) ->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_TARGET) ->label('sdk.response.model', Response::MODEL_TARGET)
->label('docs', false)
->param('targetId', '', new UID(), 'Target ID.') ->param('targetId', '', new UID(), 'Target ID.')
->param('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)') ->param('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)')
->inject('queueForEvents') ->inject('queueForEvents')

View file

@ -671,12 +671,11 @@ App::post('/v1/messaging/providers/apns')
->param('authKeyId', '', new Text(0), 'APNS authentication key ID.', true) ->param('authKeyId', '', new Text(0), 'APNS authentication key ID.', true)
->param('teamId', '', new Text(0), 'APNS team ID.', true) ->param('teamId', '', new Text(0), 'APNS team ID.', true)
->param('bundleId', '', new Text(0), 'APNS bundle ID.', true) ->param('bundleId', '', new Text(0), 'APNS bundle ID.', true)
->param('endpoint', '', new Text(0), 'APNS endpoint.', true)
->param('enabled', null, new Boolean(), 'Set as enabled.', true) ->param('enabled', null, new Boolean(), 'Set as enabled.', true)
->inject('queueForEvents') ->inject('queueForEvents')
->inject('dbForProject') ->inject('dbForProject')
->inject('response') ->inject('response')
->action(function (string $providerId, string $name, string $authKey, string $authKeyId, string $teamId, string $bundleId, string $endpoint, ?bool $enabled, Event $queueForEvents, Database $dbForProject, Response $response) { ->action(function (string $providerId, string $name, string $authKey, string $authKeyId, string $teamId, string $bundleId, ?bool $enabled, Event $queueForEvents, Database $dbForProject, Response $response) {
$providerId = $providerId == 'unique()' ? ID::unique() : $providerId; $providerId = $providerId == 'unique()' ? ID::unique() : $providerId;
$credentials = []; $credentials = [];
@ -697,17 +696,12 @@ App::post('/v1/messaging/providers/apns')
$credentials['bundleId'] = $bundleId; $credentials['bundleId'] = $bundleId;
} }
if (!empty($endpoint)) {
$credentials['endpoint'] = $endpoint;
}
if ( if (
$enabled === true $enabled === true
&& \array_key_exists('authKey', $credentials) && \array_key_exists('authKey', $credentials)
&& \array_key_exists('authKeyId', $credentials) && \array_key_exists('authKeyId', $credentials)
&& \array_key_exists('teamId', $credentials) && \array_key_exists('teamId', $credentials)
&& \array_key_exists('bundleId', $credentials) && \array_key_exists('bundleId', $credentials)
&& \array_key_exists('endpoint', $credentials)
) { ) {
$enabled = true; $enabled = true;
} else { } else {
@ -1565,11 +1559,10 @@ App::patch('/v1/messaging/providers/apns/:providerId')
->param('authKeyId', '', new Text(0), 'APNS authentication key ID.', true) ->param('authKeyId', '', new Text(0), 'APNS authentication key ID.', true)
->param('teamId', '', new Text(0), 'APNS team ID.', true) ->param('teamId', '', new Text(0), 'APNS team ID.', true)
->param('bundleId', '', new Text(0), 'APNS bundle ID.', true) ->param('bundleId', '', new Text(0), 'APNS bundle ID.', true)
->param('endpoint', '', new Text(0), 'APNS endpoint.', true)
->inject('queueForEvents') ->inject('queueForEvents')
->inject('dbForProject') ->inject('dbForProject')
->inject('response') ->inject('response')
->action(function (string $providerId, string $name, ?bool $enabled, string $authKey, string $authKeyId, string $teamId, string $bundleId, string $endpoint, Event $queueForEvents, Database $dbForProject, Response $response) { ->action(function (string $providerId, string $name, ?bool $enabled, string $authKey, string $authKeyId, string $teamId, string $bundleId, Event $queueForEvents, Database $dbForProject, Response $response) {
$provider = $dbForProject->getDocument('providers', $providerId); $provider = $dbForProject->getDocument('providers', $providerId);
if ($provider->isEmpty()) { if ($provider->isEmpty()) {
@ -1603,10 +1596,6 @@ App::patch('/v1/messaging/providers/apns/:providerId')
$credentials['bundle'] = $bundleId; $credentials['bundle'] = $bundleId;
} }
if (!empty($endpoint)) {
$credentials['endpoint'] = $endpoint;
}
$provider->setAttribute('credentials', $credentials); $provider->setAttribute('credentials', $credentials);
if ($enabled === true || $enabled === false) { if ($enabled === true || $enabled === false) {
@ -1616,7 +1605,6 @@ App::patch('/v1/messaging/providers/apns/:providerId')
&& \array_key_exists('authKeyId', $credentials) && \array_key_exists('authKeyId', $credentials)
&& \array_key_exists('teamId', $credentials) && \array_key_exists('teamId', $credentials)
&& \array_key_exists('bundleId', $credentials) && \array_key_exists('bundleId', $credentials)
&& \array_key_exists('endpoint', $credentials)
) { ) {
$enabled = true; $enabled = true;
} else { } else {
@ -1696,12 +1684,9 @@ App::post('/v1/messaging/topics')
$topic = new Document([ $topic = new Document([
'$id' => $topicId, '$id' => $topicId,
'name' => $name, 'name' => $name,
'description' => $description
]); ]);
if ($description) {
$topic->setAttribute('description', $description);
}
try { try {
$topic = $dbForProject->createDocument('topics', $topic); $topic = $dbForProject->createDocument('topics', $topic);
} catch (DuplicateException) { } catch (DuplicateException) {

View file

@ -474,6 +474,7 @@ App::post('/v1/teams/:teamId/memberships')
'phone' => empty($phone) ? null : $phone, 'phone' => empty($phone) ? null : $phone,
'emailVerification' => false, 'emailVerification' => false,
'status' => true, 'status' => true,
// TODO: Set password empty?
'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS), 'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS),
'hash' => Auth::DEFAULT_ALGO, 'hash' => Auth::DEFAULT_ALGO,
'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS,

View file

@ -14,6 +14,7 @@ use Utopia\Database\Validator\Queries;
use Appwrite\Utopia\Database\Validator\Queries\Users; use Appwrite\Utopia\Database\Validator\Queries\Users;
use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Limit;
use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\Query\Offset;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response; use Appwrite\Utopia\Response;
use Utopia\App; use Utopia\App;
use Utopia\Audit\Audit; use Utopia\Audit\Audit;
@ -35,6 +36,7 @@ use Utopia\Validator\Assoc;
use Utopia\Validator\WhiteList; use Utopia\Validator\WhiteList;
use Utopia\Validator\Text; use Utopia\Validator\Text;
use Utopia\Validator\Boolean; use Utopia\Validator\Boolean;
use Utopia\Validator\Range;
use MaxMind\Db\Reader; use MaxMind\Db\Reader;
use Utopia\Validator\Integer; use Utopia\Validator\Integer;
use Appwrite\Auth\Validator\PasswordHistory; use Appwrite\Auth\Validator\PasswordHistory;
@ -1420,6 +1422,134 @@ App::patch('/v1/users/:userId/targets/:targetId')
->dynamic($target, Response::MODEL_TARGET); ->dynamic($target, Response::MODEL_TARGET);
}); });
App::post('/v1/users/:userId/sessions')
->desc('Create session')
->groups(['api', 'users'])
->label('event', 'users.[userId].sessions.[sessionId].create')
->label('scope', 'users.write')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{request.userId}')
->label('usage.metric', 'sessions.{scope}.requests.create')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'users')
->label('sdk.method', 'createSession')
->label('sdk.description', '/docs/references/users/create-session.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_SESSION)
->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.')
->inject('request')
->inject('response')
->inject('dbForProject')
->inject('project')
->inject('locale')
->inject('geodb')
->inject('queueForEvents')
->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 === false || $user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$secret = Auth::codeGenerator();
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$session = new Document(array_merge(
[
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'provider' => Auth::SESSION_PROVIDER_SERVER,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
],
$detector->getOS(),
$detector->getClient(),
$detector->getDevice()
));
$countryName = $locale->getText('countries.' . strtolower($session->getAttribute('countryCode')), $locale->getText('locale.country.unknown'));
$session = $dbForProject->createDocument('sessions', $session);
$session
->setAttribute('secret', $secret)
->setAttribute('expire', $expire)
->setAttribute('countryName', $countryName);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION));
return $response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($session, Response::MODEL_SESSION);
});
App::post('/v1/users/:userId/tokens')
->desc('Create token')
->groups(['api', 'users'])
->label('event', 'users.[userId].tokens.[tokenId].create')
->label('scope', 'users.write')
->label('audits.event', 'tokens.create')
->label('audits.resource', 'user/{request.userId}')
->label('usage.metric', 'tokens.requests.create')
->label('sdk.auth', [APP_AUTH_TYPE_KEY])
->label('sdk.namespace', 'users')
->label('sdk.method', 'createToken')
->label('sdk.description', '/docs/references/users/create-token.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_TOKEN)
->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', 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')
->inject('queueForEvents')
->action(function (string $userId, int $length, int $expire, Request $request, Response $response, Database $dbForProject, Event $queueForEvents) {
$user = $dbForProject->getDocument('users', $userId);
if ($user === false || $user->isEmpty()) {
throw new Exception(Exception::USER_NOT_FOUND);
}
$secret = Auth::tokenGenerator($length);
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $expire));
$token = new Document([
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'type' => Auth::TOKEN_TYPE_GENERIC,
'secret' => Auth::hash($secret),
'expire' => $expire,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP()
]);
$token = $dbForProject->createDocument('tokens', $token);
$dbForProject->deleteCachedDocument('users', $user->getId());
$token->setAttribute('secret', $secret);
$queueForEvents
->setParam('userId', $user->getId())
->setParam('tokenId', $token->getId())
->setPayload($response->output($token, Response::MODEL_TOKEN));
return $response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($token, Response::MODEL_TOKEN);
});
App::delete('/v1/users/:userId/sessions/:sessionId') App::delete('/v1/users/:userId/sessions/:sessionId')
->desc('Delete user session') ->desc('Delete user session')
->groups(['api', 'users']) ->groups(['api', 'users'])

View file

@ -425,8 +425,8 @@ App::init()
->addHeader('Server', 'Appwrite') ->addHeader('Server', 'Appwrite')
->addHeader('X-Content-Type-Options', 'nosniff') ->addHeader('X-Content-Type-Options', 'nosniff')
->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE')
->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma') ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Forwarded-For, X-Forwarded-User-Agent')
->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') ->addHeader('Access-Control-Expose-Headers', 'X-Appwrite-Session, X-Fallback-Cookies')
->addHeader('Access-Control-Allow-Origin', $refDomain) ->addHeader('Access-Control-Allow-Origin', $refDomain)
->addHeader('Access-Control-Allow-Credentials', 'true'); ->addHeader('Access-Control-Allow-Credentials', 'true');
@ -589,8 +589,8 @@ App::options()
$response $response
->addHeader('Server', 'Appwrite') ->addHeader('Server', 'Appwrite')
->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE') ->addHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE')
->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies') ->addHeader('Access-Control-Allow-Headers', 'Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent')
->addHeader('Access-Control-Expose-Headers', 'X-Fallback-Cookies') ->addHeader('Access-Control-Expose-Headers', 'X-Appwrite-Session, X-Fallback-Cookies')
->addHeader('Access-Control-Allow-Origin', $origin) ->addHeader('Access-Control-Allow-Origin', $origin)
->addHeader('Access-Control-Allow-Credentials', 'true') ->addHeader('Access-Control-Allow-Credentials', 'true')
->noContent(); ->noContent();

View file

@ -311,6 +311,12 @@ App::init()
} }
break; break;
case 'phone':
if (($auths['phone'] ?? true) === false) {
throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Phone authentication is disabled for this project');
}
break;
default: default:
throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route'); throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route');
break; break;

View file

@ -55,6 +55,12 @@ App::init()
} }
break; break;
case 'phone':
if (($auths['phone'] ?? true) === false) {
throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Phone authentication is disabled for this project');
}
break;
default: default:
throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route'); throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route');
break; break;

View file

@ -1118,9 +1118,18 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons
Auth::$cookieName, // Get sessions Auth::$cookieName, // Get sessions
$request->getCookie(Auth::$cookieName . '_legacy', '') $request->getCookie(Auth::$cookieName . '_legacy', '')
) )
);// Get fallback session from old clients (no SameSite support) );
// Get fallback session from clients who block 3rd-party cookies // Get session from header for SSR clients
if (empty($session['id']) && empty($session['secret'])) {
$sessionHeader = $request->getHeader('x-appwrite-session', '');
if (!empty($sessionHeader)) {
$session = Auth::decodeSession($sessionHeader);
}
}
// Get fallback session from old clients (no SameSite support) or clients who block 3rd-party cookies
if ($response) { if ($response) {
$response->addHeader('X-Debug-Fallback', 'false'); $response->addHeader('X-Debug-Fallback', 'false');
} }

View file

@ -0,0 +1 @@
Use this endpoint to create a session from token. Provide the **userId** and **secret** parameters from the successful response of authentication flows initiated by token creation. For example, magic URL and phone login.

View file

@ -1,3 +0,0 @@
Use this endpoint to complete creating the session with the Magic URL. Both the **userId** and **secret** arguments will be passed as query parameters to the redirect URL you have provided when sending your request to the [POST /account/sessions/magic-url](https://appwrite.io/docs/references/cloud/client-web/account#createMagicURLSession) endpoint.
Please note that in order to avoid a [Redirect Attack](https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.md) the only valid redirect URLs are the ones from domains you have set when adding your platforms in the console interface.

View file

@ -1 +0,0 @@
Use this endpoint to complete creating a session with SMS. Use the **userId** from the [createPhoneSession](https://appwrite.io/docs/references/cloud/client-web/account#createPhoneSession) endpoint and the **secret** received via SMS to successfully update and confirm the phone session.

View file

@ -0,0 +1,3 @@
Creates a session for a user. Returns an immediately usable session object.
If you want to generate a token for a custom authentication flow, use the [POST /users/{userId}/tokens](https://appwrite.io/docs/server/users#createToken) endpoint.

View file

@ -0,0 +1 @@
Returns a token with a secret key for creating a session. If the provided user ID has not be registered, a new user will be created. Use the returned user ID and secret and submit a request to the [PUT /account/sessions/custom](https://appwrite.io/docs/references/cloud/client-web/account#updateCustomSession) endpoint to complete the login process.

View file

@ -52,6 +52,8 @@ class Auth
public const TOKEN_TYPE_INVITE = 4; public const TOKEN_TYPE_INVITE = 4;
public const TOKEN_TYPE_MAGIC_URL = 5; public const TOKEN_TYPE_MAGIC_URL = 5;
public const TOKEN_TYPE_PHONE = 6; public const TOKEN_TYPE_PHONE = 6;
public const TOKEN_TYPE_OAUTH2 = 7;
public const TOKEN_TYPE_GENERIC = 8;
/** /**
* Session Providers. * Session Providers.
@ -60,6 +62,9 @@ class Auth
public const SESSION_PROVIDER_ANONYMOUS = 'anonymous'; public const SESSION_PROVIDER_ANONYMOUS = 'anonymous';
public const SESSION_PROVIDER_MAGIC_URL = 'magic-url'; public const SESSION_PROVIDER_MAGIC_URL = 'magic-url';
public const SESSION_PROVIDER_PHONE = 'phone'; 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. * Token Expiration times.
@ -69,6 +74,16 @@ class Auth
public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */ public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */
public const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */ public const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */
public const TOKEN_EXPIRATION_PHONE = 60 * 15; /* 15 minutes */ public const TOKEN_EXPIRATION_PHONE = 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;
/** /**
* @var string * @var string
@ -117,6 +132,27 @@ class Auth
])); ]));
} }
/**
* Token type to session provider mapping.
*/
public static function getSessionProviderByTokenType(int $type): string
{
switch ($type) {
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 Auth::SESSION_PROVIDER_TOKEN;
}
}
/** /**
* Decode Session. * Decode Session.
* *
@ -270,13 +306,20 @@ class Auth
* *
* Generate random password string * Generate random password string
* *
* @param int $length * @param int $length Length of returned token
* *
* @return string * @return string
*/ */
public static function tokenGenerator(int $length = 128): string public static function tokenGenerator(int $length = 256): string
{ {
return \bin2hex(\random_bytes($length)); if ($length <= 0) {
throw new \Exception('Token length must be greater than 0');
}
$bytesLength = (int) ceil($length / 2);
$token = \bin2hex(\random_bytes($bytesLength));
return substr($token, 0, $length);
} }
/** /**
@ -303,43 +346,24 @@ class Auth
* Verify token and check that its not expired. * Verify token and check that its not expired.
* *
* @param array $tokens * @param array $tokens
* @param int $type * @param int $type Type of token to verify, if null will verify any type
* @param string $secret * @param string $secret
* *
* @return bool|string * @return false|Document
*/ */
public static function tokenVerify(array $tokens, int $type, string $secret) public static function tokenVerify(array $tokens, int $type = null, string $secret): false|Document
{ {
foreach ($tokens as $token) { foreach ($tokens as $token) {
/** @var Document $token */ /** @var Document $token */
if ( if (
$token->isSet('type') &&
$token->isSet('secret') && $token->isSet('secret') &&
$token->isSet('expire') && $token->isSet('expire') &&
$token->getAttribute('type') == $type && $token->isSet('type') &&
($type === null || $token->getAttribute('type') === $type) &&
$token->getAttribute('secret') === self::hash($secret) && $token->getAttribute('secret') === self::hash($secret) &&
DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now()) DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now())
) { ) {
return (string)$token->getId(); return $token;
}
}
return false;
}
public static function phoneTokenVerify(array $tokens, string $secret)
{
foreach ($tokens as $token) {
/** @var Document $token */
if (
$token->isSet('type') &&
$token->isSet('secret') &&
$token->isSet('expire') &&
$token->getAttribute('type') == Auth::TOKEN_TYPE_PHONE &&
$token->getAttribute('secret') === self::hash($secret) &&
DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now())
) {
return (string) $token->getId();
} }
} }

View file

@ -99,7 +99,13 @@ class Schema
/** @var Route $route */ /** @var Route $route */
$namespace = $route->getLabel('sdk.namespace', ''); $namespace = $route->getLabel('sdk.namespace', '');
$method = $route->getLabel('sdk.method', ''); $methods = $route->getLabel('sdk.method', '');
if (!\is_array($methods)) {
$methods = [$methods];
}
foreach ($methods as $method) {
$name = $namespace . \ucfirst($method); $name = $namespace . \ucfirst($method);
if (empty($name)) { if (empty($name)) {
@ -123,6 +129,7 @@ class Schema
} }
} }
} }
}
return [ return [
'query' => $queries, 'query' => $queries,

View file

@ -82,6 +82,12 @@ class Specs extends Action
'description' => '', 'description' => '',
'in' => 'header', 'in' => 'header',
], ],
'Session' => [
'type' => 'apiKey',
'name' => 'X-Appwrite-Session',
'description' => 'The user session to authenticate with',
'in' => 'header',
]
], ],
APP_PLATFORM_SERVER => [ APP_PLATFORM_SERVER => [
'Project' => [ 'Project' => [
@ -108,6 +114,24 @@ class Specs extends Action
'description' => '', 'description' => '',
'in' => 'header', 'in' => 'header',
], ],
'Session' => [
'type' => 'apiKey',
'name' => 'X-Appwrite-Session',
'description' => 'The user session to authenticate with',
'in' => 'header',
],
'ForwardedFor' => [
'type' => 'apiKey',
'name' => 'X-Forwarded-For',
'description' => 'The IP address of the client that made the request',
'in' => 'header',
],
'ForwardedUserAgent' => [
'type' => 'apiKey',
'name' => 'X-Forwarded-User-Agent',
'description' => 'The user agent string of the client that made the request',
'in' => 'header',
],
], ],
APP_PLATFORM_CONSOLE => [ APP_PLATFORM_CONSOLE => [
'Project' => [ 'Project' => [
@ -173,6 +197,7 @@ class Specs extends Action
if (empty($routeSecurity)) { if (empty($routeSecurity)) {
$sdkPlaforms[] = APP_PLATFORM_CLIENT; $sdkPlaforms[] = APP_PLATFORM_CLIENT;
$sdkPlaforms[] = APP_PLATFORM_SERVER;
} }
if (!$route->getLabel('docs', true)) { if (!$route->getLabel('docs', true)) {

View file

@ -348,7 +348,6 @@ class Messaging extends Action
$credentials['authKeyId'], $credentials['authKeyId'],
$credentials['teamId'], $credentials['teamId'],
$credentials['bundleId'], $credentials['bundleId'],
$credentials['endpoint']
), ),
'fcm' => new FCM($credentials['serviceAccountJSON']), 'fcm' => new FCM($credentials['serviceAccountJSON']),
default => null default => null

View file

@ -124,7 +124,11 @@ class OpenAPI3 extends Format
continue; continue;
} }
$id = $route->getLabel('sdk.method', \uniqid()); $method = $route->getLabel('sdk.method', [\uniqid()]);
if (\is_array($method)) {
$method = $method[0];
}
$desc = (!empty($route->getLabel('sdk.description', ''))) ? \realpath(__DIR__ . '/../../../../' . $route->getLabel('sdk.description', '')) : null; $desc = (!empty($route->getLabel('sdk.description', ''))) ? \realpath(__DIR__ . '/../../../../' . $route->getLabel('sdk.description', '')) : null;
$produces = $route->getLabel('sdk.response.type', null); $produces = $route->getLabel('sdk.response.type', null);
$model = $route->getLabel('sdk.response.model', 'none'); $model = $route->getLabel('sdk.response.model', 'none');
@ -149,21 +153,26 @@ class OpenAPI3 extends Format
} }
if (empty($routeSecurity)) { if (empty($routeSecurity)) {
if (!$route->getLabel('sdk.hideServer', false)) {
$sdkPlatforms[] = APP_PLATFORM_SERVER;
}
if (!$route->getLabel('sdk.hideClient', false)) {
$sdkPlatforms[] = APP_PLATFORM_CLIENT; $sdkPlatforms[] = APP_PLATFORM_CLIENT;
} }
}
$temp = [ $temp = [
'summary' => $route->getDesc(), 'summary' => $route->getDesc(),
'operationId' => $route->getLabel('sdk.namespace', 'default') . ucfirst($id), 'operationId' => $route->getLabel('sdk.namespace', 'default') . ucfirst($method),
'tags' => [$route->getLabel('sdk.namespace', 'default')], 'tags' => [$route->getLabel('sdk.namespace', 'default')],
'description' => ($desc) ? \file_get_contents($desc) : '', 'description' => ($desc) ? \file_get_contents($desc) : '',
'responses' => [], 'responses' => [],
'x-appwrite' => [ // Appwrite related metadata 'x-appwrite' => [ // Appwrite related metadata
'method' => $route->getLabel('sdk.method', \uniqid()), 'method' => $method,
'weight' => $route->getOrder(), 'weight' => $route->getOrder(),
'cookies' => $route->getLabel('sdk.cookies', false), 'cookies' => $route->getLabel('sdk.cookies', false),
'type' => $route->getLabel('sdk.methodType', ''), 'type' => $route->getLabel('sdk.methodType', ''),
'demo' => Template::fromCamelCaseToDash($route->getLabel('sdk.namespace', 'default')) . '/' . Template::fromCamelCaseToDash($id) . '.md', 'demo' => Template::fromCamelCaseToDash($route->getLabel('sdk.namespace', 'default')) . '/' . Template::fromCamelCaseToDash($method) . '.md',
'edit' => 'https://github.com/appwrite/appwrite/edit/master' . $route->getLabel('sdk.description', ''), 'edit' => 'https://github.com/appwrite/appwrite/edit/master' . $route->getLabel('sdk.description', ''),
'rate-limit' => $route->getLabel('abuse-limit', 0), 'rate-limit' => $route->getLabel('abuse-limit', 0),
'rate-time' => $route->getLabel('abuse-time', 3600), 'rate-time' => $route->getLabel('abuse-time', 3600),
@ -423,7 +432,7 @@ class OpenAPI3 extends Format
foreach ($this->enumBlacklist as $blacklist) { foreach ($this->enumBlacklist as $blacklist) {
if ( if (
$blacklist['namespace'] == $route->getLabel('sdk.namespace', '') $blacklist['namespace'] == $route->getLabel('sdk.namespace', '')
&& $blacklist['method'] == $route->getLabel('sdk.method', '') && $blacklist['method'] == $method
&& $blacklist['parameter'] == $name && $blacklist['parameter'] == $name
) { ) {
$allowed = false; $allowed = false;
@ -433,8 +442,8 @@ class OpenAPI3 extends Format
if ($allowed) { if ($allowed) {
$node['schema']['enum'] = $validator->getList(); $node['schema']['enum'] = $validator->getList();
$node['schema']['x-enum-name'] = $this->getEnumName($route->getLabel('sdk.namespace', ''), $route->getLabel('sdk.method', ''), $name); $node['schema']['x-enum-name'] = $this->getEnumName($route->getLabel('sdk.namespace', ''), $method, $name);
$node['schema']['x-enum-keys'] = $this->getEnumKeys($route->getLabel('sdk.namespace', ''), $route->getLabel('sdk.method', ''), $name); $node['schema']['x-enum-keys'] = $this->getEnumKeys($route->getLabel('sdk.namespace', ''), $method, $name);
} }
if ($validator->getType() === 'integer') { if ($validator->getType() === 'integer') {
$node['format'] = 'int32'; $node['format'] = 'int32';

View file

@ -123,7 +123,11 @@ class Swagger2 extends Format
continue; continue;
} }
$id = $route->getLabel('sdk.method', \uniqid()); $method = $route->getLabel('sdk.method', [\uniqid()]);
if (\is_array($method)) {
$method = $method[0];
}
$desc = (!empty($route->getLabel('sdk.description', ''))) ? \realpath(__DIR__ . '/../../../../' . $route->getLabel('sdk.description', '')) : null; $desc = (!empty($route->getLabel('sdk.description', ''))) ? \realpath(__DIR__ . '/../../../../' . $route->getLabel('sdk.description', '')) : null;
$produces = $route->getLabel('sdk.response.type', null); $produces = $route->getLabel('sdk.response.type', null);
$model = $route->getLabel('sdk.response.model', 'none'); $model = $route->getLabel('sdk.response.model', 'none');
@ -149,22 +153,23 @@ class Swagger2 extends Format
if (empty($routeSecurity)) { if (empty($routeSecurity)) {
$sdkPlatforms[] = APP_PLATFORM_CLIENT; $sdkPlatforms[] = APP_PLATFORM_CLIENT;
$sdkPlatforms[] = APP_PLATFORM_SERVER;
} }
$temp = [ $temp = [
'summary' => $route->getDesc(), 'summary' => $route->getDesc(),
'operationId' => $route->getLabel('sdk.namespace', 'default') . ucfirst($id), 'operationId' => $route->getLabel('sdk.namespace', 'default') . ucfirst($method),
'consumes' => [], 'consumes' => [],
'produces' => [], 'produces' => [],
'tags' => [$route->getLabel('sdk.namespace', 'default')], 'tags' => [$route->getLabel('sdk.namespace', 'default')],
'description' => ($desc) ? \file_get_contents($desc) : '', 'description' => ($desc) ? \file_get_contents($desc) : '',
'responses' => [], 'responses' => [],
'x-appwrite' => [ // Appwrite related metadata 'x-appwrite' => [ // Appwrite related metadata
'method' => $route->getLabel('sdk.method', \uniqid()), 'method' => $method,
'weight' => $route->getOrder(), 'weight' => $route->getOrder(),
'cookies' => $route->getLabel('sdk.cookies', false), 'cookies' => $route->getLabel('sdk.cookies', false),
'type' => $route->getLabel('sdk.methodType', ''), 'type' => $route->getLabel('sdk.methodType', ''),
'demo' => Template::fromCamelCaseToDash($route->getLabel('sdk.namespace', 'default')) . '/' . Template::fromCamelCaseToDash($id) . '.md', 'demo' => Template::fromCamelCaseToDash($route->getLabel('sdk.namespace', 'default')) . '/' . Template::fromCamelCaseToDash($method) . '.md',
'edit' => 'https://github.com/appwrite/appwrite/edit/master' . $route->getLabel('sdk.description', ''), 'edit' => 'https://github.com/appwrite/appwrite/edit/master' . $route->getLabel('sdk.description', ''),
'rate-limit' => $route->getLabel('abuse-limit', 0), 'rate-limit' => $route->getLabel('abuse-limit', 0),
'rate-time' => $route->getLabel('abuse-time', 3600), 'rate-time' => $route->getLabel('abuse-time', 3600),
@ -424,7 +429,7 @@ class Swagger2 extends Format
// Do not add the enum // Do not add the enum
$allowed = true; $allowed = true;
foreach ($this->enumBlacklist as $blacklist) { foreach ($this->enumBlacklist as $blacklist) {
if ($blacklist['namespace'] == $route->getLabel('sdk.namespace', '') && $blacklist['method'] == $route->getLabel('sdk.method', '') && $blacklist['parameter'] == $name) { if ($blacklist['namespace'] == $route->getLabel('sdk.namespace', '') && $blacklist['method'] == $method && $blacklist['parameter'] == $name) {
$allowed = false; $allowed = false;
break; break;
} }
@ -432,8 +437,8 @@ class Swagger2 extends Format
if ($allowed) { if ($allowed) {
$node['enum'] = $validator->getList(); $node['enum'] = $validator->getList();
$node['x-enum-name'] = $this->getEnumName($route->getLabel('sdk.namespace', ''), $route->getLabel('sdk.method', ''), $name); $node['x-enum-name'] = $this->getEnumName($route->getLabel('sdk.namespace', ''), $method, $name);
$node['x-enum-keys'] = $this->getEnumKeys($route->getLabel('sdk.namespace', ''), $route->getLabel('sdk.method', ''), $name); $node['x-enum-keys'] = $this->getEnumKeys($route->getLabel('sdk.namespace', ''), $method, $name);
} }
if ($validator->getType() === 'integer') { if ($validator->getType() === 'integer') {

View file

@ -25,7 +25,11 @@ class Request extends UtopiaRequest
$parameters = parent::getParams(); $parameters = parent::getParams();
if (self::hasFilter() && self::hasRoute()) { if (self::hasFilter() && self::hasRoute()) {
$endpointIdentifier = self::getRoute()->getLabel('sdk.namespace', 'unknown') . '.' . self::getRoute()->getLabel('sdk.method', 'unknown'); $method = self::getRoute()->getLabel('sdk.method', ['unknown']);
if (\is_array($method)) {
$method = $method[0];
}
$endpointIdentifier = self::getRoute()->getLabel('sdk.namespace', 'unknown') . '.' . $method;
$parameters = self::getFilter()->parse($parameters, $endpointIdentifier); $parameters = self::getFilter()->parse($parameters, $endpointIdentifier);
} }

View file

@ -160,6 +160,12 @@ class Session extends Model
'default' => false, 'default' => false,
'example' => true, 'example' => true,
]) ])
->addRule('secret', [
'type' => self::TYPE_STRING,
'description' => 'Secret used to authenticate the user. Only included if the request was made with an API key',
'default' => '',
'example' => '5e5bb8c16897e',
])
; ;
} }

View file

@ -31,8 +31,8 @@ class HTTPTest extends Scope
$this->assertEquals(204, $response['headers']['status-code']); $this->assertEquals(204, $response['headers']['status-code']);
$this->assertEquals('Appwrite', $response['headers']['server']); $this->assertEquals('Appwrite', $response['headers']['server']);
$this->assertEquals('GET, POST, PUT, PATCH, DELETE', $response['headers']['access-control-allow-methods']); $this->assertEquals('GET, POST, PUT, PATCH, DELETE', $response['headers']['access-control-allow-methods']);
$this->assertEquals('Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Fallback-Cookies', $response['headers']['access-control-allow-headers']); $this->assertEquals('Origin, Cookie, Set-Cookie, X-Requested-With, Content-Type, Access-Control-Allow-Origin, Access-Control-Request-Headers, Accept, X-Appwrite-Project, X-Appwrite-Key, X-Appwrite-Locale, X-Appwrite-Mode, X-Appwrite-JWT, X-Appwrite-Response-Format, X-Appwrite-Timeout, X-SDK-Version, X-SDK-Name, X-SDK-Language, X-SDK-Platform, X-SDK-GraphQL, X-Appwrite-ID, X-Appwrite-Timestamp, Content-Range, Range, Cache-Control, Expires, Pragma, X-Appwrite-Session, X-Fallback-Cookies, X-Forwarded-For, X-Forwarded-User-Agent', $response['headers']['access-control-allow-headers']);
$this->assertEquals('X-Fallback-Cookies', $response['headers']['access-control-expose-headers']); $this->assertEquals('X-Appwrite-Session, X-Fallback-Cookies', $response['headers']['access-control-expose-headers']);
$this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']); $this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']);
$this->assertEquals('true', $response['headers']['access-control-allow-credentials']); $this->assertEquals('true', $response['headers']['access-control-allow-credentials']);
$this->assertEmpty($response['body']); $this->assertEmpty($response['body']);

View file

@ -83,6 +83,9 @@ trait ProjectCustom
'health.read', 'health.read',
'rules.read', 'rules.read',
'rules.write', 'rules.write',
'sessions.write',
'accounts.write',
'accounts.read',
'targets.read', 'targets.read',
'targets.write', 'targets.write',
'providers.read', 'providers.read',

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,9 @@
namespace Tests\E2E\Services\Account; namespace Tests\E2E\Services\Account;
use Appwrite\Extend\Exception;
use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\ProjectConsole; use Tests\E2E\Scopes\ProjectConsole;
use Tests\E2E\Scopes\SideClient; use Tests\E2E\Scopes\SideClient;
use Utopia\Database\Helpers\ID;
use Tests\E2E\Client;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
class AccountConsoleClientTest extends Scope class AccountConsoleClientTest extends Scope
{ {

File diff suppressed because it is too large Load diff

View file

@ -6,35 +6,259 @@ use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom; use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope; use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer; use Tests\E2E\Scopes\SideServer;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Helpers\ID; use Utopia\Database\Helpers\ID;
class AccountCustomServerTest extends Scope class AccountCustomServerTest extends Scope
{ {
use AccountBase;
use ProjectCustom; use ProjectCustom;
use SideServer; use SideServer;
public function testCreateAccount(): array /**
* @depends testCreateAccount
*/
public function testCreateAccountSession($data): array
{ {
$email = uniqid() . 'user@localhost.test'; $email = $data['email'] ?? '';
$password = 'password'; $password = $data['password'] ?? '';
$name = 'User Name';
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'email' => $email,
'password' => $password,
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotFalse(\DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $response['body']['expire']));
$sessionId = $response['body']['$id'];
$session = $response['body']['secret'];
$userId = $response['body']['userId'];
$response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayHasKey('accessedAt', $response['body']);
$this->assertNotEmpty($response['body']['accessedAt']);
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'email' => $email,
'password' => $password,
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['secret']);
$this->assertNotFalse(\DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $response['body']['expire']));
// already logged in
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-session' => $session,
], $this->getHeaders()), [
'email' => $email,
'password' => $password,
]);
$this->assertEquals(201, $response['headers']['status-code']);
/** /**
* Test for FAILURE * Test for FAILURE
*/ */
$response = $this->client->call(Client::METHOD_POST, '/account', [ $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json', 'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'], ], $this->getHeaders()), [
], [ 'email' => $email . 'x',
'userId' => ID::unique(),
'email' => $email,
'password' => $password, 'password' => $password,
'name' => $name,
]); ]);
$this->assertEquals(401, $response['headers']['status-code']); $this->assertEquals(401, $response['headers']['status-code']);
return []; $response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'email' => $email,
'password' => $password . 'x',
]);
$this->assertEquals(401, $response['headers']['status-code']);
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/email', array_merge([
'origin' => 'http://localhost',
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'email' => '',
'password' => '',
]);
$this->assertEquals(400, $response['headers']['status-code']);
return array_merge($data, [
'sessionId' => $sessionId,
'session' => $session,
]);
}
public function testCreateAnonymousAccount()
{
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_POST, '/account/sessions/anonymous', array_merge(
[
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id']
],
$this->getHeaders()
));
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertIsArray($response['body']);
$this->assertNotEmpty($response['body']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertNotEmpty($response['body']['secret']);
\usleep(1000 * 30); // wait for 30ms to let the shutdown update accessedAt
$userId = $response['body']['userId'];
$response = $this->client->call(Client::METHOD_GET, '/users/' . $userId, array_merge(
[
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
],
$this->getHeaders(),
));
$this->assertEquals($response['headers']['status-code'], 200);
$this->assertArrayHasKey('accessedAt', $response['body']);
$this->assertNotEmpty($response['body']['accessedAt']);
}
public function testCreateMagicUrl(): array
{
$email = \time() . 'user@appwrite.io';
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_POST, '/account/tokens/magic-url', array_merge(
[
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id']
],
$this->getHeaders()
), [
'userId' => ID::unique(),
'email' => $email,
// 'url' => 'http://localhost/magiclogin',
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertNotEmpty($response['body']['secret']);
$this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['expire']));
$userId = $response['body']['userId'];
$lastEmail = $this->getLastEmail();
$this->assertEquals($email, $lastEmail['to'][0]['address']);
$this->assertEquals($this->getProject()['name'] . ' Login', $lastEmail['subject']);
$token = substr($lastEmail['text'], strpos($lastEmail['text'], '&secret=', 0) + 8, 64);
$expireTime = strpos($lastEmail['text'], 'expire=' . urlencode($response['body']['expire']), 0);
$this->assertNotFalse($expireTime);
$secretTest = strpos($lastEmail['text'], 'secret=' . $response['body']['secret'], 0);
$this->assertNotFalse($secretTest);
$userIDTest = strpos($lastEmail['text'], 'userId=' . $response['body']['userId'], 0);
$this->assertNotFalse($userIDTest);
$data['token'] = $token;
$data['id'] = $userId;
$data['email'] = $email;
return $data;
}
/**
* @depends testCreateMagicUrl
*/
public function testCreateSessionWithMagicUrl($data): array
{
$id = $data['id'] ?? '';
$token = $data['token'] ?? '';
$email = $data['email'] ?? '';
/**
* Test for SUCCESS
*/
$response = $this->client->call(Client::METHOD_PUT, '/account/sessions/magic-url', array_merge(
[
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id']
],
$this->getHeaders()
), [
'userId' => $id,
'secret' => $token,
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertIsArray($response['body']);
$this->assertNotEmpty($response['body']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertNotEmpty($response['body']['userId']);
$this->assertNotEmpty($response['body']['secret']);
$sessionId = $response['body']['$id'];
$session = $response['body']['secret'];
$response = $this->client->call(Client::METHOD_GET, '/account', array_merge(
[
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-session' => $session
],
$this->getHeaders()
));
$this->assertEquals($response['headers']['status-code'], 200);
$this->assertNotEmpty($response['body']);
$this->assertNotEmpty($response['body']['$id']);
$this->assertEquals(true, (new DatetimeValidator())->isValid($response['body']['registration']));
$this->assertEquals($response['body']['email'], $email);
$this->assertTrue($response['body']['emailVerification']);
$data['sessionId'] = $sessionId;
$data['session'] = $session;
return $data;
} }
} }

View file

@ -1870,8 +1870,8 @@ trait Base
} }
}'; }';
case self::$CREATE_APNS_PROVIDER: case self::$CREATE_APNS_PROVIDER:
return 'mutation createApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!, $endpoint: String!) { return 'mutation createApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!) {
messagingCreateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId, endpoint: $endpoint) { messagingCreateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId) {
_id _id
name name
provider provider
@ -1984,8 +1984,8 @@ trait Base
} }
}'; }';
case self::$UPDATE_APNS_PROVIDER: case self::$UPDATE_APNS_PROVIDER:
return 'mutation updateApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!, $endpoint: String!) { return 'mutation updateApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!) {
messagingUpdateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId, endpoint: $endpoint) { messagingUpdateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId) {
_id _id
name name
provider provider

View file

@ -87,7 +87,6 @@ class MessagingTest extends Scope
'authKeyId' => 'my-authkeyid', 'authKeyId' => 'my-authkeyid',
'teamId' => 'my-teamid', 'teamId' => 'my-teamid',
'bundleId' => 'my-bundleid', 'bundleId' => 'my-bundleid',
'endpoint' => 'my-endpoint',
], ],
]; ];
@ -177,7 +176,6 @@ class MessagingTest extends Scope
'authKeyId' => 'my-authkeyid', 'authKeyId' => 'my-authkeyid',
'teamId' => 'my-teamid', 'teamId' => 'my-teamid',
'bundleId' => 'my-bundleid', 'bundleId' => 'my-bundleid',
'endpoint' => 'my-endpoint',
], ],
]; ];
foreach (\array_keys($providersParams) as $index => $key) { foreach (\array_keys($providersParams) as $index => $key) {

View file

@ -80,7 +80,6 @@ trait MessagingBase
'authKeyId' => 'my-authkeyid', 'authKeyId' => 'my-authkeyid',
'teamId' => 'my-teamid', 'teamId' => 'my-teamid',
'bundleId' => 'my-bundleid', 'bundleId' => 'my-bundleid',
'endpoint' => 'my-endpoint',
], ],
]; ];
$providers = []; $providers = [];
@ -155,7 +154,6 @@ trait MessagingBase
'authKeyId' => 'my-authkeyid', 'authKeyId' => 'my-authkeyid',
'teamId' => 'my-teamid', 'teamId' => 'my-teamid',
'bundleId' => 'my-bundleid', 'bundleId' => 'my-bundleid',
'endpoint' => 'my-endpoint',
], ],
]; ];
foreach (\array_keys($providersParams) as $index => $key) { foreach (\array_keys($providersParams) as $index => $key) {
@ -245,6 +243,7 @@ trait MessagingBase
]); ]);
$this->assertEquals(201, $response['headers']['status-code']); $this->assertEquals(201, $response['headers']['status-code']);
$this->assertEquals('my-app', $response['body']['name']); $this->assertEquals('my-app', $response['body']['name']);
$this->assertEquals('', $response['body']['description']);
return $response['body']; return $response['body'];
} }

View file

@ -223,10 +223,10 @@ trait TeamsBaseClient
*/ */
$secondEmail = uniqid() . 'foe@localhost.test'; $secondEmail = uniqid() . 'foe@localhost.test';
$secondName = 'Another Foe'; $secondName = 'Another Foe';
$response = $this->client->call(Client::METHOD_POST, '/account', array_merge([ $response = $this->client->call(Client::METHOD_POST, '/account', [
'content-type' => 'application/json', 'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'], 'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [ ], [
'userId' => 'unique()', 'userId' => 'unique()',
'email' => $secondEmail, 'email' => $secondEmail,
'password' => 'password', 'password' => 'password',

View file

@ -230,6 +230,65 @@ trait UsersBase
} }
} }
/**
* @depends testCreateUser
*/
public function testCreateToken(array $data): void
{
/**
* Test for SUCCESS
*/
$token = $this->client->call(Client::METHOD_POST, '/users/' . $data['userId'] . '/tokens', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(201, $token['headers']['status-code']);
$this->assertEquals($data['userId'], $token['body']['userId']);
$this->assertNotEmpty($token['body']['secret']);
$this->assertNotEmpty($token['body']['expire']);
$token = $this->client->call(Client::METHOD_POST, '/users/' . $data['userId'] . '/tokens', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'length' => 15,
'expire' => 60,
]);
$this->assertEquals(201, $token['headers']['status-code']);
$this->assertEquals($data['userId'], $token['body']['userId']);
$this->assertEquals(15, strlen($token['body']['secret']));
$this->assertNotEmpty($token['body']['expire']);
/**
* Test for FAILURE
*/
$token = $this->client->call(Client::METHOD_POST, '/users/' . $data['userId'] . '/tokens', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'length' => 1,
'expire' => 1,
]);
$this->assertEquals(400, $token['headers']['status-code']);
$this->assertArrayNotHasKey('userId', $token['body']);
$this->assertArrayNotHasKey('secret', $token['body']);
$token = $this->client->call(Client::METHOD_POST, '/users/' . $data['userId'] . '/tokens', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
'expire' => 999999999999999,
]);
$this->assertEquals(400, $token['headers']['status-code']);
$this->assertArrayNotHasKey('userId', $token['body']);
$this->assertArrayNotHasKey('secret', $token['body']);
}
/** /**
* Tests all optional parameters of createUser (email, phone, anonymous..) * Tests all optional parameters of createUser (email, phone, anonymous..)
* *

View file

@ -189,8 +189,8 @@ class AuthTest extends TestCase
public function testTokenGenerator(): void public function testTokenGenerator(): void
{ {
$this->assertEquals(\mb_strlen(Auth::tokenGenerator()), 256); $this->assertEquals(\strlen(Auth::tokenGenerator()), 256);
$this->assertEquals(\mb_strlen(Auth::tokenGenerator(5)), 10); $this->assertEquals(\strlen(Auth::tokenGenerator(5)), 5);
} }
public function testCodeGenerator(): void public function testCodeGenerator(): void
@ -294,7 +294,8 @@ class AuthTest extends TestCase
]), ]),
]; ];
$this->assertEquals(Auth::tokenVerify($tokens1, Auth::TOKEN_TYPE_RECOVERY, $secret), 'token1'); $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($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, $secret), false);
$this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false); $this->assertEquals(Auth::tokenVerify($tokens2, Auth::TOKEN_TYPE_RECOVERY, 'false-secret'), false);