From 8a70c029f6d4b6c52e4e10da1d16881c1a8b22e5 Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 9 May 2026 11:11:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Laravel=20Sanctum?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=EF=BC=8C=E5=A2=9E=E5=BC=BA=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=20API=20=E9=89=B4=E6=9D=83=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=9B=B8=E5=85=B3=E4=B8=AD=E9=97=B4=E4=BB=B6=E4=B8=8E?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/V1/Admin/Auth/CaptchaController.php | 25 ++++ .../Api/V1/Admin/Auth/LoginController.php | 88 +++++++++++ .../Api/V1/Admin/PingController.php | 2 +- app/Http/Middleware/EnsureAdminApi.php | 30 +++- app/Lottery/ErrorCode.php | 12 ++ app/Models/AdminUser.php | 3 + app/Providers/AppServiceProvider.php | 18 +++ app/Services/AdminCaptchaService.php | 137 ++++++++++++++++++ bootstrap/app.php | 14 ++ composer.json | 1 + composer.lock | 65 ++++++++- config/sanctum.php | 87 +++++++++++ ...35_create_personal_access_tokens_table.php | 33 +++++ ...name_and_nullable_email_to_admin_users.php | 79 ++++++++++ database/seeders/AdminRbacAndUserSeeder.php | 11 +- lang/en/admin.php | 8 + lang/ne/admin.php | 8 + lang/zh/admin.php | 8 + routes/api.php | 19 ++- tests/Feature/AdminAuthLoginTest.php | 83 +++++++++++ 20 files changed, 717 insertions(+), 14 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php create mode 100644 app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php create mode 100644 app/Services/AdminCaptchaService.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_05_09_023835_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php create mode 100644 lang/en/admin.php create mode 100644 lang/ne/admin.php create mode 100644 lang/zh/admin.php create mode 100644 tests/Feature/AdminAuthLoginTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php b/app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php new file mode 100644 index 0000000..5c32685 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php @@ -0,0 +1,25 @@ +create(); + + return ApiResponse::success([ + 'captcha_key' => $payload['captcha_key'], + 'image_base64' => $payload['image_base64'], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php new file mode 100644 index 0000000..9aa378d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php @@ -0,0 +1,88 @@ +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, + ], + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/PingController.php b/app/Http/Controllers/Api/V1/Admin/PingController.php index 3b8d794..71febb7 100644 --- a/app/Http/Controllers/Api/V1/Admin/PingController.php +++ b/app/Http/Controllers/Api/V1/Admin/PingController.php @@ -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 diff --git a/app/Http/Middleware/EnsureAdminApi.php b/app/Http/Middleware/EnsureAdminApi.php index 0742e60..71e0621 100644 --- a/app/Http/Middleware/EnsureAdminApi.php +++ b/app/Http/Middleware/EnsureAdminApi.php @@ -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 守卫:后续在此注入 Sanctum(admin_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); } } diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php index c117725..856cbf8 100644 --- a/app/Lottery/ErrorCode.php +++ b/app/Lottery/ErrorCode.php @@ -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; diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php index 3ef991d..3c20a01 100644 --- a/app/Models/AdminUser.php +++ b/app/Models/AdminUser.php @@ -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', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b58ce0d..2b9490f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()); + }); } } diff --git a/app/Services/AdminCaptchaService.php b/app/Services/AdminCaptchaService.php new file mode 100644 index 0000000..983c6a6 --- /dev/null +++ b/app/Services/AdminCaptchaService.php @@ -0,0 +1,137 @@ +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( + '', + $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( + '%s', + $x, + $y, + $fill, + $rot, + $x, + $y, + $escape($ch), + ); + $x += 34; + } + + return sprintf( + '%s%s', + $w, + $h, + $w, + $h, + $bg, + $lines, + $texts, + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index b38605f..456fce6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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; diff --git a/composer.json b/composer.json index c41cca7..6fce83c 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/composer.lock b/composer.lock index 4e769d8..119db8e 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + 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, + ], + +]; diff --git a/database/migrations/2026_05_09_023835_create_personal_access_tokens_table.php b/database/migrations/2026_05_09_023835_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_05_09_023835_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php b/database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php new file mode 100644 index 0000000..4d886ef --- /dev/null +++ b/database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php @@ -0,0 +1,79 @@ +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]); + } + } +}; diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php index 42a8ba9..1f39853 100644 --- a/database/seeders/AdminRbacAndUserSeeder.php +++ b/database/seeders/AdminRbacAndUserSeeder.php @@ -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], [], diff --git a/lang/en/admin.php b/lang/en/admin.php new file mode 100644 index 0000000..e475da2 --- /dev/null +++ b/lang/en/admin.php @@ -0,0 +1,8 @@ + 'Unauthenticated or session expired.', + 'invalid_captcha' => 'Invalid or expired captcha.', + 'invalid_credentials' => 'Invalid account or password.', + 'account_disabled' => 'This account has been disabled.', +]; diff --git a/lang/ne/admin.php b/lang/ne/admin.php new file mode 100644 index 0000000..5ad3782 --- /dev/null +++ b/lang/ne/admin.php @@ -0,0 +1,8 @@ + 'प्रमाणीकरण छैन वा सेसन समाप्त भएको छ।', + 'invalid_captcha' => 'क्याप्चा गलत वा समय सकियो।', + 'invalid_credentials' => 'खाता वा पासवर्ड गलत।', + 'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।', +]; diff --git a/lang/zh/admin.php b/lang/zh/admin.php new file mode 100644 index 0000000..55b6584 --- /dev/null +++ b/lang/zh/admin.php @@ -0,0 +1,8 @@ + '未登录或登录已失效。', + 'invalid_captcha' => '验证码错误或已过期,请重试。', + 'invalid_credentials' => '账号或密码错误。', + 'account_disabled' => '该账号已被禁用。', +]; diff --git a/routes/api.php b/routes/api.php index 7f2e167..2c0fae0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,5 +1,7 @@ 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'); + }); }); }); diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php new file mode 100644 index 0000000..0d9e0e1 --- /dev/null +++ b/tests/Feature/AdminAuthLoginTest.php @@ -0,0 +1,83 @@ +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); +});