diff --git a/api/src/DTO/ResetPassword.php b/api/src/DTO/ResetPassword.php index 3445e30fad..7bd261031a 100644 --- a/api/src/DTO/ResetPassword.php +++ b/api/src/DTO/ResetPassword.php @@ -51,6 +51,7 @@ class ResetPassword { public ?string $id = null; #[InputFilter\Trim] + #[InputFilter\Lowercase] #[ApiProperty(readable: true, writable: true)] #[Groups(['create', 'read'])] public ?string $email = null; diff --git a/api/src/Entity/CampCollaboration.php b/api/src/Entity/CampCollaboration.php index d26edbe1fb..72f6a11515 100644 --- a/api/src/Entity/CampCollaboration.php +++ b/api/src/Entity/CampCollaboration.php @@ -131,6 +131,7 @@ class CampCollaboration extends BaseEntity implements BelongsToCampInterface { * a user account. Either this field or the user field should be null. */ #[InputFilter\Trim] + #[InputFilter\Lowercase] #[Assert\Email] #[Assert\Length(min: 1, max: 128)] #[AssertEitherIsNull(other: 'user')] diff --git a/api/src/Entity/Profile.php b/api/src/Entity/Profile.php index 536f67adba..52ccf5b924 100644 --- a/api/src/Entity/Profile.php +++ b/api/src/Entity/Profile.php @@ -53,6 +53,7 @@ class Profile extends BaseEntity { * Can only be changed by setting the newEmail field, which triggers an email verification flow. */ #[InputFilter\Trim] + #[InputFilter\Lowercase] #[Assert\NotBlank] #[Assert\Email] #[ApiProperty(example: self::EXAMPLE_EMAIL)] @@ -65,6 +66,7 @@ class Profile extends BaseEntity { * If set, a verification email is sent to this email address. */ #[InputFilter\Trim] + #[InputFilter\Lowercase] #[Assert\Email] #[ApiProperty(example: self::EXAMPLE_EMAIL)] #[Groups(['write'])] diff --git a/api/src/InputFilter/Lowercase.php b/api/src/InputFilter/Lowercase.php new file mode 100644 index 0000000000..ebb402ac91 --- /dev/null +++ b/api/src/InputFilter/Lowercase.php @@ -0,0 +1,13 @@ +findOneBy(['email' => $email]); + $profiles = $profileRepository->findBy(['email' => strtolower($email)]); + $profile = count($profiles) > 0 ? $profiles[0] : null; $user = $profile?->user; if (is_null($profile)) { diff --git a/api/src/Security/OAuth/HitobitoAuthenticator.php b/api/src/Security/OAuth/HitobitoAuthenticator.php index 7b814e570e..85a350404b 100644 --- a/api/src/Security/OAuth/HitobitoAuthenticator.php +++ b/api/src/Security/OAuth/HitobitoAuthenticator.php @@ -63,7 +63,8 @@ public function authenticate(Request $request): Passport { } // do we have a matching user by email? - $profile = $profileRepository->findOneBy(['email' => $email]); + $profiles = $profileRepository->findBy(['email' => strtolower($email)]); + $profile = count($profiles) > 0 ? $profiles[0] : null; $user = $profile?->user; if (is_null($profile)) { diff --git a/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php b/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php index 527af5f829..e37432115e 100644 --- a/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php +++ b/api/tests/Api/CampCollaborations/CreateCampCollaborationTest.php @@ -257,6 +257,29 @@ public function testCreateCampCollaborationTrimsInviteEmail() { ])); } + public function testCreateCampCollaborationLowercasesInviteEmail(): void { + static::createClientWithCredentials()->request( + 'POST', + '/camp_collaborations', + [ + 'json' => $this->getExampleWritePayload( + [ + 'inviteEmail' => 'sOmeonE@example.COM', + ], + ['user'] + ), + ] + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains($this->getExampleReadPayload([ + 'inviteEmail' => 'someone@example.com', + '_links' => [ + 'user' => null, + ], + ])); + } + public function testCreateCampCollaborationWithInviteEmailInsteadOfUserIsPossible() { static::createClientWithCredentials()->request('POST', '/camp_collaborations', ['json' => $this->getExampleWritePayload([ 'inviteEmail' => 'someone@example.com', diff --git a/api/tests/Api/ResetPassword/ResetPasswordTest.php b/api/tests/Api/ResetPassword/ResetPasswordTest.php index 5e5d087a25..882fc7ea80 100644 --- a/api/tests/Api/ResetPassword/ResetPasswordTest.php +++ b/api/tests/Api/ResetPassword/ResetPasswordTest.php @@ -107,6 +107,24 @@ public function testPostResetPasswordTrimsEmail() { self::assertEmailCount(1); } + public function testPostResetPasswordLowercasesEmail() { + /** @var User $user */ + $user = static::getFixture('user1manager'); + + $this->createBasicClient()->request( + 'POST', + '/auth/reset_password', + [ + 'json' => [ + 'email' => strtoupper("{$user->getEmail()}"), + ], + ] + ); + + $this->assertResponseStatusCodeSame(204); + self::assertEmailCount(1); + } + public function testPostResetPasswordReturns204ForUnknownEmailButSendsNoEmails() { $this->createBasicClient()->request( 'POST', diff --git a/api/tests/Api/Users/CreateUserTest.php b/api/tests/Api/Users/CreateUserTest.php index bc22473620..7b422b4ccd 100644 --- a/api/tests/Api/Users/CreateUserTest.php +++ b/api/tests/Api/Users/CreateUserTest.php @@ -208,6 +208,34 @@ public function testCreateUserTrimsEmail() { )); } + public function testCreateUserLowercaseEmail(): void { + static::createBasicClient()->request( + 'POST', + '/users', + [ + 'json' => $this->getExampleWritePayload( + mergeEmbeddedAttributes: [ + 'profile' => [ + 'email' => 'Bi-pi@example.COM', + ], + ] + ), + ] + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains($this->getExampleReadPayload( + [ + '_embedded' => [ + 'profile' => [ + 'email' => 'bi-pi@example.com', + ], + ], + ], + ['password'] + )); + } + public function testCreateUserValidatesMissingEmail() { // use this easy way here, because unsetting a nested attribute would be complicated $exampleWritePayload = $this->getExampleWritePayload(); diff --git a/api/tests/InputFilter/LowercaseFilterTest.php b/api/tests/InputFilter/LowercaseFilterTest.php new file mode 100644 index 0000000000..14554f4eda --- /dev/null +++ b/api/tests/InputFilter/LowercaseFilterTest.php @@ -0,0 +1,77 @@ + $input]; + $outputData = ['key' => $output]; + $trim = new LowercaseFilter(); + + // when + $result = $trim->applyTo($data, 'key'); + + // then + $this->assertEquals($outputData, $result); + } + + public static function getExamples(): array { + return [ + ['', ''], + ['abc', 'abc'], + ['AbC', 'abc'], + ['This Is A Test', 'this is a test'], + ['TeSt123', 'test123'], + ['Test@Example.com', 'test@example.com'], + ['JohnDoe@GMail.COM', 'johndoe@gmail.com'], + ['USER-NAME+TAG@EXAMPLE.NET', 'user-name+tag@example.net'], + ['info@sub.domain.COM', 'info@sub.domain.com'], + ]; + } + + public function testDoesNothingWhenKeyIsMissing(): void { + // given + $data = ['otherkey' => 'something']; + $lowercase = new LowercaseFilter(); + + // when + $result = $lowercase->applyTo($data, 'key'); + + // then + $this->assertEquals($data, $result); + } + + public function testDoesNothingWhenValueIsNull(): void { + // given + $data = ['key' => null]; + $trim = new LowercaseFilter(); + + // when + $result = $trim->applyTo($data, 'key'); + + // then + $this->assertEquals($data, $result); + } + + public function testThrowsWhenValueIsNotStringable(): void { + // given + $data = ['key' => new \stdClass()]; + $trim = new LowercaseFilter(); + + // then + $this->expectException(UnexpectedValueException::class); + + // when + $trim->applyTo($data, 'key'); + } +}