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 = '' .'' .''.$code.'' .''; 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); } }