让分类、会员和示例工具共用同一账号,本地即可跑通登录与授权。 Co-authored-by: Cursor <cursoragent@cursor.com>
432 lines
17 KiB
PHP
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);
|
|
}
|
|
}
|