create session with magic url
This commit is contained in:
parent
09aea0fd95
commit
22ceb1dc7c
|
@ -9,6 +9,12 @@
|
|||
"emails.verification.footer": "If you didn’t ask to verify this address, you can ignore this message.",
|
||||
"emails.verification.thanks": "Thanks",
|
||||
"emails.verification.signature": "{{project}} team",
|
||||
"emails.magicSession.subject": "Create Session",
|
||||
"emails.magicSession.hello": "Hey,",
|
||||
"emails.magicSession.body": "Follow this link to create a session.",
|
||||
"emails.magicSession.footer": "If you didn’t ask to create session for this email, you can ignore this message.",
|
||||
"emails.magicSession.thanks": "Thanks",
|
||||
"emails.magicSession.signature": "{{project}} team",
|
||||
"emails.recovery.subject": "Password Reset",
|
||||
"emails.recovery.hello": "Hello {{name}}",
|
||||
"emails.recovery.body": "Follow this link to reset your {{project}} password.",
|
||||
|
|
|
@ -611,6 +611,273 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect')
|
|||
;
|
||||
});
|
||||
|
||||
|
||||
App::post('/v1/account/sessions/url')
|
||||
->desc('Create Magic URL for creating sessions')
|
||||
->groups(['api', 'account'])
|
||||
->label('scope', 'account')
|
||||
->label('sdk.auth', [])
|
||||
->label('sdk.namespace', 'account')
|
||||
->label('sdk.method', 'createMagicURL')
|
||||
->label('sdk.description', '/docs/references/account/create-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},userId:{userId}')
|
||||
->param('email', '', new Email(), 'User email.')
|
||||
->param('url', '', function ($clients) { return 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'])
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('project')
|
||||
->inject('projectDB')
|
||||
->inject('locale')
|
||||
->inject('audits')
|
||||
->inject('events')
|
||||
->inject('mails')
|
||||
->action(function ($email, $url, $request, $response, $project, $projectDB, $locale, $audits, $events, $mails) {
|
||||
/** @var Utopia\Swoole\Request $request */
|
||||
/** @var Appwrite\Utopia\Response $response */
|
||||
/** @var Appwrite\Database\Document $project */
|
||||
/** @var Appwrite\Database\Database $projectDB */
|
||||
/** @var Utopia\Locale\Locale $locale */
|
||||
/** @var Appwrite\Event\Event $audits */
|
||||
/** @var Appwrite\Event\Event $events */
|
||||
/** @var Appwrite\Event\Event $mails */
|
||||
|
||||
$isPrivilegedUser = Auth::isPrivilegedUser(Authorization::$roles);
|
||||
$isAppUser = Auth::isAppUser(Authorization::$roles);
|
||||
|
||||
$user = $projectDB->getCollectionFirst([ // Get user by email address
|
||||
'limit' => 1,
|
||||
'filters' => [
|
||||
'$collection='.Database::SYSTEM_COLLECTION_USERS,
|
||||
'email='.$email,
|
||||
],
|
||||
]);
|
||||
|
||||
if (empty($user)) {
|
||||
$limit = $project->getAttribute('usersAuthLimit', 0);
|
||||
|
||||
if ($limit !== 0) {
|
||||
$projectDB->getCollection([ // Count users
|
||||
'filters' => [
|
||||
'$collection='.Database::SYSTEM_COLLECTION_USERS,
|
||||
],
|
||||
]);
|
||||
|
||||
$sum = $projectDB->getSum();
|
||||
|
||||
if($sum >= $limit) {
|
||||
throw new Exception('Project registration is restricted. Contact your administrator for more information.', 501);
|
||||
}
|
||||
}
|
||||
|
||||
Authorization::disable();
|
||||
|
||||
$user = $projectDB->createDocument([
|
||||
'$collection' => Database::SYSTEM_COLLECTION_USERS,
|
||||
'$permissions' => [
|
||||
'read' => ['*'],
|
||||
'write' => ['user:{self}'],
|
||||
],
|
||||
'email' => $email,
|
||||
'emailVerification' => false,
|
||||
'status' => Auth::USER_STATUS_UNACTIVATED,
|
||||
'password' => null,
|
||||
'passwordUpdate' => \time(),
|
||||
'registration' => \time(),
|
||||
'reset' => false,
|
||||
'name' => null,
|
||||
], ['email' => $email]);
|
||||
|
||||
Authorization::reset();
|
||||
$userAdded = true;
|
||||
}
|
||||
|
||||
$loginSecret = Auth::tokenGenerator();
|
||||
|
||||
$expire = \time() + Auth::TOKEN_EXPIRATION_CONFIRM;
|
||||
|
||||
$token = new Document([
|
||||
'$collection' => Database::SYSTEM_COLLECTION_TOKENS,
|
||||
'$permissions' => ['read' => ['user:'.$user->getId()], 'write' => ['user:'.$user->getId()]],
|
||||
'userId' => $user->getId(),
|
||||
'type' => Auth::TOKEN_TYPE_MAGIC_URL,
|
||||
'secret' => Auth::hash($loginSecret), // One way hash encryption to protect DB leak
|
||||
'expire' => $expire,
|
||||
'userAgent' => $request->getUserAgent('UNKNOWN'),
|
||||
'ip' => $request->getIP(),
|
||||
]);
|
||||
|
||||
Authorization::setRole('user:'.$user->getId());
|
||||
|
||||
$token = $projectDB->createDocument($token->getArrayCopy());
|
||||
|
||||
if (false === $token) {
|
||||
throw new Exception('Failed saving verification to DB', 500);
|
||||
}
|
||||
|
||||
$user->setAttribute('tokens', $token, Document::SET_TYPE_APPEND);
|
||||
|
||||
$user = $projectDB->updateDocument($user->getArrayCopy());
|
||||
|
||||
if (false === $user) {
|
||||
throw new Exception('Failed to save user to DB', 500);
|
||||
}
|
||||
|
||||
$url = Template::parseURL($url);
|
||||
$url['query'] = Template::mergeQuery(((isset($url['query'])) ? $url['query'] : ''), ['userId' => $user->getId(), 'secret' => $loginSecret, 'expire' => $expire]);
|
||||
$url = Template::unParseURL($url);
|
||||
|
||||
$mails
|
||||
->setParam('event', ($userAdded ?? false) ? 'users.create' : '')
|
||||
->setParam('from', $project->getId())
|
||||
->setParam('recipient', $user->getAttribute('email'))
|
||||
->setParam('url', $url)
|
||||
->setParam('locale', $locale->default)
|
||||
->setParam('project', $project->getAttribute('name', ['[APP-NAME]']))
|
||||
->setParam('type', MAIL_TYPE_MAGIC_SESSION)
|
||||
->trigger()
|
||||
;
|
||||
|
||||
$events
|
||||
->setParam('eventData',
|
||||
$response->output($token->setAttribute('secret', $loginSecret),
|
||||
Response::MODEL_TOKEN
|
||||
))
|
||||
;
|
||||
|
||||
$token // Hide secret for clients, sp
|
||||
->setAttribute('secret',
|
||||
($isPrivilegedUser || $isAppUser) ? $loginSecret : '');
|
||||
|
||||
$audits
|
||||
->setParam('userId', $user->getId())
|
||||
->setParam('event', ($userAdded ?? false) ? 'users.create' : '')
|
||||
->setParam('resource', 'users/'.$user->getId())
|
||||
;
|
||||
|
||||
$response
|
||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||
->dynamic($token, Response::MODEL_TOKEN)
|
||||
;
|
||||
});
|
||||
|
||||
App::put('/v1/account/sessions/url')
|
||||
->desc('Create session with Magic URL')
|
||||
->groups(['api', 'account'])
|
||||
->label('scope', 'public')
|
||||
->label('event', 'account.sessions.create')
|
||||
->label('sdk.auth', [])
|
||||
->label('sdk.namespace', 'account')
|
||||
->label('sdk.method', 'createMagicUrlSession')
|
||||
->label('sdk.description', '/docs/references/account/create-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 UID(), 'User unique ID.')
|
||||
->param('secret', '', new Text(256), 'Valid verification token.')
|
||||
->inject('request')
|
||||
->inject('response')
|
||||
->inject('projectDB')
|
||||
->inject('locale')
|
||||
->inject('geodb')
|
||||
->inject('audits')
|
||||
->action(function ($userId, $secret, $request, $response, $projectDB, $locale, $geodb, $audits) {
|
||||
/** @var Appwrite\Utopia\Response $response */
|
||||
/** @var Appwrite\Database\Database $projectDB */
|
||||
/** @var Appwrite\Event\Event $audits */
|
||||
|
||||
$profile = $projectDB->getCollectionFirst([ // Get user by email address
|
||||
'limit' => 1,
|
||||
'filters' => [
|
||||
'$collection='.Database::SYSTEM_COLLECTION_USERS,
|
||||
'$id='.$userId,
|
||||
],
|
||||
]);
|
||||
|
||||
if (empty($profile)) {
|
||||
throw new Exception('User not found', 404);
|
||||
}
|
||||
|
||||
$token = Auth::tokenVerify($profile->getAttribute('tokens', []), Auth::TOKEN_TYPE_VERIFICATION, $secret);
|
||||
|
||||
if (!$token) {
|
||||
throw new Exception('Invalid login token', 401);
|
||||
}
|
||||
|
||||
$detector = new Detector($request->getUserAgent('UNKNOWN'));
|
||||
$record = $geodb->get($request->getIP());
|
||||
$secret = Auth::tokenGenerator();
|
||||
$expiry = \time() + Auth::TOKEN_EXPIRATION_LOGIN_LONG;
|
||||
$session = new Document(array_merge(
|
||||
[
|
||||
'$collection' => Database::SYSTEM_COLLECTION_SESSIONS,
|
||||
'$permissions' => ['read' => ['user:' . $profile->getId()], 'write' => ['user:' . $profile->getId()]],
|
||||
'userId' => $profile->getId(),
|
||||
'provider' => Auth::SESSION_PROVIDER_MAGIC_URL,
|
||||
'secret' => Auth::hash($secret), // One way hash encryption to protect DB leak
|
||||
'expire' => $expiry,
|
||||
'userAgent' => $request->getUserAgent('UNKNOWN'),
|
||||
'ip' => $request->getIP(),
|
||||
'countryCode' => ($record) ? \strtolower($record['country']['iso_code']) : '--',
|
||||
],
|
||||
$detector->getOS(),
|
||||
$detector->getClient(),
|
||||
$detector->getDevice()
|
||||
));
|
||||
|
||||
Authorization::setRole('user:'.$profile->getId());
|
||||
|
||||
$session = $projectDB->createDocument($session->getArrayCopy());
|
||||
|
||||
if (false === $session) {
|
||||
throw new Exception('Failed saving session to DB', 500);
|
||||
}
|
||||
|
||||
$profile->setAttribute('sessions', $session, Document::SET_TYPE_APPEND);
|
||||
|
||||
$user = $projectDB->updateDocument($profile->getArrayCopy());
|
||||
|
||||
if (false === $user) {
|
||||
throw new Exception('Failed saving user to DB', 500);
|
||||
}
|
||||
|
||||
$audits
|
||||
->setParam('userId', $user->getId())
|
||||
->setParam('event', 'account.sessions.create')
|
||||
->setParam('resource', 'users/'.$user->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), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, null)
|
||||
->addCookie(Auth::$cookieName, Auth::encodeSession($user->getId(), $secret), $expiry, '/', Config::getParam('cookieDomain'), ('https' == $protocol), true, Config::getParam('cookieSamesite'))
|
||||
->setStatusCode(Response::STATUS_CODE_CREATED)
|
||||
;
|
||||
|
||||
$countryName = (isset($countries[strtoupper($session->getAttribute('countryCode'))]))
|
||||
? $countries[strtoupper($session->getAttribute('countryCode'))]
|
||||
: $locale->getText('locale.country.unknown');
|
||||
|
||||
$session
|
||||
->setAttribute('current', true)
|
||||
->setAttribute('countryName', $countryName)
|
||||
;
|
||||
|
||||
$response->dynamic($session, Response::MODEL_SESSION);
|
||||
});
|
||||
|
||||
App::post('/v1/account/sessions/anonymous')
|
||||
->desc('Create Anonymous Session')
|
||||
->groups(['api', 'account', 'auth'])
|
||||
|
|
|
@ -72,6 +72,7 @@ const DELETE_TYPE_ABUSE = 'abuse';
|
|||
const DELETE_TYPE_CERTIFICATES = 'certificates';
|
||||
// Mail Types
|
||||
const MAIL_TYPE_VERIFICATION = 'verification';
|
||||
const MAIL_TYPE_MAGIC_SESSION = 'magicSession';
|
||||
const MAIL_TYPE_RECOVERY = 'recovery';
|
||||
const MAIL_TYPE_INVITATION = 'invitation';
|
||||
// Auth Types
|
||||
|
|
|
@ -58,6 +58,9 @@ class MailsV1 extends Worker
|
|||
case MAIL_TYPE_VERIFICATION:
|
||||
$subject = $locale->getText("$prefix.subject");
|
||||
break;
|
||||
case MAIL_TYPE_MAGIC_SESSION:
|
||||
$subject = $locale->getText("$prefix.subject");
|
||||
break;
|
||||
default:
|
||||
throw new Exception('Undefined Mail Type : ' . $type, 500);
|
||||
}
|
||||
|
@ -132,6 +135,8 @@ class MailsV1 extends Worker
|
|||
return 'emails.invitation';
|
||||
case MAIL_TYPE_VERIFICATION:
|
||||
return 'emails.verification';
|
||||
case MAIL_TYPE_MAGIC_SESSION:
|
||||
return 'emails.magicSession';
|
||||
default:
|
||||
throw new Exception('Undefined Mail Type : ' . $type, 500);
|
||||
}
|
||||
|
|
|
@ -32,12 +32,14 @@ class Auth
|
|||
const TOKEN_TYPE_VERIFICATION = 2;
|
||||
const TOKEN_TYPE_RECOVERY = 3;
|
||||
const TOKEN_TYPE_INVITE = 4;
|
||||
const TOKEN_TYPE_MAGIC_URL = 5;
|
||||
|
||||
/**
|
||||
* Session Providers.
|
||||
*/
|
||||
const SESSION_PROVIDER_EMAIL = 'email';
|
||||
const SESSION_PROVIDER_ANONYMOUS = 'anonymous';
|
||||
const SESSION_PROVIDER_MAGIC_URL = 'magicUrl';
|
||||
|
||||
/**
|
||||
* Token Expiration times.
|
||||
|
|
Loading…
Reference in a new issue