Files
qztool/backend/app/Services/AuthService.php
T
DevandCursor 60daff2365 搭建轻舟一期门户、后台和统一登录。
让分类、会员和示例工具共用同一账号,本地即可跑通登录与授权。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:46:35 +08:00

432 lines
17 KiB
PHP

<?php
namespace App\Services;
use App\Models\LoginLog;
use App\Models\MessageLog;
use App\Models\OauthAccessToken;
use App\Models\OauthRefreshToken;
use App\Models\User;
use App\Models\UserIdentity;
use App\Models\UserSession;
use App\Models\VerificationCode;
use App\Support\Presenter;
use App\Support\Settings;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class AuthService
{
public function publicKeyPem(): string
{
$details = openssl_pkey_get_details(openssl_pkey_get_private($this->privateKey()));
return $details['key'];
}
public function decrypt(string $cipher): string
{
$raw = base64_decode($cipher, true);
if ($raw === false) {
throw ValidationException::withMessages(['password' => '密码格式无效']);
}
$ok = openssl_private_decrypt($raw, $plain, $this->privateKey(), OPENSSL_PKCS1_OAEP_PADDING);
if (! $ok || ! is_string($plain) || $plain === '') {
throw ValidationException::withMessages(['password' => '密码无法解密']);
}
return $plain;
}
public function assertPassword(string $plain): void
{
$longEnough = mb_strlen($plain) >= 8;
$hasLetter = preg_match('/\p{L}/u', $plain) === 1;
$hasDigit = preg_match('/\d/', $plain) === 1;
if (! $longEnough || ! $hasLetter || ! $hasDigit) {
throw ValidationException::withMessages(['password' => '密码至少 8 位,并同时包含字母和数字']);
}
}
public function captcha(): array
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
$key = Str::random(40);
Cache::put('captcha:'.$key, strtolower($code), now()->addMinutes(5));
$svg = '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="42">'
.'<rect width="120" height="42" fill="#f4efe6"/>'
.'<text x="12" y="28" font-size="22" font-family="monospace" fill="#0f4c5c" letter-spacing="4">'.$code.'</text>'
.'</svg>';
return ['key' => $key, 'image' => 'data:image/svg+xml;base64,'.base64_encode($svg)];
}
public function sendCode(string $scene, string $channel, string $target, string $ip): array
{
$target = $this->normalize($channel === 'email' ? 'email' : 'phone', $target);
$this->assertTarget($channel, $target);
if ($channel === 'email' && ! Settings::bool('mail_enabled')) {
throw ValidationException::withMessages(['account' => '邮箱通道未开启']);
}
if (config('qz.sms_driver') !== 'debug') {
throw new HttpException(503, '短信通道尚未配置');
}
$exists = UserIdentity::query()->where('type', $channel === 'email' ? 'email' : 'phone')->where('identifier', $target)->exists();
if ($scene === 'register' && $exists) {
throw ValidationException::withMessages(['account' => '该账号已注册']);
}
if (in_array($scene, ['login', 'reset'], true) && ! $exists && ! ($scene === 'login' && Settings::bool('sms_register_creates_user', true) && $channel === 'sms')) {
throw ValidationException::withMessages(['account' => '账号不存在']);
}
if ($scene === 'bind' && $exists) {
throw ValidationException::withMessages(['account' => '该账号已绑定其他用户']);
}
$dayKey = 'code-day:'.$target.':'.now()->toDateString();
$ipKey = 'code-ip:'.$ip.':'.now()->toDateString();
if ((int) Cache::get($dayKey, 0) >= 10 || (int) Cache::get($ipKey, 0) >= 30) {
throw ValidationException::withMessages(['account' => '发送次数已达今日上限']);
}
if (! Cache::add('code-wait:'.$target, 1, 60)) {
throw ValidationException::withMessages(['account' => '请稍后再获取验证码']);
}
$code = (string) config('qz.debug_sms_code');
VerificationCode::query()->create([
'scene' => $scene,
'channel' => $channel,
'target' => $target,
'code_hash' => hash('sha256', $code),
'expires_at' => now()->addMinutes(5),
'ip' => $ip,
]);
Cache::put($dayKey, (int) Cache::get($dayKey, 0) + 1, now()->endOfDay());
Cache::put($ipKey, (int) Cache::get($ipKey, 0) + 1, now()->endOfDay());
MessageLog::query()->create([
'channel' => $channel,
'target_mask' => Presenter::mask($channel === 'email' ? 'email' : 'phone', $target),
'scene' => $scene,
'status' => 'debug',
]);
$payload = ['sent' => true];
if (app()->environment('local')) {
$payload['debug_code'] = $code;
}
return $payload;
}
public function verifyCode(string $scene, string $target, string $code): void
{
$failKey = 'code-fail:'.$scene.':'.$target;
if ((int) Cache::get($failKey, 0) >= 5) {
throw ValidationException::withMessages(['code' => '验证码错误次数过多,请稍后再试']);
}
$row = VerificationCode::query()
->where('scene', $scene)
->where('target', $target)
->whereNull('consumed_at')
->latest('id')
->first();
$valid = $row && $row->expires_at->isFuture() && hash_equals($row->code_hash, hash('sha256', $code));
if (! $valid) {
Cache::put($failKey, (int) Cache::get($failKey, 0) + 1, now()->addMinutes(15));
throw ValidationException::withMessages(['code' => '验证码错误或已过期']);
}
$row->update(['consumed_at' => now()]);
Cache::forget($failKey);
}
public function register(string $channel, string $account, string $code, string $password, ?string $nickname, Request $request): User
{
$type = $channel === 'email' ? 'email' : 'phone';
$account = $this->normalize($type, $account);
$this->verifyCode('register', $account, $code);
$this->assertPassword($password);
$user = DB::transaction(function () use ($type, $account, $password, $nickname) {
$user = User::query()->create([
'nickname' => $nickname ?: $this->defaultNickname($type, $account),
'password' => $password,
'role' => 'user',
'status' => 'active',
]);
$user->identities()->create([
'type' => $type,
'identifier' => $account,
'verified_at' => now(),
]);
return $user;
});
$this->establish($user, $request, $account, 'password');
return $user;
}
public function loginWithPassword(string $account, string $password, ?string $captchaKey, ?string $captchaCode, Request $request): User
{
$account = trim($account);
$normalized = str_contains($account, '@') ? $this->normalize('email', $account) : $this->normalize('phone', $account);
$this->assertNotLocked($normalized);
$this->assertCaptcha($normalized, $captchaKey, $captchaCode);
$identity = UserIdentity::query()->where('identifier', $normalized)->first();
$user = $identity?->user;
if (! $user || ! $user->password || ! Hash::check($password, $user->password)) {
$this->recordFailure($normalized);
$this->log($user, $normalized, 'password', false, $request, '账号或密码错误');
throw ValidationException::withMessages(['account' => '账号或密码错误']);
}
$this->assertActive($user);
Cache::forget($this->failKey($normalized));
$this->establish($user, $request, $normalized, 'password');
return $user;
}
public function loginWithSms(string $phone, string $code, Request $request): User
{
$phone = $this->normalize('phone', $phone);
$this->verifyCode('login', $phone, $code);
$identity = UserIdentity::query()->where('type', 'phone')->where('identifier', $phone)->first();
$user = $identity?->user;
if (! $user) {
if (! Settings::bool('sms_register_creates_user', true)) {
throw ValidationException::withMessages(['account' => '账号不存在']);
}
$user = DB::transaction(function () use ($phone) {
$user = User::query()->create([
'nickname' => $this->defaultNickname('phone', $phone),
'role' => 'user',
'status' => 'active',
]);
$user->identities()->create([
'type' => 'phone',
'identifier' => $phone,
'verified_at' => now(),
]);
return $user;
});
}
$this->assertActive($user);
$this->establish($user, $request, $phone, 'sms');
return $user;
}
public function resetPassword(string $phone, string $code, string $password): void
{
$phone = $this->normalize('phone', $phone);
$this->verifyCode('reset', $phone, $code);
$this->assertPassword($password);
$user = UserIdentity::query()->where('type', 'phone')->where('identifier', $phone)->firstOrFail()->user;
$user->password = $password;
$user->save();
$this->revokeTokens($user);
}
public function bind(User $user, string $channel, string $account, string $code): void
{
$type = $channel === 'email' ? 'email' : 'phone';
$account = $this->normalize($type, $account);
if ($channel === 'email' && ! Settings::bool('mail_enabled')) {
throw ValidationException::withMessages(['account' => '邮箱通道未开启']);
}
if ($user->identities()->where('type', $type)->exists()) {
throw ValidationException::withMessages(['account' => '已绑定该类型账号,如需更换请先联系运营']);
}
$this->verifyCode('bind', $account, $code);
$user->identities()->create([
'type' => $type,
'identifier' => $account,
'verified_at' => now(),
]);
}
public function changePassword(User $user, ?string $oldPassword, string $newPassword): void
{
$this->assertPassword($newPassword);
if ($user->password) {
if (! $oldPassword || ! Hash::check($oldPassword, $user->password)) {
throw ValidationException::withMessages(['old_password' => '原密码不正确']);
}
}
$user->password = $newPassword;
$user->save();
$this->revokeTokens($user);
}
public function logout(Request $request, bool $everywhere = true): void
{
$user = $request->user();
if ($user && $everywhere) {
$this->revokeTokens($user);
UserSession::query()->where('user_id', $user->id)->whereNull('revoked_at')->update(['revoked_at' => now()]);
DB::table('sessions')->where('user_id', $user->id)->delete();
$this->notifyLogout($user);
}
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
public function revokeTokens(User $user): void
{
OauthAccessToken::query()->where('user_id', $user->id)->whereNull('revoked_at')->update(['revoked_at' => now()]);
OauthRefreshToken::query()->where('user_id', $user->id)->whereNull('revoked_at')->update(['revoked_at' => now()]);
}
public function normalize(string $type, string $value): string
{
$value = trim($value);
if ($type === 'email') {
$value = strtolower($value);
if (! filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw ValidationException::withMessages(['account' => '邮箱格式不正确']);
}
return $value;
}
if (! preg_match('/^1[3-9]\d{9}$/', $value)) {
throw ValidationException::withMessages(['account' => '请输入中国大陆手机号']);
}
return $value;
}
private function establish(User $user, Request $request, string $account, string $method): void
{
Auth::login($user);
$request->session()->regenerate();
UserSession::query()->create([
'user_id' => $user->id,
'session_id' => $request->session()->getId(),
'ip' => $request->ip(),
'user_agent' => Str::limit((string) $request->userAgent(), 255, ''),
'last_seen_at' => now(),
]);
$this->log($user, $account, $method, true, $request, '登录成功');
}
private function assertActive(User $user): void
{
if ($user->status !== 'active') {
throw ValidationException::withMessages(['account' => '账号已停用']);
}
}
private function assertNotLocked(string $account): void
{
if (Cache::has('login-lock:'.$account)) {
throw ValidationException::withMessages(['account' => '账号已临时锁定,请 15 分钟后再试']);
}
}
private function assertCaptcha(string $account, ?string $key, ?string $code): void
{
if ((int) Cache::get($this->failKey($account), 0) < 3) {
return;
}
$expected = $key ? Cache::pull('captcha:'.$key) : null;
if (! $expected || ! $code || ! hash_equals($expected, strtolower($code))) {
throw new \Illuminate\Http\Exceptions\HttpResponseException(response()->json([
'message' => '请完成图形验证码',
'code' => 'captcha_required',
], 422));
}
}
private function recordFailure(string $account): void
{
$count = (int) Cache::get($this->failKey($account), 0) + 1;
Cache::put($this->failKey($account), $count, now()->addMinutes(15));
if ($count >= 5) {
Cache::put('login-lock:'.$account, 1, now()->addMinutes(15));
Cache::forget($this->failKey($account));
}
}
private function failKey(string $account): string
{
return 'login-fail:'.$account;
}
private function assertTarget(string $channel, string $target): void
{
$this->normalize($channel === 'email' ? 'email' : 'phone', $target);
}
private function defaultNickname(string $type, string $account): string
{
return $type === 'email' ? strstr($account, '@', true) : Presenter::mask('phone', $account);
}
private function log(?User $user, string $account, string $method, bool $success, Request $request, string $message): void
{
LoginLog::query()->create([
'user_id' => $user?->id,
'account' => Presenter::mask(str_contains($account, '@') ? 'email' : 'phone', $account),
'method' => $method,
'success' => $success,
'ip' => $request->ip(),
'user_agent' => Str::limit((string) $request->userAgent(), 255, ''),
'message' => $message,
]);
}
private function notifyLogout(User $user): void
{
$clients = \App\Models\OauthClient::query()->where('status', 'active')->get();
foreach ($clients as $client) {
foreach ($client->logout_uris ?? [] as $uri) {
try {
\Illuminate\Support\Facades\Http::timeout(2)->asJson()->post($uri, [
'sub' => (string) $user->id,
'client_id' => $client->client_id,
]);
} catch (\Throwable) {
// 工具退出回调失败不阻断平台退出。
}
}
}
}
private function privateKey(): string
{
$path = storage_path('app/keys/login-private.pem');
if (! is_file($path)) {
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0700, true);
}
$config = [
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$cnf = PHP_BINDIR.DIRECTORY_SEPARATOR.'extras'.DIRECTORY_SEPARATOR.'ssl'.DIRECTORY_SEPARATOR.'openssl.cnf';
if (is_file($cnf)) {
$config['config'] = $cnf;
}
$resource = openssl_pkey_new($config);
if ($resource === false) {
throw new \RuntimeException('无法生成登录密钥:'.openssl_error_string());
}
openssl_pkey_export($resource, $pem, null, $config);
file_put_contents($path, $pem);
}
return (string) file_get_contents($path);
}
}