Files
datahub/backend/app/Controller/Api/V1/AuthController.php
T
2026-04-17 11:06:34 +08:00

719 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Controller\Api\V1;
use App\Controller\AbstractController;
use App\Middleware\AuthMiddleware;
use App\Model\User;
use App\Service\OperationLogService;
use App\Utils\RequestHelper;
use Carbon\Carbon;
use Hyperf\HttpServer\Annotation\Controller;
use Hyperf\HttpServer\Annotation\Middleware;
use Hyperf\HttpServer\Annotation\RequestMapping;
use Hyperf\HttpServer\Contract\RequestInterface;
use Hyperf\HttpServer\Contract\ResponseInterface;
use OpenApi\Attributes as OA;
use Qbhy\HyperfAuth\AuthManager;
#[OA\Tag(name: 'Auth', description: '认证与个人信息')]
#[Controller(prefix: "/api/v1")]
class AuthController extends AbstractController
{
/**
* 用户注册
*/
#[OA\Post(
path: '/register',
summary: '用户注册',
description: '注册新用户,需提供用户名、密码和邮箱',
tags: ['Auth'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['username', 'password', 'email'],
properties: [
new OA\Property(property: 'username', type: 'string', minLength: 3, maxLength: 20, example: 'new_user'),
new OA\Property(property: 'password', type: 'string', minLength: 6, maxLength: 32, example: 'Pass_1234'),
new OA\Property(property: 'email', type: 'string', format: 'email', maxLength: 100, example: 'user@example.com'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: '注册成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '注册成功'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'username', type: 'string', example: 'new_user'),
new OA\Property(property: 'email', type: 'string', example: 'user@example.com'),
], type: 'object'),
])
),
new OA\Response(response: 400, description: '参数校验失败或唯一性冲突', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "register", methods: "POST")]
public function register(RequestInterface $request, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
{
$username = $request->input('username');
$password = $request->input('password');
$email = $request->input('email');
// 校验 username
if (!is_string($username) || trim($username) === '') {
return $response->json([
'code' => 400,
'message' => '用户名不能为空',
])->withStatus(400);
}
$username = trim($username);
$username_length = strlen($username);
if ($username_length < 3 || $username_length > 20) {
return $response->json([
'code' => 400,
'message' => '用户名长度需在 3-20 个字符',
])->withStatus(400);
}
// 校验 password
if (!is_string($password) || $password === '') {
return $response->json([
'code' => 400,
'message' => '密码不能为空',
])->withStatus(400);
}
$password_length = strlen($password);
if ($password_length < 6 || $password_length > 32) {
return $response->json([
'code' => 400,
'message' => '密码长度需在 6-32 个字符',
])->withStatus(400);
}
// 校验 email
if (!is_string($email) || trim($email) === '') {
return $response->json([
'code' => 400,
'message' => '邮箱不能为空',
])->withStatus(400);
}
$email = trim($email);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $response->json([
'code' => 400,
'message' => '邮箱格式不正确',
])->withStatus(400);
}
if (strlen($email) > 100) {
return $response->json([
'code' => 400,
'message' => '邮箱长度不能超过 100 个字符',
])->withStatus(400);
}
// 唯一性检查
if (User::query()->where('username', $username)->exists()) {
return $response->json([
'code' => 400,
'message' => '用户名已存在',
])->withStatus(400);
}
if (User::query()->where('email', $email)->exists()) {
return $response->json([
'code' => 400,
'message' => '邮箱已被注册',
])->withStatus(400);
}
// 创建用户
$user = User::create([
'username' => $username,
'password' => $password,
'email' => $email,
'status' => 1,
]);
return [
'code' => 0,
'message' => '注册成功',
'data' => [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
],
];
}
/**
* 用户登录
*/
#[OA\Post(
path: '/login',
summary: '用户登录',
description: '使用用户名和密码登录,返回 access_token 和 refresh_token',
tags: ['Auth'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['username', 'password'],
properties: [
new OA\Property(property: 'username', type: 'string', example: 'admin'),
new OA\Property(property: 'password', type: 'string', example: 'Pass_1234'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: '登录成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '登录成功'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'access_token', type: 'string'),
new OA\Property(property: 'refresh_token', type: 'string'),
new OA\Property(property: 'token_type', type: 'string', example: 'Bearer'),
new OA\Property(property: 'expires_in', type: 'integer', example: 7200),
new OA\Property(property: 'user', properties: [
new OA\Property(property: 'id', type: 'integer'),
new OA\Property(property: 'username', type: 'string'),
new OA\Property(property: 'email', type: 'string'),
new OA\Property(property: 'role', type: 'string', example: 'administrator'),
], type: 'object'),
], type: 'object'),
])
),
new OA\Response(response: 400, description: '参数校验失败', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 401, description: '用户名或密码错误', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 403, description: '账号已被禁用', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "login", methods: "POST")]
public function login(RequestInterface $request, ResponseInterface $response, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
{
$username = $request->input('username');
$password = $request->input('password');
// 校验参数
if (!is_string($username) || trim($username) === '') {
return $response->json([
'code' => 400,
'message' => '用户名不能为空',
])->withStatus(400);
}
if (!is_string($password) || $password === '') {
return $response->json([
'code' => 400,
'message' => '密码不能为空',
])->withStatus(400);
}
// 查找用户
$user = User::query()->where('username', trim($username))->first();
if (!$user) {
return $response->json([
'code' => 401,
'message' => '用户名或密码错误',
])->withStatus(401);
}
// 验证密码
if (!$user->verifyPassword($password)) {
return $response->json([
'code' => 401,
'message' => '用户名或密码错误',
])->withStatus(401);
}
// 检查用户状态
if ($user->status !== 1) {
return $response->json([
'code' => 403,
'message' => '账号已被禁用',
])->withStatus(403);
}
$user->load('role');
// 生成 Access Token(注入 role 到 JWT payload,前端从 JWT 解码 role 作为可信权限来源)
$token = $auth->guard('jwt')->login($user, ['role' => $user->role?->name ?? 'accessor']);
// 生成 Refresh Token
$refreshToken = bin2hex(random_bytes(32));
$user->refresh_token = $refreshToken;
$user->refresh_token_expires_at = Carbon::now()->addDays(30);
$user->save();
OperationLogService::log(
user_id: $user->id,
action: 'auth.login',
target_type: 'user',
target_id: $user->id,
description: "用户 {$user->username} 登录",
ip: RequestHelper::getClientIp($this->request),
);
return [
'code' => 0,
'message' => '登录成功',
'data' => [
'access_token' => $token,
'refresh_token' => $refreshToken,
'token_type' => 'Bearer',
'expires_in' => 7200,
'user' => [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'role' => $user->role?->name ?? 'accessor',
],
],
];
}
/**
* 刷新 Access Token
*/
#[OA\Get(
path: '/refresh',
summary: '刷新 Access Token',
description: '使用 refresh_token 获取新的 access_token,同时轮换 refresh_token',
security: [['bearerAuth' => []]],
tags: ['Auth'],
parameters: [
new OA\Parameter(name: 'refresh_token', in: 'query', required: true, description: 'Refresh Token', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Token 刷新成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: 'Token 刷新成功'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'access_token', type: 'string'),
new OA\Property(property: 'refresh_token', type: 'string'),
new OA\Property(property: 'token_type', type: 'string', example: 'Bearer'),
new OA\Property(property: 'expires_in', type: 'integer', example: 7200),
], type: 'object'),
])
),
new OA\Response(response: 400, description: '缺少 refresh_token 参数', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 401, description: 'refresh_token 无效或已过期', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 403, description: '账号已被禁用', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "refresh", methods: "GET")]
#[Middleware(AuthMiddleware::class)]
public function refresh(RequestInterface $request, ResponseInterface $response, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array
{
$refreshToken = $request->input('refresh_token');
if (!is_string($refreshToken) || $refreshToken === '') {
return $response->json([
'code' => 400,
'message' => '缺少 refresh_token 参数',
])->withStatus(400);
}
// 查找用户
$user = User::query()->where('refresh_token', $refreshToken)->first();
if (!$user) {
return $response->json([
'code' => 401,
'message' => '无效的 refresh_token',
])->withStatus(401);
}
// 验证 refresh token 是否过期
if (!$user->isRefreshTokenValid()) {
return $response->json([
'code' => 401,
'message' => 'refresh_token 已过期,请重新登录',
])->withStatus(401);
}
// 检查用户状态
if ($user->status !== 1) {
return $response->json([
'code' => 403,
'message' => '账号已被禁用',
])->withStatus(403);
}
// 生成新的 Access Token(注入 role 到 JWT payload
$user->load('role');
$token = $auth->guard('jwt')->login($user, ['role' => $user->role?->name ?? 'accessor']);
// 生成新的 Refresh Token(轮换以提升安全性)
$newRefreshToken = bin2hex(random_bytes(32));
$user->refresh_token = $newRefreshToken;
$user->refresh_token_expires_at = Carbon::now()->addDays(30);
$user->save();
return [
'code' => 0,
'message' => 'Token 刷新成功',
'data' => [
'access_token' => $token,
'refresh_token' => $newRefreshToken,
'token_type' => 'Bearer',
'expires_in' => 7200,
],
];
}
/**
* 获取当前用户信息
*/
#[OA\Get(
path: '/me',
summary: '获取当前用户信息',
security: [['bearerAuth' => []]],
tags: ['Auth'],
responses: [
new OA\Response(
response: 200,
description: '获取成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '获取成功'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'id', type: 'integer', example: 1),
new OA\Property(property: 'username', type: 'string', example: 'admin'),
new OA\Property(property: 'email', type: 'string', example: 'admin@example.com'),
new OA\Property(property: 'status', type: 'integer', example: 1),
new OA\Property(property: 'api_key_enabled', type: 'boolean', example: true, description: 'API Key 功能是否启用'),
new OA\Property(property: 'ext', type: 'object', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
], type: 'object'),
])
),
new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "me", methods: "GET")]
#[Middleware(AuthMiddleware::class)]
public function me(ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
{
$user = $this->getAuthUser();
if (!$user) {
return $response->json([
'code' => 401,
'message' => '未授权',
])->withStatus(401);
}
$user->load('role');
return [
'code' => 0,
'message' => '获取成功',
'data' => [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'role' => $user->role?->name ?? 'accessor',
'status' => $user->status,
'api_key_enabled' => $user->api_key_enabled,
'ext' => $user->ext,
'created_at' => $user->created_at->toDateTimeString(),
],
];
}
/**
* 更新个人信息
*
* 当前用户更新自己的 email 和 ext 字段
*/
#[OA\Put(
path: '/me/profile',
summary: '更新个人信息',
description: '当前用户更新自己的 email 和 ext 字段',
security: [['bearerAuth' => []]],
tags: ['Auth'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(properties: [
new OA\Property(property: 'email', type: 'string', format: 'email', maxLength: 100, example: 'new@example.com'),
new OA\Property(property: 'ext', type: 'object', nullable: true, example: ['nickname' => 'user']),
])
),
responses: [
new OA\Response(
response: 200,
description: '更新成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '个人信息更新成功'),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'id', type: 'integer'),
new OA\Property(property: 'username', type: 'string'),
new OA\Property(property: 'email', type: 'string'),
new OA\Property(property: 'status', type: 'integer'),
new OA\Property(property: 'ext', type: 'object', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
], type: 'object'),
])
),
new OA\Response(response: 400, description: '参数校验失败或唯一性冲突', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "me/profile", methods: "PUT")]
#[Middleware(AuthMiddleware::class)]
public function updateProfile(RequestInterface $request, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
{
$user = $this->getAuthUser();
if (!$user) {
return $response->json([
'code' => 401,
'message' => '未授权',
])->withStatus(401);
}
$email = $request->input('email');
$ext = $request->input('ext');
$updates = [];
if ($email !== null) {
if (!is_string($email) || trim($email) === '') {
return $response->json([
'code' => 400,
'message' => '邮箱不能为空',
])->withStatus(400);
}
$email = trim($email);
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $response->json([
'code' => 400,
'message' => '邮箱格式不正确',
])->withStatus(400);
}
if (strlen($email) > 100) {
return $response->json([
'code' => 400,
'message' => '邮箱长度不能超过 100 个字符',
])->withStatus(400);
}
if (User::query()->where('email', $email)->where('id', '!=', $user->id)->exists()) {
return $response->json([
'code' => 400,
'message' => '邮箱已被注册',
])->withStatus(400);
}
$updates['email'] = $email;
}
if ($ext !== null) {
if (!is_array($ext)) {
return $response->json([
'code' => 400,
'message' => 'ext 必须为对象',
])->withStatus(400);
}
$updates['ext'] = $ext;
}
if ($updates === []) {
return $response->json([
'code' => 400,
'message' => '缺少可更新字段',
])->withStatus(400);
}
$user->fill($updates);
$user->save();
$user->refresh();
return [
'code' => 0,
'message' => '个人信息更新成功',
'data' => [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'status' => $user->status,
'ext' => $user->ext,
'created_at' => $user->created_at->toDateTimeString(),
],
];
}
/**
* 修改密码
*
* 需验证旧密码,修改成功后清除 refresh_token 强制重新登录
*/
#[OA\Put(
path: '/me/password',
summary: '修改密码',
description: '修改当前用户密码,需验证旧密码。修改成功后清除 refresh_token,需重新登录',
security: [['bearerAuth' => []]],
tags: ['Auth'],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['old_password', 'new_password'],
properties: [
new OA\Property(property: 'old_password', type: 'string', example: 'OldPass_1234'),
new OA\Property(property: 'new_password', type: 'string', minLength: 6, maxLength: 32, example: 'NewPass_5678'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: '密码修改成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '密码修改成功,请重新登录'),
])
),
new OA\Response(response: 400, description: '参数校验失败或旧密码不正确', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "me/password", methods: "PUT")]
#[Middleware(AuthMiddleware::class)]
public function changePassword(RequestInterface $request, ResponseInterface $response): \Psr\Http\Message\ResponseInterface|array
{
$user = $this->getAuthUser();
if (!$user) {
return $response->json([
'code' => 401,
'message' => '未授权',
])->withStatus(401);
}
$old_password = $request->input('old_password');
$new_password = $request->input('new_password');
// 验证旧密码
if (!is_string($old_password) || $old_password === '') {
return $response->json([
'code' => 400,
'message' => '旧密码不能为空',
])->withStatus(400);
}
if (!$user->verifyPassword($old_password)) {
return $response->json([
'code' => 400,
'message' => '旧密码不正确',
])->withStatus(400);
}
// 验证新密码
if (!is_string($new_password) || $new_password === '') {
return $response->json([
'code' => 400,
'message' => '新密码不能为空',
])->withStatus(400);
}
$new_password_length = strlen($new_password);
if ($new_password_length < 6 || $new_password_length > 32) {
return $response->json([
'code' => 400,
'message' => '新密码长度需在 6-32 个字符',
])->withStatus(400);
}
// 修改密码并清除 refresh_token
$user->password = $new_password;
$user->refresh_token = null;
$user->refresh_token_expires_at = null;
$user->save();
OperationLogService::log(
user_id: $user->id,
action: 'user.password_change',
target_type: 'user',
target_id: $user->id,
description: "用户 {$user->username} 修改密码",
ip: RequestHelper::getClientIp($this->request),
);
return [
'code' => 0,
'message' => '密码修改成功,请重新登录',
];
}
/**
* 退出登录
*/
#[OA\Get(
path: '/logout',
summary: '退出登录',
description: '退出登录,清除 refresh_token 并注销当前 JWT token',
security: [['bearerAuth' => []]],
tags: ['Auth'],
responses: [
new OA\Response(
response: 200,
description: '退出成功',
content: new OA\JsonContent(properties: [
new OA\Property(property: 'code', type: 'integer', example: 0),
new OA\Property(property: 'message', type: 'string', example: '退出成功'),
])
),
new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')),
]
)]
#[RequestMapping(path: "logout", methods: "GET")]
#[Middleware(AuthMiddleware::class)]
public function logout(AuthManager $auth): array
{
$user = $this->getAuthUser();
if ($user) {
OperationLogService::log(
user_id: $user->id,
action: 'auth.logout',
target_type: 'user',
target_id: $user->id,
description: "用户 {$user->username} 退出登录",
ip: RequestHelper::getClientIp($this->request),
);
// 清除 refresh token
$user->refresh_token = null;
$user->refresh_token_expires_at = null;
$user->save();
}
// 注销当前 token
$auth->guard('jwt')->logout();
return [
'code' => 0,
'message' => '退出成功',
];
}
}