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');
+ }
+}