Browse Source

Merge pull request #28422 from nextcloud/enh/27465/notification-email

tags/v23.0.0beta1
John Molakvoæ 2 years ago
parent
commit
78d62063ff
No account linked to committer's email address
46 changed files with 1422 additions and 448 deletions
  1. 2
    5
      apps/dav/lib/Connector/Sabre/Principal.php
  2. 1
    1
      apps/dav/lib/DAV/GroupPrincipalBackend.php
  3. 17
    17
      apps/dav/tests/unit/Connector/Sabre/PrincipalTest.php
  4. 1
    1
      apps/files_sharing/lib/Controller/ShareAPIController.php
  5. 4
    4
      apps/files_sharing/tests/Controller/ShareAPIControllerTest.php
  6. 5
    0
      apps/provisioning_api/appinfo/routes.php
  7. 1
    0
      apps/provisioning_api/composer/composer/autoload_classmap.php
  8. 1
    0
      apps/provisioning_api/composer/composer/autoload_static.php
  9. 2
    2
      apps/provisioning_api/composer/composer/installed.php
  10. 14
    6
      apps/provisioning_api/lib/Controller/AUserData.php
  11. 56
    27
      apps/provisioning_api/lib/Controller/UsersController.php
  12. 143
    0
      apps/provisioning_api/lib/Controller/VerificationController.php
  13. 8
    5
      apps/provisioning_api/tests/Controller/UsersControllerTest.php
  14. 2
    2
      apps/settings/lib/Controller/UsersController.php
  15. 4
    5
      apps/settings/tests/Controller/UsersControllerTest.php
  16. 1
    1
      apps/user_ldap/lib/User/User.php
  17. 1
    1
      core/Command/User/Info.php
  18. 1
    1
      core/Command/User/ListCommand.php
  19. 30
    82
      core/Controller/LostController.php
  20. 20
    0
      core/templates/confirmation.php
  21. 13
    0
      core/templates/success.php
  22. 4
    0
      lib/composer/composer/autoload_classmap.php
  23. 4
    0
      lib/composer/composer/autoload_static.php
  24. 121
    10
      lib/private/Accounts/AccountManager.php
  25. 20
    1
      lib/private/Accounts/AccountProperty.php
  26. 9
    0
      lib/private/Accounts/AccountPropertyCollection.php
  27. 2
    2
      lib/private/Collaboration/Collaborators/UserPlugin.php
  28. 1
    1
      lib/private/Mail/EMailTemplate.php
  29. 90
    0
      lib/private/Security/VerificationToken/CleanUpJob.php
  30. 129
    0
      lib/private/Security/VerificationToken/VerificationToken.php
  31. 4
    0
      lib/private/Server.php
  32. 1
    1
      lib/private/Setup.php
  33. 1
    0
      lib/private/User/Manager.php
  34. 66
    11
      lib/private/User/User.php
  35. 20
    0
      lib/public/Accounts/IAccountProperty.php
  36. 9
    0
      lib/public/Accounts/IAccountPropertyCollection.php
  37. 62
    1
      lib/public/IUser.php
  38. 2
    0
      lib/public/IUserManager.php
  39. 1
    1
      lib/public/Mail/IEMailTemplate.php
  40. 62
    0
      lib/public/Security/VerificationToken/IVerificationToken.php
  41. 74
    0
      lib/public/Security/VerificationToken/InvalidTokenException.php
  42. 43
    258
      tests/Core/Controller/LostControllerTest.php
  43. 36
    0
      tests/lib/Accounts/AccountManagerTest.php
  44. 21
    1
      tests/lib/AllConfigTest.php
  45. 309
    0
      tests/lib/Security/VerificationToken/VerificationTokenTest.php
  46. 4
    1
      tests/lib/User/UserTest.php

+ 2
- 5
apps/dav/lib/Connector/Sabre/Principal.php View File

@@ -300,16 +300,13 @@ class Principal implements BackendInterface {
if (!$allowEnumeration) {
if ($allowEnumerationFullMatch) {
$users = $this->userManager->getByEmail($value);
$users = \array_filter($users, static function (IUser $user) use ($value) {
return $user->getEMailAddress() === $value;
});
} else {
$users = [];
}
} else {
$users = $this->userManager->getByEmail($value);
$users = \array_filter($users, function (IUser $user) use ($currentUser, $value, $limitEnumerationPhone, $limitEnumerationGroup, $allowEnumerationFullMatch, $currentUserGroups) {
if ($allowEnumerationFullMatch && $user->getEMailAddress() === $value) {
if ($allowEnumerationFullMatch && $user->getSystemEMailAddress() === $value) {
return true;
}

@@ -516,7 +513,7 @@ class Principal implements BackendInterface {
'{http://nextcloud.com/ns}language' => $this->languageFactory->getUserLanguage($user),
];

$email = $user->getEMailAddress();
$email = $user->getSystemEMailAddress();
if (!empty($email)) {
$principal['{http://sabredav.org/ns}email-address'] = $email;
}

+ 1
- 1
apps/dav/lib/DAV/GroupPrincipalBackend.php View File

@@ -331,7 +331,7 @@ class GroupPrincipalBackend implements BackendInterface {
'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => 'INDIVIDUAL',
];

$email = $user->getEMailAddress();
$email = $user->getSystemEMailAddress();
if (!empty($email)) {
$principal['{http://sabredav.org/ns}email-address'] = $email;
}

+ 17
- 17
apps/dav/tests/unit/Connector/Sabre/PrincipalTest.php View File

@@ -120,7 +120,7 @@ class PrincipalTest extends TestCase {
->willReturn('Dr. Foo-Bar');
$fooUser
->expects($this->once())
->method('getEMailAddress')
->method('getSystemEMailAddress')
->willReturn('');
$barUser = $this->createMock(User::class);
$barUser
@@ -129,7 +129,7 @@ class PrincipalTest extends TestCase {
->willReturn('bar');
$barUser
->expects($this->once())
->method('getEMailAddress')
->method('getSystemEMailAddress')
->willReturn('bar@nextcloud.com');
$this->userManager
->expects($this->once())
@@ -205,7 +205,7 @@ class PrincipalTest extends TestCase {
$fooUser = $this->createMock(User::class);
$fooUser
->expects($this->once())
->method('getEMailAddress')
->method('getSystemEMailAddress')
->willReturn('foo@nextcloud.com');
$fooUser
->expects($this->once())
@@ -605,15 +605,15 @@ class PrincipalTest extends TestCase {
$user2 = $this->createMock(IUser::class);
$user2->method('getUID')->willReturn('user2');
$user2->method('getDisplayName')->willReturn('User 2');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar');
$user3 = $this->createMock(IUser::class);
$user3->method('getUID')->willReturn('user3');
$user2->method('getDisplayName')->willReturn('User 22');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar123');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar123');
$user4 = $this->createMock(IUser::class);
$user4->method('getUID')->willReturn('user4');
$user2->method('getDisplayName')->willReturn('User 222');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar456');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar456');

$this->userManager->expects($this->at(0))
->method('searchDisplayName')
@@ -665,20 +665,20 @@ class PrincipalTest extends TestCase {
$user2 = $this->createMock(IUser::class);
$user2->method('getUID')->willReturn('user2');
$user2->method('getDisplayName')->willReturn('User 2');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar');
$user3 = $this->createMock(IUser::class);
$user3->method('getUID')->willReturn('user3');
$user2->method('getDisplayName')->willReturn('User 22');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar123');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar123');
$user4 = $this->createMock(IUser::class);
$user4->method('getUID')->willReturn('user4');
$user2->method('getDisplayName')->willReturn('User 222');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar456');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar456');

$this->userManager->expects($this->at(0))
$this->userManager->expects($this->once())
->method('getByEmail')
->with('user2@foo.bar')
->willReturn([$user2, $user3, $user4]);
->willReturn([$user2]);

$this->assertEquals(['principals/users/user2'], $this->connector->searchPrincipals('principals/users',
['{http://sabredav.org/ns}email-address' => 'user2@foo.bar']));
@@ -726,15 +726,15 @@ class PrincipalTest extends TestCase {
$user2 = $this->createMock(IUser::class);
$user2->method('getUID')->willReturn('user2');
$user2->method('getDisplayName')->willReturn('User 2');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar');
$user3 = $this->createMock(IUser::class);
$user3->method('getUID')->willReturn('user3');
$user3->method('getDisplayName')->willReturn('User 22');
$user3->method('getEMailAddress')->willReturn('user2@foo.bar123');
$user3->method('getSystemEMailAddress')->willReturn('user2@foo.bar123');
$user4 = $this->createMock(IUser::class);
$user4->method('getUID')->willReturn('user4');
$user4->method('getDisplayName')->willReturn('User 222');
$user4->method('getEMailAddress')->willReturn('user2@foo.bar456');
$user4->method('getSystemEMailAddress')->willReturn('user2@foo.bar456');


$this->userSession->expects($this->at(0))
@@ -787,15 +787,15 @@ class PrincipalTest extends TestCase {
$user2 = $this->createMock(IUser::class);
$user2->method('getUID')->willReturn('user2');
$user2->method('getDisplayName')->willReturn('User 2');
$user2->method('getEMailAddress')->willReturn('user2@foo.bar');
$user2->method('getSystemEMailAddress')->willReturn('user2@foo.bar');
$user3 = $this->createMock(IUser::class);
$user3->method('getUID')->willReturn('user3');
$user3->method('getDisplayName')->willReturn('User 22');
$user3->method('getEMailAddress')->willReturn('user2@foo.bar123');
$user3->method('getSystemEMailAddress')->willReturn('user2@foo.bar123');
$user4 = $this->createMock(IUser::class);
$user4->method('getUID')->willReturn('user4');
$user4->method('getDisplayName')->willReturn('User 222');
$user4->method('getEMailAddress')->willReturn('user2@foo.bar456');
$user4->method('getSystemEMailAddress')->willReturn('user2@foo.bar456');


$this->userSession->expects($this->at(0))

+ 1
- 1
apps/files_sharing/lib/Controller/ShareAPIController.php View File

@@ -238,7 +238,7 @@ class ShareAPIController extends OCSController {
$result['share_with'] = $share->getSharedWith();
$result['share_with_displayname'] = $sharedWith !== null ? $sharedWith->getDisplayName() : $share->getSharedWith();
$result['share_with_displayname_unique'] = $sharedWith !== null ? (
$sharedWith->getEMailAddress() !== '' ? $sharedWith->getEMailAddress() : $sharedWith->getUID()
!empty($sharedWith->getSystemEMailAddress()) ? $sharedWith->getSystemEMailAddress() : $sharedWith->getUID()
) : $share->getSharedWith();
$result['status'] = [];


+ 4
- 4
apps/files_sharing/tests/Controller/ShareAPIControllerTest.php View File

@@ -47,6 +47,7 @@ use OCP\Files\Mount\IMountPoint;
use OCP\Files\NotFoundException;
use OCP\Files\Storage;
use OCP\IConfig;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IPreview;
@@ -785,7 +786,7 @@ class ShareAPIControllerTest extends TestCase {
$user = $this->getMockBuilder(IUser::class)->getMock();
$user->method('getUID')->willReturn('userId');
$user->method('getDisplayName')->willReturn('userDisplay');
$user->method('getEMailAddress')->willReturn('userId@example.com');
$user->method('getSystemEMailAddress')->willReturn('userId@example.com');

$group = $this->getMockBuilder('OCP\IGroup')->getMock();
$group->method('getGID')->willReturn('groupId');
@@ -3586,7 +3587,7 @@ class ShareAPIControllerTest extends TestCase {
$initiator->method('getDisplayName')->willReturn('initiatorDN');
$recipient = $this->getMockBuilder(IUser::class)->getMock();
$recipient->method('getDisplayName')->willReturn('recipientDN');
$recipient->method('getEmailAddress')->willReturn('recipient');
$recipient->method('getSystemEMailAddress')->willReturn('recipient');


$result = [];
@@ -4387,7 +4388,7 @@ class ShareAPIControllerTest extends TestCase {
public function testFormatShare(array $expects, \OCP\Share\IShare $share, array $users, $exception) {
$this->userManager->method('get')->willReturnMap($users);

$recipientGroup = $this->createMock('\OCP\IGroup');
$recipientGroup = $this->createMock(IGroup::class);
$recipientGroup->method('getDisplayName')->willReturn('recipientGroupDisplayName');
$this->groupManager->method('get')->willReturnMap([
['recipientGroup', $recipientGroup],
@@ -4397,7 +4398,6 @@ class ShareAPIControllerTest extends TestCase {
->with('files_sharing.sharecontroller.showShare', ['token' => 'myToken'])
->willReturn('myLink');


$this->rootFolder->method('getUserFolder')
->with($this->currentUser)
->willReturnSelf();

+ 5
- 0
apps/provisioning_api/appinfo/routes.php View File

@@ -74,4 +74,9 @@ return [
['name' => 'AppConfig#setValue', 'url' => '/api/v1/config/apps/{app}/{key}', 'verb' => 'POST'],
['name' => 'AppConfig#deleteKey', 'url' => '/api/v1/config/apps/{app}/{key}', 'verb' => 'DELETE'],
],
'routes' => [
// Verification
['name' => 'Verification#showVerifyMail', 'url' => '/mailVerification/{key}/{token}/{userId}', 'verb' => 'GET'],
['name' => 'Verification#verifyMail', 'url' => '/mailVerification/{key}/{token}/{userId}', 'verb' => 'POST'],
]
];

+ 1
- 0
apps/provisioning_api/composer/composer/autoload_classmap.php View File

@@ -14,6 +14,7 @@ return array(
'OCA\\Provisioning_API\\Controller\\AppsController' => $baseDir . '/../lib/Controller/AppsController.php',
'OCA\\Provisioning_API\\Controller\\GroupsController' => $baseDir . '/../lib/Controller/GroupsController.php',
'OCA\\Provisioning_API\\Controller\\UsersController' => $baseDir . '/../lib/Controller/UsersController.php',
'OCA\\Provisioning_API\\Controller\\VerificationController' => $baseDir . '/../lib/Controller/VerificationController.php',
'OCA\\Provisioning_API\\FederatedShareProviderFactory' => $baseDir . '/../lib/FederatedShareProviderFactory.php',
'OCA\\Provisioning_API\\Listener\\UserDeletedListener' => $baseDir . '/../lib/Listener/UserDeletedListener.php',
'OCA\\Provisioning_API\\Middleware\\Exceptions\\NotSubAdminException' => $baseDir . '/../lib/Middleware/Exceptions/NotSubAdminException.php',

+ 1
- 0
apps/provisioning_api/composer/composer/autoload_static.php View File

@@ -29,6 +29,7 @@ class ComposerStaticInitProvisioning_API
'OCA\\Provisioning_API\\Controller\\AppsController' => __DIR__ . '/..' . '/../lib/Controller/AppsController.php',
'OCA\\Provisioning_API\\Controller\\GroupsController' => __DIR__ . '/..' . '/../lib/Controller/GroupsController.php',
'OCA\\Provisioning_API\\Controller\\UsersController' => __DIR__ . '/..' . '/../lib/Controller/UsersController.php',
'OCA\\Provisioning_API\\Controller\\VerificationController' => __DIR__ . '/..' . '/../lib/Controller/VerificationController.php',
'OCA\\Provisioning_API\\FederatedShareProviderFactory' => __DIR__ . '/..' . '/../lib/FederatedShareProviderFactory.php',
'OCA\\Provisioning_API\\Listener\\UserDeletedListener' => __DIR__ . '/..' . '/../lib/Listener/UserDeletedListener.php',
'OCA\\Provisioning_API\\Middleware\\Exceptions\\NotSubAdminException' => __DIR__ . '/..' . '/../lib/Middleware/Exceptions/NotSubAdminException.php',

+ 2
- 2
apps/provisioning_api/composer/composer/installed.php View File

@@ -5,7 +5,7 @@
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
'reference' => 'fa56c13484afa1baf908b93ed5b6990c6a0e9ad6',
'reference' => '2e49000abb5acb09de041369a2239db23fa63ec7',
'name' => '__root__',
'dev' => false,
),
@@ -16,7 +16,7 @@
'type' => 'library',
'install_path' => __DIR__ . '/../',
'aliases' => array(),
'reference' => 'fa56c13484afa1baf908b93ed5b6990c6a0e9ad6',
'reference' => '2e49000abb5acb09de041369a2239db23fa63ec7',
'dev_requirement' => false,
),
),

+ 14
- 6
apps/provisioning_api/lib/Controller/AUserData.php View File

@@ -54,6 +54,13 @@ use OCP\User\Backend\ISetPasswordBackend;
abstract class AUserData extends OCSController {
public const SCOPE_SUFFIX = 'Scope';

public const USER_FIELD_DISPLAYNAME = 'display';
public const USER_FIELD_LANGUAGE = 'language';
public const USER_FIELD_LOCALE = 'locale';
public const USER_FIELD_PASSWORD = 'password';
public const USER_FIELD_QUOTA = 'quota';
public const USER_FIELD_NOTIFICATION_EMAIL = 'notify_email';

/** @var IUserManager */
protected $userManager;
/** @var IConfig */
@@ -139,14 +146,14 @@ abstract class AUserData extends OCSController {
$data['lastLogin'] = $targetUserObject->getLastLogin() * 1000;
$data['backend'] = $targetUserObject->getBackendClassName();
$data['subadmin'] = $this->getUserSubAdminGroupsData($targetUserObject->getUID());
$data['quota'] = $this->fillStorageInfo($targetUserObject->getUID());
$data[self::USER_FIELD_QUOTA] = $this->fillStorageInfo($targetUserObject->getUID());

try {
if ($includeScopes) {
$data[IAccountManager::PROPERTY_AVATAR . self::SCOPE_SUFFIX] = $userAccount->getProperty(IAccountManager::PROPERTY_AVATAR)->getScope();
}

$data[IAccountManager::PROPERTY_EMAIL] = $targetUserObject->getEMailAddress();
$data[IAccountManager::PROPERTY_EMAIL] = $targetUserObject->getSystemEMailAddress();
if ($includeScopes) {
$data[IAccountManager::PROPERTY_EMAIL . self::SCOPE_SUFFIX] = $userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getScope();
}
@@ -187,8 +194,9 @@ abstract class AUserData extends OCSController {
}

$data['groups'] = $gids;
$data['language'] = $this->l10nFactory->getUserLanguage($targetUserObject);
$data['locale'] = $this->config->getUserValue($targetUserObject->getUID(), 'core', 'locale');
$data[self::USER_FIELD_LANGUAGE] = $this->l10nFactory->getUserLanguage($targetUserObject);
$data[self::USER_FIELD_LOCALE] = $this->config->getUserValue($targetUserObject->getUID(), 'core', 'locale');
$data[self::USER_FIELD_NOTIFICATION_EMAIL] = $targetUserObject->getPrimaryEMailAddress();

$backend = $targetUserObject->getBackend();
$data['backendCapabilities'] = [
@@ -238,7 +246,7 @@ abstract class AUserData extends OCSController {
'used' => $storage['used'],
'total' => $storage['total'],
'relative' => $storage['relative'],
'quota' => $storage['quota'],
self::USER_FIELD_QUOTA => $storage['quota'],
];
} catch (NotFoundException $ex) {
// User fs is not setup yet
@@ -251,7 +259,7 @@ abstract class AUserData extends OCSController {
$quota = OC_Helper::computerFileSize($quota);
}
$data = [
'quota' => $quota !== false ? $quota : 'none',
self::USER_FIELD_QUOTA => $quota !== false ? $quota : 'none',
'used' => 0
];
}

+ 56
- 27
apps/provisioning_api/lib/Controller/UsersController.php View File

@@ -42,6 +42,7 @@ declare(strict_types=1);
*/
namespace OCA\Provisioning_API\Controller;

use InvalidArgumentException;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumber;
use libphonenumber\PhoneNumberFormat;
@@ -418,15 +419,15 @@ class UsersController extends AUserData {
}

if ($displayName !== '') {
$this->editUser($userid, 'display', $displayName);
$this->editUser($userid, self::USER_FIELD_DISPLAYNAME, $displayName);
}

if ($quota !== '') {
$this->editUser($userid, 'quota', $quota);
$this->editUser($userid, self::USER_FIELD_QUOTA, $quota);
}

if ($language !== '') {
$this->editUser($userid, 'language', $language);
$this->editUser($userid, self::USER_FIELD_LANGUAGE, $language);
}

// Send new user mail only if a mail is set
@@ -466,7 +467,7 @@ class UsersController extends AUserData {
]
);
throw $e;
} catch (\InvalidArgumentException $e) {
} catch (InvalidArgumentException $e) {
$this->logger->error('Failed addUser attempt with invalid argument exeption.',
[
'app' => 'ocs_api',
@@ -621,6 +622,10 @@ class UsersController extends AUserData {
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
}

$subAdminManager = $this->groupManager->getSubAdmin();
$isAdminOrSubadmin = $this->groupManager->isAdmin($currentLoggedInUser->getUID())
|| $subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser);

$permittedFields = [];
if ($targetUser->getUID() === $currentLoggedInUser->getUID()) {
// Editing self (display, email)
@@ -628,11 +633,8 @@ class UsersController extends AUserData {
$permittedFields[] = IAccountManager::COLLECTION_EMAIL . self::SCOPE_SUFFIX;
} else {
// Check if admin / subadmin
$subAdminManager = $this->groupManager->getSubAdmin();
if ($this->groupManager->isAdmin($currentLoggedInUser->getUID())
|| $subAdminManager->isUserAccessible($currentLoggedInUser, $targetUser)) {
if ($isAdminOrSubadmin) {
// They have permissions over the user

$permittedFields[] = IAccountManager::COLLECTION_EMAIL;
} else {
// No rights
@@ -652,6 +654,11 @@ class UsersController extends AUserData {
$mailCollection->removePropertyByValue($key);
if ($value !== '') {
$mailCollection->addPropertyWithDefaults($value);
$property = $mailCollection->getPropertyByValue($key);
if ($isAdminOrSubadmin && $property) {
// admin set mails are auto-verified
$property->setLocallyVerified(IAccountManager::VERIFIED);
}
}
$this->accountManager->updateAccount($userAccount);
break;
@@ -670,7 +677,7 @@ class UsersController extends AUserData {
try {
$targetProperty->setScope($value);
$this->accountManager->updateAccount($userAccount);
} catch (\InvalidArgumentException $e) {
} catch (InvalidArgumentException $e) {
throw new OCSException('', 102);
}
} else {
@@ -711,7 +718,7 @@ class UsersController extends AUserData {
if ($this->config->getSystemValue('allow_user_to_change_display_name', true) !== false) {
if ($targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)) {
$permittedFields[] = 'display';
$permittedFields[] = self::USER_FIELD_DISPLAYNAME;
$permittedFields[] = IAccountManager::PROPERTY_DISPLAYNAME;
}
$permittedFields[] = IAccountManager::PROPERTY_EMAIL;
@@ -722,15 +729,16 @@ class UsersController extends AUserData {

$permittedFields[] = IAccountManager::COLLECTION_EMAIL;

$permittedFields[] = 'password';
$permittedFields[] = self::USER_FIELD_PASSWORD;
$permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
if ($this->config->getSystemValue('force_language', false) === false ||
$this->groupManager->isAdmin($currentLoggedInUser->getUID())) {
$permittedFields[] = 'language';
$permittedFields[] = self::USER_FIELD_LANGUAGE;
}

if ($this->config->getSystemValue('force_locale', false) === false ||
$this->groupManager->isAdmin($currentLoggedInUser->getUID())) {
$permittedFields[] = 'locale';
$permittedFields[] = self::USER_FIELD_LOCALE;
}

$permittedFields[] = IAccountManager::PROPERTY_PHONE;
@@ -746,7 +754,7 @@ class UsersController extends AUserData {

// If admin they can edit their own quota
if ($this->groupManager->isAdmin($currentLoggedInUser->getUID())) {
$permittedFields[] = 'quota';
$permittedFields[] = self::USER_FIELD_QUOTA;
}
} else {
// Check if admin / subadmin
@@ -756,19 +764,20 @@ class UsersController extends AUserData {
// They have permissions over the user
if ($targetUser->getBackend() instanceof ISetDisplayNameBackend
|| $targetUser->getBackend()->implementsActions(Backend::SET_DISPLAYNAME)) {
$permittedFields[] = 'display';
$permittedFields[] = self::USER_FIELD_DISPLAYNAME;
$permittedFields[] = IAccountManager::PROPERTY_DISPLAYNAME;
}
$permittedFields[] = IAccountManager::PROPERTY_EMAIL;
$permittedFields[] = IAccountManager::COLLECTION_EMAIL;
$permittedFields[] = 'password';
$permittedFields[] = 'language';
$permittedFields[] = 'locale';
$permittedFields[] = self::USER_FIELD_PASSWORD;
$permittedFields[] = self::USER_FIELD_LANGUAGE;
$permittedFields[] = self::USER_FIELD_LOCALE;
$permittedFields[] = IAccountManager::PROPERTY_PHONE;
$permittedFields[] = IAccountManager::PROPERTY_ADDRESS;
$permittedFields[] = IAccountManager::PROPERTY_WEBSITE;
$permittedFields[] = IAccountManager::PROPERTY_TWITTER;
$permittedFields[] = 'quota';
$permittedFields[] = self::USER_FIELD_QUOTA;
$permittedFields[] = self::USER_FIELD_NOTIFICATION_EMAIL;
} else {
// No rights
throw new OCSException('', OCSController::RESPOND_NOT_FOUND);
@@ -780,11 +789,11 @@ class UsersController extends AUserData {
}
// Process the edit
switch ($key) {
case 'display':
case self::USER_FIELD_DISPLAYNAME:
case IAccountManager::PROPERTY_DISPLAYNAME:
$targetUser->setDisplayName($value);
break;
case 'quota':
case self::USER_FIELD_QUOTA:
$quota = $value;
if ($quota !== 'none' && $quota !== 'default') {
if (is_numeric($quota)) {
@@ -814,7 +823,7 @@ class UsersController extends AUserData {
}
$targetUser->setQuota($quota);
break;
case 'password':
case self::USER_FIELD_PASSWORD:
try {
if (!$targetUser->canChangePassword()) {
throw new OCSException('Setting the password is not supported by the users backend', 103);
@@ -824,19 +833,39 @@ class UsersController extends AUserData {
throw new OCSException($e->getMessage(), 103);
}
break;
case 'language':
case self::USER_FIELD_LANGUAGE:
$languagesCodes = $this->l10nFactory->findAvailableLanguages();
if (!in_array($value, $languagesCodes, true) && $value !== 'en') {
throw new OCSException('Invalid language', 102);
}
$this->config->setUserValue($targetUser->getUID(), 'core', 'lang', $value);
break;
case 'locale':
case self::USER_FIELD_LOCALE:
if (!$this->l10nFactory->localeExists($value)) {
throw new OCSException('Invalid locale', 102);
}
$this->config->setUserValue($targetUser->getUID(), 'core', 'locale', $value);
break;
case self::USER_FIELD_NOTIFICATION_EMAIL:
$success = false;
if ($value === '' || filter_var($value, FILTER_VALIDATE_EMAIL)) {
try {
$targetUser->setPrimaryEMailAddress($value);
$success = true;
} catch (InvalidArgumentException $e) {
$this->logger->info(
'Cannot set primary email, because provided address is not verified',
[
'app' => 'provisioning_api',
'exception' => $e,
]
);
}
}
if (!$success) {
throw new OCSException('', 102);
}
break;
case IAccountManager::PROPERTY_EMAIL:
if (filter_var($value, FILTER_VALIDATE_EMAIL) || $value === '') {
$targetUser->setEMailAddress($value);
@@ -845,7 +874,7 @@ class UsersController extends AUserData {
}
break;
case IAccountManager::COLLECTION_EMAIL:
if (filter_var($value, FILTER_VALIDATE_EMAIL) && $value !== $targetUser->getEMailAddress()) {
if (filter_var($value, FILTER_VALIDATE_EMAIL) && $value !== $targetUser->getSystemEMailAddress()) {
$userAccount = $this->accountManager->getAccount($targetUser);
$mailCollection = $userAccount->getPropertyCollection(IAccountManager::COLLECTION_EMAIL);
foreach ($mailCollection->getProperties() as $property) {
@@ -872,7 +901,7 @@ class UsersController extends AUserData {
if ($userProperty->getName() === IAccountManager::PROPERTY_PHONE) {
$this->knownUserService->deleteByContactUserId($targetUser->getUID());
}
} catch (\InvalidArgumentException $e) {
} catch (InvalidArgumentException $e) {
throw new OCSException('Invalid ' . $e->getMessage(), 102);
}
}
@@ -895,7 +924,7 @@ class UsersController extends AUserData {
try {
$userProperty->setScope($value);
$this->accountManager->updateAccount($userAccount);
} catch (\InvalidArgumentException $e) {
} catch (InvalidArgumentException $e) {
throw new OCSException('Invalid ' . $e->getMessage(), 102);
}
}

+ 143
- 0
apps/provisioning_api/lib/Controller/VerificationController.php View File

@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2021 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace OCA\Provisioning_API\Controller;

use InvalidArgumentException;
use OC\Security\Crypto;
use OCP\Accounts\IAccountManager;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Security\VerificationToken\InvalidTokenException;
use OCP\Security\VerificationToken\IVerificationToken;

class VerificationController extends Controller {

/** @var IVerificationToken */
private $verificationToken;
/** @var IUserManager */
private $userManager;
/** @var IL10N */
private $l10n;
/** @var IUserSession */
private $userSession;
/** @var IAccountManager */
private $accountManager;
/** @var Crypto */
private $crypto;

public function __construct(
string $appName,
IRequest $request,
IVerificationToken $verificationToken,
IUserManager $userManager,
IL10N $l10n,
IUserSession $userSession,
IAccountManager $accountManager,
Crypto $crypto
) {
parent::__construct($appName, $request);
$this->verificationToken = $verificationToken;
$this->userManager = $userManager;
$this->l10n = $l10n;
$this->userSession = $userSession;
$this->accountManager = $accountManager;
$this->crypto = $crypto;
}

/**
* @NoCSRFRequired
* @NoAdminRequired
* @NoSubAdminRequired
*/
public function showVerifyMail(string $token, string $userId, string $key) {
if ($this->userSession->getUser()->getUID() !== $userId) {
// not a public page, hence getUser() must return an IUser
throw new InvalidArgumentException('Logged in user is not mail address owner');
}
$email = $this->crypto->decrypt($key);

return new TemplateResponse(
'core', 'confirmation', [
'title' => $this->l10n->t('Email confirmation'),
'message' => $this->l10n->t('To enable the email address %s please click the button below.', [$email]),
'action' => $this->l10n->t('Confirm'),
], TemplateResponse::RENDER_AS_GUEST);
}

/**
* @NoAdminRequired
* @NoSubAdminRequired
*/
public function verifyMail(string $token, string $userId, string $key) {
try {
if ($this->userSession->getUser()->getUID() !== $userId) {
throw new InvalidArgumentException('Logged in user is not mail address owner');
}
$email = $this->crypto->decrypt($key);
$ref = \substr(hash('sha256', $email), 0, 8);

$user = $this->userManager->get($userId);
$this->verificationToken->check($token, $user, 'verifyMail' . $ref, $email);

$userAccount = $this->accountManager->getAccount($user);
$emailProperty = $userAccount->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)
->getPropertyByValue($email);

if ($emailProperty === null) {
throw new InvalidArgumentException($this->l10n->t('Email was already removed from account and cannot be confirmed anymore.'));
}
$emailProperty->setLocallyVerified(IAccountManager::VERIFIED);
$this->accountManager->updateAccount($userAccount);
$this->verificationToken->delete($token, $user, 'verifyMail' . $ref);
} catch (InvalidTokenException $e) {
$error = $e->getCode() === InvalidTokenException::TOKEN_EXPIRED
? $this->l10n->t('Could not verify mail because the token is expired.')
: $this->l10n->t('Could not verify mail because the token is invalid.');
} catch (InvalidArgumentException $e) {
$error = $e->getMessage();
} catch (\Exception $e) {
$error = $this->l10n->t('An unexpected error occurred. Please consult your sysadmin.');
}

if (isset($error)) {
return new TemplateResponse(
'core', 'error', [
'errors' => [['error' => $error]]
], TemplateResponse::RENDER_AS_GUEST);
}

return new TemplateResponse(
'core', 'success', [
'title' => $this->l10n->t('Email confirmation successful'),
'message' => $this->l10n->t('Email confirmation successful'),
], TemplateResponse::RENDER_AS_GUEST);
}
}

+ 8
- 5
apps/provisioning_api/tests/Controller/UsersControllerTest.php View File

@@ -952,7 +952,7 @@ class UsersControllerTest extends TestCase {
->disableOriginalConstructor()
->getMock();
$targetUser->expects($this->once())
->method('getEMailAddress')
->method('getSystemEMailAddress')
->willReturn('demo@nextcloud.com');
$this->userSession
->expects($this->once())
@@ -1067,6 +1067,7 @@ class UsersControllerTest extends TestCase {
'setPassword' => true,
],
'additional_mail' => [],
'notify_email' => null,
];
$this->assertEquals($expected, $this->invokePrivate($this->api, 'getUserData', ['UID']));
}
@@ -1083,9 +1084,9 @@ class UsersControllerTest extends TestCase {
->disableOriginalConstructor()
->getMock();
$targetUser
->expects($this->once())
->method('getEMailAddress')
->willReturn('demo@nextcloud.com');
->expects($this->once())
->method('getSystemEMailAddress')
->willReturn('demo@nextcloud.com');
$this->userSession
->expects($this->once())
->method('getUser')
@@ -1195,6 +1196,7 @@ class UsersControllerTest extends TestCase {
'setPassword' => true,
],
'additional_mail' => [],
'notify_email' => null,
];
$this->assertEquals($expected, $this->invokePrivate($this->api, 'getUserData', ['UID']));
}
@@ -1306,7 +1308,7 @@ class UsersControllerTest extends TestCase {
->willReturn('Subadmin User');
$targetUser
->expects($this->once())
->method('getEMailAddress')
->method('getSystemEMailAddress')
->willReturn('subadmin@nextcloud.com');
$targetUser
->method('getUID')
@@ -1361,6 +1363,7 @@ class UsersControllerTest extends TestCase {
'setPassword' => false,
],
'additional_mail' => [],
'notify_email' => null,
];
$this->assertEquals($expected, $this->invokePrivate($this->api, 'getUserData', ['UID']));
}

+ 2
- 2
apps/settings/lib/Controller/UsersController.php View File

@@ -482,7 +482,7 @@ class UsersController extends Controller {
}
}

$oldEmailAddress = $userAccount->getUser()->getEMailAddress();
$oldEmailAddress = $userAccount->getUser()->getSystemEMailAddress();
$oldEmailAddress = strtolower((string)$oldEmailAddress);
if ($oldEmailAddress !== $userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue()) {
// this is the only permission a backend provides and is also used
@@ -490,7 +490,7 @@ class UsersController extends Controller {
if (!$userAccount->getUser()->canChangeDisplayName()) {
throw new ForbiddenException($this->l10n->t('Unable to change email address'));
}
$userAccount->getUser()->setEMailAddress($userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue());
$userAccount->getUser()->setSystemEMailAddress($userAccount->getProperty(IAccountManager::PROPERTY_EMAIL)->getValue());
}

try {

+ 4
- 5
apps/settings/tests/Controller/UsersControllerTest.php View File

@@ -621,16 +621,15 @@ class UsersControllerTest extends \Test\TestCase {
$user = $this->createMock(IUser::class);

$user->method('getDisplayName')->willReturn($oldDisplayName);
$user->method('getEMailAddress')->willReturn($oldEmailAddress);
$user->method('getSystemEMailAddress')->willReturn($oldEmailAddress);
$user->method('canChangeDisplayName')->willReturn(true);

if ($data[IAccountManager::PROPERTY_EMAIL]['value'] === $oldEmailAddress ||
($oldEmailAddress === null && $data[IAccountManager::PROPERTY_EMAIL]['value'] === '')) {
$user->expects($this->never())->method('setEMailAddress');
$user->expects($this->never())->method('setSystemEMailAddress');
} else {
$user->expects($this->once())->method('setEMailAddress')
->with($data[IAccountManager::PROPERTY_EMAIL]['value'])
->willReturn(true);
$user->expects($this->once())->method('setSystemEMailAddress')
->with($data[IAccountManager::PROPERTY_EMAIL]['value']);
}

if ($data[IAccountManager::PROPERTY_DISPLAYNAME]['value'] === $oldDisplayName ||

+ 1
- 1
apps/user_ldap/lib/User/User.php View File

@@ -448,7 +448,7 @@ class User {
if ($email !== '') {
$user = $this->userManager->get($this->uid);
if (!is_null($user)) {
$currentEmail = (string)$user->getEMailAddress();
$currentEmail = (string)$user->getSystemEMailAddress();
if ($currentEmail !== $email) {
$user->setEMailAddress($email);
}

+ 1
- 1
core/Command/User/Info.php View File

@@ -76,7 +76,7 @@ class Info extends Base {
$data = [
'user_id' => $user->getUID(),
'display_name' => $user->getDisplayName(),
'email' => $user->getEMailAddress() ? $user->getEMailAddress() : '',
'email' => (string)$user->getSystemEMailAddress(),
'cloud_id' => $user->getCloudId(),
'enabled' => $user->isEnabled(),
'groups' => $groups,

+ 1
- 1
core/Command/User/ListCommand.php View File

@@ -104,7 +104,7 @@ class ListCommand extends Base {
return [
'user_id' => $user->getUID(),
'display_name' => $user->getDisplayName(),
'email' => $user->getEMailAddress() ? $user->getEMailAddress() : '',
'email' => (string)$user->getSystemEMailAddress(),
'cloud_id' => $user->getCloudId(),
'enabled' => $user->isEnabled(),
'groups' => $groups,

+ 30
- 82
core/Controller/LostController.php View File

@@ -40,7 +40,6 @@ use OC\Core\Exception\ResetPasswordException;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Defaults;
use OCP\Encryption\IEncryptionModule;
use OCP\Encryption\IManager;
@@ -54,8 +53,8 @@ use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Mail\IMailer;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\Security\VerificationToken\InvalidTokenException;
use OCP\Security\VerificationToken\IVerificationToken;
use function array_filter;
use function count;
use function reset;
@@ -82,67 +81,46 @@ class LostController extends Controller {
protected $encryptionManager;
/** @var IConfig */
protected $config;
/** @var ISecureRandom */
protected $secureRandom;
/** @var IMailer */
protected $mailer;
/** @var ITimeFactory */
protected $timeFactory;
/** @var ICrypto */
protected $crypto;
/** @var ILogger */
private $logger;
/** @var Manager */
private $twoFactorManager;
/** @var IInitialStateService */
private $initialStateService;

/**
* @param string $appName
* @param IRequest $request
* @param IURLGenerator $urlGenerator
* @param IUserManager $userManager
* @param Defaults $defaults
* @param IL10N $l10n
* @param IConfig $config
* @param ISecureRandom $secureRandom
* @param string $defaultMailAddress
* @param IManager $encryptionManager
* @param IMailer $mailer
* @param ITimeFactory $timeFactory
* @param ICrypto $crypto
*/
public function __construct($appName,
IRequest $request,
IURLGenerator $urlGenerator,
IUserManager $userManager,
Defaults $defaults,
IL10N $l10n,
IConfig $config,
ISecureRandom $secureRandom,
$defaultMailAddress,
IManager $encryptionManager,
IMailer $mailer,
ITimeFactory $timeFactory,
ICrypto $crypto,
ILogger $logger,
Manager $twoFactorManager,
IInitialStateService $initialStateService) {
/** @var IVerificationToken */
private $verificationToken;

public function __construct(
$appName,
IRequest $request,
IURLGenerator $urlGenerator,
IUserManager $userManager,
Defaults $defaults,
IL10N $l10n,
IConfig $config,
$defaultMailAddress,
IManager $encryptionManager,
IMailer $mailer,
ILogger $logger,
Manager $twoFactorManager,
IInitialStateService $initialStateService,
IVerificationToken $verificationToken
) {
parent::__construct($appName, $request);
$this->urlGenerator = $urlGenerator;
$this->userManager = $userManager;
$this->defaults = $defaults;
$this->l10n = $l10n;
$this->secureRandom = $secureRandom;
$this->from = $defaultMailAddress;
$this->encryptionManager = $encryptionManager;
$this->config = $config;
$this->mailer = $mailer;
$this->timeFactory = $timeFactory;
$this->crypto = $crypto;
$this->logger = $logger;
$this->twoFactorManager = $twoFactorManager;
$this->initialStateService = $initialStateService;
$this->verificationToken = $verificationToken;
}

/**
@@ -192,36 +170,14 @@ class LostController extends Controller {
* @param string $userId
* @throws \Exception
*/
protected function checkPasswordResetToken($token, $userId) {
$user = $this->userManager->get($userId);
if ($user === null || !$user->isEnabled()) {
throw new \Exception($this->l10n->t('Couldn\'t reset password because the token is invalid'));
}

$encryptedToken = $this->config->getUserValue($userId, 'core', 'lostpassword', null);
if ($encryptedToken === null) {
throw new \Exception($this->l10n->t('Couldn\'t reset password because the token is invalid'));
}

protected function checkPasswordResetToken(string $token, string $userId): void {
try {
$mailAddress = !is_null($user->getEMailAddress()) ? $user->getEMailAddress() : '';
$decryptedToken = $this->crypto->decrypt($encryptedToken, $mailAddress.$this->config->getSystemValue('secret'));
} catch (\Exception $e) {
throw new \Exception($this->l10n->t('Couldn\'t reset password because the token is invalid'));
}

$splittedToken = explode(':', $decryptedToken);
if (count($splittedToken) !== 2) {
throw new \Exception($this->l10n->t('Couldn\'t reset password because the token is invalid'));
}

if ($splittedToken[0] < ($this->timeFactory->getTime() - 60 * 60 * 24 * 7) ||
$user->getLastLogin() > $splittedToken[0]) {
throw new \Exception($this->l10n->t('Couldn\'t reset password because the token is expired'));
}

if (!hash_equals($splittedToken[1], $token)) {
throw new \Exception($this->l10n->t('Couldn\'t reset password because the token is invalid'));
$this->verificationToken->check($token, $this->userManager->get($userId), 'lostpassword', '', true);
} catch (InvalidTokenException $e) {
$error = $e->getCode() === InvalidTokenException::TOKEN_EXPIRED
? $this->l10n->t('Could not reset password because the token is expired')
: $this->l10n->t('Could not reset password because the token is invalid');
throw new \Exception($error, (int)$e->getCode(), $e);
}
}

@@ -343,15 +299,7 @@ class LostController extends Controller {
// secret being the users' email address appended with the system secret.
// This makes the token automatically invalidate once the user changes
// their email address.
$token = $this->secureRandom->generate(
21,
ISecureRandom::CHAR_DIGITS.
ISecureRandom::CHAR_LOWER.
ISecureRandom::CHAR_UPPER
);
$tokenValue = $this->timeFactory->getTime() .':'. $token;
$encryptedValue = $this->crypto->encrypt($tokenValue, $email . $this->config->getSystemValue('secret'));
$this->config->setUserValue($user->getUID(), 'core', 'lostpassword', $encryptedValue);
$token = $this->verificationToken->create($user, 'lostpassword', $email);

$link = $this->urlGenerator->linkToRouteAbsolute('core.lost.resetform', ['userId' => $user->getUID(), 'token' => $token]);


+ 20
- 0
core/templates/confirmation.php View File

@@ -0,0 +1,20 @@
<?php
/** @var array $_ */
/** @var \OCP\IL10N $l */
/** @var \OCP\Defaults $theme */

?>

<div class="update">
<form method="POST" action="<?php print_unescaped($_['targetUrl']);?>">
<h2><?php p($_['title']) ?></h2>
<p><?php p($_['message']) ?></p>
<div class="buttons">
<input type="submit" class="primary" value="<?php p($_['action']); ?>">
</div>
<?php foreach ($_['parameters'] as $name => $value) {?>
<input type="hidden" name="<?php p($name); ?>" value="<?php p($value); ?>">
<?php } ?>
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']) ?>">
</form>
</div>

+ 13
- 0
core/templates/success.php View File

@@ -0,0 +1,13 @@
<?php
/** @var array $_ */
/** @var \OCP\IL10N $l */
/** @var \OCP\Defaults $theme */
?>

<div class="update">
<h2><?php p($_['title']) ?></h2>
<p><?php p($_['message']) ?></p>
<p><a class="button primary" href="<?php p(\OC::$server->get(\OCP\IURLGenerator::class)->linkTo('', 'index.php')) ?>">
<?php p($l->t('Go to %s', [$theme->getName()])); ?>
</a></p>
</div>

+ 4
- 0
lib/composer/composer/autoload_classmap.php View File

@@ -488,6 +488,8 @@ return array(
'OCP\\Security\\ICrypto' => $baseDir . '/lib/public/Security/ICrypto.php',
'OCP\\Security\\IHasher' => $baseDir . '/lib/public/Security/IHasher.php',
'OCP\\Security\\ISecureRandom' => $baseDir . '/lib/public/Security/ISecureRandom.php',
'OCP\\Security\\VerificationToken\\IVerificationToken' => $baseDir . '/lib/public/Security/VerificationToken/IVerificationToken.php',
'OCP\\Security\\VerificationToken\\InvalidTokenException' => $baseDir . '/lib/public/Security/VerificationToken/InvalidTokenException.php',
'OCP\\Session\\Exceptions\\SessionNotAvailableException' => $baseDir . '/lib/public/Session/Exceptions/SessionNotAvailableException.php',
'OCP\\Settings\\IIconSection' => $baseDir . '/lib/public/Settings/IIconSection.php',
'OCP\\Settings\\IManager' => $baseDir . '/lib/public/Settings/IManager.php',
@@ -1371,6 +1373,8 @@ return array(
'OC\\Security\\RateLimiting\\Limiter' => $baseDir . '/lib/private/Security/RateLimiting/Limiter.php',
'OC\\Security\\SecureRandom' => $baseDir . '/lib/private/Security/SecureRandom.php',
'OC\\Security\\TrustedDomainHelper' => $baseDir . '/lib/private/Security/TrustedDomainHelper.php',
'OC\\Security\\VerificationToken\\CleanUpJob' => $baseDir . '/lib/private/Security/VerificationToken/CleanUpJob.php',
'OC\\Security\\VerificationToken\\VerificationToken' => $baseDir . '/lib/private/Security/VerificationToken/VerificationToken.php',
'OC\\Server' => $baseDir . '/lib/private/Server.php',
'OC\\ServerContainer' => $baseDir . '/lib/private/ServerContainer.php',
'OC\\ServerNotAvailableException' => $baseDir . '/lib/private/ServerNotAvailableException.php',

+ 4
- 0
lib/composer/composer/autoload_static.php View File

@@ -517,6 +517,8 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OCP\\Security\\ICrypto' => __DIR__ . '/../../..' . '/lib/public/Security/ICrypto.php',
'OCP\\Security\\IHasher' => __DIR__ . '/../../..' . '/lib/public/Security/IHasher.php',
'OCP\\Security\\ISecureRandom' => __DIR__ . '/../../..' . '/lib/public/Security/ISecureRandom.php',
'OCP\\Security\\VerificationToken\\IVerificationToken' => __DIR__ . '/../../..' . '/lib/public/Security/VerificationToken/IVerificationToken.php',
'OCP\\Security\\VerificationToken\\InvalidTokenException' => __DIR__ . '/../../..' . '/lib/public/Security/VerificationToken/InvalidTokenException.php',
'OCP\\Session\\Exceptions\\SessionNotAvailableException' => __DIR__ . '/../../..' . '/lib/public/Session/Exceptions/SessionNotAvailableException.php',
'OCP\\Settings\\IIconSection' => __DIR__ . '/../../..' . '/lib/public/Settings/IIconSection.php',
'OCP\\Settings\\IManager' => __DIR__ . '/../../..' . '/lib/public/Settings/IManager.php',
@@ -1400,6 +1402,8 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Security\\RateLimiting\\Limiter' => __DIR__ . '/../../..' . '/lib/private/Security/RateLimiting/Limiter.php',
'OC\\Security\\SecureRandom' => __DIR__ . '/../../..' . '/lib/private/Security/SecureRandom.php',
'OC\\Security\\TrustedDomainHelper' => __DIR__ . '/../../..' . '/lib/private/Security/TrustedDomainHelper.php',
'OC\\Security\\VerificationToken\\CleanUpJob' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/CleanUpJob.php',
'OC\\Security\\VerificationToken\\VerificationToken' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/VerificationToken.php',
'OC\\Server' => __DIR__ . '/../../..' . '/lib/private/Server.php',
'OC\\ServerContainer' => __DIR__ . '/../../..' . '/lib/private/ServerContainer.php',
'OC\\ServerNotAvailableException' => __DIR__ . '/../../..' . '/lib/private/ServerNotAvailableException.php',

+ 121
- 10
lib/private/Accounts/AccountManager.php View File

@@ -32,6 +32,7 @@
*/
namespace OC\Accounts;

use Exception;
use InvalidArgumentException;
use libphonenumber\NumberParseException;
use libphonenumber\PhoneNumber;
@@ -45,9 +46,17 @@ use OCP\Accounts\IAccountPropertyCollection;
use OCP\Accounts\PropertyDoesNotExistException;
use OCP\BackgroundJob\IJobList;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Defaults;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\Security\ICrypto;
use OCP\Security\VerificationToken\IVerificationToken;
use OCP\Util;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
@@ -88,17 +97,46 @@ class AccountManager implements IAccountManager {

/** @var LoggerInterface */
private $logger;

public function __construct(IDBConnection $connection,
IConfig $config,
EventDispatcherInterface $eventDispatcher,
IJobList $jobList,
LoggerInterface $logger) {
/** @var IVerificationToken */
private $verificationToken;
/** @var IMailer */
private $mailer;
/** @var Defaults */
private $defaults;
/** @var IL10N */
private $l10n;
/** @var IURLGenerator */
private $urlGenerator;
/** @var ICrypto */
private $crypto;
/** @var IFactory */
private $l10nfactory;

public function __construct(
IDBConnection $connection,
IConfig $config,
EventDispatcherInterface $eventDispatcher,
IJobList $jobList,
LoggerInterface $logger,
IVerificationToken $verificationToken,
IMailer $mailer,
Defaults $defaults,
IFactory $factory,
IURLGenerator $urlGenerator,
ICrypto $crypto
) {
$this->connection = $connection;
$this->config = $config;
$this->eventDispatcher = $eventDispatcher;
$this->jobList = $jobList;
$this->logger = $logger;
$this->verificationToken = $verificationToken;
$this->mailer = $mailer;
$this->defaults = $defaults;
$this->urlGenerator = $urlGenerator;
$this->crypto = $crypto;
// DIing IL10N results in a dependency loop
$this->l10nfactory = $factory;
}

/**
@@ -337,7 +375,6 @@ class AccountManager implements IAccountManager {

/**
* check if we need to ask the server for email verification, if yes we create a cronjob
*
*/
protected function checkEmailVerification(IAccount $updatedAccount, array $oldData): void {
try {
@@ -358,11 +395,73 @@ class AccountManager implements IAccountManager {
]
);

$property->setVerified(self::VERIFICATION_IN_PROGRESS);
}
}

protected function checkLocalEmailVerification(IAccount $updatedAccount, array $oldData): void {
$mailCollection = $updatedAccount->getPropertyCollection(self::COLLECTION_EMAIL);
foreach ($mailCollection->getProperties() as $property) {
if ($property->getLocallyVerified() !== self::NOT_VERIFIED) {
continue;
}
if ($this->sendEmailVerificationEmail($updatedAccount->getUser(), $property->getValue())) {
$property->setLocallyVerified(self::VERIFICATION_IN_PROGRESS);
}
}
}

protected function sendEmailVerificationEmail(IUser $user, string $email): bool {
$ref = \substr(hash('sha256', $email), 0, 8);
$key = $this->crypto->encrypt($email);
$token = $this->verificationToken->create($user, 'verifyMail' . $ref, $email);

$link = $this->urlGenerator->linkToRouteAbsolute('provisioning_api.Verification.verifyMail',
[
'userId' => $user->getUID(),
'token' => $token,
'key' => $key
]);

$emailTemplate = $this->mailer->createEMailTemplate('core.EmailVerification', [
'link' => $link,
]);

$property->setVerified(self::VERIFICATION_IN_PROGRESS);
if (!$this->l10n) {
$this->l10n = $this->l10nfactory->get('core');
}

$emailTemplate->setSubject($this->l10n->t('%s email verification', [$this->defaults->getName()]));
$emailTemplate->addHeader();
$emailTemplate->addHeading($this->l10n->t('Email verification'));

$emailTemplate->addBodyText(
htmlspecialchars($this->l10n->t('Click the following button to confirm your email.')),
$this->l10n->t('Click the following link to confirm your email.')
);

$emailTemplate->addBodyButton(
htmlspecialchars($this->l10n->t('Confirm your email')),
$link,
false
);
$emailTemplate->addFooter();

try {
$message = $this->mailer->createMessage();
$message->setTo([$email => $user->getDisplayName()]);
$message->setFrom([Util::getDefaultEmailAddress('verification-noreply') => $this->defaults->getName()]);
$message->useTemplate($emailTemplate);
$this->mailer->send($message);
} catch (Exception $e) {
// Log the exception and continue
$this->logger->info('Failed to send verification mail', [
'app' => 'core',
'exception' => $e
]);
return false;
}
return true;
}

/**
@@ -406,7 +505,6 @@ class AccountManager implements IAccountManager {
}
}


/**
* add new user to accounts table
*
@@ -435,6 +533,12 @@ class AccountManager implements IAccountManager {
foreach ($data as $dataRow) {
$propertyName = $dataRow['name'];
unset($dataRow['name']);

if (isset($dataRow['locallyVerified']) && $dataRow['locallyVerified'] === self::NOT_VERIFIED) {
// do not write default value, save DB space
unset($dataRow['locallyVerified']);
}

if (!$this->isCollection($propertyName)) {
$preparedData[$propertyName] = $dataRow;
continue;
@@ -511,7 +615,6 @@ class AccountManager implements IAccountManager {
continue;
}


$query->setParameter('name', $property['name'])
->setParameter('value', $property['value'] ?? '');
$query->executeStatement();
@@ -587,6 +690,7 @@ class AccountManager implements IAccountManager {
$data['verified'] ?? self::NOT_VERIFIED,
''
);
$p->setLocallyVerified($data['locallyVerified'] ?? self::NOT_VERIFIED);
$collection->addProperty($p);

return $collection;
@@ -599,6 +703,10 @@ class AccountManager implements IAccountManager {
$account->setPropertyCollection($this->arrayDataToCollection($account, $accountData));
} else {
$account->setProperty($accountData['name'], $accountData['value'] ?? '', $accountData['scope'] ?? self::SCOPE_LOCAL, $accountData['verified'] ?? self::NOT_VERIFIED);
if (isset($accountData['locallyVerified'])) {
$property = $account->getProperty($accountData['name']);
$property->setLocallyVerified($accountData['locallyVerified']);
}
}
}
return $account;
@@ -640,14 +748,17 @@ class AccountManager implements IAccountManager {
$oldData = $this->getUser($account->getUser(), false);
$this->updateVerificationStatus($account, $oldData);
$this->checkEmailVerification($account, $oldData);
$this->checkLocalEmailVerification($account, $oldData);

$data = [];
foreach ($account->getAllProperties() as $property) {
/** @var IAccountProperty $property */
$data[] = [
'name' => $property->getName(),
'value' => $property->getValue(),
'scope' => $property->getScope(),
'verified' => $property->getVerified(),
'locallyVerified' => $property->getLocallyVerified(),
];
}


+ 20
- 1
lib/private/Accounts/AccountProperty.php View File

@@ -27,6 +27,7 @@ declare(strict_types=1);
*/
namespace OC\Accounts;

use InvalidArgumentException;
use OCP\Accounts\IAccountManager;
use OCP\Accounts\IAccountProperty;

@@ -42,6 +43,8 @@ class AccountProperty implements IAccountProperty {
private $verified;
/** @var string */
private $verificationData;
/** @var string */
private $locallyVerified = IAccountManager::NOT_VERIFIED;

public function __construct(string $name, string $value, string $scope, string $verified, string $verificationData) {
$this->name = $name;
@@ -90,7 +93,7 @@ class AccountProperty implements IAccountProperty {
IAccountManager::SCOPE_PRIVATE,
IAccountManager::SCOPE_PUBLISHED
])) {
throw new \InvalidArgumentException('Invalid scope');
throw new InvalidArgumentException('Invalid scope');
}
$this->scope = $newScope;
return $this;
@@ -178,4 +181,20 @@ class AccountProperty implements IAccountProperty {
public function getVerificationData(): string {
return $this->verificationData;
}

public function setLocallyVerified(string $verified): IAccountProperty {
if (!in_array($verified, [
IAccountManager::NOT_VERIFIED,
IAccountManager::VERIFICATION_IN_PROGRESS,
IAccountManager::VERIFIED,
])) {
throw new InvalidArgumentException('Provided verification value is invalid');
}
$this->locallyVerified = $verified;
return $this;
}

public function getLocallyVerified(): string {
return $this->locallyVerified;
}
}

+ 9
- 0
lib/private/Accounts/AccountPropertyCollection.php View File

@@ -84,6 +84,15 @@ class AccountPropertyCollection implements IAccountPropertyCollection {
return $this;
}

public function getPropertyByValue(string $value): ?IAccountProperty {
foreach ($this->properties as $i => $property) {
if ($property->getValue() === $value) {
return $property;
}
}
return null;
}

public function removePropertyByValue(string $value): IAccountPropertyCollection {
foreach ($this->properties as $i => $property) {
if ($property->getValue() === $value) {

+ 2
- 2
lib/private/Collaboration/Collaborators/UserPlugin.php View File

@@ -157,7 +157,7 @@ class UserPlugin implements ISearchPlugin {
$userStatuses = $this->userStatusManager->getUserStatuses(array_keys($users));
foreach ($users as $uid => $user) {
$userDisplayName = $user->getDisplayName();
$userEmail = $user->getEMailAddress();
$userEmail = $user->getSystemEMailAddress();
$uid = (string) $uid;

$status = [];
@@ -244,7 +244,7 @@ class UserPlugin implements ISearchPlugin {
if ($addUser) {
$status = [];
$uid = $user->getUID();
$userEmail = $user->getEMailAddress();
$userEmail = $user->getSystemEMailAddress();
if (array_key_exists($user->getUID(), $userStatuses)) {
$userStatus = $userStatuses[$user->getUID()];
$status = [

+ 1
- 1
lib/private/Mail/EMailTemplate.php View File

@@ -568,7 +568,7 @@ EOF;
*
* @param string $text Text of button; Note: When $plainText falls back to this, HTML is automatically escaped in the HTML email
* @param string $url URL of button
* @param string $plainText Text of button in plain text version
* @param string|false $plainText Text of button in plain text version
* if empty the $text is used, if false none will be used
*
* @since 12.0.0

+ 90
- 0
lib/private/Security/VerificationToken/CleanUpJob.php View File

@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2021 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace OC\Security\VerificationToken;

use OCP\AppFramework\Utility\ITimeFactory;
use OCP\IConfig;
use OCP\ILogger;
use OCP\IUserManager;
use OCP\Security\VerificationToken\InvalidTokenException;
use OCP\Security\VerificationToken\IVerificationToken;

class CleanUpJob extends \OCP\BackgroundJob\Job {

/** @var int */
protected $runNotBefore;
/** @var string */
protected $userId;
/** @var string */
protected $subject;
/** @var string */
protected $pwdPrefix;
/** @var IConfig */
private $config;
/** @var IVerificationToken */
private $verificationToken;
/** @var IUserManager */
private $userManager;

public function __construct(ITimeFactory $time, IConfig $config, IVerificationToken $verificationToken, IUserManager $userManager) {
parent::__construct($time);
$this->config = $config;
$this->verificationToken = $verificationToken;
$this->userManager = $userManager;
}

public function setArgument($argument) {
parent::setArgument($argument);
$args = \json_decode($argument);
$this->userId = (string)$args['userId'];
$this->subject = (string)$args['subject'];
$this->pwdPrefix = (string)$args['pp'];
$this->runNotBefore = (int)$args['notBefore'];
}

protected function run($argument) {
try {
$user = $this->userManager->get($this->userId);
if ($user === null) {
return;
}
$this->verificationToken->check('irrelevant', $user, $this->subject, $this->pwdPrefix);
} catch (InvalidTokenException $e) {
if ($e->getCode() === InvalidTokenException::TOKEN_EXPIRED) {
// make sure to only remove expired tokens
$this->config->deleteUserValue($this->userId, 'core', $this->subject);
}
}
}

public function execute($jobList, ILogger $logger = null) {
if ($this->time->getTime() >= $this->runNotBefore) {
$jobList->remove($this, $this->argument);
parent::execute($jobList, $logger);
}
}
}

+ 129
- 0
lib/private/Security/VerificationToken/VerificationToken.php View File

@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2021 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace OC\Security\VerificationToken;

use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\IConfig;
use OCP\IUser;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\Security\VerificationToken\InvalidTokenException;
use OCP\Security\VerificationToken\IVerificationToken;
use function json_encode;

class VerificationToken implements IVerificationToken {
protected const TOKEN_LIFETIME = 60 * 60 * 24 * 7;

/** @var IConfig */
private $config;
/** @var ICrypto */
private $crypto;
/** @var ITimeFactory */
private $timeFactory;
/** @var ISecureRandom */
private $secureRandom;
/** @var IJobList */
private $jobList;

public function __construct(
IConfig $config,
ICrypto $crypto,
ITimeFactory $timeFactory,
ISecureRandom $secureRandom,
IJobList $jobList
) {
$this->config = $config;
$this->crypto = $crypto;
$this->timeFactory = $timeFactory;
$this->secureRandom = $secureRandom;
$this->jobList = $jobList;
}

/**
* @throws InvalidTokenException
*/
protected function throwInvalidTokenException(int $code): void {
throw new InvalidTokenException($code);
}

public function check(string $token, ?IUser $user, string $subject, string $passwordPrefix = '', bool $expiresWithLogin = false): void {
if ($user === null || !$user->isEnabled()) {
$this->throwInvalidTokenException(InvalidTokenException::USER_UNKNOWN);
}

$encryptedToken = $this->config->getUserValue($user->getUID(), 'core', $subject, null);
if ($encryptedToken === null) {
$this->throwInvalidTokenException(InvalidTokenException::TOKEN_NOT_FOUND);
}

try {
$decryptedToken = $this->crypto->decrypt($encryptedToken, $passwordPrefix.$this->config->getSystemValue('secret'));
} catch (\Exception $e) {
$this->throwInvalidTokenException(InvalidTokenException::TOKEN_DECRYPTION_ERROR);
}

$splitToken = explode(':', $decryptedToken ?? '');
if (count($splitToken) !== 2) {
$this->throwInvalidTokenException(InvalidTokenException::TOKEN_INVALID_FORMAT);
}

if ($splitToken[0] < ($this->timeFactory->getTime() - self::TOKEN_LIFETIME)
|| ($expiresWithLogin && $user->getLastLogin() > $splitToken[0])) {
$this->throwInvalidTokenException(InvalidTokenException::TOKEN_EXPIRED);
}

if (!hash_equals($splitToken[1], $token)) {
$this->throwInvalidTokenException(InvalidTokenException::TOKEN_MISMATCH);
}
}

public function create(IUser $user, string $subject, string $passwordPrefix = ''): string {
$token = $this->secureRandom->generate(
21,
ISecureRandom::CHAR_DIGITS.
ISecureRandom::CHAR_LOWER.
ISecureRandom::CHAR_UPPER
);
$tokenValue = $this->timeFactory->getTime() .':'. $token;
$encryptedValue = $this->crypto->encrypt($tokenValue, $passwordPrefix . $this->config->getSystemValue('secret'));
$this->config->setUserValue($user->getUID(), 'core', $subject, $encryptedValue);
$jobArgs = json_encode([
'userId' => $user->getUID(),
'subject' => $subject,
'pp' => $passwordPrefix,
'notBefore' => $this->timeFactory->getTime() + self::TOKEN_LIFETIME * 2, // multiply to provide a grace period
]);
$this->jobList->add(CleanUpJob::class, $jobArgs);

return $token;
}

public function delete(string $token, IUser $user, string $subject): void {
$this->config->deleteUserValue($user->getUID(), 'core', $subject);
}
}

+ 4
- 0
lib/private/Server.php View File

@@ -135,6 +135,7 @@ use OC\Security\CSRF\TokenStorage\SessionStorage;
use OC\Security\Hasher;
use OC\Security\SecureRandom;
use OC\Security\TrustedDomainHelper;
use OC\Security\VerificationToken\VerificationToken;
use OC\Session\CryptoWrapper;
use OC\Share20\ProviderFactory;
use OC\Share20\ShareHelper;
@@ -224,6 +225,7 @@ use OCP\Security\ICredentialsManager;
use OCP\Security\ICrypto;
use OCP\Security\IHasher;
use OCP\Security\ISecureRandom;
use OCP\Security\VerificationToken\IVerificationToken;
use OCP\Share\IShareHelper;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
@@ -795,6 +797,8 @@ class Server extends ServerContainer implements IServerContainer {
/** @deprecated 19.0.0 */
$this->registerDeprecatedAlias('SecureRandom', \OCP\Security\ISecureRandom::class);

$this->registerAlias(IVerificationToken::class, VerificationToken::class);

$this->registerAlias(ICrypto::class, Crypto::class);
/** @deprecated 19.0.0 */
$this->registerDeprecatedAlias('Crypto', ICrypto::class);

+ 1
- 1
lib/private/Setup.php View File

@@ -439,7 +439,7 @@ class Setup {

// Set email for admin
if (!empty($options['adminemail'])) {
$config->setUserValue($user->getUID(), 'settings', 'email', $options['adminemail']);
$user->setSystemEMailAddress($options['adminemail']);
}
}


+ 1
- 0
lib/private/User/Manager.php View File

@@ -700,6 +700,7 @@ class Manager extends PublicEmitter implements IUserManager {
* @since 9.1.0
*/
public function getByEmail($email) {
// looking for 'email' only (and not primary_mail) is intentional
$userIds = $this->config->getUsersForUserValueCaseInsensitive('settings', 'email', $email);

$users = array_map(function ($uid) {

+ 66
- 11
lib/private/User/User.php View File

@@ -34,10 +34,12 @@
*/
namespace OC\User;

use InvalidArgumentException;
use OC\Accounts\AccountManager;
use OC\Avatar\AvatarManager;
use OC\Hooks\Emitter;
use OC_Helper;
use OCP\Accounts\IAccountManager;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Group\Events\BeforeUserRemovedEvent;
use OCP\Group\Events\UserRemovedEvent;
@@ -55,6 +57,8 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;

class User implements IUser {
/** @var IAccountManager */
protected $accountManager;
/** @var string */
private $uid;

@@ -165,24 +169,61 @@ class User implements IUser {
}

/**
* set the email address of the user
*
* @param string|null $mailAddress
* @return void
* @since 9.0.0
* @inheritDoc
*/
public function setEMailAddress($mailAddress) {
$oldMailAddress = $this->getEMailAddress();
$this->setSystemEMailAddress($mailAddress);
}

/**
* @inheritDoc
*/
public function setSystemEMailAddress(string $mailAddress): void {
$oldMailAddress = $this->getSystemEMailAddress();

if ($mailAddress === '') {
$this->config->deleteUserValue($this->uid, 'settings', 'email');
} else {
$this->config->setUserValue($this->uid, 'settings', 'email', $mailAddress);
}

$primaryAddress = $this->getPrimaryEMailAddress();
if ($primaryAddress === $mailAddress) {
// on match no dedicated primary settings is necessary
$this->setPrimaryEMailAddress('');
}

if ($oldMailAddress !== $mailAddress) {
if ($mailAddress === '') {
$this->config->deleteUserValue($this->uid, 'settings', 'email');
} else {
$this->config->setUserValue($this->uid, 'settings', 'email', $mailAddress);
}
$this->triggerChange('eMailAddress', $mailAddress, $oldMailAddress);
}
}

/**
* @inheritDoc
*/
public function setPrimaryEMailAddress(string $mailAddress): void {
if ($mailAddress === '') {
$this->config->deleteUserValue($this->uid, 'settings', 'primary_email');
return;
}

$this->ensureAccountManager();
$account = $this->accountManager->getAccount($this);
$property = $account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)
->getPropertyByValue($mailAddress);

if ($property === null || $property->getLocallyVerified() !== IAccountManager::VERIFIED) {
throw new InvalidArgumentException('Only verified emails can be set as primary');
}
$this->config->setUserValue($this->uid, 'settings', 'primary_email', $mailAddress);
}

private function ensureAccountManager() {
if (!$this->accountManager instanceof IAccountManager) {
$this->accountManager = \OC::$server->get(IAccountManager::class);
}
}

/**
* returns the timestamp of the user's last login or 0 if the user did never
* login
@@ -390,9 +431,23 @@ class User implements IUser {
* @since 9.0.0
*/
public function getEMailAddress() {
return $this->getPrimaryEMailAddress() ?? $this->getSystemEMailAddress();
}

/**
* @inheritDoc
*/
public function getSystemEMailAddress(): ?string {
return $this->config->getUserValue($this->uid, 'settings', 'email', null);
}

/**
* @inheritDoc
*/
public function getPrimaryEMailAddress(): ?string {
return $this->config->getUserValue($this->uid, 'settings', 'primary_email', null);
}

/**
* get the users' quota
*

+ 20
- 0
lib/public/Accounts/IAccountProperty.php View File

@@ -115,4 +115,24 @@ interface IAccountProperty extends \JsonSerializable {
* @since 22.0.0
*/
public function getVerificationData(): string;

/**
* Set the instance-based verification status of a property
*
* @since 23.0.0
*
* @param string $verified must be one of the verification constants of IAccountManager
* @return IAccountProperty
* @throws InvalidArgumentException
*/
public function setLocallyVerified(string $verified): IAccountProperty;

/**
* Get the instance-based verification status of a property
*
* @since 23.0.0
*
* @return string
*/
public function getLocallyVerified(): string;
}

+ 9
- 0
lib/public/Accounts/IAccountPropertyCollection.php View File

@@ -89,4 +89,13 @@ interface IAccountPropertyCollection extends JsonSerializable {
* @since 22.0.0
*/
public function removePropertyByValue(string $value): IAccountPropertyCollection;

/**
* retrieves a property identified by its value. null, if none was found.
*
* Returns only the first property if there are more with the same value.
*
* @since 23.0.0
*/
public function getPropertyByValue(string $value): ?IAccountProperty;
}

+ 62
- 1
lib/public/IUser.php View File

@@ -27,6 +27,8 @@
*/
namespace OCP;

use InvalidArgumentException;

/**
* Interface IUser
*
@@ -157,13 +159,42 @@ interface IUser {
public function setEnabled(bool $enabled = true);

/**
* get the users email address
* get the user's email address
*
* @return string|null
* @since 9.0.0
*/
public function getEMailAddress();

/**
* get the user's system email address
*
* The system mail address may be read only and may be set from different
* sources like LDAP, SAML or simply the admin.
*
* Use this getter only when the system address is needed. For picking the
* proper address to e.g. send a mail to, use getEMailAddress().
*
* @return string|null
* @since 23.0.0
*/
public function getSystemEMailAddress(): ?string;

/**
* get the user's preferred email address
*
* The primary mail address may be set be the user to specify a different
* email address where mails by Nextcloud are sent to. It is not necessarily
* set.
*
* Use this getter only when the primary address is needed. For picking the
* proper address to e.g. send a mail to, use getEMailAddress().
*
* @return string|null
* @since 23.0.0
*/
public function getPrimaryEMailAddress(): ?string;

/**
* get the avatar image if it exists
*
@@ -184,12 +215,42 @@ interface IUser {
/**
* set the email address of the user
*
* It is an alias to setSystemEMailAddress()
*
* @param string|null $mailAddress
* @return void
* @since 9.0.0
* @deprecated 23.0.0 use setSystemEMailAddress() or setPrimaryEMailAddress()
*/
public function setEMailAddress($mailAddress);

/**
* Set the system email address of the user
*
* This is supposed to be used when the email is set from different sources
* (i.e. other user backends, admin).
*
* @since 23.0.0
*/
public function setSystemEMailAddress(string $mailAddress): void;

/**
* Set the primary email address of the user.
*
* This method should be typically called when the user is changing their
* own primary address and is not allowed to change their system email.
*
* The mail address provided here must be already registered as an
* additional mail in the user account and also be verified locally. Also
* an empty string is allowed to delete this preference.
*
* @throws InvalidArgumentException when the provided email address does not
* satisfy constraints.
*
* @since 23.0.0
*/
public function setPrimaryEMailAddress(string $mailAddress): void;

/**
* get the users' quota in human readable form. If a specific quota is not
* set for the user, the default value is returned. If a default setting

+ 2
- 0
lib/public/IUserManager.php View File

@@ -196,6 +196,8 @@ interface IUserManager {
public function callForSeenUsers(\Closure $callback);

/**
* returns all users having the provided email set as system email address
*
* @param string $email
* @return IUser[]
* @since 9.1.0

+ 1
- 1
lib/public/Mail/IEMailTemplate.php View File

@@ -130,7 +130,7 @@ interface IEMailTemplate {
*
* @param string $text Text of button; Note: When $plainText falls back to this, HTML is automatically escaped in the HTML email
* @param string $url URL of button
* @param string $plainText Text of button in plain text version
* @param string|false $plainText Text of button in plain text version
* if empty the $text is used, if false none will be used
*
* @since 12.0.0

+ 62
- 0
lib/public/Security/VerificationToken/IVerificationToken.php View File

@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2021 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace OCP\Security\VerificationToken;

use OCP\IUser;

/**
* @since 23.0.0
*/
interface IVerificationToken {

/**
* Checks whether the a provided tokent matches a stored token and its
* constraints. An InvalidTokenException is thrown on issues, otherwise
* the check is successful.
*
* null can be passed as $user, but mind that this is for conveniently
* passing the return of IUserManager::getUser() to this method. When
* $user is null, InvalidTokenException is thrown for all the issued
* tokens are user related.
*
* @throws InvalidTokenException
* @since 23.0.0
*/
public function check(string $token, ?IUser $user, string $subject, string $passwordPrefix = '', bool $expiresWithLogin = false): void;

/**
* @since 23.0.0
*/
public function create(IUser $user, string $subject, string $passwordPrefix = ''): string;

/**
* Deletes the token identified by the provided parameters
*
* @since 23.0.0
*/
public function delete(string $token, IUser $user, string $subject): void;
}

+ 74
- 0
lib/public/Security/VerificationToken/InvalidTokenException.php View File

@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2021 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace OCP\Security\VerificationToken;

/** @since 23.0.0 */
class InvalidTokenException extends \Exception {

/**
* @since 23.0.0
*/
public function __construct(int $code) {
parent::__construct('', $code);
}

/**
* @var int
* @since 23.0.0
*/
public const USER_UNKNOWN = 1;

/**
* @var int
* @since 23.0.0
*/
public const TOKEN_NOT_FOUND = 2;

/**
* @var int
* @since 23.0.0
*/
public const TOKEN_DECRYPTION_ERROR = 3;

/**
* @var int
* @since 23.0.0
*/
public const TOKEN_INVALID_FORMAT = 4;

/**
* @var int
* @since 23.0.0
*/
public const TOKEN_EXPIRED = 5;

/**
* @var int
* @since 23.0.0
*/
public const TOKEN_MISMATCH = 6;
}

+ 43
- 258
tests/Core/Controller/LostControllerTest.php View File

@@ -26,7 +26,6 @@ use OC\Core\Controller\LostController;
use OC\Mail\Message;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Defaults;
use OCP\Encryption\IEncryptionModule;
use OCP\Encryption\IManager;
@@ -40,8 +39,8 @@ use OCP\IUser;
use OCP\IUserManager;
use OCP\Mail\IEMailTemplate;
use OCP\Mail\IMailer;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\Security\VerificationToken\InvalidTokenException;
use OCP\Security\VerificationToken\IVerificationToken;

/**
* Class LostControllerTest
@@ -66,22 +65,18 @@ class LostControllerTest extends \Test\TestCase {
private $config;
/** @var IMailer | \PHPUnit\Framework\MockObject\MockObject */
private $mailer;
/** @var ISecureRandom | \PHPUnit\Framework\MockObject\MockObject */
private $secureRandom;
/** @var IManager|\PHPUnit\Framework\MockObject\MockObject */
private $encryptionManager;
/** @var ITimeFactory | \PHPUnit\Framework\MockObject\MockObject */
private $timeFactory;
/** @var IRequest|\PHPUnit\Framework\MockObject\MockObject */
private $request;
/** @var ICrypto|\PHPUnit\Framework\MockObject\MockObject */
private $crypto;
/** @var ILogger|\PHPUnit\Framework\MockObject\MockObject */
private $logger;
/** @var Manager|\PHPUnit\Framework\MockObject\MockObject */
private $twofactorManager;
/** @var IInitialStateService|\PHPUnit\Framework\MockObject\MockObject */
private $initialStateService;
/** @var IVerificationToken|\PHPUnit\Framework\MockObject\MockObject */
private $verificationToken;

protected function setUp(): void {
parent::setUp();
@@ -123,10 +118,6 @@ class LostControllerTest extends \Test\TestCase {
->disableOriginalConstructor()->getMock();
$this->mailer = $this->getMockBuilder('\OCP\Mail\IMailer')
->disableOriginalConstructor()->getMock();
$this->secureRandom = $this->getMockBuilder('\OCP\Security\ISecureRandom')
->disableOriginalConstructor()->getMock();
$this->timeFactory = $this->getMockBuilder('\OCP\AppFramework\Utility\ITimeFactory')
->disableOriginalConstructor()->getMock();
$this->request = $this->getMockBuilder(IRequest::class)
->disableOriginalConstructor()->getMock();
$this->encryptionManager = $this->getMockBuilder(IManager::class)
@@ -134,10 +125,10 @@ class LostControllerTest extends \Test\TestCase {
$this->encryptionManager->expects($this->any())
->method('isEnabled')
->willReturn(true);
$this->crypto = $this->createMock(ICrypto::class);
$this->logger = $this->createMock(ILogger::class);
$this->twofactorManager = $this->createMock(Manager::class);
$this->initialStateService = $this->createMock(IInitialStateService::class);
$this->verificationToken = $this->createMock(IVerificationToken::class);
$this->lostController = new LostController(
'Core',
$this->request,
@@ -146,89 +137,31 @@ class LostControllerTest extends \Test\TestCase {
$this->defaults,
$this->l10n,
$this->config,
$this->secureRandom,
'lostpassword-noreply@localhost',
$this->encryptionManager,
$this->mailer,
$this->timeFactory,
$this->crypto,
$this->logger,
$this->twofactorManager,
$this->initialStateService
$this->initialStateService,
$this->verificationToken
);
}

public function testResetFormWithNotExistingUser() {
$this->userManager->method('get')
->with('NotExistingUser')
->willReturn(null);

$expectedResponse = new TemplateResponse(
'core',
'error',
[
'errors' => [
['error' => 'Couldn\'t reset password because the token is invalid'],
]
],
'guest'
);
$this->assertEquals($expectedResponse, $this->lostController->resetform('MySecretToken', 'NotExistingUser'));
}

public function testResetFormInvalidTokenMatch() {
$this->config->method('getUserValue')
->with('ValidTokenUser', 'core', 'lostpassword', null)
->willReturn('encryptedToken');
$this->existingUser->method('getLastLogin')
->willReturn(12344);
public function testResetFormTokenError() {
$this->userManager->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);
$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedToken'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');
$this->verificationToken->expects($this->once())
->method('check')
->with('12345:MySecretToken', $this->existingUser, 'lostpassword')
->willThrowException(new InvalidTokenException(InvalidTokenException::TOKEN_DECRYPTION_ERROR));

$response = $this->lostController->resetform('12345:MySecretToken', 'ValidTokenUser');
$expectedResponse = new TemplateResponse('core',
'error',
[
'errors' => [
['error' => 'Couldn\'t reset password because the token is invalid'],
]
],
'guest');
$this->assertEquals($expectedResponse, $response);
}


public function testResetFormExpiredToken() {
$this->userManager->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);
$this->config
->expects($this->once())
->method('getUserValue')
->with('ValidTokenUser', 'core', 'lostpassword', null)
->willReturn('encryptedToken');
$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedToken'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');
$this->timeFactory
->expects($this->once())
->method('getTime')
->willReturn(999999);

$response = $this->lostController->resetform('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser');
$expectedResponse = new TemplateResponse('core',
'error',
[
'errors' => [
['error' => 'Couldn\'t reset password because the token is expired'],
['error' => 'Could not reset password because the token is invalid'],
]
],
'guest');
@@ -236,39 +169,14 @@ class LostControllerTest extends \Test\TestCase {
}

public function testResetFormValidToken() {
$this->existingUser->method('getLastLogin')
->willReturn(12344);
$this->userManager->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);
$this->timeFactory
->expects($this->once())
->method('getTime')
->willReturn(12348);
$this->verificationToken->expects($this->once())
->method('check')
->with('MySecretToken', $this->existingUser, 'lostpassword');

$this->config->method('getUserValue')
->with('ValidTokenUser', 'core', 'lostpassword', null)
->willReturn('encryptedToken');

$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedToken'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');
$this->urlGenerator
->expects($this->once())
->method('linkToRouteAbsolute')
->with('core.lost.setPassword', ['userId' => 'ValidTokenUser', 'token' => 'TheOnlyAndOnlyOneTokenToResetThePassword'])
->willReturn('https://example.tld/index.php/lostpassword/');

$this->initialStateService->expects($this->at(0))
->method('provideInitialState')
->with('core', 'resetPasswordUser', 'ValidTokenUser');
$this->initialStateService->expects($this->at(1))
->method('provideInitialState')
->with('core', 'resetPasswordTarget', 'https://example.tld/index.php/lostpassword/');

$response = $this->lostController->resetform('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser');
$response = $this->lostController->resetform('MySecretToken', 'ValidTokenUser');
$expectedResponse = new TemplateResponse('core',
'login',
[],
@@ -319,24 +227,14 @@ class LostControllerTest extends \Test\TestCase {
}

public function testEmailSuccessful() {
$this->secureRandom
->expects($this->once())
->method('generate')
->with('21')
->willReturn('ThisIsMaybeANotSoSecretToken!');
$this->userManager
->expects($this->any())
->method('get')
->with('ExistingUser')
->willReturn($this->existingUser);
$this->timeFactory
->expects($this->once())
->method('getTime')
->willReturn(12348);
$this->config
->expects($this->once())
->method('setUserValue')
->with('ExistingUser', 'core', 'lostpassword', 'encryptedToken');
$this->verificationToken->expects($this->once())
->method('create')
->willReturn('ThisIsMaybeANotSoSecretToken!');
$this->urlGenerator
->expects($this->once())
->method('linkToRouteAbsolute')
@@ -379,12 +277,6 @@ class LostControllerTest extends \Test\TestCase {
->method('send')
->with($message);

$this->crypto->method('encrypt')
->with(
$this->equalTo('12348:ThisIsMaybeANotSoSecretToken!'),
$this->equalTo('test@example.comSECRET')
)->willReturn('encryptedToken');

$response = $this->lostController->email('ExistingUser');
$expectedResponse = new JSONResponse(['status' => 'success']);
$expectedResponse->throttle();
@@ -392,11 +284,6 @@ class LostControllerTest extends \Test\TestCase {
}

public function testEmailWithMailSuccessful() {
$this->secureRandom
->expects($this->once())
->method('generate')
->with('21')
->willReturn('ThisIsMaybeANotSoSecretToken!');
$this->userManager
->expects($this->any())
->method('get')
@@ -407,14 +294,9 @@ class LostControllerTest extends \Test\TestCase {
->method('getByEmail')
->with('test@example.com')
->willReturn([$this->existingUser]);
$this->timeFactory
->expects($this->once())
->method('getTime')
->willReturn(12348);
$this->config
->expects($this->once())
->method('setUserValue')
->with('ExistingUser', 'core', 'lostpassword', 'encryptedToken');
$this->verificationToken->expects($this->once())
->method('create')
->willReturn('ThisIsMaybeANotSoSecretToken!');
$this->urlGenerator
->expects($this->once())
->method('linkToRouteAbsolute')
@@ -457,12 +339,6 @@ class LostControllerTest extends \Test\TestCase {
->method('send')
->with($message);

$this->crypto->method('encrypt')
->with(
$this->equalTo('12348:ThisIsMaybeANotSoSecretToken!'),
$this->equalTo('test@example.comSECRET')
)->willReturn('encryptedToken');

$response = $this->lostController->email('test@example.com');
$expectedResponse = new JSONResponse(['status' => 'success']);
$expectedResponse->throttle();
@@ -470,24 +346,14 @@ class LostControllerTest extends \Test\TestCase {
}

public function testEmailCantSendException() {
$this->secureRandom
->expects($this->once())
->method('generate')
->with('21')
->willReturn('ThisIsMaybeANotSoSecretToken!');
$this->userManager
->expects($this->any())
->method('get')
->with('ExistingUser')
->willReturn($this->existingUser);
$this->config
->expects($this->once())
->method('setUserValue')
->with('ExistingUser', 'core', 'lostpassword', 'encryptedToken');
$this->timeFactory
->expects($this->once())
->method('getTime')
->willReturn(12348);
$this->verificationToken->expects($this->once())
->method('create')
->willReturn('ThisIsMaybeANotSoSecretToken!');
$this->urlGenerator
->expects($this->once())
->method('linkToRouteAbsolute')
@@ -530,12 +396,6 @@ class LostControllerTest extends \Test\TestCase {
->with($message)
->will($this->throwException(new \Exception()));

$this->crypto->method('encrypt')
->with(
$this->equalTo('12348:ThisIsMaybeANotSoSecretToken!'),
$this->equalTo('test@example.comSECRET')
)->willReturn('encryptedToken');

$this->logger->expects($this->exactly(1))
->method('logException');

@@ -560,14 +420,6 @@ class LostControllerTest extends \Test\TestCase {
->willReturn($this->existingUser);
$this->config->expects($this->never())
->method('deleteUserValue');
$this->timeFactory->method('getTime')
->willReturn(12348);

$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedData'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');

$response = $this->lostController->setPassword('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser', 'NewPassword', true);
$expectedResponse = ['status' => 'error', 'msg' => ''];
@@ -590,14 +442,6 @@ class LostControllerTest extends \Test\TestCase {
$this->config->expects($this->once())
->method('deleteUserValue')
->with('ValidTokenUser', 'core', 'lostpassword');
$this->timeFactory->method('getTime')
->willReturn(12348);

$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedData'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');

$response = $this->lostController->setPassword('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser', 'NewPassword', true);
$expectedResponse = ['user' => 'ValidTokenUser', 'status' => 'success'];
@@ -611,19 +455,14 @@ class LostControllerTest extends \Test\TestCase {
$this->userManager->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);
$this->timeFactory->method('getTime')
->willReturn(617146);

$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedData'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');
$this->verificationToken->expects($this->atLeastOnce())
->method('check')
->willThrowException(new InvalidTokenException(InvalidTokenException::TOKEN_EXPIRED));

$response = $this->lostController->setPassword('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser', 'NewPassword', true);
$expectedResponse = [
'status' => 'error',
'msg' => 'Couldn\'t reset password because the token is expired',
'msg' => 'Could not reset password because the token is expired',
];
$this->assertSame($expectedResponse, $response);
}
@@ -636,45 +475,14 @@ class LostControllerTest extends \Test\TestCase {
->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);

$this->crypto->method('decrypt')
->with(
$this->equalTo('invalidEncryptedData'),
$this->equalTo('test@example.comSECRET')
)->willReturn('TheOnlyAndOnlyOneTokenToResetThePassword');
$this->verificationToken->expects($this->atLeastOnce())
->method('check')
->willThrowException(new InvalidTokenException(InvalidTokenException::TOKEN_INVALID_FORMAT));

$response = $this->lostController->setPassword('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser', 'NewPassword', true);
$expectedResponse = [
'status' => 'error',
'msg' => 'Couldn\'t reset password because the token is invalid',
];
$this->assertSame($expectedResponse, $response);
}

public function testSetPasswordExpiredTokenDueToLogin() {
$this->config->method('getUserValue')
->with('ValidTokenUser', 'core', 'lostpassword', null)
->willReturn('encryptedData');
$this->existingUser->method('getLastLogin')
->willReturn(12346);
$this->userManager
->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);
$this->timeFactory
->method('getTime')
->willReturn(12345);

$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedData'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');

$response = $this->lostController->setPassword('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser', 'NewPassword', true);
$expectedResponse = [
'status' => 'error',
'msg' => 'Couldn\'t reset password because the token is expired',
'msg' => 'Could not reset password because the token is invalid',
];
$this->assertSame($expectedResponse, $response);
}
@@ -686,33 +494,14 @@ class LostControllerTest extends \Test\TestCase {
$this->userManager->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);

$this->crypto->method('decrypt')
->with(
$this->equalTo('aValidtoken'),
$this->equalTo('test@example.comSECRET')
)->willThrowException(new \Exception());

$response = $this->lostController->setPassword('', 'ValidTokenUser', 'NewPassword', true);
$expectedResponse = [
'status' => 'error',
'msg' => 'Couldn\'t reset password because the token is invalid'
];
$this->assertSame($expectedResponse, $response);
}

public function testIsSetPasswordTokenNullFailing() {
$this->config->method('getUserValue')
->with('ValidTokenUser', 'core', 'lostpassword', null)
->willReturn(null);
$this->userManager->method('get')
->with('ValidTokenUser')
->willReturn($this->existingUser);
$this->verificationToken->expects($this->atLeastOnce())
->method('check')
->willThrowException(new InvalidTokenException(InvalidTokenException::TOKEN_MISMATCH));

$response = $this->lostController->setPassword('', 'ValidTokenUser', 'NewPassword', true);
$expectedResponse = [
'status' => 'error',
'msg' => 'Couldn\'t reset password because the token is invalid'
'msg' => 'Could not reset password because the token is invalid'
];
$this->assertSame($expectedResponse, $response);
}
@@ -732,10 +521,14 @@ class LostControllerTest extends \Test\TestCase {
->with('DisabledUser')
->willReturn($user);

$this->verificationToken->expects($this->atLeastOnce())
->method('check')
->willThrowException(new InvalidTokenException(InvalidTokenException::USER_UNKNOWN));

$response = $this->lostController->setPassword('TheOnlyAndOnlyOneTokenToResetThePassword', 'DisabledUser', 'NewPassword', true);
$expectedResponse = [
'status' => 'error',
'msg' => 'Couldn\'t reset password because the token is invalid'
'msg' => 'Could not reset password because the token is invalid'
];
$this->assertSame($expectedResponse, $response);
}
@@ -798,14 +591,6 @@ class LostControllerTest extends \Test\TestCase {
$this->config->expects($this->once())
->method('deleteUserValue')
->with('ValidTokenUser', 'core', 'lostpassword');
$this->timeFactory->method('getTime')
->willReturn(12348);

$this->crypto->method('decrypt')
->with(
$this->equalTo('encryptedData'),
$this->equalTo('test@example.comSECRET')
)->willReturn('12345:TheOnlyAndOnlyOneTokenToResetThePassword');

$response = $this->lostController->setPassword('TheOnlyAndOnlyOneTokenToResetThePassword', 'ValidTokenUser', 'NewPassword', false);
$expectedResponse = ['user' => 'ValidTokenUser', 'status' => 'success'];

+ 36
- 0
tests/lib/Accounts/AccountManagerTest.php View File

@@ -25,9 +25,15 @@ use OC\Accounts\Account;
use OC\Accounts\AccountManager;
use OCP\Accounts\IAccountManager;
use OCP\BackgroundJob\IJobList;
use OCP\Defaults;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\Security\ICrypto;
use OCP\Security\VerificationToken\IVerificationToken;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -41,6 +47,18 @@ use Test\TestCase;
* @package Test\Accounts
*/
class AccountManagerTest extends TestCase {
/** @var IVerificationToken|MockObject */
protected $verificationToken;
/** @var IMailer|MockObject */
protected $mailer;
/** @var ICrypto|MockObject */
protected $crypto;
/** @var IURLGenerator|MockObject */
protected $urlGenerator;
/** @var Defaults|MockObject */
protected $defaults;
/** @var IFactory|MockObject */
protected $l10nFactory;

/** @var \OCP\IDBConnection */
private $connection;
@@ -70,6 +88,12 @@ class AccountManagerTest extends TestCase {
$this->config = $this->createMock(IConfig::class);
$this->jobList = $this->createMock(IJobList::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->verificationToken = $this->createMock(IVerificationToken::class);
$this->mailer = $this->createMock(IMailer::class);
$this->defaults = $this->createMock(Defaults::class);
$this->l10nFactory = $this->createMock(IFactory::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->crypto = $this->createMock(ICrypto::class);

$this->accountManager = new AccountManager(
$this->connection,
@@ -77,6 +101,12 @@ class AccountManagerTest extends TestCase {
$this->eventDispatcher,
$this->jobList,
$this->logger,
$this->verificationToken,
$this->mailer,
$this->defaults,
$this->l10nFactory,
$this->urlGenerator,
$this->crypto
);
}

@@ -310,6 +340,12 @@ class AccountManagerTest extends TestCase {
$this->eventDispatcher,
$this->jobList,
$this->logger,
$this->verificationToken,
$this->mailer,
$this->defaults,
$this->l10nFactory,
$this->urlGenerator,
$this->crypto
])
->setMethods($mockedMethods)
->getMock();

+ 21
- 1
tests/lib/AllConfigTest.php View File

@@ -15,6 +15,8 @@ namespace Test;
*
* @package Test
*/

use OC\SystemConfig;
use OCP\IDBConnection;

class AllConfigTest extends \Test\TestCase {
@@ -145,7 +147,7 @@ class AllConfigTest extends \Test\TestCase {
$config->setUserValue('userSetBool', 'appSetBool', 'keySetBool', $value);
}

public function testSetUserValueWithPreConditionFailure() {
$this->expectException(\OCP\PreConditionNotMetException::class);

@@ -437,4 +439,22 @@ class AllConfigTest extends \Test\TestCase {
// cleanup
$this->connection->executeUpdate('DELETE FROM `*PREFIX*preferences`');
}

public function testGetUsersForUserValueCaseInsensitive() {
// mock the check for the database to run the correct SQL statements for each database type
$systemConfig = $this->createMock(SystemConfig::class);
$systemConfig->expects($this->once())
->method('getValue')
->with($this->equalTo('dbtype'), $this->equalTo('sqlite'))
->willReturn(\OC::$server->getConfig()->getSystemValue('dbtype', 'sqlite'));
$config = $this->getConfig($systemConfig);

$config->setUserValue('user1', 'myApp', 'myKey', 'test123');
$config->setUserValue('user2', 'myApp', 'myKey', 'TEST123');
$config->setUserValue('user3', 'myApp', 'myKey', 'test12345');

$users = $config->getUsersForUserValueCaseInsensitive('myApp', 'myKey', 'test123');
$this->assertSame(2, count($users));
$this->assertSame(['user1', 'user2'], $users);
}
}

+ 309
- 0
tests/lib/Security/VerificationToken/VerificationTokenTest.php View File

@@ -0,0 +1,309 @@
<?php

declare(strict_types=1);

/**
* @copyright Copyright (c) 2021 Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

namespace Test\Security\VerificationToken;

use OC\Security\VerificationToken\VerificationToken;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\IConfig;
use OCP\IUser;
use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
use OCP\Security\VerificationToken\InvalidTokenException;
use Test\TestCase;

class VerificationTokenTest extends TestCase {
/** @var VerificationToken */
protected $token;
/** @var IConfig|\PHPUnit\Framework\MockObject\MockObject */
protected $config;
/** @var ISecureRandom|\PHPUnit\Framework\MockObject\MockObject */
protected $secureRandom;
/** @var ICrypto|\PHPUnit\Framework\MockObject\MockObject */
protected $crypto;
/** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */
protected $timeFactory;

protected function setUp(): void {
parent::setUp();

$this->config = $this->createMock(IConfig::class);
$this->crypto = $this->createMock(ICrypto::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->secureRandom = $this->createMock(ISecureRandom::class);
$this->jobList = $this->createMock(IJobList::class);

$this->token = new VerificationToken(
$this->config,
$this->crypto,
$this->timeFactory,
$this->secureRandom,
$this->jobList
);
}

public function testTokenUserUnknown() {
$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::USER_UNKNOWN);
$this->token->check('encryptedToken', null, 'fingerprintToken', 'foobar');
}

public function testTokenUserUnknown2() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(false);

$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::USER_UNKNOWN);
$this->token->check('encryptedToken', $user, 'fingerprintToken', 'foobar');
}

public function testTokenNotFound() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(true);
$user->expects($this->atLeastOnce())
->method('getUID')
->willReturn('alice');

// implicit: IConfig::getUserValue returns null by default

$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::TOKEN_NOT_FOUND);
$this->token->check('encryptedToken', $user, 'fingerprintToken', 'foobar');
}

public function testTokenDecryptionError() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(true);
$user->expects($this->atLeastOnce())
->method('getUID')
->willReturn('alice');

$this->config->expects($this->atLeastOnce())
->method('getUserValue')
->with('alice', 'core', 'fingerprintToken', null)
->willReturn('encryptedToken');
$this->config->expects($this->any())
->method('getSystemValue')
->with('secret')
->willReturn('357111317');

$this->crypto->method('decrypt')
->with('encryptedToken', 'foobar' . '357111317')
->willThrowException(new \Exception('decryption failed'));

$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::TOKEN_DECRYPTION_ERROR);
$this->token->check('encryptedToken', $user, 'fingerprintToken', 'foobar');
}

public function testTokenInvalidFormat() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(true);
$user->expects($this->atLeastOnce())
->method('getUID')
->willReturn('alice');

$this->config->expects($this->atLeastOnce())
->method('getUserValue')
->with('alice', 'core', 'fingerprintToken', null)
->willReturn('encryptedToken');
$this->config->expects($this->any())
->method('getSystemValue')
->with('secret')
->willReturn('357111317');

$this->crypto->method('decrypt')
->with('encryptedToken', 'foobar' . '357111317')
->willReturn('decrypted^nonsense');

$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::TOKEN_INVALID_FORMAT);
$this->token->check('encryptedToken', $user, 'fingerprintToken', 'foobar');
}

public function testTokenExpired() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(true);
$user->expects($this->atLeastOnce())
->method('getUID')
->willReturn('alice');
$user->expects($this->any())
->method('getLastLogin')
->willReturn(604803);

$this->config->expects($this->atLeastOnce())
->method('getUserValue')
->with('alice', 'core', 'fingerprintToken', null)
->willReturn('encryptedToken');
$this->config->expects($this->any())
->method('getSystemValue')
->with('secret')
->willReturn('357111317');

$this->crypto->method('decrypt')
->with('encryptedToken', 'foobar' . '357111317')
->willReturn('604800:mY70K3n');

$this->timeFactory->expects($this->any())
->method('getTime')
->willReturn(604800 * 3);

$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::TOKEN_EXPIRED);
$this->token->check('encryptedToken', $user, 'fingerprintToken', 'foobar');
}

public function testTokenExpiredByLogin() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(true);
$user->expects($this->atLeastOnce())
->method('getUID')
->willReturn('alice');
$user->expects($this->any())
->method('getLastLogin')
->willReturn(604803);

$this->config->expects($this->atLeastOnce())
->method('getUserValue')
->with('alice', 'core', 'fingerprintToken', null)
->willReturn('encryptedToken');
$this->config->expects($this->any())
->method('getSystemValue')
->with('secret')
->willReturn('357111317');

$this->crypto->method('decrypt')
->with('encryptedToken', 'foobar' . '357111317')
->willReturn('604800:mY70K3n');

$this->timeFactory->expects($this->any())
->method('getTime')
->willReturn(604801);

$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::TOKEN_EXPIRED);
$this->token->check('encryptedToken', $user, 'fingerprintToken', 'foobar', true);
}

public function testTokenMismatch() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(true);
$user->expects($this->atLeastOnce())
->method('getUID')
->willReturn('alice');
$user->expects($this->any())
->method('getLastLogin')
->willReturn(604703);

$this->config->expects($this->atLeastOnce())
->method('getUserValue')
->with('alice', 'core', 'fingerprintToken', null)
->willReturn('encryptedToken');
$this->config->expects($this->any())
->method('getSystemValue')
->with('secret')
->willReturn('357111317');

$this->crypto->method('decrypt')
->with('encryptedToken', 'foobar' . '357111317')
->willReturn('604802:mY70K3n');

$this->timeFactory->expects($this->any())
->method('getTime')
->willReturn(604801);

$this->expectException(InvalidTokenException::class);
$this->expectExceptionCode(InvalidTokenException::TOKEN_MISMATCH);
$this->token->check('encryptedToken', $user, 'fingerprintToken', 'foobar');
}

public function testTokenSuccess() {
$user = $this->createMock(IUser::class);
$user->expects($this->atLeastOnce())
->method('isEnabled')
->willReturn(true);
$user->expects($this->atLeastOnce())
->method('getUID')
->willReturn('alice');
$user->expects($this->any())
->method('getLastLogin')
->willReturn(604703);

$this->config->expects($this->atLeastOnce())
->method('getUserValue')
->with('alice', 'core', 'fingerprintToken', null)
->willReturn('encryptedToken');
$this->config->expects($this->any())
->method('getSystemValue')
->with('secret')
->willReturn('357111317');

$this->crypto->method('decrypt')
->with('encryptedToken', 'foobar' . '357111317')
->willReturn('604802:barfoo');

$this->timeFactory->expects($this->any())
->method('getTime')
->willReturn(604801);

$this->token->check('barfoo', $user, 'fingerprintToken', 'foobar');
}

public function testCreate() {
$user = $this->createMock(IUser::class);
$user->expects($this->any())
->method('getUID')
->willReturn('alice');

$this->secureRandom->expects($this->atLeastOnce())
->method('generate')
->willReturn('barfoo');
$this->crypto->expects($this->atLeastOnce())
->method('encrypt')
->willReturn('encryptedToken');
$this->config->expects($this->atLeastOnce())
->method('setUserValue')
->with('alice', 'core', 'fingerprintToken', 'encryptedToken');

$vToken = $this->token->create($user, 'fingerprintToken', 'foobar');
$this->assertSame('barfoo', $vToken);
}
}

+ 4
- 1
tests/lib/User/UserTest.php View File

@@ -676,11 +676,14 @@ class UserTest extends TestCase {
$emitter->expects($this->never())
->method('emit');

$this->dispatcher->expects($this->never())
->method('dispatch');

$config = $this->createMock(IConfig::class);
$config->expects($this->any())
->method('getUserValue')
->willReturn('foo@bar.com');
$config->expects($this->never())
$config->expects($this->any())
->method('setUserValue');

$user = new User('foo', $backend, $this->dispatcher, $emitter, $config);

Loading…
Cancel
Save