админка
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user