Просмотр исходного кода

feat: 登录迁到 v3(sessions / me),dashboard-v6 切过去

取代 POST /v2/sign-in 与 GET /v2/auth/current(v2 原样保留,openapi 标 deprecated;
dashboard-v4 与 wikipali-mobile 仍在用)。

- POST /v3/sessions {login, password}:一次返回 {token, user},不再二次查询;
  login 收用户名或邮箱(邮箱不区分大小写),失败统一 422 errors.login;
  按「账号 + IP」与 IP 限流。token 与 v2 签的完全相同,两边互认
- GET /v3/me(auth.v3):用户字段沿用 UserService::profile() 的驼峰口径,
  roles 恒为数组,另加 email,不再回显 token
- PasswordHasher 加 check()(hash_equals),仍是 md5
- v6:SignIn.tsx 一次请求登录,密码不再 trim;?url= 只接受同源地址
  (原先 atob 后直接赋给 location.href,可开放重定向 / javascript: XSS);
  load.ts 启动时用 /v3/me 恢复登录态,只有 401 才清 token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 1 неделя назад
Родитель
Сommit
8c5fc3603f

+ 8 - 8
CLAUDE.md

@@ -245,14 +245,13 @@ v6 全量稳定 → 下线 v4 → 删掉 C 类与 B 类的 v2 残留 → v2 前
 ### 认证迁移待办(2026-10 定)
 ### 认证迁移待办(2026-10 定)
 
 
 v2 认证端点**一律不动**(v4 部署已冻结,修了也上不去),漏洞随 v4 下线消失。
 v2 认证端点**一律不动**(v4 部署已冻结,修了也上不去),漏洞随 v4 下线消失。
-找回密码、注册在 v3 新建端点(见「v2 → v3 对照表」);之后还剩两件:
-
-- **TODO 登录 → `POST /v3/sessions` + `GET /v3/me`**:v6 + mobile 一起切
-  (mobile 的 `src/api/auth.ts` 在调 `/v2/sign-in`、`/v2/auth/current`)。
-  一次请求同时返回 token 与用户,消掉 v6 `SignIn.tsx` 的二次查询;
-  顺带修 `?url=` 跳转(现在 `atob` 后原样赋给 `location.href`,可开放重定向 /
-  `javascript:` XSS),只接受同源相对路径;登录加限流。
-- **TODO v4 下线后:md5 → bcrypt,删 v2 auth 全家**。v2 登录在 SQL 里比对
+找回密码、注册、登录在 v3 新建端点(见「v2 → v3 对照表」);之后还剩两件:
+
+- **TODO mobile 登录切 v3**:后端 `POST /v3/sessions` + `GET /v3/me` 与 v6 已切完;
+  wikipali-mobile 的 `src/api/auth.ts` 仍调 `/v2/sign-in`、`/v2/auth/current`,
+  它是独立仓库,由用户决定何时切。v3 的 user 字段与 v2 auth/current 同名(驼峰),
+  另多 `email`、少回显的 `token`。
+- **TODO v4 下线(且 mobile 已切)后:md5 → bcrypt,删 v2 auth 全家**。v2 登录在 SQL 里比对
   `md5(password)`,v4 在线时密码只能存 md5;v3 写密码统一走
   `md5(password)`,v4 在线时密码只能存 md5;v3 写密码统一走
   `App\Services\V3\PasswordHasher`,届时只改那一处(登录时校验 md5 并就地
   `App\Services\V3\PasswordHasher`,届时只改那一处(登录时校验 md5 并就地
   rehash,或加新列——加列由用户决定)。随后删 `sign-in`、`sign-up`、
   rehash,或加新列——加列由用户决定)。随后删 `sign-in`、`sign-up`、
@@ -333,6 +332,7 @@ Resource / FormRequest / 测试同理;URL 只换版本前缀
 | `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
 | `related-paragraph` / `RelatedParagraphController` | `tipitaka-related-paragraphs` (+`/aggregate`) / `V3\TipitakaRelatedParagraphController` + `V3\TipitakaRelatedParagraphAggregateController` | 字段改名 `book_title_pali`→`title`、`cs6_para`→`cs_para`;响应 `{ok,…}`→原生 Resource;消除逐行 N+1(批量 whereIn);新增 `book_name`/`cs_para` 过滤器与独立 `/aggregate` 聚合端点(`group_by=book_id`→book_name 列表 / `group_by=book_name`→cs_para 列表,分页) |
 | `auth/forgot-password` + `auth/reset-password` / `ForgotPasswordController` + `ResetPasswordController` | `password-resets` (+`/{token}`) / `V3\PasswordResetController` | 三条并成一个资源:POST 发信、GET 看账号、PATCH 设密码(204)。库里只存 token 的 sha256、60 分钟过期、邮箱未注册也 204、不收 `dashboard` 参数(链接用 `mint.server.dashboard_v6_base_path` 拼到 v6 的 `/anonymous/reset-password/{token}`)、限流 |
 | `auth/forgot-password` + `auth/reset-password` / `ForgotPasswordController` + `ResetPasswordController` | `password-resets` (+`/{token}`) / `V3\PasswordResetController` | 三条并成一个资源:POST 发信、GET 看账号、PATCH 设密码(204)。库里只存 token 的 sha256、60 分钟过期、邮箱未注册也 204、不收 `dashboard` 参数(链接用 `mint.server.dashboard_v6_base_path` 拼到 v6 的 `/anonymous/reset-password/{token}`)、限流 |
 | `email-certification` + `invite/{id}`(读)+ `sign-up` / `EmailCertificationController` + `InviteController@show` + `SignUpController@store` | `email-certifications` + `invites` (+`/{invite}`) + `users` / `V3\EmailCertificationController` + `V3\InviteController` + `V3\UserController` | 注册。验证码改在服务端比对:v2 的 `GET email-certification/{id}`(把码交给前端)在 v3 没有对应端点,换成 `POST invites {email, code}` 签发 invite;自助与邀请注册都凭 invite 走 `POST users`。验证码 6 位、30 分钟、试错 5 次作废;验证通过前不写 invites 表。studio 发邀请(`POST /v2/invite`)不在此列,将来迁成 `studios/{studio}/invites` |
 | `email-certification` + `invite/{id}`(读)+ `sign-up` / `EmailCertificationController` + `InviteController@show` + `SignUpController@store` | `email-certifications` + `invites` (+`/{invite}`) + `users` / `V3\EmailCertificationController` + `V3\InviteController` + `V3\UserController` | 注册。验证码改在服务端比对:v2 的 `GET email-certification/{id}`(把码交给前端)在 v3 没有对应端点,换成 `POST invites {email, code}` 签发 invite;自助与邀请注册都凭 invite 走 `POST users`。验证码 6 位、30 分钟、试错 5 次作废;验证通过前不写 invites 表。studio 发邀请(`POST /v2/invite`)不在此列,将来迁成 `studios/{studio}/invites` |
+| `sign-in` + `auth/current` / `AuthController@signIn` + `@getUserInfoByToken` | `sessions` + `me` / `V3\SessionController` + `V3\MeController` | 登录一次返回 `{token, user}`,不再二次查询;`login` 字段收用户名或邮箱(邮箱不区分大小写),失败统一 422 `errors.login`;按账号 + IP 限流。`me` 的字段沿用 v2 的驼峰用户摘要(`UserService::profile()`),`roles` 恒为数组,另加 `email`,不再回显 token。token 与 v2 完全相同、两边互认 |
 
 
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;

+ 33 - 0
api-v13/app/Http/Controllers/V3/MeController.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Resources\V3\MeResource;
+use App\Services\V3\SessionService;
+use Illuminate\Http\Request;
+
+/**
+ * 当前登录用户本人。
+ *
+ * 取代 `GET /v2/auth/current`(`AuthController@getUserInfoByToken`)。v2 那份在
+ * dashboard-v4 下线前原样保留,不要改动;wikipali-mobile 目前也还在用它。
+ */
+class MeController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly SessionService $sessions) {}
+
+    /**
+     * 我的资料
+     *
+     * 前端启动时用已存的 token 调它恢复登录态;401 表示 token 无效或过期,应清掉 token。
+     * 不回传 token(v2 的 auth/current 会把请求里的 token 原样回显)。
+     */
+    public function show(Request $request): MeResource
+    {
+        return MeResource::make($this->sessions->me($this->currentUser($request)['user_uid']));
+    }
+}

+ 42 - 0
api-v13/app/Http/Controllers/V3/SessionController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\StoreSessionRequest;
+use App\Http\Resources\V3\SessionResource;
+use App\Services\V3\SessionService;
+
+/**
+ * 登录。
+ *
+ * 取代 `POST /v2/sign-in`(`AuthController@signIn`)+ 紧随其后的
+ * `GET /v2/auth/current`。v2 那两条在 dashboard-v4 下线前原样保留,不要改动;
+ * wikipali-mobile 目前也还在用它们。
+ */
+class SessionController extends Controller
+{
+    public function __construct(private readonly SessionService $sessions) {}
+
+    /**
+     * 登录
+     *
+     * 用用户名或邮箱 + 密码换取 bearer token,同时返回当前用户资料,前端不必再查一次。
+     * 账号或密码不对返回 422(errors.login),不区分是哪一个错。
+     * 按 IP 与账号限流,超出返回 429。
+     *
+     * @unauthenticated
+     *
+     * @bodyParam login string required 用户名或邮箱(邮箱不区分大小写)
+     * @bodyParam password string required 密码
+     *
+     * @responseStatus 201 登录成功
+     * @responseStatus 429 尝试过于频繁
+     */
+    public function store(StoreSessionRequest $request)
+    {
+        return SessionResource::make(
+            $this->sessions->signIn($request->validated('login'), $request->validated('password'))
+        )->response()->setStatusCode(201);
+    }
+}

+ 28 - 0
api-v13/app/Http/Requests/V3/StoreSessionRequest.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `POST /v3/sessions` 的入参:用户名或邮箱 + 密码。
+ */
+class StoreSessionRequest extends FormRequest
+{
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            // 用户名(varchar 64)或邮箱(varchar 256)
+            'login' => ['required', 'string', 'max:256'],
+            'password' => ['required', 'string', 'max:64'],
+        ];
+    }
+}

+ 46 - 0
api-v13/app/Http/Resources/V3/MeResource.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\UserService;
+use App\Services\V3\SessionService;
+use Illuminate\Http\Request;
+
+/**
+ * 当前登录用户本人的资料。
+ *
+ * 前几个字段与 v3 其他资源里的 user 摘要同形({@see UserService::profile()},
+ * 驼峰是 v2 留下的既有口径,前端的 IUser 就是这个形状);roles 恒为数组,
+ * 另加只有本人能看的 email。
+ *
+ * 载荷是 {@see SessionService} 拼好的数组。
+ */
+class MeResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     nickName: string,
+     *     userName: string,
+     *     realName: string,
+     *     sn: int,
+     *     avatar: string|null,
+     *     roles: string[],
+     *     email: string
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => $this->resource['id'],
+            'nickName' => $this->resource['nickName'],
+            'userName' => $this->resource['userName'],
+            'realName' => $this->resource['realName'],
+            'sn' => $this->resource['sn'],
+            'avatar' => $this->resource['avatar'],
+            'roles' => $this->resource['roles'],
+            'email' => $this->resource['email'],
+        ];
+    }
+}

+ 29 - 0
api-v13/app/Http/Resources/V3/SessionResource.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use Illuminate\Http\Request;
+
+/**
+ * 一次登录的结果:bearer token + 当前用户资料。
+ *
+ * token 是 JWT,有效期一年,与 v2 `POST /v2/sign-in` 签出的完全相同、两边互认。
+ * 服务端不保存会话,退出登录就是客户端丢掉 token。
+ */
+class SessionResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     token: string,
+     *     user: array{id: string, nickName: string, userName: string, realName: string, sn: int, avatar: string|null, roles: string[], email: string}
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'token' => $this->resource['token'],
+            'user' => MeResource::make($this->resource['user'])->toArray($request),
+        ];
+    }
+}

+ 6 - 0
api-v13/app/Providers/AppServiceProvider.php

@@ -129,6 +129,12 @@ class AppServiceProvider extends ServiceProvider
 
 
         // 比对验证码、建账号:按 IP 限,挡住对 6 位验证码的穷举(单个码另有 5 次试错上限)
         // 比对验证码、建账号:按 IP 限,挡住对 6 位验证码的穷举(单个码另有 5 次试错上限)
         RateLimiter::for('sign-up', fn (Request $request): Limit => Limit::perMinute(20)->by('ip:'.$request->ip()));
         RateLimiter::for('sign-up', fn (Request $request): Limit => Limit::perMinute(20)->by('ip:'.$request->ip()));
+
+        // 登录:按「账号 + IP」挡住对单个账号的猜密码,按 IP 挡住换着账号撞库
+        RateLimiter::for('sessions', fn (Request $request): array => [
+            Limit::perMinute(5)->by('login:'.mb_strtolower((string) $request->input('login')).'|'.$request->ip()),
+            Limit::perMinute(30)->by('ip:'.$request->ip()),
+        ]);
     }
     }
 
 
     /**
     /**

+ 8 - 0
api-v13/app/Services/V3/PasswordHasher.php

@@ -17,4 +17,12 @@ class PasswordHasher
     {
     {
         return md5($password);
         return md5($password);
     }
     }
+
+    /**
+     * 明文是否与库里存的哈希相符。用 hash_equals 防计时攻击。
+     */
+    public function check(string $password, string $stored): bool
+    {
+        return hash_equals($stored, $this->hash($password));
+    }
 }
 }

+ 91 - 0
api-v13/app/Services/V3/SessionService.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Models\UserInfo;
+use App\Services\AuthService;
+use App\Services\UserService;
+use Illuminate\Validation\ValidationException;
+
+/**
+ * 登录与「我是谁」。
+ *
+ * 与 v2 的 `AuthController@signIn` / `@getUserInfoByToken` 不共用代码(CLAUDE.md 铁律 2)。
+ * 签 token 用共享的 `AuthService::getUserToken()`,v2 / v3 签出的 token 完全一样,
+ * 两边互认——v4 登录拿到的 token 在 v3 也能用,反之亦然。
+ *
+ * 与 v2 的区别:
+ * - 登录一次就返回 token 与用户信息,前端不必再调一次 `auth/current`。
+ * - 邮箱登录不区分大小写;用户名仍区分(与 user_infos 的唯一索引一致)。
+ * - 密码比对走 {@see PasswordHasher::check()}(hash_equals);v2 在 SQL 里比 md5。
+ * - 登录失败统一 422 `errors.login`,不区分「没这个人」与「密码错」。
+ */
+class SessionService
+{
+    public function __construct(
+        private readonly PasswordHasher $hasher,
+        private readonly UserService $users,
+    ) {}
+
+    /**
+     * 用用户名或邮箱 + 密码登录。
+     *
+     * @return array{token: string, user: array<string, mixed>}
+     *
+     * @throws ValidationException 账号或密码不对
+     */
+    public function signIn(string $login, string $password): array
+    {
+        $user = UserInfo::where('username', $login)
+            ->orWhereRaw('lower(email) = ?', [mb_strtolower($login)])
+            ->first();
+
+        if (! $user || ! $this->hasher->check($password, (string) $user->password)) {
+            throw ValidationException::withMessages(['login' => __('auth.failed')]);
+        }
+
+        $token = AuthService::getUserToken($user->userid);
+        if (! $token) {
+            // getUserToken 只在查无此人时返回 null,而上面刚查到——走到这里是数据不一致
+            abort(500);
+        }
+
+        return [
+            'token' => $token,
+            'user' => $this->profile($user),
+        ];
+    }
+
+    /**
+     * 当前登录用户的资料。token 有效但账号已不存在(被删)时 401。
+     *
+     * @return array<string, mixed>
+     */
+    public function me(string $userUid): array
+    {
+        $user = UserInfo::where('userid', $userUid)->first();
+        if (! $user) {
+            abort(401);
+        }
+
+        return $this->profile($user);
+    }
+
+    /**
+     * 公开摘要(与 v3 其他资源里的 user 同形)+ 本人才需要的补充:
+     * roles 恒为数组(没有就是 []),root 管理员固定是 ['root']——与 v2 auth/current 一致。
+     *
+     * @return array<string, mixed>
+     */
+    private function profile(UserInfo $user): array
+    {
+        $profile = $this->users->profile($user);
+        $profile['roles'] = $user->userid === config('mint.admin.root_uuid')
+            ? ['root']
+            : (is_array($profile['roles'] ?? null) ? $profile['roles'] : []);
+        $profile['avatar'] ??= null;
+        $profile['email'] = $user->email;
+
+        return $profile;
+    }
+}

+ 10 - 2
api-v13/routes/v3/auth.php

@@ -7,13 +7,13 @@
 |
 |
 | 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
 | 由 routes/api.php 的 v3 组 require 进来,前缀已在那边加好。
 |
 |
-| 登录(sessions / me)还在 v2,见根目录 CLAUDE.md「认证迁移待办」。
-|
 */
 */
 
 
 use App\Http\Controllers\V3\EmailCertificationController;
 use App\Http\Controllers\V3\EmailCertificationController;
 use App\Http\Controllers\V3\InviteController;
 use App\Http\Controllers\V3\InviteController;
+use App\Http\Controllers\V3\MeController;
 use App\Http\Controllers\V3\PasswordResetController;
 use App\Http\Controllers\V3\PasswordResetController;
+use App\Http\Controllers\V3\SessionController;
 use App\Http\Controllers\V3\UserController;
 use App\Http\Controllers\V3\UserController;
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Facades\Route;
 
 
@@ -43,3 +43,11 @@ Route::get('invites/{invite}', [InviteController::class, 'show'])
 Route::post('users', [UserController::class, 'store'])
 Route::post('users', [UserController::class, 'store'])
     ->middleware('throttle:sign-up')
     ->middleware('throttle:sign-up')
     ->name('users.store');
     ->name('users.store');
+
+// 登录与「我是谁」。退出登录是客户端丢掉 token,服务端不存会话,所以没有 DELETE sessions
+Route::post('sessions', [SessionController::class, 'store'])
+    ->middleware('throttle:sessions')
+    ->name('sessions.store');
+Route::get('me', [MeController::class, 'show'])
+    ->middleware('auth.v3')
+    ->name('me.show');

+ 124 - 0
api-v13/tests/Feature/SessionV3Test.php

@@ -0,0 +1,124 @@
+<?php
+
+use App\Models\UserInfo;
+use App\Services\AuthService;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+
+uses(RefreshDatabase::class);
+
+/**
+ * v3 登录与「我是谁」。这里断言的是形状而非契约(spectator 未安装)。
+ *
+ * 端点:
+ *   POST /api/v3/sessions   用户名或邮箱 + 密码 → token + 用户
+ *   GET  /api/v3/me         当前用户(bearer)
+ */
+
+/**
+ * 建一个密码为 $password 的账号,返回 userid。
+ */
+function makeAccount(string $username, string $password = 'secret-pw', ?string $role = null): string
+{
+    $uid = makeStudio($username);
+    UserInfo::where('userid', $uid)->update(['password' => md5($password), 'role' => $role]);
+
+    return $uid;
+}
+
+it('signs in with a username and returns token and user in one response', function () {
+    $uid = makeAccount('alice_1', 'secret-pw', json_encode(['basic']));
+
+    $data = $this->postJson('/api/v3/sessions', ['login' => 'alice_1', 'password' => 'secret-pw'])
+        ->assertCreated()
+        ->json('data');
+
+    expect($data)->toHaveKeys(['token', 'user'])
+        ->and($data['user'])->toHaveKeys(['id', 'nickName', 'userName', 'realName', 'avatar', 'roles', 'email'])
+        ->and($data['user']['id'])->toBe($uid)
+        ->and($data['user']['roles'])->toBe(['basic'])
+        ->and($data['user'])->not->toHaveKey('password');
+
+    // token 能直接用来访问 v3 与 v2(两边互认)
+    $this->getJson('/api/v3/me', ['Authorization' => 'Bearer '.$data['token']])
+        ->assertOk()
+        ->assertJsonPath('data.id', $uid);
+    $this->getJson('/api/v2/auth/current', ['Authorization' => 'Bearer '.$data['token']])
+        ->assertJsonPath('ok', true);
+});
+
+it('signs in with an email, case-insensitively', function () {
+    makeAccount('bob_1');
+
+    $this->postJson('/api/v3/sessions', ['login' => 'BOB_1@Example.Test', 'password' => 'secret-pw'])
+        ->assertCreated()
+        ->assertJsonPath('data.user.userName', 'bob_1');
+});
+
+it('does not trim the password', function () {
+    makeAccount('carol_1', ' spaced ');
+
+    $this->postJson('/api/v3/sessions', ['login' => 'carol_1', 'password' => ' spaced '])->assertCreated();
+    $this->postJson('/api/v3/sessions', ['login' => 'carol_1', 'password' => 'spaced'])->assertStatus(422);
+});
+
+it('rejects a wrong password and an unknown account the same way', function () {
+    makeAccount('dave_1');
+
+    $wrong = $this->postJson('/api/v3/sessions', ['login' => 'dave_1', 'password' => 'nope'])
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['login']])
+        ->json('errors.login.0');
+    $unknown = $this->postJson('/api/v3/sessions', ['login' => 'nobody_1', 'password' => 'nope'])
+        ->assertStatus(422)
+        ->json('errors.login.0');
+
+    expect($wrong)->toBe($unknown);
+});
+
+it('requires login and password', function () {
+    $this->postJson('/api/v3/sessions', [])
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['login', 'password']]);
+});
+
+it('throttles guessing on one account', function () {
+    makeAccount('erin_1');
+
+    foreach (range(1, 5) as $_) {
+        $this->postJson('/api/v3/sessions', ['login' => 'erin_1', 'password' => 'nope'])->assertStatus(422);
+    }
+
+    $this->postJson('/api/v3/sessions', ['login' => 'erin_1', 'password' => 'secret-pw'])->assertStatus(429);
+});
+
+it('reports the root admin as root', function () {
+    $uid = makeAccount('root_1');
+    config(['mint.admin.root_uuid' => $uid]);
+
+    $this->postJson('/api/v3/sessions', ['login' => 'root_1', 'password' => 'secret-pw'])
+        ->assertCreated()
+        ->assertJsonPath('data.user.roles', ['root']);
+});
+
+it('returns an empty roles array when the account has none', function () {
+    $uid = makeAccount('frank_1');
+
+    $this->getJson('/api/v3/me', authHeader($uid))
+        ->assertOk()
+        ->assertJsonPath('data.roles', [])
+        ->assertJsonPath('data.avatar', null)
+        ->assertJsonMissingPath('data.token');
+});
+
+it('rejects /me without a valid token', function () {
+    $this->getJson('/api/v3/me')->assertUnauthorized();
+    $this->getJson('/api/v3/me', ['Authorization' => 'Bearer garbage'])->assertUnauthorized();
+});
+
+it('rejects /me when the account no longer exists', function () {
+    $uid = makeAccount('gone_1');
+    $token = AuthService::getUserToken($uid);
+    UserInfo::where('userid', $uid)->delete();
+
+    $this->getJson('/api/v3/me', ['Authorization' => 'Bearer '.$token])->assertUnauthorized();
+});

+ 159 - 2
dashboard-v6/src/api/schema.d.ts

@@ -937,7 +937,8 @@ export interface paths {
         };
         };
         /**
         /**
          * getUserInfoByToken auth/current
          * getUserInfoByToken auth/current
-         * @description 实现:`AuthController@getUserInfoByToken`
+         * @deprecated
+         * @description 已被 `GET /v3/me` 取代。保留给 dashboard-v4 与 wikipali-mobile(尚未切换),待两者都不再使用后删除。
          */
          */
         get: operations["get_api_v2_auth_current"];
         get: operations["get_api_v2_auth_current"];
         put?: never;
         put?: never;
@@ -5908,7 +5909,8 @@ export interface paths {
         put?: never;
         put?: never;
         /**
         /**
          * signIn sign-in
          * signIn sign-in
-         * @description 实现:`AuthController@signIn`
+         * @deprecated
+         * @description 已被 `POST /v3/sessions` 取代。保留给 dashboard-v4 与 wikipali-mobile(尚未切换),待两者都不再使用后删除。
          */
          */
         post: operations["post_api_v2_sign_in"];
         post: operations["post_api_v2_sign_in"];
         delete?: never;
         delete?: never;
@@ -7803,6 +7805,29 @@ export interface paths {
         patch?: never;
         patch?: never;
         trace?: never;
         trace?: never;
     };
     };
+    "/v3/me": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 我的资料
+         * @description 前端启动时用已存的 token 调它恢复登录态;401 表示 token 无效或过期,应清掉 token。
+         *     不回传 token(v2 的 auth/current 会把请求里的 token 原样回显)。
+         *
+         *     实现:`V3\MeController@show`
+         */
+        get: operations["get_api_v3_me"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/me/reactions": {
     "/v3/me/reactions": {
         parameters: {
         parameters: {
             query?: never;
             query?: never;
@@ -8056,6 +8081,30 @@ export interface paths {
         patch?: never;
         patch?: never;
         trace?: never;
         trace?: never;
     };
     };
+    "/v3/sessions": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        get?: never;
+        put?: never;
+        /**
+         * 登录
+         * @description 用用户名或邮箱 + 密码换取 bearer token,同时返回当前用户资料,前端不必再查一次。
+         *     账号或密码不对返回 422(errors.login),不区分是哪一个错。
+         *     按 IP 与账号限流,超出返回 429。
+         *
+         *     实现:`V3\SessionController@store`
+         */
+        post: operations["post_api_v3_sessions"];
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/tipitaka-reading/{channel}": {
     "/v3/tipitaka-reading/{channel}": {
         parameters: {
         parameters: {
             query?: never;
             query?: never;
@@ -34375,6 +34424,38 @@ export interface operations {
             422: components["responses"]["ProblemValidation"];
             422: components["responses"]["ProblemValidation"];
         };
         };
     };
     };
+    get_api_v3_me: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            nickName?: string;
+                            userName?: string;
+                            realName?: string;
+                            sn?: number;
+                            avatar?: string | null;
+                            roles?: string[];
+                            email?: string;
+                        };
+                    };
+                };
+            };
+            401: components["responses"]["ProblemUnauthorized"];
+        };
+    };
     get_api_v3_me_reactions: {
     get_api_v3_me_reactions: {
         parameters: {
         parameters: {
             query?: {
             query?: {
@@ -35012,6 +35093,82 @@ export interface operations {
             422: components["responses"]["ProblemValidation"];
             422: components["responses"]["ProblemValidation"];
         };
         };
     };
     };
+    post_api_v3_sessions: {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        requestBody: {
+            content: {
+                "application/json": {
+                    /** @description 用户名或邮箱(邮箱不区分大小写) */
+                    login: string;
+                    /** @description 密码 */
+                    password: string;
+                };
+            };
+        };
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            token?: string;
+                            user?: {
+                                id?: string;
+                                nickName?: string;
+                                userName?: string;
+                                realName?: string;
+                                sn?: number;
+                                avatar?: string | null;
+                                roles?: string[];
+                                email?: string;
+                            };
+                        };
+                    };
+                };
+            };
+            /** @description 登录成功 */
+            201: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            token?: string;
+                            user?: {
+                                id?: string;
+                                nickName?: string;
+                                userName?: string;
+                                realName?: string;
+                                sn?: number;
+                                avatar?: string | null;
+                                roles?: string[];
+                                email?: string;
+                            };
+                        };
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+            /** @description 尝试过于频繁 */
+            429: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/problem+json": components["schemas"]["ProblemDetails"];
+                };
+            };
+        };
+    };
     get_api_v3_tipitaka_reading_channel_: {
     get_api_v3_tipitaka_reading_channel_: {
         parameters: {
         parameters: {
             query?: {
             query?: {

+ 49 - 0
dashboard-v6/src/api/session.ts

@@ -0,0 +1,49 @@
+// src/api/session.ts
+//
+// 登录与「我是谁」。**已完全切到 v3**(取代 /v2/sign-in 与 /v2/auth/current),
+// 没有 v2 分支,要回退就 revert 那个 commit。
+
+import { api, unwrapQuiet } from "./client";
+import type { paths } from "./schema";
+import type { IUser } from "../reducers/current-user";
+
+type Me = NonNullable<
+  paths["/v3/me"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
+
+/**
+ * v3 的用户资料 → store 里的 IUser。
+ *
+ * 字段名本来就一致(v3 沿用了驼峰的用户摘要口径),这里只把规格里的可选 / 可空
+ * 收成 IUser 要的必填值。
+ */
+const toUser = (me: Me): IUser => ({
+  id: me.id ?? "",
+  nickName: me.nickName ?? "",
+  realName: me.realName ?? "",
+  avatar: me.avatar ?? "",
+  roles: me.roles ?? [],
+});
+
+/**
+ * 用户名或邮箱 + 密码登录,一次请求同时拿到 token 与用户资料。
+ *
+ * 用 unwrapQuiet:账号密码不对(422)由登录表单自己显示,不该再弹窗。
+ */
+export const signInWithPassword = async (
+  login: string,
+  password: string,
+): Promise<{ token: string; user: IUser }> => {
+  const data = unwrapQuiet(
+    await api.POST("/v3/sessions", { body: { login, password } }),
+  ).data;
+  return { token: data?.token ?? "", user: toUser(data?.user ?? {}) };
+};
+
+/**
+ * 用已存的 token 取当前用户(应用启动时恢复登录态)。
+ *
+ * 用 unwrapQuiet:401 是「token 失效」的正常信号,由调用方清掉 token,不弹窗。
+ */
+export const fetchMe = async (): Promise<IUser> =>
+  toUser(unwrapQuiet(await api.GET("/v3/me")).data ?? {});

+ 52 - 58
dashboard-v6/src/components/users/SignIn.tsx

@@ -3,31 +3,37 @@ import { ProForm, ProFormText } from "@ant-design/pro-components";
 import { Alert, message } from "antd";
 import { Alert, message } from "antd";
 import { useNavigate, useSearchParams } from "react-router";
 import { useNavigate, useSearchParams } from "react-router";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
 import { EyeInvisibleOutlined, EyeTwoTone } from "@ant-design/icons";
+import { useState } from "react";
 
 
 import { useAppDispatch } from "../../hooks";
 import { useAppDispatch } from "../../hooks";
-import { type IUser, signIn, TO_WORKSPACE } from "../../reducers/current-user";
-import { get, post } from "../../request";
-import { useState } from "react";
-import { set } from "../../reducers/session";
+import { signIn, TO_WORKSPACE } from "../../reducers/current-user";
+import { signInWithPassword } from "../../api/session";
+import { ApiError } from "../../api/error";
 
 
 interface IFormData {
 interface IFormData {
-  email: string;
-  password: string;
-}
-interface ISignInResponse {
-  ok: boolean;
-  message: string;
-  data: string;
-}
-interface IUserResponse {
-  ok: boolean;
-  message: string;
-  data: IUser;
-}
-interface ISignInRequest {
-  username: string;
+  login: string;
   password: string;
   password: string;
 }
 }
+
+/**
+ * 登录后要回到的地址。
+ *
+ * `?url=` 是 base64 编码的完整地址(见 LoginButton)。只接受**同源**地址:
+ * 原先 `atob()` 后原样赋给 location.href,传 `javascript:` 就是 XSS,
+ * 传别的域名就是开放重定向。
+ */
+const safeReturnUrl = (encoded: string | null): string | null => {
+  if (!encoded) {
+    return null;
+  }
+  try {
+    const url = new URL(atob(encoded), window.location.origin);
+    return url.origin === window.location.origin ? url.href : null;
+  } catch {
+    return null;
+  }
+};
+
 const Widget = () => {
 const Widget = () => {
   const intl = useIntl();
   const intl = useIntl();
   const dispatch = useAppDispatch();
   const dispatch = useAppDispatch();
@@ -41,56 +47,44 @@ const Widget = () => {
       <ProForm<IFormData>
       <ProForm<IFormData>
         onFinish={async (values: IFormData) => {
         onFinish={async (values: IFormData) => {
           setError(undefined);
           setError(undefined);
-          const user = {
-            username: values.email.trim(),
-            password: values.password.trim(),
-          };
-          const res = await post<ISignInRequest, ISignInResponse>(
-            "/api/v2/sign-in",
-            user
-          );
-          if (!res.ok) {
-            setError("用户名或密码错误");
-            return;
-          }
-          //TODO sign-in 同时返回用户信息,之后直接dispatch 无需二次查询
-          set(res.data);
+          let session: Awaited<ReturnType<typeof signInWithPassword>>;
           try {
           try {
-            const json = await get<IUserResponse>("/api/v2/auth/current");
-            if (!json.ok) {
-              setError("用户名或密码错误");
-              console.error(json.message);
-              return;
-            }
-            console.debug("获取用户信息成功", json.data);
-            dispatch(signIn([json.data, res.data]));
-            let url: string | null = null;
-            searchParams.forEach((value: string, key: string) => {
-              if (key === "url") {
-                url = value;
-              }
-            });
-            if (url) {
-              window.location.href = atob(url);
-            } else {
-              navigate(TO_WORKSPACE);
-            }
-            message.success(intl.formatMessage({ id: "flashes.success" }));
+            // 只去掉账号两端的空格;密码原样提交,空格也是密码的一部分
+            session = await signInWithPassword(
+              values.login.trim(),
+              values.password,
+            );
           } catch (e) {
           } catch (e) {
-            console.error(e);
-            setError("登录失败,请重试");
+            setError(
+              e instanceof ApiError && e.status === 422
+                ? intl.formatMessage({ id: "auth.sign-in.failed" })
+                : e instanceof Error
+                  ? e.message
+                  : String(e),
+            );
+            return;
+          }
+
+          dispatch(signIn([session.user, session.token]));
+          message.success(intl.formatMessage({ id: "flashes.success" }));
+
+          const back = safeReturnUrl(searchParams.get("url"));
+          if (back) {
+            window.location.href = back;
+          } else {
+            navigate(TO_WORKSPACE);
           }
           }
         }}
         }}
       >
       >
         <ProForm.Group>
         <ProForm.Group>
           <ProFormText
           <ProFormText
             width="md"
             width="md"
-            name="email"
+            name="login"
             required
             required
             label={intl.formatMessage({
             label={intl.formatMessage({
               id: "forms.fields.email.or.username.label",
               id: "forms.fields.email.or.username.label",
             })}
             })}
-            rules={[{ required: true, max: 255, min: 4 }]}
+            rules={[{ required: true, max: 256 }]}
           />
           />
         </ProForm.Group>
         </ProForm.Group>
         <ProForm.Group>
         <ProForm.Group>
@@ -105,7 +99,7 @@ const Widget = () => {
             label={intl.formatMessage({
             label={intl.formatMessage({
               id: "forms.fields.password.label",
               id: "forms.fields.password.label",
             })}
             })}
-            rules={[{ required: true, max: 32, min: 4 }]}
+            rules={[{ required: true, max: 64 }]}
           />
           />
         </ProForm.Group>
         </ProForm.Group>
       </ProForm>
       </ProForm>

+ 11 - 30
dashboard-v6/src/load.ts

@@ -4,10 +4,12 @@
 import {
 import {
   get as getToken,
   get as getToken,
   guest,
   guest,
-  type IUser,
   signIn,
   signIn,
   studioSignIn,
   studioSignIn,
 } from "./reducers/current-user";
 } from "./reducers/current-user";
+import { remove as removeToken } from "./reducers/session";
+import { fetchMe } from "./api/session";
+import { ApiError } from "./api/error";
 //import { DURATION } from "./reducers/current-user";
 //import { DURATION } from "./reducers/current-user";
 import { type ISite, refresh as refreshLayout } from "./reducers/layout";
 import { type ISite, refresh as refreshLayout } from "./reducers/layout";
 import {
 import {
@@ -39,20 +41,6 @@ export interface ISiteInfoResponse {
   subhead: string;
   subhead: string;
   models?: ISettingModels;
   models?: ISettingModels;
 }
 }
-interface IUserData {
-  id: string;
-  nickName: string;
-  realName: string;
-  avatar: string;
-  roles: string[];
-  token: string;
-}
-export interface ITokenRefreshResponse {
-  ok: boolean;
-  message: string;
-  data: IUserData;
-}
-
 interface ITermResponse {
 interface ITermResponse {
   ok: boolean;
   ok: boolean;
   message: string;
   message: string;
@@ -95,22 +83,15 @@ const init = () => {
   //获取用户登录信息
   //获取用户登录信息
   const token = getToken();
   const token = getToken();
   if (token) {
   if (token) {
-    get<ITokenRefreshResponse>("/api/v2/auth/current").then((response) => {
-      console.log("auth", response);
-      if (response.ok) {
-        const it: IUser = {
-          id: response.data.id,
-          nickName: response.data.nickName,
-          realName: response.data.realName,
-          avatar: response.data.avatar,
-          roles: response.data.roles,
-        };
-        store.dispatch(signIn([it, response.data.token]));
-      } else {
-        localStorage.removeItem("token");
+    // token 失效(401)才清掉;网络或服务故障时保留,下次启动再试
+    fetchMe()
+      .then((user) => store.dispatch(signIn([user, token])))
+      .catch((e: unknown) => {
+        if (e instanceof ApiError && e.status === 401) {
+          removeToken();
+        }
         store.dispatch(guest(true));
         store.dispatch(guest(true));
-      }
-    });
+      });
 
 
     get<IGroupMemberListResponse>("/api/v2/group-member?view=user").then(
     get<IGroupMemberListResponse>("/api/v2/group-member?view=user").then(
       (response) => {
       (response) => {

+ 1 - 0
dashboard-v6/src/locales/en-US/auth/index.ts

@@ -40,6 +40,7 @@ const items = {
   "auth.sign-up.success.hint": "Sign in with your username or email.",
   "auth.sign-up.success.hint": "Sign in with your username or email.",
   "auth.sign-up.invite.invalid":
   "auth.sign-up.invite.invalid":
     "This invitation is invalid or has already been used",
     "This invitation is invalid or has already been used",
+  "auth.sign-in.failed": "Incorrect username or password",
 };
 };
 
 
 export default items;
 export default items;

+ 1 - 0
dashboard-v6/src/locales/zh-Hans/auth/index.ts

@@ -37,6 +37,7 @@ const items = {
   "auth.sign-up.success": "注册成功",
   "auth.sign-up.success": "注册成功",
   "auth.sign-up.success.hint": "请用用户名或邮箱登录。",
   "auth.sign-up.success.hint": "请用用户名或邮箱登录。",
   "auth.sign-up.invite.invalid": "邀请无效或已被使用",
   "auth.sign-up.invite.invalid": "邀请无效或已被使用",
+  "auth.sign-in.failed": "用户名或密码错误",
 };
 };
 
 
 export default items;
 export default items;

+ 1 - 0
dashboard-v6/src/locales/zh-Hant/auth/index.ts

@@ -37,6 +37,7 @@ const items = {
   "auth.sign-up.success": "註冊成功",
   "auth.sign-up.success": "註冊成功",
   "auth.sign-up.success.hint": "請用用戶名或郵箱登入。",
   "auth.sign-up.success.hint": "請用用戶名或郵箱登入。",
   "auth.sign-up.invite.invalid": "邀請無效或已被使用",
   "auth.sign-up.invite.invalid": "邀請無效或已被使用",
+  "auth.sign-in.failed": "用戶名或密碼錯誤",
 };
 };
 
 
 export default items;
 export default items;

+ 4 - 0
openapi/public/assets/protocol/main.yaml

@@ -689,6 +689,8 @@ paths:
     $ref: ./resources/auto/v3-invites.yaml
     $ref: ./resources/auto/v3-invites.yaml
   '/v3/invites/{invite}':
   '/v3/invites/{invite}':
     $ref: ./resources/auto/v3-invites-invite.yaml
     $ref: ./resources/auto/v3-invites-invite.yaml
+  /v3/me:
+    $ref: ./resources/auto/v3-me.yaml
   /v3/me/reactions:
   /v3/me/reactions:
     $ref: ./resources/auto/v3-me-reactions.yaml
     $ref: ./resources/auto/v3-me-reactions.yaml
   '/v3/me/reactions/{reaction}':
   '/v3/me/reactions/{reaction}':
@@ -709,6 +711,8 @@ paths:
     $ref: ./resources/auto/v3-search-suggest.yaml
     $ref: ./resources/auto/v3-search-suggest.yaml
   '/v3/search/{search}':
   '/v3/search/{search}':
     $ref: ./resources/auto/v3-search-search.yaml
     $ref: ./resources/auto/v3-search-search.yaml
+  /v3/sessions:
+    $ref: ./resources/auto/v3-sessions.yaml
   '/v3/tipitaka-reading/{channel}':
   '/v3/tipitaka-reading/{channel}':
     $ref: ./resources/auto/v3-tipitaka-reading-channel.yaml
     $ref: ./resources/auto/v3-tipitaka-reading-channel.yaml
   /v3/tipitaka-related-paragraphs:
   /v3/tipitaka-related-paragraphs:

+ 3 - 0
openapi/public/assets/protocol/overrides/v2-auth-current.yaml

@@ -1,5 +1,8 @@
 # 手写补充:GET /v2/auth/current 的 data 逐字段类型(对齐移动端 AuthUser)。
 # 手写补充:GET /v2/auth/current 的 data 逐字段类型(对齐移动端 AuthUser)。
 get:
 get:
+  deprecated: true
+  description: |
+    已被 `GET /v3/me` 取代。保留给 dashboard-v4 与 wikipali-mobile(尚未切换),待两者都不再使用后删除。
   responses:
   responses:
     200:
     200:
       content:
       content:

+ 3 - 0
openapi/public/assets/protocol/overrides/v2-sign-in.yaml

@@ -1,5 +1,8 @@
 # 手写补充:POST /v2/sign-in 的 data 是 token 字符串,不是对象。
 # 手写补充:POST /v2/sign-in 的 data 是 token 字符串,不是对象。
 post:
 post:
+  deprecated: true
+  description: |
+    已被 `POST /v3/sessions` 取代。保留给 dashboard-v4 与 wikipali-mobile(尚未切换),待两者都不再使用后删除。
   responses:
   responses:
     200:
     200:
       content:
       content:

+ 3 - 1
openapi/public/assets/protocol/resources/auto/v2-auth-current.yaml

@@ -4,7 +4,8 @@ get:
   tags:
   tags:
     - auth
     - auth
   operationId: get_api_v2_auth_current
   operationId: get_api_v2_auth_current
-  description: 实现:`AuthController@getUserInfoByToken`
+  description: |
+    已被 `GET /v3/me` 取代。保留给 dashboard-v4 与 wikipali-mobile(尚未切换),待两者都不再使用后删除。
   responses:
   responses:
     200:
     200:
       description: '成功。业务失败时 HTTP 仍可能为 200,以 ok 字段为准。'
       description: '成功。业务失败时 HTTP 仍可能为 200,以 ok 字段为准。'
@@ -36,3 +37,4 @@ get:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true

+ 3 - 1
openapi/public/assets/protocol/resources/auto/v2-sign-in.yaml

@@ -4,7 +4,8 @@ post:
   tags:
   tags:
     - sign-in
     - sign-in
   operationId: post_api_v2_sign_in
   operationId: post_api_v2_sign_in
-  description: 实现:`AuthController@signIn`
+  description: |
+    已被 `POST /v3/sessions` 取代。保留给 dashboard-v4 与 wikipali-mobile(尚未切换),待两者都不再使用后删除。
   requestBody:
   requestBody:
     required: false
     required: false
     content:
     content:
@@ -35,3 +36,4 @@ post:
       $ref: '../../main.yaml#/components/responses/Unauthorized'
       $ref: '../../main.yaml#/components/responses/Unauthorized'
     422:
     422:
       $ref: '../../main.yaml#/components/responses/ValidationError'
       $ref: '../../main.yaml#/components/responses/ValidationError'
+  deprecated: true

+ 43 - 0
openapi/public/assets/protocol/resources/auto/v3-me.yaml

@@ -0,0 +1,43 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-me.yaml
+get:
+  summary: 我的资料
+  tags:
+    - me
+  operationId: get_api_v3_me
+  description: |-
+    前端启动时用已存的 token 调它恢复登录态;401 表示 token 无效或过期,应清掉 token。
+    不回传 token(v2 的 auth/current 会把请求里的 token 原样回显)。
+
+    实现:`V3\MeController@show`
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  id:
+                    type: string
+                  nickName:
+                    type: string
+                  userName:
+                    type: string
+                  realName:
+                    type: string
+                  sn:
+                    type: integer
+                  avatar:
+                    type: string
+                    nullable: true
+                  roles:
+                    type: array
+                    items:
+                      type: string
+                  email:
+                    type: string
+    401:
+      $ref: '../../main.yaml#/components/responses/ProblemUnauthorized'

+ 83 - 0
openapi/public/assets/protocol/resources/auto/v3-sessions.yaml

@@ -0,0 +1,83 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-sessions.yaml
+post:
+  summary: 登录
+  tags:
+    - sessions
+  operationId: post_api_v3_sessions
+  description: |-
+    用用户名或邮箱 + 密码换取 bearer token,同时返回当前用户资料,前端不必再查一次。
+    账号或密码不对返回 422(errors.login),不区分是哪一个错。
+    按 IP 与账号限流,超出返回 429。
+
+    实现:`V3\SessionController@store`
+  requestBody:
+    required: true
+    content:
+      application/json:
+        schema:
+          type: object
+          properties:
+            login:
+              type: string
+              description: 用户名或邮箱(邮箱不区分大小写)
+            password:
+              type: string
+              description: 密码
+          required:
+            - login
+            - password
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  token:
+                    type: string
+                  user:
+                    type: object
+                    properties:
+                      id: { type: string }
+                      nickName: { type: string }
+                      userName: { type: string }
+                      realName: { type: string }
+                      sn: { type: integer }
+                      avatar: { type: string, nullable: true }
+                      roles: { type: array, items: { type: string } }
+                      email: { type: string }
+    201:
+      description: 登录成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: object
+                properties:
+                  token:
+                    type: string
+                  user:
+                    type: object
+                    properties:
+                      id: { type: string }
+                      nickName: { type: string }
+                      userName: { type: string }
+                      realName: { type: string }
+                      sn: { type: integer }
+                      avatar: { type: string, nullable: true }
+                      roles: { type: array, items: { type: string } }
+                      email: { type: string }
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'
+    429:
+      description: 尝试过于频繁
+      content:
+        application/problem+json:
+          schema:
+            $ref: '../../main.yaml#/components/schemas/ProblemDetails'