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,
);
}
}