Pārlūkot izejas kodu

feat(auth): 添加认证模块

runphp 7 mēneši atpakaļ
vecāks
revīzija
361ff44d10

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+.idea/

+ 42 - 0
README.md

@@ -0,0 +1,42 @@
+# Auth 模块说明
+
+`Auth` 模块是 SixShop 的权限管理模块,用于管理用户、角色、权限等。
+
+## 接口
+
+### AuthInterface 生成token和验证token
+
+```php
+interface AuthInterface
+{
+    /**
+     * 用户ID生成token
+     */
+    public function generateToken(string $userId): string;
+
+    /**
+     * 验证token是否有效,并返回用户ID
+     */
+    public function verifyToken(string $jwt): string;
+
+    /**
+     * 刷新token,返回新的token
+     */
+    public function refreshToken(string $jwt): string;
+
+    /**
+     * 注销token
+     */
+    public function revokeToken(string $jwt): void;
+    
+    /**
+     * 获取用户类型
+     */
+    public function getUserType(): UserTypeEnum;
+}
+```
+
+## HOOKS
+
+1. **token_verify**: token验证触发
+1. **token_revoke**: token注销触发

+ 8 - 0
command.php

@@ -0,0 +1,8 @@
+<?php
+declare(strict_types=1);
+
+use SixShop\Auth\Command\JWTGenerateSecretCommand;
+
+return [
+    'jwt:secret' => JWTGenerateSecretCommand::class,
+];

+ 34 - 0
composer.json

@@ -0,0 +1,34 @@
+{
+  "name": "six-shop/auth",
+  "description": "认证模块",
+  "type": "sixshop-extension",
+  "keywords": [
+    "sixshop",
+    "thinkphp"
+  ],
+  "require": {
+    "php": ">=8.3",
+    "six-shop/core": ">=0.4 <1.0"
+  },
+  "authors": [
+    {
+      "name": "hui he",
+      "email": "runphp@qq.com"
+    }
+  ],
+  "license": "MIT",
+  "autoload": {
+    "psr-4": {
+      "SixShop\\Auth\\": "src"
+    },
+    "files": [
+      "src/helper.php"
+    ]
+  },
+  "extra": {
+    "sixshop": {
+      "id": "auth",
+      "class": "SixShop\\auth\\Extension"
+    }
+  }
+}

+ 36 - 0
config.php

@@ -0,0 +1,36 @@
+<?php
+declare(strict_types=1);
+
+return [
+    [
+        'type' => 'input',
+        'title' => '应用市场服务器地址',
+        'field' => 'market_server_url',
+        'value' => 'http://apix.jd29.com',
+        'props' => [
+            'placeholder' => '请输入应用市场服务器地址',
+        ],
+        '_fc_id' => 'id_Fsfdmeegdfsrafc',
+        'name' => 'ref_Fbqmmeegdfsragc',
+        '_fc_drag_tag' => 'input',
+        'display' => true,
+        'hidden' => false,
+        'info' => '应用市场服务器地址',
+    ],
+    [
+        'type' => 'input',
+        'title' => '应用市场API密钥',
+        'field' => 'market_api_key',
+        'props' => [
+            'placeholder' => '请输入应用市场API密钥',
+            'rows' => 4,
+            'type' => 'textarea'
+        ],
+        '_fc_id' => 'id_Fjjbmeegdfsrabc',
+        'name' => 'ref_Fstymeegdfsracc',
+        '_fc_drag_tag' => 'input',
+        'display' => true,
+        'hidden' => false,
+        'info' => '应用市场API密钥',
+    ],
+];

+ 65 - 0
database/migrations/20250710063222_extension_auth_permission.php

@@ -0,0 +1,65 @@
+<?php
+
+use think\migration\Migrator;
+
+class ExtensionAuthPermission extends Migrator
+{
+    /**
+     * Change Method.
+     *
+     * Write your reversible migrations using this method.
+     *
+     * More information on writing migrations is available here:
+     * http://docs.phinx.org/en/latest/migrations.html#the-abstractmigration-class
+     *
+     * The following commands can be used in this method and Phinx will
+     * automatically reverse them when rolling back:
+     *
+     *    createTable
+     *    renameTable
+     *    addColumn
+     *    renameColumn
+     *    addIndex
+     *    addForeignKey
+     *
+     * Remember to call "create()" or "update()" and NOT "save()" when working
+     * with the Table class.
+     */
+    public function change(): void
+    {
+        $table = $this->table('extension_auth_permission', [
+            'engine' => 'InnoDB',
+            'collation' => 'utf8mb4_general_ci',
+            'comment' => '权限表',
+        ]);
+        $table
+            ->addColumn('name', 'string', [
+                'limit' => 255,
+                'default' => '',
+                'comment' => '权限名称',
+            ])
+            ->addColumn('rule', 'string', [
+                'limit' => 50,
+                'default' => '',
+                'comment' => '权限规则',
+            ])
+            ->addColumn('route', 'string', [
+                'limit' => 255,
+                'default' => '',
+                'comment' => '权限路由',
+            ])
+            ->addColumn('method', 'string', [
+                'limit' => 50,
+                'default' => '',
+                'comment' => '权限方法',
+            ])
+            ->addColumn('description', 'string', [
+                'limit' => 255,
+                'default' => '',
+                'comment' => '权限描述',
+            ])
+            ->addTimestamps()
+            ->addIndex('route', ['unique' => true, 'name' => 'uniq_route'])
+            ->create();
+    }
+}

+ 16 - 0
info.php

@@ -0,0 +1,16 @@
+<?php
+declare(strict_types=1);
+
+return [
+    'id' => 'auth', # 扩展的唯一标识符
+    'name' => '认证', # 扩展的名称
+    'category' => 'core', # 扩展的分类 core:核心扩展,other:其他扩展
+    'description' => '这是系统的认证扩展。', # 扩展的描述
+    'version' => '1.0.0',  # 扩展的版本
+    'core_version' => '^1.0',  # 支持的核心版本
+    'author' => 'runphp', # 作者
+    'email' => 'runphp@qq.com', # 作者的邮箱
+    'website' => '', # 扩展的地址,可以是扩展的仓库地址,帮助用户寻找扩展,安装扩展等网络地址
+    'image' => '', # 扩展的图片,用于展示扩展的图标,或者是扩展的截图等图片地址
+    'license' => 'MIT', # 扩展的开源协议
+];

+ 79 - 0
src/Auth.php

@@ -0,0 +1,79 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth;
+
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Ramsey\Uuid\Uuid;
+use SixShop\Auth\Contracts\AuthInterface;
+use SixShop\Auth\Enum\UserTypeEnum;
+use think\facade\Event;
+
+class Auth implements AuthInterface
+{
+    const string ALGORITHM = 'HS256';
+
+    const int SLEEP_WAY = 60;
+
+    private array $config;
+
+    public function __construct(private readonly UserTypeEnum $userType)
+    {
+        $this->config = [
+            'jwt_secret' => env('JWT_SECRET', ''),
+            'expire_in' => env('JWT_EXPIRE_IN', 3600),
+        ];
+    }
+
+    public function refreshToken(string $jwt): string
+    {
+        $res = $this->generateToken($this->verifyToken($jwt));
+        $this->revokeToken($jwt);
+        return $res;
+    }
+
+    public function generateToken(string $userId): string
+    {
+        $payload = [
+            'iss' => 'SixShop', // 签发者
+            'aud' => $this->userType->value, // 接收者
+            'sub' => encrypt_data($userId, $this->config['jwt_secret']), // 主题
+            'exp' => time() + $this->config['expire_in'], // 过期时间
+            'iat' => time(), // 签发时间
+            'jti' => Uuid::uuid4()->toString(), // 唯一标识
+        ];
+        return JWT::encode($payload, $this->config['jwt_secret'], self::ALGORITHM);
+    }
+
+    /**
+     * @throws \Exception
+     */
+    public function verifyToken(string $jwt): string
+    {
+        JWT::$leeway = self::SLEEP_WAY;
+        $payload = JWT::decode($jwt, new Key($this->config['jwt_secret'], self::ALGORITHM));
+        $res = match (UserTypeEnum::tryFrom($payload->aud)) {
+            $this->userType => decrypt_data($payload->sub, $this->config['jwt_secret']),
+            default => throw new \Exception('token 类型错误'),
+        };
+        Event::trigger('token_verify', $payload);
+        return $res;
+    }
+
+    public function revokeToken(string $jwt): void
+    {
+        JWT::$leeway = self::SLEEP_WAY;
+        try {
+            $payload = JWT::decode($jwt, new Key($this->config['jwt_secret'], self::ALGORITHM));
+        } catch (\Exception) {
+            return;
+        }
+        Event::trigger('token_revoke', $payload);
+    }
+
+    public function getUserType(): UserTypeEnum
+    {
+        return $this->userType;
+    }
+}

+ 68 - 0
src/Command/JWTGenerateSecretCommand.php

@@ -0,0 +1,68 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Command;
+
+use think\console\Command;
+use think\helper\Str;
+
+class JWTGenerateSecretCommand extends Command
+{
+    public function configure(): void
+    {
+        $this->setName('jwt:secret')
+            ->setDescription('Set the JWTAuth secret key used to sign the tokens');
+    }
+
+    public function handle(): void
+    {
+        $key = Str::random(64);
+
+        if (file_exists($path = $this->envPath()) === false) {
+            $this->displayKey($key);
+            return;
+        }
+        if (Str::contains(file_get_contents($path), 'JWT_SECRET') === false) {
+            // create new entry
+            file_put_contents($path, PHP_EOL . "JWT_SECRET=$key" . PHP_EOL, FILE_APPEND);
+        } else {
+
+            if ($this->isConfirmed() === false) {
+                $this->output->writeln('Phew... No changes were made to your secret key.');
+
+                return;
+            }
+
+            // update existing entry
+            file_put_contents($path, str_replace(
+                'JWT_SECRET=' . $this->app->config->get('jwt.secret'),
+                'JWT_SECRET=' . $key, file_get_contents($path)
+            ));
+        }
+
+        $this->displayKey($key, $path);
+    }
+
+    private function envPath(): string
+    {
+        $envName = $this->app->getEnvName();
+        $home = getenv('HOME');
+        $envFile = $envName ? $home . '/.env.' . $envName : $home . '/.env';
+        if (is_file($envFile)) {
+            return $envFile;
+        }
+        return $envName ? $this->app->getRootPath() . '.env.' . $envName : $this->app->getRootPath() . '.env';
+    }
+
+    protected function displayKey($key, $path): void
+    {
+        $this->output->writeln("<info>jwt-auth secret [$key] set successfully. path: $path</info>");
+    }
+
+    protected function isConfirmed()
+    {
+        return $this->output->confirm($this->input,
+            'This will invalidate all existing tokens. Are you sure you want to override the secret key?'
+        );
+    }
+}

+ 34 - 0
src/Contracts/AuthInterface.php

@@ -0,0 +1,34 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Contracts;
+
+use SixShop\Auth\Enum\UserTypeEnum;
+
+interface AuthInterface
+{
+    /**
+     * 用户ID生成token
+     */
+    public function generateToken(string $userId): string;
+
+    /**
+     * 验证token是否有效,并返回用户ID
+     */
+    public function verifyToken(string $jwt): string;
+
+    /**
+     * 刷新token,返回新的token
+     */
+    public function refreshToken(string $jwt): string;
+
+    /**
+     * 注销token
+     */
+    public function revokeToken(string $jwt): void;
+
+    /**
+     * 获取用户类型
+     */
+    public function getUserType(): UserTypeEnum;
+}

+ 59 - 0
src/Entity/ExtensionAuthPermissionEntity.php

@@ -0,0 +1,59 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Entity;
+
+use Opis\Closure\ReflectionClosure;
+use SixShop\Auth\Model\ExtensionAuthPermissionModel;
+use SixShop\Core\Entity\BaseEntity;
+use think\route\Resource;
+use think\route\Rule;
+use function Opis\Closure\init;
+/**
+ * @mixin ExtensionAuthPermissionModel
+ */
+class ExtensionAuthPermissionEntity extends BaseEntity
+{
+
+    public function syncPermission(Rule $rule): void
+    {
+        $route = $rule->getRoute();
+        if (is_array($route)) {
+            $route = implode('/', $route);
+        }
+        if ($route instanceof \Closure) {
+            init();
+            $rc = new ReflectionClosure($route);
+            $route = $rc->info()->key();
+        }
+        $permission = $this->where(['route' => $route])->findOrEmpty();
+        $description = $rule->getOption('description');
+        if ($rule->getParent() instanceof Resource) {
+            $parts = explode('/', $route);
+            $rest = end($parts);
+            $name = str_replace('/', ':', $rule->getRule());
+            if ($rest != 'edit') {
+                $name .= ':' . $rest;
+            }
+            $description .= match ($rest) {
+                'index' => '列表',
+                'create' => '创建',
+                'save' => '保存',
+                'read' => '查看',
+                'edit' => '编辑',
+                'update' => '更新',
+                'delete' => '删除',
+                default => '',
+            };
+        } else {
+            $name = str_replace('/', ':', $rule->getRule());
+        }
+        $permission->save([
+            'name' => $name,
+            'rule' => $rule->getRule(),
+            'route' => $route,
+            'method' => $rule->getMethod(),
+            'description' => $description,
+        ]);
+    }
+}

+ 21 - 0
src/Enum/UserTypeEnum.php

@@ -0,0 +1,21 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Enum;
+
+/**
+ * 不同用户类型
+ */
+enum UserTypeEnum: string
+{
+    /**
+     * 普通用户
+     */
+    case USER = 'user';
+
+    /**
+     * 管理员
+     */
+    case ADMIN = 'admin';
+}
+

+ 25 - 0
src/Extension.php

@@ -0,0 +1,25 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth;
+
+
+use SixShop\Auth\Hook\AuthHook;
+use SixShop\Core\ExtensionAbstract;
+use SixShop\Auth\Hook\ConfigFileHook;
+
+class Extension extends ExtensionAbstract
+{
+    public function getHooks(): array
+    {
+        return [
+            AuthHook::class,
+            ConfigFileHook::class
+        ];
+    }
+
+    protected function getBaseDir(): string
+    {
+        return dirname(__DIR__);
+    }
+}

+ 35 - 0
src/Hook/AuthHook.php

@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Hook;
+
+use SixShop\Auth\Auth;
+use SixShop\Core\Attribute\Hook;
+use think\Cache;
+
+class AuthHook
+{
+
+    const string TOKEN_REVOKE = 'token_revoke:';
+
+    public function __construct(private Cache $cache)
+    {
+    }
+
+    /**
+     * @throws \Exception
+     */
+    #[Hook("token_verify")]
+    public function checkToken($payload): void
+    {
+        if ($this->cache->has(self::TOKEN_REVOKE . $payload->jti)) {
+            throw new \Exception('token 已失效');
+        }
+    }
+
+    #[Hook("token_revoke")]
+    public function revokeToken($payload): void
+    {
+        $this->cache->remember(self::TOKEN_REVOKE . $payload->jti, 1, $payload->exp - time() + Auth::SLEEP_WAY);
+    }
+}

+ 63 - 0
src/Hook/ConfigFileHook.php

@@ -0,0 +1,63 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Hook;
+
+use SixShop\Core\Attribute\Hook;
+use SixShop\Core\Helper;
+use Symfony\Component\HttpClient\HttpClient;
+
+class ConfigFileHook
+{
+    private array $normalModuleList = [];
+
+    /**
+     * @param array{market_server_url: array<string, string>, market_server_url: array<string, string>, market_api_key: string} $config
+     */
+    #[Hook('after_write_extension_config:auth')]
+    public function updateNormalModuleList(array $config): void
+    {
+        $marketServerUrl = $config['market_server_url']['value'] ?? '';
+        $marketApiKey    = $config['market_api_key']['value'] ?? '';
+
+        // 方案A:当 key 为空时,跳过外部校验与请求,保持保存成功且不抛错
+        if ($marketApiKey === '') {
+            $this->normalModuleList = [];
+            return;
+        }
+
+        try {
+            $client = HttpClient::create([
+                'base_uri' => $marketServerUrl,
+                'headers' => ['X-API-KEY' => $marketApiKey],
+            ]);
+            $response = $client->request('GET', '/php/site/extension');
+            if ($response->getStatusCode() !== 200) {
+                // 有 key 但校验/请求失败时,也不抛错,静默返回
+                return;
+            }
+            $content = $response->getContent(false); // 不抛异常,手动校验
+            if (!json_validate($content)) {
+                return;
+            }
+            $data = json_decode($content, true);
+            if (!isset($data['data']) || !is_array($data['data'])) {
+                return;
+            }
+            $this->normalModuleList = array_column($data['data'], 'slug');
+        } catch (\Throwable $e) {
+            // 不抛错,保证保存流程成功
+            return;
+        }
+    }
+
+    public function __destruct()
+    {
+        $header = '// This file is automatically generated at:' . date('Y-m-d H:i:s') . PHP_EOL . 'declare (strict_types = 1);' . PHP_EOL;
+        $content = '<?php ' . PHP_EOL . $header . "return " . var_export($this->normalModuleList, true) . ';';
+        file_put_contents(
+            root_path('runtime') . 'module_name_list_normal.php',
+            $content
+        );
+    }
+}

+ 35 - 0
src/Middleware/AuthMiddleware.php

@@ -0,0 +1,35 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Middleware;
+
+use Closure;
+use SixShop\Auth\Contracts\AuthInterface;
+use SixShop\Core\Request;
+
+readonly class AuthMiddleware
+{
+    public function __construct(private AuthInterface $authService)
+    {
+    }
+
+    public function handle(Request $request, Closure $next, bool $isLogin = true)
+    {
+        $authorization = $request->header('Authorization');
+        if ($authorization) {
+            $jwt = trim(ltrim($authorization, 'Bearer'));
+            try {
+                $request->{$this->authService->getUserType()->value . 'ID'} = $this->authService->verifyToken($jwt);
+                $request->token = $jwt;
+            } catch (\Exception $e) {
+                if ($isLogin) {
+                    return abort(401, $e->getMessage());
+                }
+            }
+        } else if ($isLogin) {
+            return abort(401, 'Authorization header is required');
+        }
+
+        return $next($request);
+    }
+}

+ 26 - 0
src/Middleware/PermissionMiddleware.php

@@ -0,0 +1,26 @@
+<?php
+declare(strict_types=1);
+
+namespace SixShop\Auth\Middleware;
+
+use Closure;
+use SixShop\Auth\Entity\ExtensionAuthPermissionEntity;
+use SixShop\Core\Request;
+
+class PermissionMiddleware
+{
+    public function __construct(private ExtensionAuthPermissionEntity $extensionAuthPermissionEntity)
+    {
+    }
+
+    public function handle(Request $request, Closure $next)
+    {
+        if ($request->adminID == 1) {
+            // super adminer
+            $this->extensionAuthPermissionEntity->syncPermission($request->rule());
+        } else {
+            // todo check permission
+        }
+        return $next($request);
+    }
+}

+ 23 - 0
src/Model/ExtensionAuthPermissionModel.php

@@ -0,0 +1,23 @@
+<?php
+declare(strict_types=1);
+namespace SixShop\Auth\Model;
+
+use think\Model;
+
+/**
+ * Class SixShop\Auth\Model\ExtensionAuthPermissionModel
+ *
+ * @property int $id
+ * @property string $create_time
+ * @property string $description 权限描述
+ * @property string $method 权限方法
+ * @property string $name 权限名称
+ * @property string $route 权限路由
+ * @property string $rule 权限规则
+ * @property string $update_time
+ */
+class ExtensionAuthPermissionModel extends Model
+{
+    protected $name = 'extension_auth_permission';
+    protected $pk = 'id';
+}

+ 47 - 0
src/helper.php

@@ -0,0 +1,47 @@
+<?php
+declare(strict_types=1);
+
+
+if (!function_exists('encrypt_data')) {
+    /**
+     * 使用AES-256-CBC加密数据
+     *
+     * @param string $data 待加密的数据
+     * @param string $key 加密密钥
+     * @return string Base64编码后的加密结果
+     */
+    function encrypt_data(string $data, string $key): string
+    {
+        // 生成16字节IV
+        $iv = openssl_random_pseudo_bytes(16);
+
+        // 加密数据
+        $encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
+
+        // 组合IV和密文并进行Base64编码
+        return base64_encode($iv . $encrypted);
+    }
+}
+
+// 检查函数是否已存在,避免重复定义
+if (!function_exists('decrypt_data')) {
+    /**
+     * 使用AES-256-CBC解密数据
+     *
+     * @param string $result Base64编码后的加密结果
+     * @param string $key 解密密钥
+     * @return string 解密后的原始数据
+     */
+    function decrypt_data(string $result, string $key): string
+    {
+        // Base64解码
+        $data = base64_decode($result);
+
+        // 提取IV和密文
+        $iv = substr($data, 0, 16);
+        $ciphertext = substr($data, 16);
+
+        // 解密数据
+        return openssl_decrypt($ciphertext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
+    }
+}