From 8639e5b3e23959da80b37c7fb00b9d14942be78a Mon Sep 17 00:00:00 2001 From: zds <49744633+zds-s@users.noreply.github.com> Date: Fri, 29 Mar 2024 13:36:20 +0800 Subject: [PATCH] Added new components security-bundle and security-http (#58) * Added new components security-bundle and security-http --- .github/workflows/close-pull-request.yml | 13 + .github/workflows/release.yml | 24 ++ LICENSE | 21 ++ README.md | 3 + composer.json | 38 +++ src/AbstractUserProvider.php | 100 ++++++++ src/Config.php | 29 +++ src/ConfigProvider.php | 21 ++ src/Context/Context.php | 41 ++++ src/Contract/ContextInterface.php | 22 ++ src/Contract/TokenInterface.php | 18 ++ src/Contract/UserInterface.php | 38 +++ src/Contract/UserProviderInterface.php | 27 +++ src/Event/Attempting.php | 27 +++ src/Event/Authenticated.php | 27 +++ src/Event/Failed.php | 27 +++ src/Event/Login.php | 27 +++ src/Event/Logout.php | 27 +++ src/Event/PasswordReset.php | 15 ++ src/Event/Registered.php | 15 ++ src/Event/Validated.php | 27 +++ src/Event/Verified.php | 27 +++ src/Exception/NotFoundUserEntityException.php | 15 ++ .../NotFoundUserIdentifierException.php | 15 ++ src/Security.php | 41 ++++ tests/AbstractUserProviderTest.php | 225 ++++++++++++++++++ tests/ConfigProviderTest.php | 28 +++ tests/ConfigTest.php | 32 +++ tests/Context/ConTextTest.php | 41 ++++ tests/SecurityTest.php | 49 ++++ tests/Stub/UserModel.php | 61 +++++ 31 files changed, 1121 insertions(+) create mode 100644 .github/workflows/close-pull-request.yml create mode 100644 .github/workflows/release.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/AbstractUserProvider.php create mode 100644 src/Config.php create mode 100644 src/ConfigProvider.php create mode 100644 src/Context/Context.php create mode 100644 src/Contract/ContextInterface.php create mode 100644 src/Contract/TokenInterface.php create mode 100644 src/Contract/UserInterface.php create mode 100644 src/Contract/UserProviderInterface.php create mode 100644 src/Event/Attempting.php create mode 100644 src/Event/Authenticated.php create mode 100644 src/Event/Failed.php create mode 100644 src/Event/Login.php create mode 100644 src/Event/Logout.php create mode 100644 src/Event/PasswordReset.php create mode 100644 src/Event/Registered.php create mode 100644 src/Event/Validated.php create mode 100644 src/Event/Verified.php create mode 100644 src/Exception/NotFoundUserEntityException.php create mode 100644 src/Exception/NotFoundUserIdentifierException.php create mode 100644 src/Security.php create mode 100644 tests/AbstractUserProviderTest.php create mode 100644 tests/ConfigProviderTest.php create mode 100644 tests/ConfigTest.php create mode 100644 tests/Context/ConTextTest.php create mode 100644 tests/SecurityTest.php create mode 100644 tests/Stub/UserModel.php diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..c8182b8 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [ opened ] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "Hi, this is a READ-ONLY repository, please submit your PR on the https://github.com/mineadmin/components repository.

This Pull Request will close automatically.

Thanks! " \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2fc8404 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +on: + push: + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +name: Release + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: false + prerelease: false \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c572245 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 MineAdmin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b931c8a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Hyperf Security Bundle + +像 Symfony/security 那样提供用户认证、授权、协程/请求安全上下文等功能 \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..9ab4e3d --- /dev/null +++ b/composer.json @@ -0,0 +1,38 @@ +{ + "name": "mineadmin/security-bundle", + "description": "MineAdmin Security bundle,类似 Symfony/Security 组件,提供用户认证、授权、安全上下文等功能。", + "license": "MIT", + "type": "library", + "authors": [ + { + "name": "xmo", + "email": "root@imoi.cn", + "role": "Developer" + }, + { + "name": "zds", + "email": "2771717608@qq.com", + "role": "Developer" + } + ], + "require": { + "php": ">=8.1", + "hyperf/framework": "^3.1", + "friendsofhyperf/encryption": "^3.1" + }, + "autoload": { + "psr-4": { + "Mine\\SecurityBundle\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Mine\\SecurityBundle\\Tests\\": "tests" + } + }, + "extra": { + "hyperf": { + "config": "Mine\\SecurityBundle\\ConfigProvider" + } + } +} \ No newline at end of file diff --git a/src/AbstractUserProvider.php b/src/AbstractUserProvider.php new file mode 100644 index 0000000..f2de726 --- /dev/null +++ b/src/AbstractUserProvider.php @@ -0,0 +1,100 @@ +where($user->getRememberTokenName(), $token)->first(); + }, $this->getUserEntity()->getSecurityBuilder(), $this->getUserEntity(), $token); + } + + public function updateRememberToken(UserInterface $user, string $token): bool + { + return value(function (Builder $builder, UserInterface $user, string $token) { + return $builder->update([ + $user->getRememberTokenName() => $token, + ]); + }, $user->getSecurityBuilder(), $user, $token); + } + + public function retrieveById(mixed $identifier): ?object + { + return value( + function (Builder $builder, UserInterface $entity, mixed $identifier) { + return $builder->where($entity->getIdentifierName(), $identifier)->first(); + }, + $this->getUserEntity()->getSecurityBuilder(), + $this->getUserEntity(), + $identifier + ); + } + + public function credentials(array $credentials): false|UserInterface + { + $userEntity = $this->getUserEntity(); + $builder = $userEntity->getSecurityBuilder(); + $identifierName = $userEntity->getIdentifierName(); + if (isset($credentials[$identifierName])) { + /** + * @var UserInterface $entity + */ + $entity = $builder->where($identifierName, $credentials[$identifierName])->first(); + if ($entity === null) { + return false; + } + if ($this->verifyPassword($entity, $credentials['password'])) { + $this->dispatcher->dispatch(new Login($entity)); + return $entity; + } + } + return false; + } + + protected function verifyPassword(UserInterface $user, string $password): bool + { + if (password_verify($password, $user->getPassword())) { + $this->dispatcher->dispatch(new Verified($user)); + return true; + } + $this->dispatcher->dispatch(new Validated($user)); + return false; + } + + protected function getUserEntity(): UserInterface + { + $entityClass = $this->config->get('entity', '\\App\\Model\\User'); + if (! class_exists($entityClass)) { + new NotFoundUserEntityException(); + } + return new $entityClass(); + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..46d67fa --- /dev/null +++ b/src/Config.php @@ -0,0 +1,29 @@ +config->get(self::PREFIX . '.' . $key, $default); + } +} diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php new file mode 100644 index 0000000..ca30582 --- /dev/null +++ b/src/ConfigProvider.php @@ -0,0 +1,21 @@ +user; + } +} diff --git a/src/Event/Authenticated.php b/src/Event/Authenticated.php new file mode 100644 index 0000000..38947ae --- /dev/null +++ b/src/Event/Authenticated.php @@ -0,0 +1,27 @@ +user; + } +} diff --git a/src/Event/Failed.php b/src/Event/Failed.php new file mode 100644 index 0000000..f29b80f --- /dev/null +++ b/src/Event/Failed.php @@ -0,0 +1,27 @@ +user; + } +} diff --git a/src/Event/Login.php b/src/Event/Login.php new file mode 100644 index 0000000..7576a6d --- /dev/null +++ b/src/Event/Login.php @@ -0,0 +1,27 @@ +user; + } +} diff --git a/src/Event/Logout.php b/src/Event/Logout.php new file mode 100644 index 0000000..8f974b1 --- /dev/null +++ b/src/Event/Logout.php @@ -0,0 +1,27 @@ +user; + } +} diff --git a/src/Event/PasswordReset.php b/src/Event/PasswordReset.php new file mode 100644 index 0000000..ca484c9 --- /dev/null +++ b/src/Event/PasswordReset.php @@ -0,0 +1,15 @@ +user; + } +} diff --git a/src/Event/Verified.php b/src/Event/Verified.php new file mode 100644 index 0000000..2539b1b --- /dev/null +++ b/src/Event/Verified.php @@ -0,0 +1,27 @@ +user; + } +} diff --git a/src/Exception/NotFoundUserEntityException.php b/src/Exception/NotFoundUserEntityException.php new file mode 100644 index 0000000..2f72dea --- /dev/null +++ b/src/Exception/NotFoundUserEntityException.php @@ -0,0 +1,15 @@ +container->get($this->config->get('token')); + } + + public function getContext(): ContextInterface + { + return $this->container->get($this->config->get('context')); + } + + public function getUserProvider(): UserProviderInterface + { + return $this->container->get($this->config->get('user_provider')); + } +} diff --git a/tests/AbstractUserProviderTest.php b/tests/AbstractUserProviderTest.php new file mode 100644 index 0000000..9e420e1 --- /dev/null +++ b/tests/AbstractUserProviderTest.php @@ -0,0 +1,225 @@ +set(ConfigInterface::class, new \Hyperf\Config\Config([ + 'encryption' => [ + 'key' => 'base64:MhEHk72OcV2ttAljUu9Caaam3iP2BnGcwb6GWKkUfV4=', + 'cipher' => 'AES-256-CBC', + ], + ])); + } + + public function testConstruct(): void + { + $instance = new class(\Mockery::mock(EventDispatcherInterface::class), \Mockery::mock(Config::class)) extends AbstractUserProvider { + public function retrieveByCredentials(array $credentials): ?object + { + return null; + } + + public function validateCredentials(UserInterface $user, array $credentials): bool + { + return false; + } + }; + $this->assertInstanceOf(AbstractUserProvider::class, $instance); + } + + public function testCredentials() + { + $event = \Mockery::mock(EventDispatcherInterface::class); + $config = \Mockery::mock(Config::class); + $config->allows('get') + ->with('entity', '\App\Model\User') + ->andReturn(UserModel::class); + $builder = \Mockery::mock(Builder::class); + $verifyModel = new UserModel(); + $verifyModel->setPassword(password_hash('xxxxxx', PASSWORD_DEFAULT)); + $builder->allows('first')->andReturn(null, new UserModel(), $verifyModel); + $builder->allows('where') + ->andReturnUsing(function ($column, $value) use ($builder) { + if ($column === 'email' && $value === 'zds@qq.com') { + return $builder; + } + }); + ApplicationContext::getContainer()->set('mocker.builder', $builder); + $instance = new class($event, $config) extends AbstractUserProvider { + public function retrieveByCredentials(array $credentials): ?object + { + return null; + } + + public function validateCredentials(UserInterface $user, array $credentials): bool + { + return false; + } + }; + $this->assertFalse($instance->credentials([ + 'email' => 'zds@qq.com', + 'password' => '123456', + ])); + $event->allows('dispatch')->andReturnUsing(function ($event) { + $this->assertInstanceOf(Validated::class, $event); + }, function ($event) { + if ($event instanceof Verified) { + $this->assertInstanceOf(Verified::class, $event); + } else { + $this->assertInstanceOf(Login::class, $event); + } + }); + + $this->assertFalse($instance->credentials([ + 'email' => 'zds@qq.com', + 'password' => '123456', + ])); + + $this->assertInstanceOf(UserInterface::class, $instance->credentials([ + 'email' => 'zds@qq.com', + 'password' => 'xxxxxx', + ])); + } + + public function testRetrieveById(): void + { + $event = \Mockery::mock(EventDispatcherInterface::class); + $config = \Mockery::mock(Config::class); + $config->allows('get') + ->with('entity', '\App\Model\User') + ->andReturn(UserModel::class); + $builder = \Mockery::mock(Builder::class); + $verifyModel = new UserModel(); + $verifyModel->setPassword(password_hash('xxxxxx', PASSWORD_DEFAULT)); + $builder->allows('first')->andReturn(null, new UserModel(), $verifyModel); + $builder->allows('where') + ->andReturnUsing(function ($column, $value) use ($builder) { + if ($column === 'email') { + return $builder; + } + }); + ApplicationContext::getContainer()->set('mocker.builder', $builder); + $instance = new class($event, $config) extends AbstractUserProvider { + public function retrieveByCredentials(array $credentials): ?object + { + return null; + } + + public function validateCredentials(UserInterface $user, array $credentials): bool + { + return false; + } + }; + $this->assertNull($instance->retrieveById(1)); + $this->assertInstanceOf(UserInterface::class, $instance->retrieveById(2)); + } + + public function testUpdateRememberToken(): void + { + $event = \Mockery::mock(EventDispatcherInterface::class); + $config = \Mockery::mock(Config::class); + $config->allows('get') + ->with('entity', '\App\Model\User') + ->andReturn(UserModel::class); + $builder = \Mockery::mock(Builder::class); + $verifyModel = new UserModel(); + $verifyModel->setPassword(password_hash('xxxxxx', PASSWORD_DEFAULT)); + $builder->allows('update')->andReturnUsing(function ($data) { + if ($data['remember_token'] === '123456') { + return true; + } + + return false; + }); + ApplicationContext::getContainer()->set('mocker.builder', $builder); + $instance = new class($event, $config) extends AbstractUserProvider { + public function retrieveByCredentials(array $credentials): ?object + { + return null; + } + + public function validateCredentials(UserInterface $user, array $credentials): bool + { + return false; + } + }; + $this->assertTrue($instance->updateRememberToken(new UserModel(), '123456')); + $this->assertFalse($instance->updateRememberToken(new UserModel(), 'xxx')); + } + + public function testRetrieveByToken(): void + { + $event = \Mockery::mock(EventDispatcherInterface::class); + $config = \Mockery::mock(Config::class); + $config->allows('get') + ->with('entity', '\App\Model\User') + ->andReturn(UserModel::class); + $builder = \Mockery::mock(Builder::class); + $verifyModel = new UserModel(); + $verifyModel->setPassword(password_hash('xxxxxx', PASSWORD_DEFAULT)); + $builder->allows('update')->andReturnUsing(function ($data) { + if ($data['remember_token'] === '123456') { + return true; + } + + return false; + }); + $builder->allows('where')->andReturnUsing(function ($column, $value) use ($builder) { + $this->assertEquals('remember_token', $column); + $this->assertEquals('123456', $value); + return $builder; + }, function ($column, $value) use ($builder) { + $this->assertEquals('remember_token', $column); + $this->assertNotEquals('123456', $value); + return $builder; + }); + $builder->allows('first')->andReturn(new UserModel(), null); + ApplicationContext::getContainer()->set('mocker.builder', $builder); + $instance = new class($event, $config) extends AbstractUserProvider { + public function retrieveByCredentials(array $credentials): ?object + { + return null; + } + + public function validateCredentials(UserInterface $user, array $credentials): bool + { + return false; + } + }; + $this->assertInstanceOf(UserInterface::class, $instance->retrieveByToken('123456')); + $this->assertNull($instance->retrieveByToken('11111')); + } +} diff --git a/tests/ConfigProviderTest.php b/tests/ConfigProviderTest.php new file mode 100644 index 0000000..f3e0977 --- /dev/null +++ b/tests/ConfigProviderTest.php @@ -0,0 +1,28 @@ +assertIsArray((new ConfigProvider())()); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..5e5f3d4 --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,32 @@ +allows('get')->with('security.xxx', null)->andReturn('xxx'); + $config = new Config($mock); + $this->assertEquals('xxx', $config->get('xxx')); + } +} diff --git a/tests/Context/ConTextTest.php b/tests/Context/ConTextTest.php new file mode 100644 index 0000000..56e3ec6 --- /dev/null +++ b/tests/Context/ConTextTest.php @@ -0,0 +1,41 @@ +set('key', 'value'); + $this->assertEquals('value', $context->get('key')); + $this->assertEquals(null, $context->get('not_exist')); + $this->assertEquals('xxx', $context->get('not_exist', 'xxx')); + $this->assertEquals('xxx', $context->getOrSet('not_exist', 'xxx')); + $this->assertEquals('xxx2', $context->getOrSet('not_exist1', function () { + return 'xxx2'; + })); + $this->assertTrue($context->has('key')); + $this->assertFalse($context->has('key2')); + } +} diff --git a/tests/SecurityTest.php b/tests/SecurityTest.php new file mode 100644 index 0000000..251379e --- /dev/null +++ b/tests/SecurityTest.php @@ -0,0 +1,49 @@ +assertInstanceOf(Security::class, new Security($config, ApplicationContext::getContainer())); + } + + public function testGet() + { + $config = \Mockery::mock(Config::class); + $config->allows('get')->andReturn('xxx'); + $container = \Mockery::mock(ContainerInterface::class); + $security = new Security($config, $container); + $container->allows('get') + ->andReturn(\Mockery::mock(TokenInterface::class), \Mockery::mock(UserProviderInterface::class), \Mockery::mock(ContextInterface::class)); + $this->assertInstanceOf(TokenInterface::class, $security->getToken()); + $this->assertInstanceOf(UserProviderInterface::class, $security->getUserProvider()); + $this->assertInstanceOf(ContextInterface::class, $security->getContext()); + } +} diff --git a/tests/Stub/UserModel.php b/tests/Stub/UserModel.php new file mode 100644 index 0000000..b98f4d0 --- /dev/null +++ b/tests/Stub/UserModel.php @@ -0,0 +1,61 @@ +remember_token; + } + + public function setRememberToken(string $token): void + { + $this->remember_token = $token; + } + + public function getRememberTokenName(): string + { + return 'remember_token'; + } + + public function getPassword(): string + { + return $this->attributes['password'] ?? '123456'; + } + + public function setPassword(string $password): void + { + $this->attributes['password'] = $password; + } + + public function getSecurityBuilder(): Builder + { + return ApplicationContext::getContainer()->get('mocker.builder'); + } +}