админка
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth;
|
||||
|
||||
use App\Models\GameAccountUser;
|
||||
use App\Services\AzerothCoreAccountService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Auth\UserProvider;
|
||||
|
||||
class AzerothCoreUserProvider implements UserProvider
|
||||
{
|
||||
public function __construct(private readonly AzerothCoreAccountService $accounts)
|
||||
{
|
||||
}
|
||||
|
||||
public function retrieveById($identifier): ?Authenticatable
|
||||
{
|
||||
if (! is_numeric($identifier)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->accounts->findUserById((int) $identifier);
|
||||
}
|
||||
|
||||
public function retrieveByToken($identifier, #[\SensitiveParameter] $token): ?Authenticatable
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function updateRememberToken(Authenticatable $user, #[\SensitiveParameter] $token): void
|
||||
{
|
||||
}
|
||||
|
||||
public function retrieveByCredentials(#[\SensitiveParameter] array $credentials): ?Authenticatable
|
||||
{
|
||||
$username = $credentials['username'] ?? null;
|
||||
|
||||
if (! is_string($username) || trim($username) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->accounts->findUserByUsername($username);
|
||||
}
|
||||
|
||||
public function validateCredentials(Authenticatable $user, #[\SensitiveParameter] array $credentials): bool
|
||||
{
|
||||
return $user instanceof GameAccountUser
|
||||
&& isset($credentials['password'])
|
||||
&& is_string($credentials['password'])
|
||||
&& $this->accounts->validatePassword($user, $credentials['password']);
|
||||
}
|
||||
|
||||
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false): void
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class InviteCodeException extends RuntimeException
|
||||
{
|
||||
public string $field = 'invite_code';
|
||||
|
||||
public static function invalid(): self
|
||||
{
|
||||
return new self('Укажи корректный инвайт-код.');
|
||||
}
|
||||
|
||||
public static function alreadyUsed(): self
|
||||
{
|
||||
return new self('Этот инвайт-код уже использован.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\UpdateAccountAccessRequest;
|
||||
use App\Services\AzerothCoreAccountService;
|
||||
use App\Services\InviteCodeService;
|
||||
use App\Support\WowData;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function index(
|
||||
Request $request,
|
||||
AzerothCoreAccountService $accounts,
|
||||
InviteCodeService $inviteCodes,
|
||||
): View
|
||||
{
|
||||
$search = trim((string) $request->query('search', ''));
|
||||
|
||||
return view('admin.accounts.index', [
|
||||
'accounts' => $accounts->listAccounts($search !== '' ? $search : null),
|
||||
'inviteCodes' => $inviteCodes->latest(),
|
||||
'inviteStats' => $inviteCodes->stats(),
|
||||
'search' => $search,
|
||||
'accessLevels' => WowData::securityLevels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateAccess(int $accountId, UpdateAccountAccessRequest $request, AzerothCoreAccountService $accounts): RedirectResponse
|
||||
{
|
||||
abort_unless($accounts->findUserById($accountId), 404);
|
||||
|
||||
$accounts->updateAccessLevel($accountId, (int) $request->input('gmlevel'));
|
||||
|
||||
return back()->with('status', 'Уровень доступа обновлён.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\CreateInviteCodesRequest;
|
||||
use App\Services\InviteCodeService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class InviteCodeController extends Controller
|
||||
{
|
||||
public function store(CreateInviteCodesRequest $request, InviteCodeService $inviteCodes): RedirectResponse
|
||||
{
|
||||
$created = $inviteCodes->createCodes(
|
||||
(int) $request->integer('quantity'),
|
||||
$request->user(),
|
||||
);
|
||||
|
||||
return back()->with('status', sprintf('Создано инвайт-кодов: %d.', $created->count()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function create(): View
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(LoginRequest $request): RedirectResponse
|
||||
{
|
||||
if (! Auth::attempt($request->safe()->only(['username', 'password']))) {
|
||||
return back()
|
||||
->withErrors(['username' => 'Неверный логин или пароль.'])
|
||||
->onlyInput('username');
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('cabinet.index'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()
|
||||
->route('login')
|
||||
->with('status', 'Сеанс завершён. Можно войти снова.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ChangePasswordRequest;
|
||||
use App\Services\AzerothCoreAccountService;
|
||||
use App\Services\GameClientDownloadService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
|
||||
class CabinetController extends Controller
|
||||
{
|
||||
public function index(Request $request, AzerothCoreAccountService $accounts): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return view('cabinet.index', [
|
||||
'user' => $user,
|
||||
'characters' => $accounts->charactersForAccount($user->id),
|
||||
'clientFilename' => config('moonwell.client.object_key'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePassword(ChangePasswordRequest $request, AzerothCoreAccountService $accounts): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $accounts->validatePassword($user, $request->string('current_password')->toString())) {
|
||||
return back()->withErrors(['current_password' => 'Текущий пароль указан неверно.']);
|
||||
}
|
||||
|
||||
$accounts->updatePassword($user, $request->string('password')->toString());
|
||||
|
||||
return back()->with('status', 'Пароль успешно обновлён.');
|
||||
}
|
||||
|
||||
public function client(GameClientDownloadService $clientDownloadService): RedirectResponse
|
||||
{
|
||||
try {
|
||||
return redirect()->away($clientDownloadService->temporaryUrl());
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return back()->with('error', 'Не удалось получить ссылку на клиент. Попробуй ещё раз позже.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Exceptions\DuplicateGameAccountException;
|
||||
use App\Exceptions\InviteCodeException;
|
||||
use App\Http\Requests\RegisterGameAccountRequest;
|
||||
use App\Services\AzerothCoreAccountRegistrar;
|
||||
use App\Services\InviteCodeService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\View\View;
|
||||
use Throwable;
|
||||
@@ -85,24 +87,36 @@ class LandingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterGameAccountRequest $request, AzerothCoreAccountRegistrar $registrar): RedirectResponse
|
||||
public function register(
|
||||
RegisterGameAccountRequest $request,
|
||||
AzerothCoreAccountRegistrar $registrar,
|
||||
InviteCodeService $inviteCodes,
|
||||
): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$result = $registrar->register(
|
||||
$result = $inviteCodes->redeemForRegistration(
|
||||
$request->string('invite_code')->toString(),
|
||||
$request->string('username')->toString(),
|
||||
$request->string('email')->toString(),
|
||||
$request->string('password')->toString(),
|
||||
fn (): array => $registrar->register(
|
||||
$request->string('username')->toString(),
|
||||
$request->string('email')->toString(),
|
||||
$request->string('password')->toString(),
|
||||
),
|
||||
);
|
||||
} catch (DuplicateGameAccountException $exception) {
|
||||
return back()
|
||||
->withErrors([$exception->field => $exception->getMessage()])
|
||||
->withInput($request->safe()->only(['username', 'email']));
|
||||
->withInput($request->safe()->only(['username', 'email', 'invite_code']));
|
||||
} catch (InviteCodeException $exception) {
|
||||
return back()
|
||||
->withErrors([$exception->field => $exception->getMessage()])
|
||||
->withInput($request->safe()->only(['username', 'email', 'invite_code']));
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return back()
|
||||
->with('registration_error', 'Не удалось создать аккаунт. Проверь настройки сервера и попробуй снова.')
|
||||
->withInput($request->safe()->only(['username', 'email']));
|
||||
->withInput($request->safe()->only(['username', 'email', 'invite_code']));
|
||||
}
|
||||
|
||||
return redirect()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureGameMaster
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless(
|
||||
$user && $user->canAccessAdminPanel(),
|
||||
Response::HTTP_FORBIDDEN,
|
||||
'Недостаточно прав для доступа к админке.',
|
||||
);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ChangePasswordRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => ['required', 'string', 'max:32'],
|
||||
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'current_password.required' => 'Укажи текущий пароль.',
|
||||
'current_password.max' => 'Текущий пароль не должен быть длиннее 32 символов.',
|
||||
'password.required' => 'Укажи новый пароль.',
|
||||
'password.min' => 'Новый пароль должен содержать минимум 8 символов.',
|
||||
'password.max' => 'Новый пароль не должен быть длиннее 32 символов.',
|
||||
'password.confirmed' => 'Подтверждение нового пароля не совпадает.',
|
||||
'password.regex' => 'Новый пароль должен содержать хотя бы одну букву и одну цифру.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreateInviteCodesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'quantity' => ['required', 'integer', 'min:1', 'max:50'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'quantity.required' => 'Укажи количество инвайт-кодов.',
|
||||
'quantity.integer' => 'Количество должно быть числом.',
|
||||
'quantity.min' => 'Нужно создать минимум один инвайт-код.',
|
||||
'quantity.max' => 'За один раз можно создать не больше 50 инвайт-кодов.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
|
||||
'password' => ['required', 'string', 'min:3', 'max:32'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'username.required' => 'Укажи логин аккаунта.',
|
||||
'username.min' => 'Логин должен содержать минимум 3 символа.',
|
||||
'username.max' => 'Логин не должен быть длиннее 32 символов.',
|
||||
'username.regex' => 'Логин может содержать только латинские буквы и цифры.',
|
||||
'password.required' => 'Укажи пароль.',
|
||||
'password.min' => 'Пароль должен содержать минимум 3 символа.',
|
||||
'password.max' => 'Пароль не должен быть длиннее 32 символов.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class RegisterGameAccountRequest extends FormRequest
|
||||
return [
|
||||
'username' => ['required', 'string', 'min:3', 'max:32', 'regex:/^[A-Za-z0-9]+$/'],
|
||||
'email' => ['required', 'string', 'email:rfc', 'max:255'],
|
||||
'invite_code' => ['required', 'string', 'min:6', 'max:32'],
|
||||
'password' => ['required', 'string', 'min:8', 'max:32', 'confirmed', 'regex:/^(?=.*[A-Za-z])(?=.*\d).+$/'],
|
||||
'terms' => ['accepted'],
|
||||
];
|
||||
@@ -39,6 +40,10 @@ class RegisterGameAccountRequest extends FormRequest
|
||||
'email.string' => 'Email должен быть строкой.',
|
||||
'email.email' => 'Укажи корректный email адрес.',
|
||||
'email.max' => 'Email не должен быть длиннее 255 символов.',
|
||||
'invite_code.required' => 'Укажи инвайт-код.',
|
||||
'invite_code.string' => 'Инвайт-код должен быть строкой.',
|
||||
'invite_code.min' => 'Инвайт-код выглядит слишком коротким.',
|
||||
'invite_code.max' => 'Инвайт-код выглядит слишком длинным.',
|
||||
'password.required' => 'Укажи пароль.',
|
||||
'password.string' => 'Пароль должен быть строкой.',
|
||||
'password.min' => 'Пароль должен содержать минимум 8 символов.',
|
||||
@@ -57,6 +62,7 @@ class RegisterGameAccountRequest extends FormRequest
|
||||
return [
|
||||
'username' => 'логин аккаунта',
|
||||
'email' => 'email',
|
||||
'invite_code' => 'инвайт-код',
|
||||
'password' => 'пароль',
|
||||
'terms' => 'правила',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateAccountAccessRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'gmlevel' => ['required', 'integer', Rule::in(array_keys(\App\Support\WowData::securityLevels()))],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'gmlevel.required' => 'Укажи уровень доступа.',
|
||||
'gmlevel.integer' => 'Уровень доступа должен быть числом.',
|
||||
'gmlevel.in' => 'Выбран недопустимый уровень доступа.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Support\WowData;
|
||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class GameAccountUser implements AuthenticatableContract
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $id,
|
||||
public readonly string $username,
|
||||
public readonly string $email,
|
||||
public readonly string $regMail,
|
||||
public readonly int $gmLevel,
|
||||
public readonly bool $locked,
|
||||
public readonly ?string $lastLoginAt,
|
||||
public readonly ?string $joinedAt,
|
||||
public readonly string $saltHex,
|
||||
public readonly string $verifierHex,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAuthIdentifierName(): string
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
|
||||
public function getAuthIdentifier(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getAuthPasswordName(): string
|
||||
{
|
||||
return 'verifier';
|
||||
}
|
||||
|
||||
public function getAuthPassword(): string
|
||||
{
|
||||
return $this->verifierHex;
|
||||
}
|
||||
|
||||
public function getRememberToken(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function setRememberToken($value): void
|
||||
{
|
||||
}
|
||||
|
||||
public function getRememberTokenName(): string
|
||||
{
|
||||
return 'remember_token';
|
||||
}
|
||||
|
||||
public function accessLabel(): string
|
||||
{
|
||||
return WowData::securityLevelName($this->gmLevel);
|
||||
}
|
||||
|
||||
public function canAccessAdminPanel(): bool
|
||||
{
|
||||
return $this->gmLevel >= config('moonwell.portal.admin_min_gm_level');
|
||||
}
|
||||
|
||||
public function joinedAtLabel(): string
|
||||
{
|
||||
return $this->formatDate($this->joinedAt);
|
||||
}
|
||||
|
||||
public function lastLoginLabel(): string
|
||||
{
|
||||
return $this->formatDate($this->lastLoginAt);
|
||||
}
|
||||
|
||||
private function formatDate(?string $value): string
|
||||
{
|
||||
return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InviteCode extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'created_by_account_id',
|
||||
'created_by_username',
|
||||
'redeemed_by_account_id',
|
||||
'redeemed_by_username',
|
||||
'redeemed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'redeemed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Auth\AzerothCoreUserProvider;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -19,6 +21,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Auth::provider('azerothcore', function ($app): AzerothCoreUserProvider {
|
||||
return $app->make(AzerothCoreUserProvider::class);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,25 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\DuplicateGameAccountException;
|
||||
use Brick\Math\BigInteger;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AzerothCoreAccountRegistrar
|
||||
{
|
||||
private const string MODULUS_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7';
|
||||
|
||||
private const int GENERATOR = 7;
|
||||
|
||||
private const int BYTE_LENGTH = 32;
|
||||
public function __construct(private readonly AzerothCoreSrpService $srp)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: int, username: string}
|
||||
*/
|
||||
public function register(string $username, string $email, string $password): array
|
||||
{
|
||||
$normalizedUsername = $this->normalizeUpperOnlyLatin(trim($username));
|
||||
$normalizedPassword = $this->normalizeUpperOnlyLatin($password);
|
||||
$normalizedEmail = $this->normalizeUpperOnlyLatin(trim($email));
|
||||
[$saltHex, $verifierHex] = $this->makeRegistrationData($normalizedUsername, $normalizedPassword);
|
||||
$normalizedUsername = $this->srp->normalizeUsername($username);
|
||||
$normalizedPassword = $this->srp->normalizePassword($password);
|
||||
$normalizedEmail = $this->srp->normalizeEmail($email);
|
||||
[$saltHex, $verifierHex] = $this->srp->makeRegistrationData($normalizedUsername, $normalizedPassword);
|
||||
|
||||
$connection = DB::connection(config('moonwell.auth_connection'));
|
||||
|
||||
@@ -97,30 +94,6 @@ class AzerothCoreAccountRegistrar
|
||||
return $connection->table('account')->where($column, $value)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function makeRegistrationData(string $username, string $password): array
|
||||
{
|
||||
$salt = random_bytes(self::BYTE_LENGTH);
|
||||
$innerDigest = hash('sha1', sprintf('%s:%s', $username, $password), true);
|
||||
$xDigest = hash('sha1', $salt.$innerDigest, true);
|
||||
$modulus = BigInteger::fromBase(self::MODULUS_HEX, 16);
|
||||
$exponent = BigInteger::fromBytes(strrev($xDigest), false);
|
||||
$verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus);
|
||||
$verifierBytes = strrev(str_pad($verifier->toBytes(false), self::BYTE_LENGTH, "\0", STR_PAD_LEFT));
|
||||
|
||||
return [
|
||||
strtoupper(bin2hex($salt)),
|
||||
strtoupper(bin2hex($verifierBytes)),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeUpperOnlyLatin(string $value): string
|
||||
{
|
||||
return strtoupper($value);
|
||||
}
|
||||
|
||||
private function isDuplicateKeyException(QueryException $exception): bool
|
||||
{
|
||||
return (string) $exception->getCode() === '23000';
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\GameAccountUser;
|
||||
use App\Support\WowData;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AzerothCoreAccountService
|
||||
{
|
||||
public function __construct(private readonly AzerothCoreSrpService $srp)
|
||||
{
|
||||
}
|
||||
|
||||
public function findUserById(int $accountId): ?GameAccountUser
|
||||
{
|
||||
$record = $this->authConnection()
|
||||
->table('account')
|
||||
->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex')
|
||||
->where('id', $accountId)
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$gmLevel = $this->accessLevelsFor([$accountId])[$accountId] ?? 0;
|
||||
|
||||
return $this->mapUser($record, $gmLevel);
|
||||
}
|
||||
|
||||
public function findUserByUsername(string $username): ?GameAccountUser
|
||||
{
|
||||
$record = $this->authConnection()
|
||||
->table('account')
|
||||
->selectRaw('id, username, email, reg_mail, locked, last_login, joindate, HEX(salt) as salt_hex, HEX(verifier) as verifier_hex')
|
||||
->where('username', $this->srp->normalizeUsername($username))
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$gmLevel = $this->accessLevelsFor([(int) $record->id])[(int) $record->id] ?? 0;
|
||||
|
||||
return $this->mapUser($record, $gmLevel);
|
||||
}
|
||||
|
||||
public function validatePassword(GameAccountUser $user, string $password): bool
|
||||
{
|
||||
return $this->srp->credentialsMatch($user->username, $password, $user->saltHex, $user->verifierHex);
|
||||
}
|
||||
|
||||
public function updatePassword(GameAccountUser $user, string $newPassword): void
|
||||
{
|
||||
[$saltHex, $verifierHex] = $this->srp->makeRegistrationData($user->username, $newPassword);
|
||||
|
||||
$this->authConnection()->update(
|
||||
'UPDATE account SET salt = UNHEX(?), verifier = UNHEX(?), session_key = NULL, failed_logins = 0 WHERE id = ?',
|
||||
[$saltHex, $verifierHex, $user->id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||
*/
|
||||
public function listAccounts(?string $search = null): Collection
|
||||
{
|
||||
$query = $this->authConnection()
|
||||
->table('account')
|
||||
->select('id', 'username', 'email', 'reg_mail', 'locked', 'last_login', 'joindate', 'online')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($search !== null && trim($search) !== '') {
|
||||
$normalizedSearch = $this->srp->normalizeUsername($search);
|
||||
|
||||
$query->where(function ($builder) use ($normalizedSearch): void {
|
||||
$builder
|
||||
->where('username', 'like', "%{$normalizedSearch}%")
|
||||
->orWhere('email', 'like', "%{$normalizedSearch}%")
|
||||
->orWhere('reg_mail', 'like', "%{$normalizedSearch}%");
|
||||
});
|
||||
}
|
||||
|
||||
$accounts = $query->get();
|
||||
$accountIds = $accounts->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
$accessLevels = $this->accessLevelsFor($accountIds);
|
||||
$characters = $this->charactersForAccountIds($accountIds);
|
||||
|
||||
return $accounts->map(function (object $account) use ($accessLevels, $characters): array {
|
||||
$accountId = (int) $account->id;
|
||||
$gmLevel = $accessLevels[$accountId] ?? 0;
|
||||
|
||||
return [
|
||||
'id' => $accountId,
|
||||
'username' => $account->username,
|
||||
'email' => $account->email,
|
||||
'reg_mail' => $account->reg_mail,
|
||||
'gm_level' => $gmLevel,
|
||||
'access_label' => WowData::securityLevelName($gmLevel),
|
||||
'locked' => (bool) $account->locked,
|
||||
'online' => (bool) $account->online,
|
||||
'joined_at' => $this->formatDate($account->joindate),
|
||||
'last_login_at' => $this->formatDate($account->last_login),
|
||||
'characters' => $characters->get($accountId, collect()),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, array<string, mixed>>
|
||||
*/
|
||||
public function charactersForAccount(int $accountId): Collection
|
||||
{
|
||||
return $this->charactersForAccountIds([$accountId])->get($accountId, collect());
|
||||
}
|
||||
|
||||
public function updateAccessLevel(int $accountId, int $gmLevel): void
|
||||
{
|
||||
$realmId = $this->targetRealmId();
|
||||
|
||||
$this->authConnection()->statement(
|
||||
'INSERT INTO account_access (id, gmlevel, RealmID, comment) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE gmlevel = VALUES(gmlevel), comment = VALUES(comment)',
|
||||
[
|
||||
$accountId,
|
||||
$gmLevel,
|
||||
$realmId,
|
||||
config('moonwell.portal.access_comment'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $accountIds
|
||||
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, array<string, mixed>>>
|
||||
*/
|
||||
private function charactersForAccountIds(array $accountIds): Collection
|
||||
{
|
||||
if ($accountIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $this->charactersConnection()
|
||||
->table('characters')
|
||||
->select('guid', 'account', 'name', 'race', 'class', 'gender', 'level', 'online', 'money', 'totaltime')
|
||||
->whereIn('account', $accountIds)
|
||||
->orderBy('account')
|
||||
->orderByDesc('level')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(function (object $character): array {
|
||||
return [
|
||||
'guid' => (int) $character->guid,
|
||||
'account' => (int) $character->account,
|
||||
'name' => $character->name,
|
||||
'level' => (int) $character->level,
|
||||
'race' => WowData::raceName((int) $character->race),
|
||||
'class' => WowData::className((int) $character->class),
|
||||
'gender' => WowData::genderName((int) $character->gender),
|
||||
'online' => (bool) $character->online,
|
||||
'money' => WowData::formatMoney((int) $character->money),
|
||||
'played' => WowData::formatPlayedTime((int) $character->totaltime),
|
||||
];
|
||||
})
|
||||
->groupBy('account')
|
||||
->map(fn (Collection $group): Collection => $group->values());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $accountIds
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function accessLevelsFor(array $accountIds): array
|
||||
{
|
||||
if ($accountIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$targetRealmId = $this->targetRealmId();
|
||||
$realmIds = array_values(array_unique([$targetRealmId, -1]));
|
||||
|
||||
return $this->authConnection()
|
||||
->table('account_access')
|
||||
->select('id', 'gmlevel', 'RealmID')
|
||||
->whereIn('id', $accountIds)
|
||||
->whereIn('RealmID', $realmIds)
|
||||
->get()
|
||||
->groupBy('id')
|
||||
->map(function (Collection $rows) use ($targetRealmId): int {
|
||||
$exactMatch = $rows->firstWhere('RealmID', $targetRealmId);
|
||||
|
||||
return (int) ($exactMatch->gmlevel ?? $rows->firstWhere('RealmID', -1)?->gmlevel ?? 0);
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function mapUser(object $record, int $gmLevel): GameAccountUser
|
||||
{
|
||||
return new GameAccountUser(
|
||||
id: (int) $record->id,
|
||||
username: $record->username,
|
||||
email: $record->email,
|
||||
regMail: $record->reg_mail,
|
||||
gmLevel: $gmLevel,
|
||||
locked: (bool) $record->locked,
|
||||
lastLoginAt: $record->last_login,
|
||||
joinedAt: $record->joindate,
|
||||
saltHex: strtoupper($record->salt_hex),
|
||||
verifierHex: strtoupper($record->verifier_hex),
|
||||
);
|
||||
}
|
||||
|
||||
private function formatDate(?string $value): string
|
||||
{
|
||||
return $value ? Carbon::parse($value)->format('d.m.Y H:i') : '—';
|
||||
}
|
||||
|
||||
private function targetRealmId(): int
|
||||
{
|
||||
return (int) config('moonwell.registration.realm_id', -1);
|
||||
}
|
||||
|
||||
private function authConnection()
|
||||
{
|
||||
return DB::connection(config('moonwell.auth_connection'));
|
||||
}
|
||||
|
||||
private function charactersConnection()
|
||||
{
|
||||
return DB::connection(config('moonwell.characters_connection'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Brick\Math\BigInteger;
|
||||
|
||||
class AzerothCoreSrpService
|
||||
{
|
||||
private const string MODULUS_HEX = '894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7';
|
||||
|
||||
private const int GENERATOR = 7;
|
||||
|
||||
private const int BYTE_LENGTH = 32;
|
||||
|
||||
public function normalizeUsername(string $username): string
|
||||
{
|
||||
return strtoupper(trim($username));
|
||||
}
|
||||
|
||||
public function normalizeEmail(string $email): string
|
||||
{
|
||||
return strtoupper(trim($email));
|
||||
}
|
||||
|
||||
public function normalizePassword(string $password): string
|
||||
{
|
||||
return strtoupper($password);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
public function makeRegistrationData(string $username, string $password): array
|
||||
{
|
||||
$saltHex = strtoupper(bin2hex(random_bytes(self::BYTE_LENGTH)));
|
||||
|
||||
return [$saltHex, $this->makeVerifierHex($username, $password, $saltHex)];
|
||||
}
|
||||
|
||||
public function makeVerifierHex(string $username, string $password, string $saltHex): string
|
||||
{
|
||||
$normalizedUsername = $this->normalizeUsername($username);
|
||||
$normalizedPassword = $this->normalizePassword($password);
|
||||
$salt = hex2bin($saltHex);
|
||||
|
||||
if ($salt === false) {
|
||||
throw new \InvalidArgumentException('Invalid salt hex provided.');
|
||||
}
|
||||
|
||||
$innerDigest = hash('sha1', sprintf('%s:%s', $normalizedUsername, $normalizedPassword), true);
|
||||
$xDigest = hash('sha1', $salt.$innerDigest, true);
|
||||
$modulus = BigInteger::fromBase(self::MODULUS_HEX, 16);
|
||||
$exponent = BigInteger::fromBytes(strrev($xDigest), false);
|
||||
$verifier = BigInteger::of(self::GENERATOR)->modPow($exponent, $modulus);
|
||||
$verifierBytes = strrev(str_pad($verifier->toBytes(false), self::BYTE_LENGTH, "\0", STR_PAD_LEFT));
|
||||
|
||||
return strtoupper(bin2hex($verifierBytes));
|
||||
}
|
||||
|
||||
public function credentialsMatch(string $username, string $password, string $saltHex, string $verifierHex): bool
|
||||
{
|
||||
return hash_equals(
|
||||
strtoupper($verifierHex),
|
||||
$this->makeVerifierHex($username, $password, $saltHex),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
class GameClientDownloadService
|
||||
{
|
||||
public function filename(): string
|
||||
{
|
||||
return basename($this->objectKey());
|
||||
}
|
||||
|
||||
public function temporaryUrl(): string
|
||||
{
|
||||
$disk = Storage::disk(config('moonwell.client.disk'));
|
||||
$objectKey = $this->objectKey();
|
||||
$filename = $this->filename();
|
||||
|
||||
try {
|
||||
return $disk->temporaryUrl(
|
||||
$objectKey,
|
||||
now()->addMinutes(config('moonwell.client.temporary_url_minutes')),
|
||||
['ResponseContentDisposition' => sprintf('attachment; filename="%s"', $filename)],
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return $disk->url($objectKey);
|
||||
}
|
||||
}
|
||||
|
||||
private function objectKey(): string
|
||||
{
|
||||
return (string) config('moonwell.client.object_key');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\InviteCodeException;
|
||||
use App\Models\GameAccountUser;
|
||||
use App\Models\InviteCode;
|
||||
use Closure;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InviteCodeService
|
||||
{
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, \App\Models\InviteCode>
|
||||
*/
|
||||
public function createCodes(int $quantity, GameAccountUser $creator): Collection
|
||||
{
|
||||
$created = collect();
|
||||
|
||||
for ($index = 0; $index < $quantity; $index++) {
|
||||
$created->push(InviteCode::query()->create([
|
||||
'code' => $this->generateUniqueCode(),
|
||||
'created_by_account_id' => $creator->id,
|
||||
'created_by_username' => $creator->username,
|
||||
]));
|
||||
}
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template TReturn
|
||||
*
|
||||
* @param Closure(): TReturn $callback
|
||||
* @return TReturn
|
||||
*/
|
||||
public function redeemForRegistration(string $code, string $username, Closure $callback): mixed
|
||||
{
|
||||
$normalizedCode = $this->normalizeCode($code);
|
||||
$normalizedUsername = strtoupper(trim($username));
|
||||
|
||||
return DB::transaction(function () use ($callback, $normalizedCode, $normalizedUsername) {
|
||||
/** @var InviteCode|null $invite */
|
||||
$invite = InviteCode::query()
|
||||
->where('code', $normalizedCode)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $invite) {
|
||||
throw InviteCodeException::invalid();
|
||||
}
|
||||
|
||||
if ($invite->redeemed_at !== null) {
|
||||
throw InviteCodeException::alreadyUsed();
|
||||
}
|
||||
|
||||
$result = $callback();
|
||||
|
||||
$invite->forceFill([
|
||||
'redeemed_by_account_id' => $result['id'],
|
||||
'redeemed_by_username' => $normalizedUsername,
|
||||
'redeemed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, \App\Models\InviteCode>
|
||||
*/
|
||||
public function latest(int $limit = 100): Collection
|
||||
{
|
||||
return InviteCode::query()
|
||||
->latest('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, available: int, redeemed: int}
|
||||
*/
|
||||
public function stats(): array
|
||||
{
|
||||
$total = InviteCode::query()->count();
|
||||
$redeemed = InviteCode::query()->whereNotNull('redeemed_at')->count();
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'available' => $total - $redeemed,
|
||||
'redeemed' => $redeemed,
|
||||
];
|
||||
}
|
||||
|
||||
private function generateUniqueCode(): string
|
||||
{
|
||||
do {
|
||||
$code = $this->normalizeCode(
|
||||
Str::upper(Str::random(4)).'-'.Str::upper(Str::random(4)).'-'.Str::upper(Str::random(4)),
|
||||
);
|
||||
} while (InviteCode::query()->where('code', $code)->exists());
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
private function normalizeCode(string $code): string
|
||||
{
|
||||
return Str::upper(trim($code));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class WowData
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function securityLevels(): array
|
||||
{
|
||||
return [
|
||||
0 => 'Игрок',
|
||||
1 => 'Модератор',
|
||||
2 => 'Гейммастер',
|
||||
3 => 'Администратор',
|
||||
];
|
||||
}
|
||||
|
||||
public static function securityLevelName(int $level): string
|
||||
{
|
||||
return self::securityLevels()[$level] ?? 'Неизвестно';
|
||||
}
|
||||
|
||||
public static function raceName(int $race): string
|
||||
{
|
||||
return [
|
||||
1 => 'Человек',
|
||||
2 => 'Орк',
|
||||
3 => 'Дворф',
|
||||
4 => 'Ночной эльф',
|
||||
5 => 'Нежить',
|
||||
6 => 'Таурен',
|
||||
7 => 'Гном',
|
||||
8 => 'Тролль',
|
||||
10 => 'Эльф крови',
|
||||
11 => 'Дреней',
|
||||
][$race] ?? 'Неизвестно';
|
||||
}
|
||||
|
||||
public static function className(int $class): string
|
||||
{
|
||||
return [
|
||||
1 => 'Воин',
|
||||
2 => 'Паладин',
|
||||
3 => 'Охотник',
|
||||
4 => 'Разбойник',
|
||||
5 => 'Жрец',
|
||||
6 => 'Рыцарь смерти',
|
||||
7 => 'Шаман',
|
||||
8 => 'Маг',
|
||||
9 => 'Чернокнижник',
|
||||
11 => 'Друид',
|
||||
][$class] ?? 'Неизвестно';
|
||||
}
|
||||
|
||||
public static function genderName(int $gender): string
|
||||
{
|
||||
return [
|
||||
0 => 'Мужской',
|
||||
1 => 'Женский',
|
||||
][$gender] ?? 'Неизвестно';
|
||||
}
|
||||
|
||||
public static function formatMoney(int $copper): string
|
||||
{
|
||||
$gold = intdiv($copper, 10000);
|
||||
$silver = intdiv($copper % 10000, 100);
|
||||
$bronze = $copper % 100;
|
||||
|
||||
return sprintf('%dз %dс %dм', $gold, $silver, $bronze);
|
||||
}
|
||||
|
||||
public static function formatPlayedTime(int $seconds): string
|
||||
{
|
||||
$days = intdiv($seconds, 86400);
|
||||
$hours = intdiv($seconds % 86400, 3600);
|
||||
$minutes = intdiv($seconds % 3600, 60);
|
||||
|
||||
if ($days > 0) {
|
||||
return sprintf('%dд %dч', $days, $hours);
|
||||
}
|
||||
|
||||
if ($hours > 0) {
|
||||
return sprintf('%dч %dм', $hours, $minutes);
|
||||
}
|
||||
|
||||
return sprintf('%dм', $minutes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user