Parcourir la source

feat(api-v13): 新增 auth.v3 中间件

v3 的登录闸:没带有效 bearer token 就在进控制器之前 401。别名 auth.v3,
照 OpsToken 的写法挂到路由组或按方法挂(middlewareFor)。

三件事:

- 401 排在 422 前面。中间件跑在 FormRequest 之前,所以「未登录 + 参数非法」
  得到的是 401 而不是 422。这是语义上正确的顺序,写在控制器第一行拿不到。
- 只认 bearer。AuthService::current() 曾经会回落读 $_COOKIE(已删,但语义上
  v3 硬规范本来就要求统一 bearer),这里先自己挡一道 bearerToken() 判空。
  不改 AuthService——它是 v2 也在用的共享代码。
- 解出的用户放进 request attributes,控制器用 ResolvesCurrentUser 的
  currentUser() 直接取,不重复解 JWT。可选登录的端点(如 tally)不挂中间件,
  用 currentUserUid(),同样只认 bearer。

鉴权和 URL 形状是两件事:要让某些动作需要登录,挂中间件就行,不需要把它们塞进
/v3/me/ 前缀。而且中间件只管得了 401,「钥匙对不对」的 403 得靠 Policy 或
Service 里抛 AuthorizationException。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
visuddhinanda il y a 3 semaines
Parent
commit
5eea12a419

+ 55 - 0
api-v13/app/Http/Controllers/Concerns/ResolvesCurrentUser.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace App\Http\Controllers\Concerns;
+
+use App\Http\Middleware\V3\Authenticate;
+use App\Services\AuthService;
+use Illuminate\Auth\AuthenticationException;
+use Illuminate\Http\Request;
+
+/**
+ * 取当前登录用户——控制器的**胶水**动作,不是业务逻辑。
+ *
+ * 放在 trait 里而不是每个控制器各写一个 private 方法:控制器文件里除了入口方法
+ * 不该再有第二个方法(见 v3-resource skill 硬规范第 5 条)。
+ *
+ * 挂了 `auth.v3` 中间件的路由,用户已经解好放在 request attributes 里,这里直接取,
+ * 不重复解 JWT;没挂中间件的路由(如 tally 那种可选登录)走 {@see self::currentUserUid()}。
+ */
+trait ResolvesCurrentUser
+{
+    /**
+     * 必须登录。挂了 `auth.v3` 的路由上它不可能抛——中间件已经挡过一道了。
+     *
+     * @return array{user_uid: string, user_id: int}
+     *
+     * @throws AuthenticationException 未登录(只会发生在忘挂中间件的路由上)
+     */
+    protected function currentUser(Request $request): array
+    {
+        $user = $request->attributes->get(Authenticate::USER);
+        if (is_array($user)) {
+            return $user;
+        }
+
+        throw new AuthenticationException;
+    }
+
+    /**
+     * 可选登录:取到就返回 uuid,没登录返回 null,不抛异常。
+     *
+     * 与中间件一致只认 bearer——`AuthService::current()` 会回落去读 cookie,
+     * 那是 v2 的旁路,v3 不走。
+     */
+    protected function currentUserUid(Request $request): ?string
+    {
+        if ($user = $request->attributes->get(Authenticate::USER)) {
+            return $user['user_uid'];
+        }
+        if (! $request->bearerToken()) {
+            return null;
+        }
+
+        return AuthService::current($request)['user_uid'] ?? null;
+    }
+}

+ 56 - 0
api-v13/app/Http/Middleware/V3/Authenticate.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace App\Http\Middleware\V3;
+
+use App\Services\AuthService;
+use Closure;
+use Illuminate\Auth\AuthenticationException;
+use Illuminate\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * v3 的登录闸:没带有效 bearer token 就在进控制器之前 401。
+ *
+ * 挂法见 routes/v3/*.php,别名 `auth.v3`(在 bootstrap/app.php 注册)。
+ * 可以挂在路由组上,也可以按方法挂:
+ *
+ *     Route::apiResource('channels', ChannelController::class)
+ *         ->middlewareFor(['store', 'update', 'destroy'], 'auth.v3');
+ *
+ * **鉴权和 URL 形状是两件事**:要让某些动作需要登录,挂中间件就行,
+ * 不需要把它们塞进 `/v3/me/` 前缀。
+ *
+ * 两个设计点:
+ *
+ * 1. **只认 bearer。** `AuthService::current()` 拿不到 bearer 时会回落去读
+ *    `$_COOKIE['user_uid']`,那是 v2 的旁路;v3 硬规范要求统一 bearer,所以这里
+ *    先自己挡一道。不改 `AuthService`——它是 v2 也在用的共享代码。
+ * 2. **解出来的用户存进 request attributes**,控制器直接取,不必再解一次 JWT。
+ */
+class Authenticate
+{
+    /**
+     * 解析出的用户存在 request attributes 的哪个键下。
+     */
+    public const USER = 'v3.user';
+
+    /**
+     * @throws AuthenticationException 没带 bearer,或 token 无效 / 过期
+     */
+    public function handle(Request $request, Closure $next): Response
+    {
+        // 不给 AuthenticationException 传消息:bootstrap/app.php 的处理器只按状态码
+        // 渲染,detail 留空,面向人的文案就是 Problem 的 title(RFC 9457 要求它稳定)
+        if (! $request->bearerToken()) {
+            throw new AuthenticationException;
+        }
+        $user = AuthService::current($request);
+        if (! $user) {
+            throw new AuthenticationException;
+        }
+
+        $request->attributes->set(self::USER, $user);
+
+        return $next($request);
+    }
+}

+ 4 - 0
api-v13/bootstrap/app.php

@@ -5,6 +5,7 @@ use App\Http\Middleware\HandleInertiaRequests;
 use App\Http\Middleware\OpsToken;
 use App\Http\Middleware\SetLocale;
 use App\Http\Middleware\UserOperation;
+use App\Http\Middleware\V3\Authenticate as V3Authenticate;
 use Illuminate\Auth\AuthenticationException;
 use Illuminate\Foundation\Application;
 use Illuminate\Foundation\Configuration\Exceptions;
@@ -39,6 +40,9 @@ return Application::configure(basePath: dirname(__DIR__))
 
         $middleware->alias([
             'ops.token' => OpsToken::class,
+            // v3 的登录闸。只认 bearer,未登录在进控制器前就 401——
+            // 所以「未登录 + 参数非法」得到的是 401 而不是 422
+            'auth.v3' => V3Authenticate::class,
         ]);
     })
     ->withExceptions(function (Exceptions $exceptions): void {