1
0
Fork 0
mirror of synced 2024-07-01 04:30:59 +12:00

Merge branch '1.5.x' of https://github.com/appwrite/appwrite into feat-mfa

This commit is contained in:
Torsten Dittmann 2024-01-18 13:10:15 +01:00
commit 1ab3dc2236
49 changed files with 2750 additions and 2027 deletions

View file

@ -7,21 +7,21 @@ return [
'name' => 'Email/Password',
'key' => 'emailPassword',
'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,
],
'magic-url' => [
'name' => 'Magic URL',
'key' => 'usersAuthMagicURL',
'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,
],
'anonymous' => [
'name' => 'Anonymous',
'key' => 'anonymous',
'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,
],
'invites' => [
@ -42,7 +42,7 @@ return [
'name' => 'Phone',
'key' => 'phone',
'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,
],
];

View file

@ -58,6 +58,14 @@ return [
'$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' => [
'$description' => 'This event triggers when a user is created.'
],

View file

@ -0,0 +1,234 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=DM+Sans:wght@500;600&display=swap"
rel="stylesheet"
/>
<style>
.main a {
color: currentColor;
}
.main {
padding: 32px;
line-height: 1.5;
color: #616b7c;
font-size: 15px;
font-weight: 400;
font-family: "Inter", sans-serif;
}
table {
width: 100%;
border-spacing: 0;
}
table,
tr,
th,
td {
margin: 0;
padding: 0;
}
td {
vertical-align: top;
}
h1 {
font-size: 22px;
margin-bottom: 0px;
margin-top: 0px;
color: #373b4d;
}
h2 {
font-size: 20px;
font-weight: 600;
color: #373b4d;
}
h3,
td h3 {
font-size: 14px;
font-weight: 500;
color: #373b4d;
line-height: 21px;
margin: 0;
padding: 0;
}
h4 {
font-family: "DM Sans", sans-serif;
font-weight: 600;
font-size: 12px;
color: #4f5769;
margin: 0;
padding: 0;
}
hr {
border: none;
border-top: 1px solid #e8e9f0;
}
.main a.button {
display: inline-block;
background: #fd366e;
color: #ffffff;
border-radius: 8px;
height: 48px;
padding: 12px 20px;
box-sizing: border-box;
cursor: pointer;
text-align: center;
text-decoration: none;
border-color: #fd366e;
border-style: solid;
border-width: 1px;
margin-right: 24px;
margin-top: 8px;
}
.main a.button:hover,
.main a.button:focus {
opacity: 0.8;
}
.main a.button.is-reverse {
background: #fff;
color: #fd366e;
}
.fs12 {
font-size: 12px;
}
.ta-right {
text-align: right !important;
}
.ta-left {
text-align: left !important;
}
.ff-dmsans {
font-family: "DM Sans", sans-serif !important;
}
.ff-inter {
font-family: "Inter", sans-serif !important;
}
.w500 {
font-weight: 500 !important;
}
.w400 {
font-weight: 400 !important;
}
.w600 {
font-weight: 600 !important;
}
.tc-accent {
color: #da1a5b;
}
.pt-16 {
padding-top: 16px !important;
}
.pt-32 {
padding-top: 32px !important;
}
.divider {
padding-top: 32px;
border-bottom: solid 1px #d8d8db;
}
.social-icon {
border-radius: 6px;
background: rgba(216, 216, 219, 0.1);
width: 32px;
height: 32px;
line-height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.social-icon > img {
margin: auto;
}
@media only screen and (max-width: 600px) {
.button {
width: 100%;
}
}
</style>
</head>
<body style="background-color: #ffffff; margin: 0; padding: 0;>
<div class="main" style="max-width: 650px; margin: 0 auto">
<table>
<tr>
<td>
<img
height="32px"
src="https://appwrite.io/assets/logotype/white.png"
/>
</td>
</tr>
</table>
<table style="margin-top: 32px">
<tr>
<td>
<h1>{{subject}}</h1>
</td>
</tr>
</table>
<table style="margin-top: 40px">
<tr>
<td>{{message}}</td>
</tr>
</table>
<table
style="
padding-top: 32px;
margin-top: 32px;
border-top: solid 1px #e8e9f0;
"
>
<tr>
<td></td>
</tr>
</table>
<table style="width: auto; margin: 0 auto">
<tr>
<td style="padding-left: 4px; padding-right: 4px">
<a
href="https://twitter.com/appwrite"
class="social-icon"
title="Twitter"
>
<img src="https://appwrite.io/email/x.png" height="24" width="24" />
</a>
</td>
<td style="padding-left: 4px; padding-right: 4px">
<a
href="https://appwrite.io/discord"
class="social-icon"
>
<img src="https://appwrite.io/email/discord.png" height="24" width="24" />
</a>
</td>
<td style="padding-left: 4px; padding-right: 4px">
<a
href="https://github.com/appwrite/appwrite"
class="social-icon"
>
<img src="https://appwrite.io/email/github.png" height="24" width="24" />
</a>
</td>
</tr>
</table>
<table style="width: auto; margin: 0 auto; margin-top: 60px">
<tr>
<td><a href="https://appwrite.io/terms">Terms</a></td>
<td style="color: #e8e9f0">
<div style="margin: 0 8px">|</div>
</td>
<td><a href="https://appwrite.io/privacy">Privacy</a></td>
</tr>
</table>
<p style="text-align: center" align="center">
&copy; {{year}} Appwrite | 251 Little Falls Drive, Wilmington 19808,
Delaware, United States
</p>
</div>
</body>
</html>

View file

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

View file

@ -1,6 +1,15 @@
<?php
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' => [
'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

@ -58,7 +58,7 @@ App::post('/v1/account')
->desc('Create account')
->groups(['api', 'account', 'auth'])
->label('event', 'users.[userId].create')
->label('scope', 'public')
->label('scope', 'sessions.write')
->label('auth.type', 'emailPassword')
->label('audits.event', 'user.create')
->label('audits.resource', 'user/{response.$id}')
@ -72,7 +72,7 @@ App::post('/v1/account')
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_USER)
->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('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)
@ -188,10 +188,10 @@ App::post('/v1/account')
App::post('/v1/account/sessions/email')
->alias('/v1/account/sessions')
->desc('Create email session')
->desc('Create email password session')
->groups(['api', 'account', 'auth', 'session'])
->label('event', 'users.[userId].sessions.[sessionId].create')
->label('scope', 'public')
->label('scope', 'sessions.write')
->label('auth.type', 'emailPassword')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
@ -200,8 +200,8 @@ App::post('/v1/account/sessions/email')
->label('usage.params', ['provider:email'])
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', 'createEmailSession')
->label('sdk.description', '/docs/references/account/create-session-email.md')
->label('sdk.method', ['createEmailPasswordSession', 'createEmailSession'])
->label('sdk.description', '/docs/references/account/create-session-email-password.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_SESSION)
@ -234,6 +234,10 @@ App::post('/v1/account/sessions/email')
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());
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
@ -241,7 +245,7 @@ App::post('/v1/account/sessions/email')
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$secret = Auth::tokenGenerator();
$secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$session = new Document(array_merge(
[
'$id' => ID::unique(),
@ -279,7 +283,6 @@ App::post('/v1/account/sessions/email')
Permission::delete(Role::user($user->getId())),
]));
if (!Config::getParam('domainVerification')) {
$response
->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]))
@ -298,6 +301,7 @@ App::post('/v1/account/sessions/email')
->setAttribute('current', true)
->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire)
->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '')
;
$queueForEvents
@ -312,8 +316,9 @@ App::get('/v1/account/sessions/oauth2/:provider')
->desc('Create OAuth2 session')
->groups(['api', 'account'])
->label('error', __DIR__ . '/../../views/general/error.phtml')
->label('scope', 'public')
->label('scope', 'sessions.write')
->label('sdk.auth', [])
->label('sdk.hideServer', true)
->label('sdk.namespace', 'account')
->label('sdk.method', 'createOAuth2Session')
->label('sdk.description', '/docs/references/account/create-session-oauth2.md')
@ -325,11 +330,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('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('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)
->inject('request')
->inject('response')
->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();
@ -366,7 +373,11 @@ App::get('/v1/account/sessions/oauth2/:provider')
$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
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
@ -755,12 +766,63 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
$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;
$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'));
$record = $geodb->get($request->getIP());
$secret = Auth::tokenGenerator();
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$session = new Document(array_merge([
'$id' => ID::unique(),
@ -778,59 +840,45 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
], $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', [
Permission::read(Role::user($user->getId())),
Permission::update(Role::user($user->getId())),
Permission::delete(Role::user($user->getId())),
]));
$dbForProject->deleteCachedDocument('users', $user->getId());
$session->setAttribute('expire', $expire);
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
}
$queueForEvents
->setParam('userId', $user->getId())
->setParam('sessionId', $session->getId())
->setPayload($response->output($session, Response::MODEL_SESSION))
;
if (!Config::getParam('domainVerification')) {
$response->addHeader('X-Fallback-Cookies', \json_encode([Auth::$cookieName => Auth::encodeSession($user->getId(), $secret)]));
}
// 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']);
// TODO: Remove this deprecated, undocumented workaround
if ($state['success']['path'] == $oauthDefaultSuccess) {
$query['project'] = $project->getId();
$query['domain'] = Config::getParam('cookieDomain');
$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'] = URLParser::unparse($state['success']);
}
$response
->addHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->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'])
;
});
@ -838,7 +886,7 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
App::get('/v1/account/identities')
->desc('List Identities')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account')
@ -889,7 +937,7 @@ App::get('/v1/account/identities')
App::delete('/v1/account/identities/:identityId')
->desc('Delete identity')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('event', 'users.[userId].identities.[identityId].delete')
->label('audits.event', 'identity.delete')
->label('audits.resource', 'identity/{request.$identityId}')
@ -923,24 +971,25 @@ App::delete('/v1/account/identities/:identityId')
return $response->noContent();
});
App::post('/v1/account/sessions/magic-url')
->desc('Create magic URL session')
App::post('/v1/account/tokens/magic-url')
->alias('/v1/account/sessions/magic-url')
->desc('Create magic URL token')
->groups(['api', 'account'])
->label('scope', 'public')
->label('scope', 'sessions.write')
->label('auth.type', 'magic-url')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}')
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', 'createMagicURLSession')
->label('sdk.description', '/docs/references/account/create-magic-url-session.md')
->label('sdk.method', ['createMagicURLToken', 'createMagicURLSession'])
->label('sdk.description', '/docs/references/account/create-token-magic-url.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_TOKEN)
->label('abuse-limit', 10)
->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('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)
@ -1020,7 +1069,7 @@ App::post('/v1/account/sessions/magic-url')
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));
$token = new Document([
@ -1028,7 +1077,7 @@ App::post('/v1/account/sessions/magic-url')
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'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,
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
@ -1050,7 +1099,7 @@ App::post('/v1/account/sessions/magic-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);
$body = $locale->getText("emails.magicSession.body");
@ -1149,13 +1198,13 @@ App::post('/v1/account/sessions/magic-url')
$queueForEvents->setPayload(
$response->output(
$token->setAttribute('secret', $loginSecret),
$token->setAttribute('secret', $tokenSecret),
Response::MODEL_TOKEN
)
);
// Hide secret for clients
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $loginSecret : '');
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $tokenSecret : '');
if (!empty($securityPhrase)) {
$token->setAttribute('securityPhrase', $securityPhrase);
@ -1167,46 +1216,21 @@ App::post('/v1/account/sessions/magic-url')
;
});
App::put('/v1/account/sessions/magic-url')
->desc('Create magic URL session (confirmation)')
->groups(['api', 'account', 'session'])
->label('scope', 'public')
->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) {
$createSession = function (string $userId, string $secret, Request $request, Response $response, Document $user, Database $dbForProject, Document $project, Locale $locale, Reader $geodb, Event $queueForEvents) {
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
$userFromRequest = Authorization::skip(fn() => $dbForProject->getDocument('users', $userId));
/** @var Utopia\Database\Document $user */
$userFromRequest = Authorization::skip(fn () => $dbForProject->getDocument('users', $userId));
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);
}
@ -1215,7 +1239,7 @@ App::put('/v1/account/sessions/magic-url')
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$record = $geodb->get($request->getIP());
$secret = Auth::tokenGenerator();
$sessionSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_SESSION);
$expire = DateTime::formatTz(DateTime::addSeconds(new \DateTime(), $duration));
$session = new Document(array_merge(
@ -1223,11 +1247,10 @@ App::put('/v1/account/sessions/magic-url')
'$id' => ID::unique(),
'userId' => $user->getId(),
'userInternalId' => $user->getInternalId(),
'provider' => Auth::SESSION_PROVIDER_MAGIC_URL,
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
'provider' => Auth::getSessionProviderByTokenType($verifiedToken->getAttribute('type')),
'secret' => Auth::hash($sessionSecret), // One way hash encryption to protect DB leak
'userAgent' => $request->getUserAgent('UNKNOWN'),
'ip' => $request->getIP(),
'factors' => ['email'],
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
],
$detector->getOS(),
@ -1245,17 +1268,16 @@ App::put('/v1/account/sessions/magic-url')
]));
$dbForProject->deleteCachedDocument('users', $user->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);
Authorization::skip(fn () => $dbForProject->deleteDocument('tokens', $verifiedToken->getId()));
$dbForProject->deleteCachedDocument('users', $user->getId());
if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_MAGIC_URL) {
$user->setAttribute('emailVerification', true);
}
if ($verifiedToken->getAttribute('type') === Auth::TOKEN_TYPE_PHONE) {
$user->setAttribute('phoneVerification', true);
}
try {
$dbForProject->updateDocument('users', $user->getId(), $user);
@ -1268,14 +1290,14 @@ App::put('/v1/account/sessions/magic-url')
->setParam('sessionId', $session->getId());
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();
$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'))
->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(), $sessionSecret), (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'));
@ -1283,23 +1305,87 @@ App::put('/v1/account/sessions/magic-url')
$session
->setAttribute('current', true)
->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire);
->setAttribute('expire', $expire)
->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $sessionSecret) : '')
;
$response->dynamic($session, Response::MODEL_SESSION);
});
};
App::post('/v1/account/sessions/phone')
->desc('Create phone session')
App::put('/v1/account/sessions/magic-url')
->alias('/v1/account/sessions/phone')
->desc('Create session (deprecated)')
->label('event', 'users.[userId].sessions.[sessionId].create')
->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('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
->label('audits.userId', '{response.userId}')
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', 'createPhoneSession')
->label('sdk.description', '/docs/references/account/create-phone-session.md')
->label('sdk.method', ['createPhoneToken', 'createPhoneSession'])
->label('sdk.description', '/docs/references/account/create-token-phone.md')
->label('sdk.response.code', Response::STATUS_CODE_CREATED)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_TOKEN)
@ -1445,7 +1531,7 @@ App::post('/v1/account/sessions/phone')
);
// Hide secret for clients
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? $secret : '');
$token->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '');
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
@ -1453,128 +1539,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(),
'factors' => ['phone'],
'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')
->desc('Create anonymous session')
->groups(['api', 'account', 'auth', 'session'])
->label('event', 'users.[userId].sessions.[sessionId].create')
->label('scope', 'public')
->label('scope', 'sessions.write')
->label('auth.type', 'anonymous')
->label('audits.event', 'session.create')
->label('audits.resource', 'user/{response.userId}')
@ -1601,6 +1570,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) {
$protocol = $request->getProtocol();
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
if ('console' === $project->getId()) {
throw new Exception(Exception::USER_ANONYMOUS_CONSOLE_PROHIBITED, 'Failed to create anonymous user');
@ -1654,7 +1626,7 @@ App::post('/v1/account/sessions/anonymous')
$duration = $project->getAttribute('auths', [])['duration'] ?? Auth::TOKEN_EXPIRATION_LOGIN_LONG;
$detector = new Detector($request->getUserAgent('UNKNOWN'));
$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(
@ -1705,6 +1677,7 @@ App::post('/v1/account/sessions/anonymous')
->setAttribute('current', true)
->setAttribute('countryName', $countryName)
->setAttribute('expire', $expire)
->setAttribute('secret', ($isPrivilegedUser || $isAppUser) ? Auth::encodeSession($user->getId(), $secret) : '')
;
$response->dynamic($session, Response::MODEL_SESSION);
@ -1713,9 +1686,9 @@ App::post('/v1/account/sessions/anonymous')
App::post('/v1/account/jwt')
->desc('Create JWT')
->groups(['api', 'account', 'auth'])
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('auth.type', 'jwt')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION])
->label('sdk.auth', [])
->label('sdk.namespace', 'account')
->label('sdk.method', 'createJWT')
->label('sdk.description', '/docs/references/account/create-jwt.md')
@ -1835,7 +1808,7 @@ App::post('/v1/account/targets/push')
App::get('/v1/account')
->desc('Get account')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account')
@ -1856,7 +1829,7 @@ App::get('/v1/account')
App::get('/v1/account/prefs')
->desc('Get account preferences')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account')
@ -1879,7 +1852,7 @@ App::get('/v1/account/prefs')
App::get('/v1/account/sessions')
->desc('List sessions')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account')
@ -1918,7 +1891,7 @@ App::get('/v1/account/sessions')
App::get('/v1/account/logs')
->desc('List logs')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account')
@ -1979,7 +1952,7 @@ App::get('/v1/account/logs')
App::get('/v1/account/sessions/:sessionId')
->desc('Get session')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.read')
->label('usage.metric', 'users.{scope}.requests.read')
->label('sdk.auth', [APP_AUTH_TYPE_SESSION, APP_AUTH_TYPE_JWT])
->label('sdk.namespace', 'account')
@ -2023,7 +1996,8 @@ App::get('/v1/account/sessions/:sessionId')
App::patch('/v1/account/name')
->desc('Update name')
->groups(['api', 'account'])
->label('scope', 'account')
->label('event', 'users.[userId].update.name')
->label('scope', 'accounts.write')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update')
@ -2057,7 +2031,7 @@ App::patch('/v1/account/password')
->desc('Update password')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.password')
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('audits.userId', '{response.$id}')
@ -2124,7 +2098,7 @@ App::patch('/v1/account/email')
->desc('Update email')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.email')
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update')
@ -2212,7 +2186,7 @@ App::patch('/v1/account/phone')
->desc('Update phone')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.phone')
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update')
@ -2290,7 +2264,7 @@ App::patch('/v1/account/prefs')
->desc('Update preferences')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.prefs')
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.update')
@ -2324,7 +2298,7 @@ App::patch('/v1/account/status')
->desc('Update status')
->groups(['api', 'account'])
->label('event', 'users.[userId].update.status')
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('audits.event', 'user.update')
->label('audits.resource', 'user/{response.$id}')
->label('usage.metric', 'users.{scope}.requests.delete')
@ -2367,7 +2341,7 @@ App::patch('/v1/account/status')
App::delete('/v1/account/sessions/:sessionId')
->desc('Delete session')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('event', 'users.[userId].sessions.[sessionId].delete')
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{user.$id}')
@ -2443,7 +2417,7 @@ App::delete('/v1/account/sessions/:sessionId')
App::patch('/v1/account/sessions/:sessionId')
->desc('Update OAuth session (refresh tokens)')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('event', 'users.[userId].sessions.[sessionId].update')
->label('audits.event', 'session.update')
->label('audits.resource', 'user/{response.userId}')
@ -2529,7 +2503,7 @@ App::patch('/v1/account/sessions/:sessionId')
App::delete('/v1/account/sessions')
->desc('Delete sessions')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('event', 'users.[userId].sessions.[sessionId].delete')
->label('audits.event', 'session.delete')
->label('audits.resource', 'user/{user.$id}')
@ -2590,7 +2564,7 @@ App::delete('/v1/account/sessions')
App::post('/v1/account/recovery')
->desc('Create password recovery')
->groups(['api', 'account'])
->label('scope', 'public')
->label('scope', 'sessions.write')
->label('event', 'users.[userId].recovery.[tokenId].create')
->label('audits.event', 'recovery.create')
->label('audits.resource', 'user/{response.userId}')
@ -2643,7 +2617,7 @@ App::post('/v1/account/recovery')
$expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_RECOVERY);
$secret = Auth::tokenGenerator();
$secret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_RECOVERY);
$recovery = new Document([
'$id' => ID::unique(),
'userId' => $profile->getId(),
@ -2768,7 +2742,7 @@ App::post('/v1/account/recovery')
App::put('/v1/account/recovery')
->desc('Create password recovery (confirmation)')
->groups(['api', 'account'])
->label('scope', 'public')
->label('scope', 'sessions.write')
->label('event', 'users.[userId].recovery.[tokenId].update')
->label('audits.event', 'recovery.update')
->label('audits.resource', 'user/{response.userId}')
@ -2786,17 +2760,12 @@ App::put('/v1/account/recovery')
->param('userId', '', new UID(), 'User ID.')
->param('secret', '', new Text(256), 'Valid reset token.')
->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('passwordAgain', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Repeat new user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary'])
->inject('response')
->inject('user')
->inject('dbForProject')
->inject('project')
->inject('queueForEvents')
->action(function (string $userId, string $secret, string $password, string $passwordAgain, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents) {
if ($password !== $passwordAgain) {
throw new Exception(Exception::USER_PASSWORD_MISMATCH);
}
->action(function (string $userId, string $secret, string $password, Response $response, Document $user, Database $dbForProject, Document $project, Event $queueForEvents) {
$profile = $dbForProject->getDocument('users', $userId);
if ($profile->isEmpty()) {
@ -2804,9 +2773,9 @@ App::put('/v1/account/recovery')
}
$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);
}
@ -2836,13 +2805,13 @@ App::put('/v1/account/recovery')
$user->setAttributes($profile->getArrayCopy());
$recoveryDocument = $dbForProject->getDocument('tokens', $recovery);
$recoveryDocument = $dbForProject->getDocument('tokens', $verifiedToken->getId());
/**
* We act like we're updating and validating
* 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());
$queueForEvents
@ -2856,7 +2825,7 @@ App::put('/v1/account/recovery')
App::post('/v1/account/verification')
->desc('Create email verification')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('event', 'users.[userId].verification.[tokenId].create')
->label('audits.event', 'verification.create')
->label('audits.resource', 'user/{response.userId}')
@ -2892,7 +2861,7 @@ App::post('/v1/account/verification')
$roles = Authorization::getRoles();
$isPrivilegedUser = Auth::isPrivilegedUser($roles);
$isAppUser = Auth::isAppUser($roles);
$verificationSecret = Auth::tokenGenerator();
$verificationSecret = Auth::tokenGenerator(Auth::TOKEN_LENGTH_VERIFICATION);
$expire = DateTime::addSeconds(new \DateTime(), Auth::TOKEN_EXPIRATION_CONFIRM);
$verification = new Document([
@ -3046,9 +3015,9 @@ App::put('/v1/account/verification')
}
$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);
}
@ -3058,13 +3027,13 @@ App::put('/v1/account/verification')
$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.
*/
$dbForProject->deleteDocument('tokens', $verification);
$dbForProject->deleteDocument('tokens', $verifiedToken->getId());
$dbForProject->deleteCachedDocument('users', $profile->getId());
$queueForEvents
@ -3078,7 +3047,7 @@ App::put('/v1/account/verification')
App::post('/v1/account/verification/phone')
->desc('Create phone verification')
->groups(['api', 'account'])
->label('scope', 'account')
->label('scope', 'accounts.write')
->label('event', 'users.[userId].verification.[tokenId].create')
->label('audits.event', 'verification.create')
->label('audits.resource', 'user/{response.userId}')
@ -3219,9 +3188,9 @@ App::put('/v1/account/verification/phone')
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);
}
@ -3231,12 +3200,12 @@ App::put('/v1/account/verification/phone')
$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.
*/
$dbForProject->deleteDocument('tokens', $verification);
$dbForProject->deleteDocument('tokens', $verifiedToken->getId());
$dbForProject->deleteCachedDocument('users', $profile->getId());
$queueForEvents
@ -3643,7 +3612,6 @@ App::put('/v1/account/targets/:targetId/push')
->label('sdk.response.code', Response::STATUS_CODE_OK)
->label('sdk.response.type', Response::CONTENT_TYPE_JSON)
->label('sdk.response.model', Response::MODEL_TARGET)
->label('docs', false)
->param('targetId', '', new UID(), 'Target ID.')
->param('identifier', '', new Text(Database::LENGTH_KEY), 'The target identifier (token, email, phone etc.)')
->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('teamId', '', new Text(0), 'APNS team 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)
->inject('queueForEvents')
->inject('dbForProject')
->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;
$credentials = [];
@ -697,17 +696,12 @@ App::post('/v1/messaging/providers/apns')
$credentials['bundleId'] = $bundleId;
}
if (!empty($endpoint)) {
$credentials['endpoint'] = $endpoint;
}
if (
$enabled === true
&& \array_key_exists('authKey', $credentials)
&& \array_key_exists('authKeyId', $credentials)
&& \array_key_exists('teamId', $credentials)
&& \array_key_exists('bundleId', $credentials)
&& \array_key_exists('endpoint', $credentials)
) {
$enabled = true;
} else {
@ -1565,11 +1559,10 @@ App::patch('/v1/messaging/providers/apns/:providerId')
->param('authKeyId', '', new Text(0), 'APNS authentication key ID.', true)
->param('teamId', '', new Text(0), 'APNS team ID.', true)
->param('bundleId', '', new Text(0), 'APNS bundle ID.', true)
->param('endpoint', '', new Text(0), 'APNS endpoint.', true)
->inject('queueForEvents')
->inject('dbForProject')
->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);
if ($provider->isEmpty()) {
@ -1603,10 +1596,6 @@ App::patch('/v1/messaging/providers/apns/:providerId')
$credentials['bundle'] = $bundleId;
}
if (!empty($endpoint)) {
$credentials['endpoint'] = $endpoint;
}
$provider->setAttribute('credentials', $credentials);
if ($enabled === true || $enabled === false) {
@ -1616,7 +1605,6 @@ App::patch('/v1/messaging/providers/apns/:providerId')
&& \array_key_exists('authKeyId', $credentials)
&& \array_key_exists('teamId', $credentials)
&& \array_key_exists('bundleId', $credentials)
&& \array_key_exists('endpoint', $credentials)
) {
$enabled = true;
} else {
@ -1696,12 +1684,9 @@ App::post('/v1/messaging/topics')
$topic = new Document([
'$id' => $topicId,
'name' => $name,
'description' => $description
]);
if ($description) {
$topic->setAttribute('description', $description);
}
try {
$topic = $dbForProject->createDocument('topics', $topic);
} catch (DuplicateException) {

View file

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

View file

@ -14,6 +14,7 @@ use Utopia\Database\Validator\Queries;
use Appwrite\Utopia\Database\Validator\Queries\Users;
use Utopia\Database\Validator\Query\Limit;
use Utopia\Database\Validator\Query\Offset;
use Appwrite\Utopia\Request;
use Appwrite\Utopia\Response;
use Utopia\App;
use Utopia\Audit\Audit;
@ -35,6 +36,7 @@ use Utopia\Validator\Assoc;
use Utopia\Validator\WhiteList;
use Utopia\Validator\Text;
use Utopia\Validator\Boolean;
use Utopia\Validator\Range;
use MaxMind\Db\Reader;
use Utopia\Validator\Integer;
use Appwrite\Auth\Validator\PasswordHistory;
@ -1420,6 +1422,134 @@ App::patch('/v1/users/:userId/targets/:targetId')
->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')
->desc('Delete user session')
->groups(['api', 'users'])

View file

@ -424,8 +424,8 @@ App::init()
->addHeader('Server', 'Appwrite')
->addHeader('X-Content-Type-Options', 'nosniff')
->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-Expose-Headers', '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-Forwarded-For, X-Forwarded-User-Agent')
->addHeader('Access-Control-Expose-Headers', 'X-Appwrite-Session, X-Fallback-Cookies')
->addHeader('Access-Control-Allow-Origin', $refDomain)
->addHeader('Access-Control-Allow-Credentials', 'true');
@ -601,8 +601,8 @@ App::options()
$response
->addHeader('Server', 'Appwrite')
->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-Expose-Headers', '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-Appwrite-Session, X-Fallback-Cookies')
->addHeader('Access-Control-Allow-Origin', $origin)
->addHeader('Access-Control-Allow-Credentials', 'true')
->noContent();

View file

@ -311,6 +311,12 @@ App::init()
}
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:
throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route');
break;

View file

@ -55,6 +55,12 @@ App::init()
}
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:
throw new Exception(Exception::USER_AUTH_METHOD_UNSUPPORTED, 'Unsupported authentication route');
break;

View file

@ -1125,9 +1125,18 @@ App::setResource('user', function ($mode, $project, $console, $request, $respons
Auth::$cookieName, // Get sessions
$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) {
$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_MAGIC_URL = 5;
public const TOKEN_TYPE_PHONE = 6;
public const TOKEN_TYPE_OAUTH2 = 7;
public const TOKEN_TYPE_GENERIC = 8;
/**
* Session Providers.
@ -60,6 +62,9 @@ class Auth
public const SESSION_PROVIDER_ANONYMOUS = 'anonymous';
public const SESSION_PROVIDER_MAGIC_URL = 'magic-url';
public const SESSION_PROVIDER_PHONE = 'phone';
public const SESSION_PROVIDER_OAUTH2 = 'oauth2';
public const SESSION_PROVIDER_TOKEN = 'token';
public const SESSION_PROVIDER_SERVER = 'server';
/**
* Token Expiration times.
@ -69,6 +74,16 @@ class Auth
public const TOKEN_EXPIRATION_RECOVERY = 3600; /* 1 hour */
public const TOKEN_EXPIRATION_CONFIRM = 3600 * 1; /* 1 hour */
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
@ -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.
*
@ -270,13 +306,20 @@ class Auth
*
* Generate random password string
*
* @param int $length
* @param int $length Length of returned token
*
* @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,48 +346,23 @@ class Auth
* Verify token and check that its not expired.
*
* @param array<Document> $tokens
* @param int $type
* @param int $type Type of token to verify, if null will verify any type
* @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) {
if (
$token->isSet('type') &&
$token->isSet('secret') &&
$token->isSet('expire') &&
$token->getAttribute('type') == $type &&
$token->isSet('type') &&
($type === null || $token->getAttribute('type') === $type) &&
$token->getAttribute('secret') === self::hash($secret) &&
DateTime::formatTz($token->getAttribute('expire')) >= DateTime::formatTz(DateTime::now())
) {
return (string)$token->getId();
}
}
return false;
}
/**
* Verify phone token and check that its not expired.
*
* @param array<Document> $tokens
* @param string $secret
* @return string|false
*/
public static function phoneTokenVerify(array $tokens, string $secret)
{
foreach ($tokens as $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();
return $token;
}
}

View file

@ -99,7 +99,13 @@ class Schema
/** @var Route $route */
$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);
if (empty($name)) {
@ -123,6 +129,7 @@ class Schema
}
}
}
}
return [
'query' => $queries,

View file

@ -82,6 +82,12 @@ class Specs extends Action
'description' => '',
'in' => 'header',
],
'Session' => [
'type' => 'apiKey',
'name' => 'X-Appwrite-Session',
'description' => 'The user session to authenticate with',
'in' => 'header',
]
],
APP_PLATFORM_SERVER => [
'Project' => [
@ -108,6 +114,24 @@ class Specs extends Action
'description' => '',
'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 => [
'Project' => [
@ -173,6 +197,7 @@ class Specs extends Action
if (empty($routeSecurity)) {
$sdkPlaforms[] = APP_PLATFORM_CLIENT;
$sdkPlaforms[] = APP_PLATFORM_SERVER;
}
if (!$route->getLabel('docs', true)) {

View file

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

View file

@ -124,7 +124,11 @@ class OpenAPI3 extends Format
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;
$produces = $route->getLabel('sdk.response.type', null);
$model = $route->getLabel('sdk.response.model', 'none');
@ -149,21 +153,26 @@ class OpenAPI3 extends Format
}
if (empty($routeSecurity)) {
if (!$route->getLabel('sdk.hideServer', false)) {
$sdkPlatforms[] = APP_PLATFORM_SERVER;
}
if (!$route->getLabel('sdk.hideClient', false)) {
$sdkPlatforms[] = APP_PLATFORM_CLIENT;
}
}
$temp = [
'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')],
'description' => ($desc) ? \file_get_contents($desc) : '',
'responses' => [],
'x-appwrite' => [ // Appwrite related metadata
'method' => $route->getLabel('sdk.method', \uniqid()),
'method' => $method,
'weight' => $route->getOrder(),
'cookies' => $route->getLabel('sdk.cookies', false),
'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', ''),
'rate-limit' => $route->getLabel('abuse-limit', 0),
'rate-time' => $route->getLabel('abuse-time', 3600),
@ -423,7 +432,7 @@ class OpenAPI3 extends Format
foreach ($this->enumBlacklist as $blacklist) {
if (
$blacklist['namespace'] == $route->getLabel('sdk.namespace', '')
&& $blacklist['method'] == $route->getLabel('sdk.method', '')
&& $blacklist['method'] == $method
&& $blacklist['parameter'] == $name
) {
$allowed = false;
@ -433,8 +442,8 @@ class OpenAPI3 extends Format
if ($allowed) {
$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-keys'] = $this->getEnumKeys($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', ''), $method, $name);
}
if ($validator->getType() === 'integer') {
$node['format'] = 'int32';

View file

@ -123,7 +123,11 @@ class Swagger2 extends Format
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;
$produces = $route->getLabel('sdk.response.type', null);
$model = $route->getLabel('sdk.response.model', 'none');
@ -149,22 +153,23 @@ class Swagger2 extends Format
if (empty($routeSecurity)) {
$sdkPlatforms[] = APP_PLATFORM_CLIENT;
$sdkPlatforms[] = APP_PLATFORM_SERVER;
}
$temp = [
'summary' => $route->getDesc(),
'operationId' => $route->getLabel('sdk.namespace', 'default') . ucfirst($id),
'operationId' => $route->getLabel('sdk.namespace', 'default') . ucfirst($method),
'consumes' => [],
'produces' => [],
'tags' => [$route->getLabel('sdk.namespace', 'default')],
'description' => ($desc) ? \file_get_contents($desc) : '',
'responses' => [],
'x-appwrite' => [ // Appwrite related metadata
'method' => $route->getLabel('sdk.method', \uniqid()),
'method' => $method,
'weight' => $route->getOrder(),
'cookies' => $route->getLabel('sdk.cookies', false),
'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', ''),
'rate-limit' => $route->getLabel('abuse-limit', 0),
'rate-time' => $route->getLabel('abuse-time', 3600),
@ -424,7 +429,7 @@ class Swagger2 extends Format
// Do not add the enum
$allowed = true;
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;
break;
}
@ -432,8 +437,8 @@ class Swagger2 extends Format
if ($allowed) {
$node['enum'] = $validator->getList();
$node['x-enum-name'] = $this->getEnumName($route->getLabel('sdk.namespace', ''), $route->getLabel('sdk.method', ''), $name);
$node['x-enum-keys'] = $this->getEnumKeys($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', ''), $method, $name);
}
if ($validator->getType() === 'integer') {

View file

@ -25,7 +25,11 @@ class Request extends UtopiaRequest
$parameters = parent::getParams();
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);
}

View file

@ -166,6 +166,12 @@ class Session extends Model
'default' => 1,
'example' => 1,
])
->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('Appwrite', $response['headers']['server']);
$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('X-Fallback-Cookies', $response['headers']['access-control-expose-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-Appwrite-Session, X-Fallback-Cookies', $response['headers']['access-control-expose-headers']);
$this->assertEquals('http://localhost', $response['headers']['access-control-allow-origin']);
$this->assertEquals('true', $response['headers']['access-control-allow-credentials']);
$this->assertEmpty($response['body']);

View file

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

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,9 @@
namespace Tests\E2E\Services\Account;
use Appwrite\Extend\Exception;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\ProjectConsole;
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
{

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\Scope;
use Tests\E2E\Scopes\SideServer;
use Utopia\Database\Validator\Datetime as DatetimeValidator;
use Utopia\Database\Helpers\ID;
class AccountCustomServerTest extends Scope
{
use AccountBase;
use ProjectCustom;
use SideServer;
public function testCreateAccount(): array
/**
* @depends testCreateAccount
*/
public function testCreateAccountSession($data): array
{
$email = uniqid() . 'user@localhost.test';
$password = 'password';
$name = 'User Name';
$email = $data['email'] ?? '';
$password = $data['password'] ?? '';
/**
* 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
*/
$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',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey'],
], [
'userId' => ID::unique(),
'email' => $email,
], $this->getHeaders()), [
'email' => $email . 'x',
'password' => $password,
'name' => $name,
]);
$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

@ -1271,8 +1271,8 @@ trait Base
}
}';
case self::$UPDATE_PASSWORD_RECOVERY:
return 'mutation confirmPasswordRecovery($userId: String!, $secret: String!, $password: String!, $passwordAgain: String!) {
accountUpdateRecovery(userId: $userId, secret: $secret, password: $password, passwordAgain: $passwordAgain) {
return 'mutation confirmPasswordRecovery($userId: String!, $secret: String!, $password: String!) {
accountUpdateRecovery(userId: $userId, secret: $secret, password: $password) {
userId
secret
expire
@ -1870,8 +1870,8 @@ trait Base
}
}';
case self::$CREATE_APNS_PROVIDER:
return 'mutation createApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!, $endpoint: String!) {
messagingCreateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId, endpoint: $endpoint) {
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) {
_id
name
provider
@ -1984,8 +1984,8 @@ trait Base
}
}';
case self::$UPDATE_APNS_PROVIDER:
return 'mutation updateApnsProvider($providerId: String!, $name: String!, $authKey: String!, $authKeyId: String!, $teamId: String!, $bundleId: String!, $endpoint: String!) {
messagingUpdateApnsProvider(providerId: $providerId, name: $name, authKey: $authKey, authKeyId: $authKeyId, teamId: $teamId, bundleId: $bundleId, endpoint: $endpoint) {
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) {
_id
name
provider

View file

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

View file

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

View file

@ -617,7 +617,6 @@ class RealtimeCustomClientTest extends Scope
'userId' => $userId,
'secret' => $recovery,
'password' => 'test-recovery',
'passwordAgain' => 'test-recovery',
]);
$response = json_decode($client->receive(), true);

View file

@ -223,10 +223,10 @@ trait TeamsBaseClient
*/
$secondEmail = uniqid() . 'foe@localhost.test';
$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',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()), [
], [
'userId' => 'unique()',
'email' => $secondEmail,
'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..)
*

View file

@ -824,7 +824,6 @@ class WebhooksCustomClientTest extends Scope
'userId' => $id,
'secret' => $secret,
'password' => $password,
'passwordAgain' => $password,
]);
$recoveryId = $recovery['body']['$id'];

View file

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