[mojang-verification] support microsoft authentication schema

This commit is contained in:
Asnxthaony 2022-06-18 19:12:31 +08:00
parent eac03141ec
commit 2aba718651
16 changed files with 256 additions and 230 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.idea/
.dist/
node_modules/
vendor/

View File

@ -0,0 +1,19 @@
# 正版验证 (微软)
为拥有正版账号的用户提供验证、绑定。
## 使用方法
本插件部分配置通过修改 `.env` 来进行。
1. 在 `https://aka.ms/aad` 创建应用
2. 增加三条配置项,`MICROSOFT_KEY`、 `MICROSOFT_SECRET``MICROSOFT_REDIRECT_URI`
3. 将 `客户端 ID`、`客户端 Secret`、`回调 URL` 分别填入 `MICROSOFT_KEY``MICROSOFT_SECRET``MICROSOFT_REDIRECT_URI`
## 示例
```
MICROSOFT_KEY=9fce0559-44b4-4c95-a144-d3ccf50ea62b
MICROSOFT_SECRET=secret@123
MICROSOFT_REDIRECT_URI=https://skin.bs-community.dev/mojang/callback
```

View File

@ -7,11 +7,25 @@ use GPlane\Mojang\MojangVerification;
use Illuminate\Contracts\Events\Dispatcher;
return function (Dispatcher $events, Filter $filter) {
config(['logging.channels.mojang-verification' => [
'driver' => 'single',
'path' => storage_path('logs/mojang-verification.log'),
]]);
config(['services.microsoft' => [
'client_id' => env('MICROSOFT_KEY'),
'client_secret' => env('MICROSOFT_SECRET'),
'redirect' => env('MICROSOFT_REDIRECT_URI'),
]]);
View::composer('GPlane\Mojang::bind', function ($view) {
$view->with('score', option('mojang_verification_score_award', 0));
});
$events->listen('auth.login.attempt', Listeners\CreateNewUser::class);
$events->listen(
'SocialiteProviders\Manager\SocialiteWasCalled',
'GPlane\Mojang\Providers\MicrosoftExtendSocialite@handle'
);
$events->listen(
Illuminate\Auth\Events\Authenticated::class,
@ -26,18 +40,13 @@ return function (Dispatcher $events, Filter $filter) {
return $badges;
});
$filter->add('auth_page_rows:register', function ($rows) {
$rows[] = 'GPlane\Mojang::notice';
return $rows;
});
Hook::addRoute(function () {
Route::prefix('mojang')
->middleware(['web', 'auth'])
->namespace('GPlane\Mojang')
->group(function () {
Route::post('verify', 'AccountController@verify');
Route::get('verify', 'AccountController@verify');
Route::get('callback', 'AccountController@verifyCallback');
Route::post('update-uuid', 'AccountController@uuid');
});
});

View File

@ -1,13 +1,12 @@
title: Bind Your Mojang Account
description: |
If you have already paid for Minecraft, you could enter your Mojang account's password below to bind it. Please make sure your email address is the same as the email address of your Minecraft account before binding.
Of course, we won't save your password. If you worry, you can use a temporary password for your Mojang account then change it back.
If you have already paid for Minecraft, you could click the button below to bind it.
After passed the verification, you will get a player with the same name as your premium Minecraft player. Besides, you will become a "Pro" user and gain :score score.
verify: Verify
failed:
rate: Operations are too frequent. Please try again later.
password: Invalid password.
not-purchased: Unable to verify game ownership.
other: Failed to verify.
notification:

View File

@ -1,4 +1,3 @@
title: Mojang Verification
description: Provides verification and binding support for paid Minecraft user.
pro: Pro
notice: You could log in using your Mojang account if you have paid for Minecraft.

View File

@ -1,13 +1,12 @@
title: 正版绑定
description: |
如果您拥有正版 Minecraft 账号,可在下方输入密码进行验证并绑定。验证前请确保您在本站使用的邮箱与您的 Minecraft 账号的邮箱一致。
请放心,我们不会保存您的密码。如果不放心,可以临时修改您的正版账号密码,并在验证后改回来。
如果您拥有正版 Minecraft 账号,可点击下面的按钮进行验证并绑定。
如果验证成功,您将获得正版账号对应的角色,并可获得 :score 积分。
verify: 验证
failed:
rate: 操作过于频繁,请稍后再试。
password: 密码错误
not-purchased: 验证游戏所有权失败
other: 验证失败,可能是无法连接 Mojang 服务器。
notification:

View File

@ -1,4 +1,3 @@
title: 正版验证
description: 为拥有正版账号的用户提供验证、绑定。
pro: 正版
notice: Mojang 正版用户可直接登录,无需注册。

View File

@ -1,12 +1,13 @@
{
"name": "mojang-verification",
"version": "1.13.2",
"version": "2.0.0",
"title": "GPlane\\Mojang::general.title",
"description": "GPlane\\Mojang::general.description",
"author": "GPlane",
"namespace": "GPlane\\Mojang",
"require": {
"blessing-skin-server": "^5|^6"
"blessing-skin-server": "^5|^6",
"oauth": "^1.0.0"
},
"enchants": {
"config": "Configuration",

View File

@ -7,25 +7,41 @@ use DB;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Http;
use Laravel\Socialite\Facades\Socialite;
use Log;
class AccountController extends Controller
{
public function verify(Request $request, AccountService $accountService)
public function verify(Request $request)
{
$user = auth()->user();
if (MojangVerification::where('user_id', $user->uid)->count() === 1) {
return back();
abort(403);
}
$result = $accountService->validate($user->email, $request->input('password'));
if ($result['valid']) {
$accountService->bindAccount($user, $result['profiles'], $result['selected']);
Log::channel('mojang-verification')->info("User [$user->email] is try to start verification");
return back();
} else {
return back()->with('mojang-failed', $result['message']);
return Socialite::driver('microsoft')->redirect();
}
public function verifyCallback(Request $request, AccountService $accountService)
{
if (!$request->has('code')) {
abort(403);
}
$user = auth()->user();
if (MojangVerification::where('user_id', $user->uid)->count() === 1) {
abort(403);
}
$userProfile = Socialite::driver('microsoft')->user();
$accountService->bindAccount($user, $userProfile);
return redirect()->route('user.home');
}
public function uuid()

View File

@ -5,15 +5,11 @@ namespace GPlane\Mojang;
use App\Models\Player;
use App\Models\User;
use App\Services\Hook;
use Composer\CaBundle\CaBundle;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail as MailService;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Log;
class AccountService
{
@ -25,106 +21,67 @@ class AccountService
$this->events = $dispatcher;
}
public function validate(string $username, string $password)
public function bindPlayers(User $user, $profile)
{
try {
$response = Http::withOptions(['verify' => CaBundle::getSystemCaRootBundlePath()])
->post(
'https://authserver.mojang.com/authenticate',
array_merge(compact('username', 'password'), [
'agent' => ['name' => 'Minecraft', 'version' => 1],
])
);
$player = Player::where('name', $profile->name)->first();
if ($response->ok()) {
$body = $response->json();
if ($player) {
if ($player->uid != $user->uid) {
$owner = $player->user;
return [
'valid' => Arr::has($body, 'selectedProfile'),
'profiles' => $body['availableProfiles'],
'selected' => Arr::get($body, 'selectedProfile'),
];
} else {
Log::warning('Received unexpected HTTP status code from Mojang server: '.$response->status());
$error = $response->json()['errorMessage'];
if (Str::contains($error, 'Invalid username or password.')) {
$message = trans('GPlane\Mojang::bind.failed.password');
} elseif ($error === 'Invalid credentials.') {
$message = trans('GPlane\Mojang::bind.failed.rate');
} else {
$message = trans('GPlane\Mojang::bind.failed.other');
}
return ['valid' => false, 'message' => $message];
}
} catch (\Exception $e) {
report($e);
return ['valid' => false, 'message' => trans('GPlane\Mojang::bind.failed.other')];
}
}
public function bindPlayers(User $user, array $profiles)
{
array_walk($profiles, function ($profile) use ($user) {
$player = Player::where('name', $profile['name'])->first();
if ($player) {
if ($player->uid != $user->uid) {
$owner = $player->user;
$player->uid = $user->uid;
$player->tid_skin = 0;
$player->tid_cape = 0;
$player->save();
$owner->score += option('score_per_player');
$owner->save();
if (config('mail.default') != '') {
@MailService::to($owner->email)->send(new Mail($owner, $profile['name']));
$playerName = $player->name;
Hook::sendNotification(
[$owner],
trans('GPlane\Mojang::bind.notification.title', [], $owner->locale),
trans('GPlane\Mojang::bind.notification.content', [
'nickname' => $owner->nickname,
'player' => $playerName,
'score' => option('score_per_player'),
], $owner->locale)
);
}
}
} else {
$this->events->dispatch('player.adding', [$profile['name'], $user]);
$player = new Player();
$player->uid = $user->uid;
$player->name = $profile['name'];
$player->tid_skin = 0;
$player->tid_cape = 0;
$player->save();
$this->events->dispatch('player.added', [$player, $user]);
}
$owner->score += option('score_per_player');
$owner->save();
// For "yggdrasil-api" plugin.
if (Schema::hasTable('uuid') && DB::table('uuid')->where('name', $profile['name'])->doesntExist()) {
DB::table('uuid')->insert(['name' => $profile['name'], 'uuid' => $profile['id']]);
if (config('mail.default') != '') {
@MailService::to($owner->email)->send(new Mail($owner, $profile->name));
$playerName = $player->name;
Hook::sendNotification(
[$owner],
trans('GPlane\Mojang::bind.notification.title', [], $owner->locale),
trans('GPlane\Mojang::bind.notification.content', [
'nickname' => $owner->nickname,
'player' => $playerName,
'score' => option('score_per_player'),
], $owner->locale)
);
}
}
});
} else {
$this->events->dispatch('player.adding', [$profile->name, $user]);
$player = new Player();
$player->uid = $user->uid;
$player->name = $profile->name;
$player->tid_skin = 0;
$player->tid_cape = 0;
$player->save();
$this->events->dispatch('player.added', [$player, $user]);
}
// For "yggdrasil-api" plugin.
if (Schema::hasTable('uuid') && DB::table('uuid')->where('name', $profile->name)->doesntExist()) {
DB::table('uuid')->insert(['name' => $profile->name, 'uuid' => $profile->id]);
}
}
public function bindAccount(User $user, array $profiles, $selected)
public function bindAccount(User $user, $profile)
{
$this->bindPlayers($user, $profiles);
$this->bindPlayers($user, $profile);
MojangVerification::updateOrCreate(
['uuid' => $selected['id']],
['uuid' => $profile->id],
['user_id' => $user->uid, 'verified' => true]
);
$this->events->dispatch('user.mojang.verified', [$user, $selected, $profiles]);
Log::channel('mojang-verification')->info("User [$user->email] account binded successfully. [name=$profile->name,uuid=$profile->id]");
$this->events->dispatch('user.mojang-ms.verified', [$user, $profile]);
$user->score += (int) option('mojang_verification_score_award', 0);
$user->save();

View File

@ -1,86 +0,0 @@
<?php
namespace GPlane\Mojang\Listeners;
use App\Models\User;
use Blessing\Filter;
use Carbon\Carbon;
use GPlane\Mojang\AccountService;
use GPlane\Mojang\MojangVerification;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\Arr;
use Vectorface\Whip\Whip;
class CreateNewUser
{
/** @var Filter */
protected $filter;
/** @var AccountService */
protected $accountService;
/** @var Dispatcher */
protected $events;
public function __construct(
Filter $filter,
AccountService $accountService,
Dispatcher $dispatcher
) {
$this->filter = $filter;
$this->accountService = $accountService;
$this->events = $dispatcher;
}
public function handle($email, $password, $authType)
{
if ($authType != 'email') {
return;
}
$user = User::where('email', $email)->first();
if ($user) {
return;
}
$result = $this->accountService->validate($email, $password);
if (!$result['valid']) {
return;
}
$uuid = Arr::get($result['selected'], 'id');
$record = MojangVerification::where('uuid', $uuid)->first();
if ($record) {
$user = User::find($record->user_id);
if ($user) {
$this->events->dispatch('user.profile.updating', [$user, 'email', ['email' => $email]]);
$user->update(['email' => $email]);
$this->events->dispatch('user.profile.updated', [$user, 'email', ['email' => $email]]);
return;
}
}
$whip = new Whip();
$ip = $whip->getValidIpAddress();
$ip = $this->filter->apply('client_ip', $ip);
$user = new User();
$user->email = $email;
$user->nickname = Arr::get($result['selected'], 'name', '');
$user->score = option('user_initial_score');
$user->avatar = 0;
$password = app('cipher')->hash(request('password'), config('secure.salt'));
$password = $this->filter->apply('user_password', $password);
$user->password = $password;
$user->ip = $ip;
$user->permission = User::NORMAL;
$user->register_at = Carbon::now();
$user->last_sign_at = Carbon::now()->subDay();
$user->save();
$this->events->dispatch('auth.registration.completed', [$user]);
$this->accountService->bindAccount($user, $result['profiles'], $result['selected']);
}
}

View File

@ -29,7 +29,7 @@ class OnAuthenticated
return $grid;
});
Hook::addScriptFileToPage(plugin_assets('mojang-verification', 'update-uuid.js'), ['user/profile']);
Hook::addScriptFileToPage(plugin('mojang-verification')->assets('update-uuid.js'), ['user/profile']);
}
} else {
$this->filter->add('grid:user.index', function ($grid) {

View File

@ -0,0 +1,13 @@
<?php
namespace GPlane\Mojang\Providers;
use SocialiteProviders\Manager\SocialiteWasCalled;
class MicrosoftExtendSocialite
{
public function handle(SocialiteWasCalled $socialiteWasCalled)
{
$socialiteWasCalled->extendSocialite('microsoft', __NAMESPACE__.'\MicrosoftProvider');
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace GPlane\Mojang\Providers;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Log;
use SocialiteProviders\Manager\OAuth2\AbstractProvider;
use SocialiteProviders\Manager\OAuth2\User;
class MicrosoftProvider extends AbstractProvider
{
protected $scopes = ['XboxLive.signin'];
protected string $xbl_token;
protected string $user_hash;
protected string $xsts_token;
protected string $minecraft_access_token;
protected function getAuthUrl($state)
{
return $this->buildAuthUrlFromBase('https://login.live.com/oauth20_authorize.srf', $state);
}
protected function getTokenUrl()
{
return 'https://login.live.com/oauth20_token.srf';
}
/**
* @see https://wiki.vg/Microsoft_Authentication_Scheme
*/
protected function getUserByToken($token)
{
$user = auth()->user();
// Authenticate with XBox Live
$response = Http::post('https://user.auth.xboxlive.com/user/authenticate', [
'Properties' => [
'AuthMethod' => 'RPS',
'SiteName' => 'user.auth.xboxlive.com',
'RpsTicket' => 'd='.$token,
],
'RelyingParty' => 'http://auth.xboxlive.com',
'TokenType' => 'JWT',
])->json();
$xbl_token = $response['Token'];
$user_hash = $response['DisplayClaims']['xui'][0]['uhs'];
// Authenticate with XSTS (Xbox One Security Token Service)
$response = Http::post('https://xsts.auth.xboxlive.com/xsts/authorize', [
'Properties' => [
'SandboxId' => 'RETAIL',
'UserTokens' => [$xbl_token],
],
'RelyingParty' => 'rp://api.minecraftservices.com/',
'TokenType' => 'JWT',
])->json();
if (Arr::exists($response, 'XErr')) {
// TODO show detail error to user
Log::channel('mojang-verification')->info("User [$user->email] authenticate with XSTS failed.", compact('response'));
abort(500, trans('GPlane\Mojang::bind.failed.other'));
}
$xsts_token = $response['Token'];
// Authenticate with Minecraft
$response = Http::post('https://api.minecraftservices.com/authentication/login_with_xbox', [
'identityToken' => 'XBL3.0 x='.$user_hash.';'.$xsts_token,
])->json();
if (Arr::exists($response, 'error')) {
// UNAUTHORIZED
Log::channel('mojang-verification')->info("User [$user->email] authenticate with Minecraft failed.", compact('response'));
abort(500);
}
$minecraft_access_token = $response['access_token'];
// Get the profile
$response = Http::withToken($minecraft_access_token)->get('https://api.minecraftservices.com/minecraft/profile')->json();
if (Arr::exists($response, 'error')) {
// logger($response);
// NOT_FOUND
// CONSTRAINT_VIOLATION
Log::channel('mojang-verification')->info("User [$user->email] get the profile failed.", compact('response'));
abort(403, trans('GPlane\Mojang::bind.failed.not-purchased'));
}
return $response;
}
protected function mapUserToObject(array $user)
{
return (new User())->setRaw($user)->map([
'id' => $user['id'],
'name' => $user['name'],
]);
}
protected function getTokenFields($code)
{
return array_merge(parent::getTokenFields($code), [
'grant_type' => 'authorization_code',
]);
}
}

View File

@ -1,26 +1,17 @@
<form class="card card-primary card-outline" method="post" action="/mojang/verify">
{{ csrf_field() }}
<div class="card-header">
<h3 class="card-title">
{{ trans('GPlane\\Mojang::bind.title') }}
</h3>
</div>
<div class="card-body">
{% if session_has('mojang-failed') %}
<div class="alert alert-danger">
{{ session_pull('mojang-failed') }}
</div>
{% endif %}
<div>
{{ trans('GPlane\\Mojang::bind.description', {score: score})|nl2br }}
</div>
<label class="form-group mt-4">
<input class="form-control" type="password" name="password">
</label>
</div>
<div class="card-footer">
<button type="submit" class="btn bg-primary">
{{ trans('general.submit') }}
</button>
</div>
<form class="card card-primary card-outline">
<div class="card-header">
<h3 class="card-title">
{{ trans('GPlane\\Mojang::bind.title') }}
</h3>
</div>
<div class="card-body">
<div>
{{ trans('GPlane\\Mojang::bind.description', {score: score})|nl2br }}
</div>
</div>
<div class="card-footer">
<a class="btn btn-primary" href="/mojang/verify">
{{ trans('GPlane\\Mojang::bind.verify') }}
</a>
</div>
</form>

View File

@ -1,3 +0,0 @@
<div class="callout callout-info">
{{ trans('GPlane\\Mojang::general.notice') }}
</div>