feat: 添加 Laravel Sanctum 支持,增强管理员 API 鉴权,更新相关中间件与路由配置
This commit is contained in:
25
app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php
Normal file
25
app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php
Normal 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 与 SVG(Base64 便于前台直接赋值给 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php
Normal file
88
app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
137
app/Services/AdminCaptchaService.php
Normal file
137
app/Services/AdminCaptchaService.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user