update api key
This commit is contained in:
@@ -7,12 +7,11 @@ namespace App\Controller\Api\V1;
|
|||||||
use App\Controller\AbstractController;
|
use App\Controller\AbstractController;
|
||||||
use App\Middleware\AuthMiddleware;
|
use App\Middleware\AuthMiddleware;
|
||||||
use App\Model\ApiKey;
|
use App\Model\ApiKey;
|
||||||
use App\Model\User;
|
|
||||||
use Hyperf\HttpServer\Annotation\Controller;
|
use Hyperf\HttpServer\Annotation\Controller;
|
||||||
use Hyperf\HttpServer\Annotation\Middleware;
|
use Hyperf\HttpServer\Annotation\Middleware;
|
||||||
use Hyperf\HttpServer\Annotation\RequestMapping;
|
use Hyperf\HttpServer\Annotation\RequestMapping;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
use Qbhy\HyperfAuth\AuthManager;
|
|
||||||
|
|
||||||
#[OA\Tag(name: 'API Keys', description: 'API Key 管理')]
|
#[OA\Tag(name: 'API Keys', description: 'API Key 管理')]
|
||||||
#[Controller(prefix: "/api/v1/me/api-keys")]
|
#[Controller(prefix: "/api/v1/me/api-keys")]
|
||||||
@@ -66,11 +65,11 @@ class ApiKeyController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
#[RequestMapping(path: "", methods: "POST")]
|
#[RequestMapping(path: "", methods: "POST")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function store(AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
|
public function store(): \Psr\Http\Message\ResponseInterface|array
|
||||||
{
|
{
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
if (!$user) {
|
||||||
return $this->response->json([
|
return $this->response->json([
|
||||||
'code' => 401,
|
'code' => 401,
|
||||||
'message' => '未授权',
|
'message' => '未授权',
|
||||||
@@ -183,11 +182,11 @@ class ApiKeyController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
#[RequestMapping(path: "", methods: "GET")]
|
#[RequestMapping(path: "", methods: "GET")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function index(AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
|
public function index(): \Psr\Http\Message\ResponseInterface|array
|
||||||
{
|
{
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
if (!$user) {
|
||||||
return $this->response->json([
|
return $this->response->json([
|
||||||
'code' => 401,
|
'code' => 401,
|
||||||
'message' => '未授权',
|
'message' => '未授权',
|
||||||
@@ -232,11 +231,11 @@ class ApiKeyController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
#[RequestMapping(path: "{id}", methods: "DELETE")]
|
#[RequestMapping(path: "{id}", methods: "DELETE")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function destroy(int $id, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
|
public function destroy(int $id): \Psr\Http\Message\ResponseInterface|array
|
||||||
{
|
{
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
if (!$user) {
|
||||||
return $this->response->json([
|
return $this->response->json([
|
||||||
'code' => 401,
|
'code' => 401,
|
||||||
'message' => '未授权',
|
'message' => '未授权',
|
||||||
@@ -309,11 +308,11 @@ class ApiKeyController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
#[RequestMapping(path: "{id}/toggle", methods: "PATCH")]
|
#[RequestMapping(path: "{id}/toggle", methods: "PATCH")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function toggle(int $id, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
|
public function toggle(int $id): \Psr\Http\Message\ResponseInterface|array
|
||||||
{
|
{
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
if (!$user) {
|
||||||
return $this->response->json([
|
return $this->response->json([
|
||||||
'code' => 401,
|
'code' => 401,
|
||||||
'message' => '未授权',
|
'message' => '未授权',
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ class AuthController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
#[RequestMapping(path: "me", methods: "GET")]
|
#[RequestMapping(path: "me", methods: "GET")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function me(AuthManager $auth, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
|
public function me(ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
|
||||||
{
|
{
|
||||||
$user = $this->getAuthUser();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
@@ -478,11 +478,11 @@ class AuthController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
#[RequestMapping(path: "me/profile", methods: "PUT")]
|
#[RequestMapping(path: "me/profile", methods: "PUT")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function updateProfile(RequestInterface $request, ResponseInterface $response, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
|
public function updateProfile(RequestInterface $request, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
|
||||||
{
|
{
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
if (!$user) {
|
||||||
return $response->json([
|
return $response->json([
|
||||||
'code' => 401,
|
'code' => 401,
|
||||||
'message' => '未授权',
|
'message' => '未授权',
|
||||||
@@ -598,11 +598,11 @@ class AuthController extends AbstractController
|
|||||||
)]
|
)]
|
||||||
#[RequestMapping(path: "me/password", methods: "PUT")]
|
#[RequestMapping(path: "me/password", methods: "PUT")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function changePassword(RequestInterface $request, ResponseInterface $response, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
|
public function changePassword(RequestInterface $request, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
|
||||||
{
|
{
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
if (!$user instanceof User) {
|
if (!$user) {
|
||||||
return $response->json([
|
return $response->json([
|
||||||
'code' => 401,
|
'code' => 401,
|
||||||
'message' => '未授权',
|
'message' => '未授权',
|
||||||
@@ -689,9 +689,9 @@ class AuthController extends AbstractController
|
|||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
public function logout(AuthManager $auth): array
|
public function logout(AuthManager $auth): array
|
||||||
{
|
{
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $this->getAuthUser();
|
||||||
|
|
||||||
if ($user instanceof User) {
|
if ($user) {
|
||||||
OperationLogService::log(
|
OperationLogService::log(
|
||||||
user_id: $user->id,
|
user_id: $user->id,
|
||||||
action: 'auth.logout',
|
action: 'auth.logout',
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use Hyperf\HttpServer\Annotation\Middleware;
|
|||||||
use Hyperf\HttpServer\Annotation\RequestMapping;
|
use Hyperf\HttpServer\Annotation\RequestMapping;
|
||||||
use OpenApi\Attributes as OA;
|
use OpenApi\Attributes as OA;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Qbhy\HyperfAuth\AuthManager;
|
|
||||||
|
|
||||||
#[OA\Tag(name: 'Roles', description: '角色与授权管理')]
|
#[OA\Tag(name: 'Roles', description: '角色与授权管理')]
|
||||||
#[Controller(prefix: "/api/v1/roles")]
|
#[Controller(prefix: "/api/v1/roles")]
|
||||||
@@ -125,7 +125,7 @@ class RoleController extends AbstractController
|
|||||||
#[RequestMapping(path: "/api/v1/users/{id}/role", methods: "PUT")]
|
#[RequestMapping(path: "/api/v1/users/{id}/role", methods: "PUT")]
|
||||||
#[Middleware(AuthMiddleware::class)]
|
#[Middleware(AuthMiddleware::class)]
|
||||||
#[Middleware(PermissionMiddleware::class)]
|
#[Middleware(PermissionMiddleware::class)]
|
||||||
public function assignRole(int $id, AuthManager $auth): ResponseInterface|array
|
public function assignRole(int $id): ResponseInterface|array
|
||||||
{
|
{
|
||||||
$target_user = User::query()->with('role')->find($id);
|
$target_user = User::query()->with('role')->find($id);
|
||||||
|
|
||||||
@@ -156,8 +156,8 @@ class RoleController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 防止 administrator 降级自己
|
// 防止 administrator 降级自己
|
||||||
$current_user = $auth->guard('jwt')->user();
|
$current_user = $this->getAuthUser();
|
||||||
if ($current_user instanceof User
|
if ($current_user
|
||||||
&& $current_user->id === $id
|
&& $current_user->id === $id
|
||||||
&& $target_user->isAdministrator()
|
&& $target_user->isAdministrator()
|
||||||
&& $new_role->name !== 'administrator'
|
&& $new_role->name !== 'administrator'
|
||||||
|
|||||||
@@ -48,10 +48,22 @@ class OperationLogService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从当前认证上下文获取用户 ID
|
* 从当前认证上下文获取用户 ID
|
||||||
|
*
|
||||||
|
* 优先从 request attribute 获取(兼容 API Key 认证),再 fallback 到 JWT guard
|
||||||
*/
|
*/
|
||||||
public static function getCurrentUserId(): ?int
|
public static function getCurrentUserId(): ?int
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// 优先从 request context 获取(API Key 认证存储在 auth_user attribute)
|
||||||
|
$request = \Hyperf\Context\Context::get(\Psr\Http\Message\ServerRequestInterface::class);
|
||||||
|
if ($request) {
|
||||||
|
$user = $request->getAttribute('auth_user');
|
||||||
|
if ($user instanceof User) {
|
||||||
|
return $user->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: JWT guard
|
||||||
$container = \Hyperf\Context\ApplicationContext::getContainer();
|
$container = \Hyperf\Context\ApplicationContext::getContainer();
|
||||||
$auth = $container->get(AuthManager::class);
|
$auth = $container->get(AuthManager::class);
|
||||||
$user = $auth->guard('jwt')->user();
|
$user = $auth->guard('jwt')->user();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use Hyperf\Database\Schema\Schema;
|
use Hyperf\Database\Schema\Schema;
|
||||||
use Hyperf\Database\Schema\Blueprint;
|
|
||||||
use Hyperf\Database\Migrations\Migration;
|
use Hyperf\Database\Migrations\Migration;
|
||||||
|
|
||||||
return new class extends Migration
|
return new class extends Migration
|
||||||
@@ -13,13 +12,15 @@ return new class extends Migration
|
|||||||
*/
|
*/
|
||||||
public function up(): void
|
public function up(): void
|
||||||
{
|
{
|
||||||
Schema::table('skus_mapping', function (Blueprint $table) {
|
|
||||||
$table->text('platform_product_id')->nullable()->change();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 删除原有唯一约束(如果存在)并创建部分唯一索引
|
|
||||||
$connection = Schema::getConnection();
|
$connection = Schema::getConnection();
|
||||||
|
|
||||||
|
// 将 platform_product_id 改为可空
|
||||||
|
$connection->statement('ALTER TABLE skus_mapping ALTER COLUMN platform_product_id DROP NOT NULL');
|
||||||
|
|
||||||
|
// 删除原有唯一约束(CONSTRAINT + INDEX 二选一,兼容两种形式)
|
||||||
|
$connection->statement('ALTER TABLE skus_mapping DROP CONSTRAINT IF EXISTS uk_platform_product');
|
||||||
$connection->statement('DROP INDEX IF EXISTS uk_platform_product');
|
$connection->statement('DROP INDEX IF EXISTS uk_platform_product');
|
||||||
|
// 创建部分唯一索引(NULL 值不参与唯一性判断)
|
||||||
$connection->statement(
|
$connection->statement(
|
||||||
'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id) WHERE platform_product_id IS NOT NULL'
|
'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id) WHERE platform_product_id IS NOT NULL'
|
||||||
);
|
);
|
||||||
@@ -31,13 +32,13 @@ return new class extends Migration
|
|||||||
public function down(): void
|
public function down(): void
|
||||||
{
|
{
|
||||||
$connection = Schema::getConnection();
|
$connection = Schema::getConnection();
|
||||||
|
|
||||||
$connection->statement('DROP INDEX IF EXISTS uk_platform_product');
|
$connection->statement('DROP INDEX IF EXISTS uk_platform_product');
|
||||||
$connection->statement(
|
$connection->statement(
|
||||||
'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id)'
|
'CREATE UNIQUE INDEX uk_platform_product ON skus_mapping (platform_id, platform_product_id)'
|
||||||
);
|
);
|
||||||
|
|
||||||
Schema::table('skus_mapping', function (Blueprint $table) {
|
// 恢复 NOT NULL(需确保无 NULL 数据)
|
||||||
$table->text('platform_product_id')->nullable(false)->change();
|
$connection->statement('ALTER TABLE skus_mapping ALTER COLUMN platform_product_id SET NOT NULL');
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace HyperfTest\Cases\Integration\Auth;
|
||||||
|
|
||||||
|
use App\Model\ApiKey;
|
||||||
|
use HyperfTest\TestCase;
|
||||||
|
use HyperfTest\Traits\AuthenticatedTestTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key 认证端到端集成测试
|
||||||
|
*
|
||||||
|
* 验证 API Key 能正确访问各业务端点(products / orders / refunds / api-keys),
|
||||||
|
* 以及无效 Key 被拒绝、JWT 认证仍然正常。
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class ApiKeyAuthEndToEndTest extends TestCase
|
||||||
|
{
|
||||||
|
use AuthenticatedTestTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 admin 用户的 API Key 明文
|
||||||
|
*/
|
||||||
|
protected function getAdminApiKey(): string
|
||||||
|
{
|
||||||
|
// 确保 admin 用户已开启 api_key_enabled
|
||||||
|
$admin_role = $this->fetchAdminRole();
|
||||||
|
$user = $this->fetchUser(static function ($query) use ($admin_role): void {
|
||||||
|
$query->where('status', 1)->where('role_id', $admin_role->id);
|
||||||
|
});
|
||||||
|
$this->runInCoroutine(static function () use ($user): void {
|
||||||
|
if (!$user->api_key_enabled) {
|
||||||
|
$user->api_key_enabled = true;
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
// 清理旧测试 keys,确保不超过上限
|
||||||
|
ApiKey::query()->where('user_id', $user->id)
|
||||||
|
->where('name', 'like', 'E2E Test Key%')
|
||||||
|
->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
$token = $this->getAdminToken();
|
||||||
|
|
||||||
|
$response = $this->post('/api/v1/me/api-keys', [
|
||||||
|
'name' => 'E2E Test Key ' . bin2hex(random_bytes(4)),
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer ' . $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
return $response->json('data.plain_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function apiKeyHeaders(): array
|
||||||
|
{
|
||||||
|
return ['X-API-Key' => $this->getAdminApiKey()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 业务端点 API Key 认证 ==========
|
||||||
|
|
||||||
|
public function test_apikey_auth_products_endpoint(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/products', [], $this->apiKeyHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_apikey_auth_orders_endpoint(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/orders', [], $this->apiKeyHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_apikey_auth_refunds_endpoint(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/refunds', [], $this->apiKeyHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_apikey_auth_list_own_keys(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/me/api-keys', [], $this->apiKeyHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 认证失败场景 ==========
|
||||||
|
|
||||||
|
public function test_invalid_apikey_returns_401(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/products', [], [
|
||||||
|
'X-API-Key' => 'dh_invalid_key_that_does_not_exist_00000000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JWT 认证仍正常 ==========
|
||||||
|
|
||||||
|
public function test_jwt_auth_still_works(): void
|
||||||
|
{
|
||||||
|
$response = $this->get('/api/v1/products', [], $this->authHeaders());
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('code', 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user