feat: 添加 Laravel Sanctum 支持,增强管理员 API 鉴权,更新相关中间件与路由配置

This commit is contained in:
2026-05-09 11:11:46 +08:00
parent e478597d13
commit 8a70c029f6
20 changed files with 717 additions and 14 deletions

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Auth;
use App\Services\AdminCaptchaService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/**
* GET /api/v1/admin/auth/captcha
*
* 返回一次性验证码 Key SVGBase64 便于前台直接赋值给 img.src
*/
final class CaptchaController
{
public function __invoke(AdminCaptchaService $captcha): JsonResponse
{
$payload = $captcha->create();
return ApiResponse::success([
'captcha_key' => $payload['captcha_key'],
'image_base64' => $payload['image_base64'],
]);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Api\V1\Admin\Auth;
use App\Lottery\ErrorCode;
use App\Models\AdminUser;
use App\Services\AdminCaptchaService;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* POST /api/v1/admin/auth/login
*
* Body: account登录账号 username 对应ASCII 仅存小写比对、password、captcha_key、captcha_code。
*/
final class LoginController
{
public function __invoke(Request $request, AdminCaptchaService $captcha): JsonResponse
{
$locale = $request->lotteryLocale();
/** @var array{account:string,password:string,captcha_key:string,captcha_code:string} $data */
$data = validator($request->all(), [
'account' => ['required', 'string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'],
'password' => ['required', 'string', 'max:256'],
'captcha_key' => ['required', 'string', 'uuid'],
'captcha_code' => ['required', 'string', 'max:32'],
], [], [
'account' => 'account',
'password' => 'password',
'captcha_key' => 'captcha_key',
'captcha_code' => 'captcha_code',
])->validate();
if (! $captcha->verify($data['captcha_key'], $data['captcha_code'])) {
return ApiResponse::error(
trans('admin.invalid_captcha', [], $locale),
ErrorCode::AdminCaptchaInvalid->value,
null,
422,
);
}
$normalizedAccount = Str::lower(trim($data['account']));
/** @var AdminUser|null $admin */
$admin = AdminUser::query()->where('username', $normalizedAccount)->first();
$passwordOk = $admin !== null && Hash::check($data['password'], $admin->password);
if (! $passwordOk) {
/** 统一措辞,弱化枚举用户 */
return ApiResponse::error(
trans('admin.invalid_credentials', [], $locale),
ErrorCode::AdminCredentialsInvalid->value,
null,
401,
);
}
if ((int) $admin->status !== 0) {
return ApiResponse::error(
trans('admin.account_disabled', [], $locale),
ErrorCode::AdminAccountDisabled->value,
null,
403,
);
}
$plainToken = $admin->createToken('admin-api')->plainTextToken;
$admin->forceFill(['last_login_at' => now()])->save();
return ApiResponse::success([
'token' => $plainToken,
'token_type' => 'Bearer',
'admin' => [
'id' => $admin->id,
'username' => $admin->username,
'nickname' => $admin->name,
'email' => $admin->email,
],
]);
}
}

View File

@@ -7,7 +7,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/**
* 无需登录(当前 admin 中间件为直通):确认 `/api/v1/admin` 前缀可达。
* Bearer Token 必填({@see EnsureAdminApi} + Sanctum确认 `/api/v1/admin` 鉴权链路可达。
* 路由GET /api/v1/admin/ping
*/
class PingController extends Controller

View File

@@ -2,19 +2,43 @@
namespace App\Http\Middleware;
use App\Lottery\ErrorCode;
use App\Models\AdminUser;
use App\Support\ApiResponse;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 后台 API 守卫:后续在此注入 Sanctumadmin_users与权限校验。
*
* 当前为占位直通,勿在生产暴露敏感 admin 路由前长期保持空实现。
* 后台 API`auth:sanctum` 之后执行,校验为 {@link AdminUser} 且未禁用;
* 上下文可通过 `$request->lotteryAdmin()` 读取。
*/
class EnsureAdminApi
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof AdminUser) {
return ApiResponse::error(
trans('admin.unauthenticated', [], $request->lotteryLocale()),
ErrorCode::AdminUnauthenticated->value,
null,
401,
);
}
if ((int) $user->status !== 0) {
return ApiResponse::error(
trans('admin.account_disabled', [], $request->lotteryLocale()),
ErrorCode::AdminAccountDisabled->value,
null,
403,
);
}
$request->attributes->set('lottery_admin', $user);
return $next($request);
}
}

View File

@@ -8,6 +8,18 @@ namespace App\Lottery;
*/
enum ErrorCode: int
{
/** 管理端 API未登录或 Token 无效 */
case AdminUnauthenticated = 8110;
/** 管理端登录:验证码错误或过期 */
case AdminCaptchaInvalid = 8111;
/** 管理端登录:账号或密码不匹配(对外统一措辞) */
case AdminCredentialsInvalid = 8112;
/** 管理端登录:账号已禁用 */
case AdminAccountDisabled = 8113;
/** 表单 / Query 校验失败(见 ValidationException → 422 */
case ValidationFailed = 9001;

View File

@@ -4,14 +4,17 @@ namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class AdminUser extends Authenticatable
{
use HasApiTokens;
use Notifiable;
protected $table = 'admin_users';
protected $fillable = [
'username',
'name',
'email',
'password',

View File

@@ -2,8 +2,11 @@
namespace App\Providers;
use App\Models\AdminUser;
use App\Models\Player;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -36,5 +39,20 @@ class AppServiceProvider extends ServiceProvider
return (string) ($this->attributes->get('lottery_locale')
?? config('lottery.locales.fallback', 'en'));
});
Request::macro('lotteryAdmin', function (): ?AdminUser {
/** @var Request $this */
$v = $this->attributes->get('lottery_admin');
return $v instanceof AdminUser ? $v : null;
});
RateLimiter::for('admin-auth-captcha', function (Request $request) {
return Limit::perMinute(45)->by($request->ip());
});
RateLimiter::for('admin-auth-login', function (Request $request) {
return Limit::perMinute(15)->by($request->ip());
});
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
/**
* 后台登录图形验证码SVG 产出 + Cache 短时保存答案摘要(单行文本,便于前台用 img[src=data:...] 展示)。
*/
class AdminCaptchaService
{
private const PREFIX = 'admin_captcha:';
private const TTL_SECONDS = 120;
/** 剔除易混淆字符 */
private const CHARSET = '23456789abcdefghjkmpqrstuvwxyz';
/**
* @return array{captcha_key: string, image_svg: string, image_base64: string}
*/
public function create(): array
{
$code = $this->randomCode();
$key = (string) Str::uuid();
Cache::put(
self::PREFIX.$key,
$this->digest($code),
now()->addSeconds(self::TTL_SECONDS),
);
$svg = $this->renderSvg($code);
return [
'captcha_key' => $key,
'image_svg' => $svg,
'image_base64' => base64_encode($svg),
];
}
public function verify(?string $captchaKey, ?string $captchaInput): bool
{
if ($captchaKey === null || $captchaKey === ''
|| $captchaInput === null || trim($captchaInput) === '') {
return false;
}
$digest = Cache::pull(self::PREFIX.$captchaKey);
if ($digest === null) {
return false;
}
$guess = strtolower(trim($captchaInput));
return hash_equals($digest, $this->digest($guess));
}
private function digest(string $normalizedCode): string
{
return hash_hmac(
'sha256',
$normalizedCode,
(string) config('app.key'),
);
}
private function randomCode(int $length = 4): string
{
$out = '';
$max = strlen(self::CHARSET) - 1;
for ($i = 0; $i < $length; $i++) {
$out .= self::CHARSET[random_int(0, $max)];
}
return $out;
}
private function renderSvg(string $code): string
{
$w = 160;
$h = 48;
$bg = '#fafafa';
$escape = static fn (string $c): string => htmlspecialchars($c, ENT_XML1 | ENT_QUOTES);
$lines = '';
for ($i = 0; $i < 4; $i++) {
$x1 = random_int(0, $w);
$y1 = random_int(0, $h);
$x2 = random_int(0, $w);
$y2 = random_int(0, $h);
$stroke = sprintf('#%06x', random_int(0x9_00_000, 0xBF_BF_BF));
$lines .= sprintf(
'<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1" opacity="0.35"/>',
$x1,
$y1,
$x2,
$y2,
$stroke,
);
}
$chars = str_split($code);
$texts = '';
$x = 18;
foreach ($chars as $ch) {
$rot = random_int(-20, 20);
$dy = random_int(-3, 3);
$y = 32 + $dy;
$fill = sprintf('#%06x', random_int(0x20_20_20, 0x3F_3F_3F));
$texts .= sprintf(
'<text x="%d" y="%d" font-family="ui-monospace,monospace" font-size="24" font-weight="600" fill="%s" transform="rotate(%d %d %d)">%s</text>',
$x,
$y,
$fill,
$rot,
$x,
$y,
$escape($ch),
);
$x += 34;
}
return sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d"><rect width="100%%" height="100%%" fill="%s"/>%s%s</svg>',
$w,
$h,
$w,
$h,
$bg,
$lines,
$texts,
);
}
}

View File

@@ -15,6 +15,7 @@ use App\Http\Middleware\NegotiateLotteryLocale;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use App\Support\LotteryLocale;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@@ -54,6 +55,19 @@ return Application::configure(basePath: dirname(__DIR__))
return (string) ($request->attributes->get('lottery_locale') ?? LotteryLocale::resolve($request));
};
$exceptions->render(function (AuthenticationException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
}
return ApiResponse::error(
trans('admin.unauthenticated', [], $locale($request)),
ErrorCode::AdminUnauthenticated->value,
null,
401,
);
});
$exceptions->render(function (ValidationException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;

View File

@@ -12,6 +12,7 @@
"php": "^8.3",
"firebase/php-jwt": "^6.11",
"laravel/framework": "^13.7",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0"
},
"require-dev": {

65
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "709df8b7c1a41b9d01918fea32398dfb",
"content-hash": "0921908c2ff678b179a811ae39a6c12b",
"packages": [
{
"name": "brick/math",
@@ -1173,6 +1173,69 @@
},
"time": "2026-04-20T16:07:33+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.3.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
"reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0|^13.0",
"illuminate/contracts": "^11.0|^12.0|^13.0",
"illuminate/database": "^11.0|^12.0|^13.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"php": "^8.2",
"symfony/console": "^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2026-04-30T11:46:25+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.13",

87
config/sanctum.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => ValidateCsrfToken::class,
],
];

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
Schema::table('admin_users', function (Blueprint $table) {
$table->string('username', 64)->nullable()->after('id');
});
$this->backfillUsernames();
Schema::table('admin_users', function (Blueprint $table) {
$table->string('username', 64)->nullable(false)->change();
$table->unique('username');
});
Schema::table('admin_users', function (Blueprint $table) {
$table->dropUnique(['email']);
});
Schema::table('admin_users', function (Blueprint $table) {
$table->string('email')->nullable()->change();
});
}
public function down(): void
{
Schema::table('admin_users', function (Blueprint $table) {
$table->dropUnique(['username']);
});
Schema::table('admin_users', function (Blueprint $table) {
$table->dropColumn('username');
});
Schema::table('admin_users', function (Blueprint $table) {
$table->string('email')->nullable(false)->change();
});
Schema::table('admin_users', function (Blueprint $table) {
$table->unique('email');
});
}
private function backfillUsernames(): void
{
$reserved = [];
foreach (DB::table('admin_users')->orderBy('id')->cursor() as $row) {
$email = (string) $row->email;
$local = Str::lower(Str::before($email, '@'));
$slug = preg_replace('/[^a-z0-9._-]/', '', $local);
$base = Str::substr($slug !== '' ? $slug : 'admin'.(string) $row->id, 0, 50);
if ($base === '') {
$base = 'admin'.(string) $row->id;
}
$candidate = $base;
$n = 0;
while (in_array($candidate, $reserved, true)
|| DB::table('admin_users')->where('username', $candidate)->where('id', '!=', $row->id)->exists()) {
$n++;
$suffix = '_'.$n;
$candidate = Str::substr($base, 0, 64 - strlen($suffix)).$suffix;
}
$reserved[] = $candidate;
DB::table('admin_users')->where('id', $row->id)->update(['username' => $candidate]);
}
}
};

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 后台角色super_admin、若干权限占位本地演示**admin@admin.com** / **123456**(仅限非 production
* 后台角色super_admin、若干权限占位本地演示账号 **admin** / **123456**(仅限非 production
*/
class AdminRbacAndUserSeeder extends Seeder
{
@@ -50,11 +50,12 @@ class AdminRbacAndUserSeeder extends Seeder
);
}
$email = 'admin@admin.com';
$username = 'admin';
AdminUser::query()->updateOrCreate(
['email' => $email],
['username' => $username],
[
'name' => 'admin',
'name' => '超级管理员',
'email' => null,
/** 明文;模型 casts `password => hashed`,勿在生产库使用种子弱口令 */
'password' => '123456',
'status' => 0,
@@ -62,7 +63,7 @@ class AdminRbacAndUserSeeder extends Seeder
);
/** @var int $uid */
$uid = (int) AdminUser::query()->where('email', $email)->value('id');
$uid = (int) AdminUser::query()->where('username', $username)->value('id');
DB::table('admin_user_roles')->updateOrInsert(
['admin_user_id' => $uid, 'role_id' => $rid],
[],

8
lang/en/admin.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
return [
'unauthenticated' => 'Unauthenticated or session expired.',
'invalid_captcha' => 'Invalid or expired captcha.',
'invalid_credentials' => 'Invalid account or password.',
'account_disabled' => 'This account has been disabled.',
];

8
lang/ne/admin.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
return [
'unauthenticated' => 'प्रमाणीकरण छैन वा सेसन समाप्त भएको छ।',
'invalid_captcha' => 'क्याप्चा गलत वा समय सकियो।',
'invalid_credentials' => 'खाता वा पासवर्ड गलत।',
'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।',
];

8
lang/zh/admin.php Normal file
View File

@@ -0,0 +1,8 @@
<?php
return [
'unauthenticated' => '未登录或登录已失效。',
'invalid_captcha' => '验证码错误或已过期,请重试。',
'invalid_credentials' => '账号或密码错误。',
'account_disabled' => '该账号已被禁用。',
];

View File

@@ -1,5 +1,7 @@
<?php
use App\Http\Controllers\Api\V1\Admin\Auth\CaptchaController;
use App\Http\Controllers\Api\V1\Admin\Auth\LoginController;
use App\Http\Controllers\Api\V1\Admin\PingController as AdminPingController;
use App\Http\Controllers\Api\V1\HealthController;
use App\Http\Controllers\Api\V1\Player\MeController;
@@ -38,11 +40,20 @@ Route::prefix('v1')->group(function (): void {
});
});
Route::middleware('lottery.admin')
->prefix('admin')
Route::prefix('admin')
->name('api.v1.admin.')
->group(function (): void {
// 名称:后台接口连通性探测
Route::get('ping', AdminPingController::class)->name('ping');
Route::middleware('throttle:admin-auth-captcha')
->get('auth/captcha', CaptchaController::class)
->name('auth.captcha');
Route::middleware('throttle:admin-auth-login')
->post('auth/login', LoginController::class)
->name('auth.login');
Route::middleware(['auth:sanctum', 'lottery.admin'])->group(function (): void {
// 名称:后台接口连通性探测(需 Bearer Token
Route::get('ping', AdminPingController::class)->name('ping');
});
});
});

View File

@@ -0,0 +1,83 @@
<?php
use App\Lottery\ErrorCode;
use App\Models\AdminUser;
use App\Services\AdminCaptchaService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
test('admin ping requires authentication', function () {
$this->getJson('/api/v1/admin/ping')->assertUnauthorized()
->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value);
});
test('admin login returns bearer token when captcha passes validation', function () {
AdminUser::query()->create([
'username' => 'tester',
'name' => '测试昵称',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
]);
$mock = Mockery::mock(AdminCaptchaService::class);
$mock->shouldReceive('verify')->once()->andReturn(true);
$this->instance(AdminCaptchaService::class, $mock);
$resp = $this->postJson('/api/v1/admin/auth/login', [
'account' => 'Tester',
'password' => 'secret-strong',
'captcha_key' => (string) Str::uuid(),
'captcha_code' => 'xwz2',
]);
$resp->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.admin.username', 'tester')
->assertJsonPath('data.admin.nickname', '测试昵称')
->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email']]]);
$token = $resp->json('data.token');
expect($token)->not->toBeNull();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/ping')
->assertOk()
->assertJsonPath('data.scope', 'admin');
});
test('admin captcha exposes key and image base64', function () {
$resp = $this->getJson('/api/v1/admin/auth/captcha');
$resp->assertOk()
->assertJsonPath('code', 0);
$data = $resp->json('data');
expect($data)->toBeArray()
->and(Str::isUuid((string) $data['captcha_key']))->toBeTrue()
->and((string) $data['image_base64'])->not->toBe('');
});
test('login rejects wrong password with masked message', function () {
AdminUser::query()->create([
'username' => 'bad_tester',
'name' => 'X',
'email' => null,
'password' => 'right-only',
'status' => 0,
]);
$mock = Mockery::mock(AdminCaptchaService::class);
$mock->shouldReceive('verify')->andReturn(true);
$this->instance(AdminCaptchaService::class, $mock);
$this->postJson('/api/v1/admin/auth/login', [
'account' => 'bad_tester',
'password' => 'wrong-password',
'captcha_key' => (string) Str::uuid(),
'captcha_code' => 'aaaa',
])->assertUnauthorized()
->assertJsonPath('code', ErrorCode::AdminCredentialsInvalid->value);
});