初始化本地仓库
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\adminapi\middleware;
|
||||
|
||||
|
||||
use app\adminapi\middleware\helper\SseHelper;
|
||||
use core\exception\handler\UnauthorizedHttpException;
|
||||
use core\jwt\JwtToken;
|
||||
use core\tool\Json;
|
||||
use madong\swagger\attribute\AllowAnonymous;
|
||||
use madong\swagger\helper\AnnotationHelper;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
|
||||
/**
|
||||
* AccessToken 中间件(JWT Token 验证)
|
||||
* 功能:
|
||||
* 1. 检查请求是否需要跳过 Token 验证(通过 AllowAnonymous 注解)
|
||||
* 2. 验证 JWT Token 有效性,无效则返回 401
|
||||
*/
|
||||
#\[\Attribute\]
|
||||
final class AccessTokenMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$route = $request->route;
|
||||
if (!$route || !isset($request->action)) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
$controllerClass = $request->controller;
|
||||
$action = $request->action;
|
||||
|
||||
$skipAuth = AnnotationHelper::getMethodAnnotation($controllerClass, $action, AllowAnonymous::class);
|
||||
if ($skipAuth && !$skipAuth->requireToken) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$jwt = new JwtToken();
|
||||
$payload = $jwt->verify();
|
||||
$userId = $payload['id'] ?? null;
|
||||
if (empty($userId)) {
|
||||
throw new UnauthorizedHttpException();
|
||||
}
|
||||
$jwt->touchCurrentToken();
|
||||
} catch (\Exception $e) {
|
||||
// 检查是否为SSE请求
|
||||
if (SseHelper::isSseRequest($request)) {
|
||||
return SseHelper::sendSseErrorViaConnection($request, $e->getMessage());
|
||||
}
|
||||
return Json::fail($e->getMessage(), [], 401);
|
||||
}
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\adminapi\middleware;
|
||||
|
||||
use app\adminapi\CurrentUser;
|
||||
use app\adminapi\event\OperationLogEvent;
|
||||
use madong\helper\Arr;
|
||||
use madong\swagger\attribute\AllowAnonymous;
|
||||
use madong\swagger\helper\AnnotationHelper;
|
||||
use support\Container;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
|
||||
#[\Attribute]
|
||||
final class OperationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
|
||||
private CurrentUser $currentUser;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->currentUser = Container::make(CurrentUser::class);
|
||||
}
|
||||
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
$controllerClass = $request->controller;
|
||||
$action = $request->action;
|
||||
|
||||
$oaAnnotations = AnnotationHelper::getMethodOaAnnotations($controllerClass, $action);
|
||||
$oaData = $this->extractOaData($oaAnnotations);
|
||||
$firstOa = Arr::first($oaData);
|
||||
|
||||
$response = $handler($request);
|
||||
|
||||
// 检查是否需要记录操作日志(跳过匿名访问)
|
||||
$allowAnonymous = AnnotationHelper::getMethodAnnotation($controllerClass, $action, AllowAnonymous::class);
|
||||
if (!$allowAnonymous || $allowAnonymous->requirePermission) {
|
||||
$logData = $this->buildLogData($request, $response, $firstOa);
|
||||
$event = new OperationLogEvent(
|
||||
$logData['name'],
|
||||
$logData['oa_tags'],
|
||||
$logData['oa_description'],
|
||||
$logData['app'],
|
||||
$logData['ip'],
|
||||
$logData['ip_location'],
|
||||
$logData['browser'],
|
||||
$logData['os'],
|
||||
$logData['url'],
|
||||
$logData['class_name'],
|
||||
$logData['action'],
|
||||
$logData['method'],
|
||||
$logData['param'],
|
||||
$logData['result'],
|
||||
$logData['user_name'],
|
||||
$logData['user_id']
|
||||
);
|
||||
$event->dispatch();
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function extractOaData(array $oaAnnotations): array
|
||||
{
|
||||
return array_map(function ($annotation) {
|
||||
return [
|
||||
'type' => $annotation::class, // 注解类型(如 OpenApi\Attributes\Get)
|
||||
'summary' => $annotation->summary, // 操作摘要
|
||||
'tags' => $annotation->tags, // 标签
|
||||
'description' => $annotation->description ?? '', // 描述
|
||||
];
|
||||
}, $oaAnnotations);
|
||||
}
|
||||
|
||||
private function buildLogData(Request $request, Response $response, ?array $firstOa): array
|
||||
{
|
||||
$adminInfo = $this->currentUser->admin();
|
||||
return [
|
||||
'name' => $firstOa['summary'] ?? '未知操作', // 用 OA summary 作为操作名称
|
||||
'oa_tags' => $firstOa['tags'] ?? [], // OA 标签
|
||||
'oa_description' => $firstOa['description'] ?? '', // OA 描述
|
||||
'app' => $request->app,
|
||||
'ip' => $request->getRealIp(),
|
||||
'ip_location' => $this->getIpLocation($request->getRealIp()), // 可选:IP 归属地
|
||||
'browser' => $this->getBrowser($request->header('user-agent')),
|
||||
'os' => $this->getOs($request->header('user-agent')),
|
||||
'url' => trim($request->path()),
|
||||
'class_name' => $request->controller,
|
||||
'action' => $request->action,
|
||||
'method' => $request->method(),
|
||||
'param' => $this->filterParams($request->all()), // 过滤后的参数(数组)
|
||||
'result' => $this->formatResponse($response), // 格式化响应(数组/字符串)
|
||||
'user_name' => $adminInfo['user_name'] ?? '匿名用户',
|
||||
'user_id' => $adminInfo['user_id'] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function getIpLocation(string $ip): string
|
||||
{
|
||||
return '未知';
|
||||
}
|
||||
|
||||
private function getBrowser($user_agent): string
|
||||
{
|
||||
$br = 'Unknown';
|
||||
if (preg_match('/MSIE/i', $user_agent)) {
|
||||
$br = 'MSIE';
|
||||
} elseif (preg_match('/Firefox/i', $user_agent)) {
|
||||
$br = 'Firefox';
|
||||
} elseif (preg_match('/Chrome/i', $user_agent)) {
|
||||
$br = 'Chrome';
|
||||
} elseif (preg_match('/Safari/i', $user_agent)) {
|
||||
$br = 'Safari';
|
||||
} elseif (preg_match('/Opera/i', $user_agent)) {
|
||||
$br = 'Opera';
|
||||
} else {
|
||||
$br = 'Other';
|
||||
}
|
||||
return $br;
|
||||
}
|
||||
|
||||
private function getOs($user_agent): string
|
||||
{
|
||||
$os = 'Unknown';
|
||||
if (preg_match('/win/i', $user_agent)) {
|
||||
$os = 'Windows';
|
||||
} elseif (preg_match('/mac/i', $user_agent)) {
|
||||
$os = 'Mac';
|
||||
} elseif (preg_match('/linux/i', $user_agent)) {
|
||||
$os = 'Linux';
|
||||
} else {
|
||||
$os = 'Other';
|
||||
}
|
||||
return $os;
|
||||
}
|
||||
|
||||
private function filterParams($params): string
|
||||
{
|
||||
$blackList = ['password', 'old_password', 'new_password', 'content'];
|
||||
foreach ($params as $key => $value) {
|
||||
if (in_array($key, $blackList)) {
|
||||
$params[$key] = '******';
|
||||
}
|
||||
}
|
||||
return json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function formatResponse(Response $response): array|string
|
||||
{
|
||||
$rawBody = $response->rawBody();
|
||||
if (str_contains($rawBody, 'json')) {
|
||||
return json_decode($rawBody, true) ?? $rawBody;
|
||||
}
|
||||
return $rawBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\adminapi\middleware;
|
||||
|
||||
use app\adminapi\middleware\helper\SseHelper;
|
||||
use app\adminapi\CurrentUser;
|
||||
use madong\swagger\attribute\Permission;
|
||||
use core\exception\handler\UnauthorizedHttpException;
|
||||
use core\jwt\JwtToken;
|
||||
use core\tool\Json;
|
||||
use madong\swagger\attribute\AllowAnonymous;
|
||||
use madong\swagger\helper\AnnotationHelper;
|
||||
use support\Container;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
use Webman\MiddlewareInterface;
|
||||
|
||||
/**
|
||||
* 方法级权限校验中间件
|
||||
* 功能:
|
||||
* 1. 支持 SkipAuth 注解跳过权限校验
|
||||
* 2. 校验类/方法级的 Permission 注解
|
||||
* 3. 处理 AND/OR 操作类型的权限逻辑
|
||||
*/
|
||||
#\[\Attribute\]
|
||||
final class PermissionMiddleware implements MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* Webman 中间件入口
|
||||
*
|
||||
* @throws \core\exception\handler\ForbiddenHttpException|\core\exception\handler\UnauthorizedHttpException
|
||||
*/
|
||||
public function process(Request $request, callable $handler): Response
|
||||
{
|
||||
|
||||
$controllerClass = $request->controller;
|
||||
$action = $request->action;
|
||||
$allowAnonymous = AnnotationHelper::getMethodAnnotation($controllerClass, $action, AllowAnonymous::class);
|
||||
if ($allowAnonymous && !$allowAnonymous->requirePermission) {
|
||||
return $handler($request);
|
||||
}
|
||||
try {
|
||||
$userId = (new JwtToken())->id();
|
||||
if (empty($userId)) {
|
||||
throw new UnauthorizedHttpException();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 检查是否为SSE请求
|
||||
if (SseHelper::isSseRequest($request)) {
|
||||
return SseHelper::sendSseErrorViaConnection($request, $e->getMessage());
|
||||
}
|
||||
return Json::fail($e->getMessage(), [], 401);
|
||||
}
|
||||
// 使用CurrentUser进行权限验证
|
||||
$currentUser = Container::make(CurrentUser::class);
|
||||
|
||||
// 顶级管理员直接跳过权限验证
|
||||
if ($currentUser->isSuperAdmin()) {
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
try {
|
||||
//权限验证
|
||||
$permissions = $this->resolvePermissions($controllerClass, $action);
|
||||
$this->validatePermissions($currentUser, $permissions);
|
||||
} catch (\Exception $e) {
|
||||
// 检查是否为SSE请求
|
||||
if (SseHelper::isSseRequest($request)) {
|
||||
return SseHelper::sendSseErrorViaConnection($request, $e->getMessage());
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $handler($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析类/方法级的 Permission 注解
|
||||
*
|
||||
* @return array<Permission> 合并后的权限注解列表
|
||||
*/
|
||||
private function resolvePermissions(string $controllerClass, string $action): array
|
||||
{
|
||||
$permissions = [];
|
||||
|
||||
// a. 类级别注解(作用于整个控制器)
|
||||
$classAnnotation = AnnotationHelper::getClassAnnotation($controllerClass, Permission::class);
|
||||
if ($classAnnotation) {
|
||||
$permissions[] = $classAnnotation;
|
||||
}
|
||||
|
||||
// b. 方法级别注解(覆盖/补充类级别规则)
|
||||
$methodAnnotation = AnnotationHelper::getMethodAnnotation($controllerClass, $action, Permission::class);
|
||||
if ($methodAnnotation) {
|
||||
$permissions[] = $methodAnnotation;
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户权限是否符合注解规则
|
||||
*
|
||||
* @throws \core\exception\handler\ForbiddenHttpException
|
||||
*/
|
||||
private function validatePermissions(CurrentUser $currentUser, array $permissions): void
|
||||
{
|
||||
foreach ($permissions as $permission) {
|
||||
$this->handleSinglePermission($currentUser, $permission);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Permission 注解的权限校验逻辑
|
||||
*
|
||||
* @param CurrentUser $currentUser 当前用户
|
||||
* @param Permission $permission 权限注解
|
||||
*
|
||||
* @throws \core\exception\handler\ForbiddenHttpException
|
||||
*/
|
||||
private function handleSinglePermission(
|
||||
CurrentUser $currentUser,
|
||||
Permission $permission
|
||||
): void
|
||||
{
|
||||
// 权限码数组
|
||||
$codes = $permission->getCodes();
|
||||
// 操作类型
|
||||
$operation = $permission->getOperation();
|
||||
|
||||
// 使用CurrentUser的checkPermission方法进行权限验证
|
||||
$currentUser->checkPermission($codes, $operation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
*+------------------
|
||||
* madong
|
||||
*+------------------
|
||||
* Copyright (c) https://gitee.com/motion-code All rights reserved.
|
||||
*+------------------
|
||||
* Author: Mr. April (405784684@qq.com)
|
||||
*+------------------
|
||||
* Official Website: http://www.madong.tech
|
||||
*/
|
||||
|
||||
namespace app\adminapi\middleware\helper;
|
||||
|
||||
use core\tool\Sse;
|
||||
use Webman\Http\Request;
|
||||
use Webman\Http\Response;
|
||||
|
||||
/**
|
||||
* SSE 处理辅助类
|
||||
* 提供 SSE 请求检测和错误处理功能
|
||||
*/
|
||||
class SseHelper
|
||||
{
|
||||
/**
|
||||
* 检查是否为SSE请求
|
||||
*
|
||||
* @param Request $request
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function isSseRequest(Request $request): bool
|
||||
{
|
||||
// 方法1:检查请求头
|
||||
$acceptHeader = $request->header('Accept', '');
|
||||
if (str_contains($acceptHeader, 'text/event-stream')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 方法2:检查路径(SSE接口通常有特定路径模式)
|
||||
$path = $request->path();
|
||||
$ssePaths = [
|
||||
'/terminal',
|
||||
'/terminal/exec',
|
||||
];
|
||||
foreach ($ssePaths as $ssePath) {
|
||||
if (str_starts_with($path, $ssePath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 方法3:检查查询参数
|
||||
$isSse = $request->input('sse', false) || $request->input('stream', false);
|
||||
if ($isSse) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过连接发送SSE错误消息
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $message
|
||||
*
|
||||
* @return Response
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function sendSseErrorViaConnection(Request $request, string $message): Response
|
||||
{
|
||||
// 检查是否有连接对象
|
||||
if (!property_exists($request, 'connection') || !$request->connection) {
|
||||
// 回退到普通的SSE响应
|
||||
return self::createSseErrorResponse($message, $request->input('uuid'));
|
||||
}
|
||||
|
||||
$connection = $request->connection;
|
||||
$uuid = $request->input('uuid');
|
||||
|
||||
try {
|
||||
// 发送SSE响应头
|
||||
$connection->send(new Response(200, [
|
||||
'Content-Type' => 'text/event-stream',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Connection' => 'keep-alive',
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Expose-Headers' => 'Content-Type',
|
||||
], "\r\n"));
|
||||
|
||||
// 发送SSE错误消息,传递uuid
|
||||
$connection->send(Sse::error($message, null, $uuid));
|
||||
|
||||
// 发送空数据表示结束,防止前端等待更多数据
|
||||
$connection->send(new Response(200, [], ""));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// 忽略连接异常,返回普通响应
|
||||
return self::createSseErrorResponse($message, $uuid);
|
||||
}
|
||||
|
||||
// 返回空响应结束请求
|
||||
return new Response(200, [], '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建SSE格式的错误响应
|
||||
*
|
||||
* @param string $message
|
||||
* @param string|null $uuid
|
||||
*
|
||||
* @return Response
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function createSseErrorResponse(string $message, ?string $uuid = null): Response
|
||||
{
|
||||
$errorMessage = Sse::error($message, [], $uuid);
|
||||
|
||||
// 创建完整的SSE响应
|
||||
$responseBody = $errorMessage;
|
||||
|
||||
return new Response(200, [
|
||||
'Content-Type' => 'text/event-stream',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Connection' => 'keep-alive',
|
||||
'X-Accel-Buffering' => 'no', // 禁用Nginx缓冲
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Expose-Headers' => 'Content-Type',
|
||||
], $responseBody);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user