icloud auth done

This commit is contained in:
2026-01-19 00:54:59 +07:00
parent c6df7f956d
commit 6c16245040
9 changed files with 254 additions and 311 deletions

View File

@@ -34,7 +34,8 @@
"ext-pdo": "*", "ext-pdo": "*",
"ext-fileinfo": "*", "ext-fileinfo": "*",
"ext-zip": "*", "ext-zip": "*",
"php-http/curl-client": "^2.4" "php-http/curl-client": "^2.4",
"ext-sodium": "*"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",

View File

@@ -9,12 +9,17 @@ return function (PDO $db): void {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
remote_name TEXT NOT NULL UNIQUE, remote_name TEXT NOT NULL UNIQUE,
apple_id TEXT NOT NULL, apple_id TEXT NOT NULL,
trust_token TEXT, password TEXT NOT NULL,
cookies TEXT,
status TEXT NOT NULL DEFAULT 'pending', trust_token TEXT NOT NULL,
connected_at INTEGER, cookies TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'connected',
connected_at INTEGER NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
); );
"); ");

View File

@@ -141,8 +141,7 @@ $routes = static function (RouteCollector $r): void {
$r->post('/storage/files/delete/multiple', [StorageController::class, 'deleteMultiple']); $r->post('/storage/files/delete/multiple', [StorageController::class, 'deleteMultiple']);
$r->get('/icloud/connect', [ICloudAuthController::class, 'connectForm']); $r->get('/icloud/connect', [ICloudAuthController::class, 'connectForm']);
$r->post('/icloud/connect', [ICloudAuthController::class, 'submitCredentials']); $r->post('/icloud/connect', [ICloudAuthController::class, 'submit']);
$r->post('/icloud/2fa', [ICloudAuthController::class, 'submit2fa']);
}; };

View File

@@ -1,114 +1,62 @@
<!-- icloud_login.php -->
<div class="login-card"> <div class="login-card">
<h1><?= htmlspecialchars($viewModel->title ?? 'iCloud Login') ?></h1> <h1>Connect iCloud</h1>
<?php if (!empty($viewModel->error)) : ?> <?php if (!empty($viewModel->error)) : ?>
<p class="error"><?= htmlspecialchars($viewModel->error) ?></p> <p class="error"><?= htmlspecialchars($viewModel->error) ?></p>
<?php endif; ?> <?php endif; ?>
<?php if (isset($viewModel->show2fa) && $viewModel->show2fa): ?> <div class="info-message">
<div class="info-message" style=" <ol>
background: rgba(66, 153, 225, 0.1); <li>Open <b>icloud.com</b> and log in</li>
border-left: 4px solid #4299e1; <li>Complete 2FA</li>
padding: 1rem; <li>Open DevTools → Network</li>
margin-bottom: 1.5rem; <li>Copy <b>Request Header → Cookie</b></li>
border-radius: 0 8px 8px 0; <li>Copy <b>X-APPLE-WEBAUTH-HSA-TRUST</b> value</li>
color: #2d3748; </ol>
"> </div>
<p style="margin: 0; font-weight: 500;">
Enter the 6-digit verification code sent to your trusted devices.
</p>
</div>
<form method="POST" action="/icloud/2fa" class="login-form"> <form method="POST" action="/icloud/connect" class="login-form">
<input type="hidden" name="apple_id" value="<?= htmlspecialchars($viewModel->appleId ?? '') ?>"> <label for="apple_id">Apple ID</label>
<input type="hidden" name="password" value="<?= htmlspecialchars($viewModel->password ?? '') ?>"> <input
type="email"
id="apple_id"
name="apple_id"
required
placeholder="yourname@icloud.com"
>
<label for="code"> <label for="password">Password</label>
6-digit verification code <input
</label> type="password"
<input type="text" id="password"
id="code" name="password"
name="code" required
pattern="[0-9]{6}" placeholder="iCloud password"
maxlength="6" >
required
placeholder="123456"
autocomplete="one-time-code"
inputmode="numeric"
style="letter-spacing: 2px; font-size: 1.2rem; text-align: center;">
<button type="submit" style="margin-top: 1rem;"> <label for="cookies">Cookies (full header)</label>
Verify Code <textarea
</button> id="cookies"
name="cookies"
rows="6"
required
placeholder="X-APPLE-WEBAUTH-HSA-TRUST=...; X-APPLE-WEBAUTH-USER=...;"
></textarea>
<div style="margin-top: 1rem; text-align: center;"> <label for="trust_token">Trust token</label>
<a href="/icloud/connect" style="color: #667eea; text-decoration: none;"> <input
← Back to login type="text"
</a> id="trust_token"
</div> name="trust_token"
required
placeholder="X-APPLE-WEBAUTH-HSA-TRUST value"
>
<input type="hidden" name="_csrf" <button type="submit">
value="<?= htmlspecialchars($viewModel->csrf) ?>"> Connect iCloud
</form> </button>
<?php else: ?> <input type="hidden" name="_csrf"
<form method="POST" action="/icloud/connect" class="login-form"> value="<?= htmlspecialchars($viewModel->csrf) ?>">
<label for="apple_id"> </form>
Apple ID
</label>
<input type="email"
id="apple_id"
name="apple_id"
required
placeholder="name@example.com"
autocomplete="username">
<label for="password">
Password
</label>
<input type="password"
id="password"
name="password"
required
placeholder="Enter your password"
autocomplete="current-password">
<button type="submit">
Connect iCloud
</button>
<input type="hidden" name="_csrf"
value="<?= htmlspecialchars($viewModel->csrf) ?>">
</form>
<?php endif; ?>
</div> </div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const codeInput = document.getElementById('code');
if (codeInput) {
codeInput.addEventListener('keypress', function (e) {
const char = String.fromCharCode(e.which);
if (!/^\d$/.test(char)) {
e.preventDefault();
}
});
codeInput.addEventListener('input', function () {
if (this.value.length === 6) {
this.form.submit();
}
});
}
});
</script>
<style>
@media (prefers-color-scheme: dark) {
.info-message {
background: rgba(66, 153, 225, 0.15) !important;
border-left-color: #4299e1 !important;
color: #cbd5e1 !important;
}
}
</style>

View File

@@ -5,7 +5,6 @@ namespace Din9xtrCloud\Controllers;
use Din9xtrCloud\Middlewares\CsrfMiddleware; use Din9xtrCloud\Middlewares\CsrfMiddleware;
use Din9xtrCloud\Rclone\RcloneClient; use Din9xtrCloud\Rclone\RcloneClient;
use Din9xtrCloud\Rclone\RcloneICloudConfigurator;
use Din9xtrCloud\Repositories\IcloudAccountRepository; use Din9xtrCloud\Repositories\IcloudAccountRepository;
use Din9xtrCloud\View; use Din9xtrCloud\View;
use Din9xtrCloud\ViewModels\Icloud\ICloudLoginViewModel; use Din9xtrCloud\ViewModels\Icloud\ICloudLoginViewModel;
@@ -18,7 +17,6 @@ use Throwable;
final readonly class ICloudAuthController final readonly class ICloudAuthController
{ {
public function __construct( public function __construct(
private RcloneICloudConfigurator $configurator,
private ResponseFactoryInterface $responseFactory, private ResponseFactoryInterface $responseFactory,
private LoggerInterface $logger, private LoggerInterface $logger,
private IcloudAccountRepository $repository, private IcloudAccountRepository $repository,
@@ -33,122 +31,107 @@ final readonly class ICloudAuthController
session_start(); session_start();
} }
$query = $request->getQueryParams(); $error = $_SESSION['icloud_error'] ?? '';
$show2fa = (bool)($query['show2fa'] ?? false); unset($_SESSION['icloud_error']);
$appleId = (string)($query['apple_id'] ?? '');
$error = '';
if (isset($_SESSION['icloud_error'])) {
$error = (string)$_SESSION['icloud_error'];
unset($_SESSION['icloud_error']);
}
$title = $show2fa ? 'iCloud 2FA Verification' : 'iCloud Login';
return View::display( return View::display(
new ICloudLoginViewModel( new ICloudLoginViewModel(
title: $title, title: 'Connect iCloud',
csrf: CsrfMiddleware::generateToken($request), csrf: CsrfMiddleware::generateToken($request),
show2fa: $show2fa, error: $error
error: $error,
appleId: $appleId
) )
); );
} }
public function submitCredentials(ServerRequestInterface $request): ResponseInterface public function submit(ServerRequestInterface $request): ResponseInterface
{ {
$data = (array)$request->getParsedBody(); $data = (array)$request->getParsedBody();
$user = $request->getAttribute('user'); $user = $request->getAttribute('user');
$appleId = (string)($data['apple_id'] ?? ''); $appleId = trim((string)($data['apple_id'] ?? ''));
$password = (string)($data['password'] ?? ''); $password = trim((string)($data['password'] ?? ''));
$remote = 'icloud_' . $user->id; $cookies = trim((string)($data['cookies'] ?? ''));
$trustToken = trim((string)($data['trust_token'] ?? ''));
try { if ($appleId === '' || $password === '' || $cookies === '' || $trustToken === '') {
$this->configurator->createRemote($remote, $appleId); return $this->fail('All fields are required');
$this->configurator->setPassword($remote, $password);
$config = $this->configurator->getConfig($remote);
$trustToken = $config['trust_token'] ?? null;
$cookies = $config['cookies'] ?? null;
$this->repository->create(
$user->id,
$remote,
$appleId,
$trustToken,
$cookies
);
return $this->responseFactory
->createResponse(302)
->withHeader('Location', '/icloud/connect?show2fa=1&apple_id=' . urlencode($appleId));
} catch (Throwable $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
if (session_status() === PHP_SESSION_NONE) session_start();
$_SESSION['icloud_error'] = $e->getMessage();
return $this->responseFactory
->createResponse(302)
->withHeader('Location', '/icloud/connect');
} }
}
public function submit2fa(ServerRequestInterface $request): ResponseInterface
{
$data = (array)$request->getParsedBody();
$user = $request->getAttribute('user');
$code = (string)($data['code'] ?? '');
$appleId = (string)($data['apple_id'] ?? '');
$remote = 'icloud_' . $user->id; $remote = 'icloud_' . $user->id;
try { try {
$res = $this->configurator->submit2fa($remote, $code); $encryptedAppleId = encryptString($appleId);
$encryptedPassword = encryptString($password);
$account = $this->repository->findByUserId($user->id); try {
if ($account) { $this->rclone->call('config/create', [
$this->repository->update($account, [ 'name' => $remote,
'status' => 'connected', 'type' => 'iclouddrive',
'connected_at' => time(), 'parameters' => [
'trust_token' => $res['trust_token'], 'apple_id' => $appleId,
'cookies' => $res['cookies'], 'password' => $password,
'cookies' => $cookies,
'trust_token' => $trustToken,
],
'nonInteractive' => true,
]);
} catch (Throwable $e) {
$this->logger->warning('iCloud remote exists, updating', [
'remote' => $remote,
'exception' => $e,
]);
$this->rclone->call('config/update', [
'name' => $remote,
'parameters' => [
'apple_id' => $appleId,
'password' => $password,
'cookies' => $cookies,
'trust_token' => $trustToken,
],
]); ]);
} }
$contents = $this->rclone->call('operations/list', [ // health-check
$list = $this->rclone->call('operations/list', [
'fs' => $remote . ':', 'fs' => $remote . ':',
'remote' => '', 'remote' => '',
'opt' => [], 'maxDepth' => 1,
'recurse' => true,
'dirsOnly' => false,
'filesOnly' => false,
'metadata' => true,
]); ]);
$this->logger->info('iCloud contents', ['user' => $user->id, '$contents' => $contents]); $this->logger->info('icloud list', $list);
$this->repository->createOrUpdate(
userId: $user->id,
remoteName: $remote,
appleId: $encryptedAppleId,
password: $encryptedPassword,
trustToken: $trustToken,
cookies: $cookies,
);
return $this->responseFactory return $this->responseFactory
->createResponse(302) ->createResponse(302)
->withHeader('Location', '/storage'); ->withHeader('Location', '/storage');
} catch (Throwable $e) { } catch (Throwable $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]); $this->logger->error('iCloud connect failed', [
'remote' => $remote,
'exception' => $e,
]);
if (session_status() === PHP_SESSION_NONE) session_start(); return $this->fail($e->getMessage());
$_SESSION['icloud_error'] = $e->getMessage();
return $this->responseFactory
->createResponse(302)
->withHeader('Location', '/icloud/connect?' . http_build_query([
'show2fa' => 1,
'apple_id' => $appleId
]));
} }
} }
private function fail(string $message): ResponseInterface
{
if (session_status() === PHP_SESSION_NONE) session_start();
$_SESSION['icloud_error'] = $message;
return $this->responseFactory
->createResponse(302)
->withHeader('Location', '/icloud/connect');
}
} }

View File

@@ -1,5 +1,7 @@
<?php <?php
use Random\RandomException;
if (!function_exists('formatBytes')) { if (!function_exists('formatBytes')) {
function formatBytes(int $bytes): string function formatBytes(int $bytes): string
@@ -94,3 +96,48 @@ if (!function_exists('validateIp')) {
: null; : null;
} }
} }
if (!function_exists('encryptString')) {
/**
* XSalsa20 + Poly1305
* @throws Throwable
*/
function encryptString(string $plaintext): string
{
$key = hex2bin($_ENV['APP_KEY']); // 32 байта
if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new RuntimeException('APP_KEY must be 32 bytes');
}
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);
return base64_encode($nonce . $ciphertext);
}
}
if (!function_exists('decryptString')) {
/**
* @throws Throwable
*/
function decryptString(string $encrypted): string
{
$key = hex2bin($_ENV['APP_KEY']);
if (strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new RuntimeException('APP_KEY must be 32 bytes');
}
$data = base64_decode($encrypted, true);
$nonce = substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
if ($plaintext === false) {
throw new RuntimeException('Failed to decrypt data');
}
return $plaintext;
}
}

View File

@@ -6,15 +6,19 @@ namespace Din9xtrCloud\Models;
final readonly class IcloudAccount final readonly class IcloudAccount
{ {
public function __construct( public function __construct(
public int $id, public int $id,
public int $userId, public int $userId,
public string $remoteName, public string $remoteName,
public string $appleId,
public ?string $trustToken, public string $appleId,
public ?string $cookies, public string $password,
public string $status,
public ?int $connectedAt, public string $trustToken,
public int $createdAt, public string $cookies,
public string $status,
public int $connectedAt,
public int $createdAt,
) )
{ {
} }

View File

@@ -1,77 +0,0 @@
<?php
declare(strict_types=1);
namespace Din9xtrCloud\Rclone;
use JsonException;
use Psr\Http\Client\ClientExceptionInterface;
final readonly class RcloneICloudConfigurator
{
public function __construct(
private RcloneClient $rclone,
)
{
}
/**
* @throws ClientExceptionInterface
* @throws JsonException
*/
public function createRemote(string $remote, string $appleId): void
{
$this->rclone->call('config/create', [
'name' => $remote,
'type' => 'iclouddrive',
'parameters' => [
'apple_id' => $appleId,
],
]);
}
/**
* @throws ClientExceptionInterface
* @throws JsonException
*/
public function setPassword(string $remote, string $password): void
{
$this->rclone->call('config/password', [
'name' => $remote,
'parameters' => [
'password' => $password,
],
]);
}
/**
* @throws ClientExceptionInterface
* @throws JsonException
*/
public function submit2fa(string $remote, string $code): array
{
$this->rclone->call('config/update', [
'name' => $remote,
'parameters' => [
'config_2fa' => $code,
],
]);
$config = $this->getConfig($remote);
return [
'trust_token' => $config['trust_token'] ?? null,
'cookies' => $config['cookies'] ?? null,
];
}
/**
* @throws ClientExceptionInterface
* @throws JsonException
*/
public function getConfig(string $remote): array
{
return $this->rclone->call('config/show', [
'name' => $remote
]);
}
}

View File

@@ -12,60 +12,92 @@ final readonly class IcloudAccountRepository
{ {
} }
public function create( public function createOrUpdate(
int $userId, int $userId,
string $remoteName, string $remoteName,
string $appleId, string $appleId,
?string $trustToken = null, string $password,
?string $cookies = null string $trustToken,
string $cookies
): IcloudAccount ): IcloudAccount
{ {
$existing = $this->findByUserId($userId);
$now = time();
if ($existing) {
$stmt = $this->db->prepare("
UPDATE icloud_accounts
SET apple_id = :apple_id,
password = :password,
trust_token = :trust_token,
cookies = :cookies,
status = 'connected',
connected_at = :connected_at
WHERE id = :id
");
$stmt->execute([
':apple_id' => $appleId,
':password' => $password,
':trust_token' => $trustToken,
':cookies' => $cookies,
':connected_at' => $now,
':id' => $existing->id,
]);
return new IcloudAccount(
id: $existing->id,
userId: $userId,
remoteName: $remoteName,
appleId: $appleId,
password: $password,
trustToken: $trustToken,
cookies: $cookies,
status: 'connected',
connectedAt: $now,
createdAt: $existing->createdAt,
);
}
$stmt = $this->db->prepare(" $stmt = $this->db->prepare("
INSERT INTO icloud_accounts (user_id, remote_name, apple_id, trust_token, cookies) INSERT INTO icloud_accounts
VALUES (:user_id, :remote_name, :apple_id, :trust_token, :cookies) (user_id, remote_name, apple_id, password, trust_token, cookies, status, connected_at, created_at)
VALUES
(:user_id, :remote_name, :apple_id, :password, :trust_token, :cookies, 'connected', :connected_at, :created_at)
"); ");
$stmt->execute([ $stmt->execute([
':user_id' => $userId, ':user_id' => $userId,
':remote_name' => $remoteName, ':remote_name' => $remoteName,
':apple_id' => $appleId, ':apple_id' => $appleId,
':password' => $password,
':trust_token' => $trustToken, ':trust_token' => $trustToken,
':cookies' => $cookies, ':cookies' => $cookies,
':connected_at' => $now,
':created_at' => $now,
]); ]);
$id = (int)$this->db->lastInsertId();
return new IcloudAccount( return new IcloudAccount(
id: $id, id: (int)$this->db->lastInsertId(),
userId: $userId, userId: $userId,
remoteName: $remoteName, remoteName: $remoteName,
appleId: $appleId, appleId: $appleId,
password: $password,
trustToken: $trustToken, trustToken: $trustToken,
cookies: $cookies, cookies: $cookies,
status: 'pending', status: 'connected',
connectedAt: null, connectedAt: $now,
createdAt: time(), createdAt: $now,
); );
} }
public function update(
IcloudAccount $account,
array $fields
): void
{
$set = [];
$params = [':id' => $account->id];
foreach ($fields as $key => $value) {
$set[] = "$key = :$key";
$params[":$key"] = $value;
}
$stmt = $this->db->prepare("UPDATE icloud_accounts SET " . implode(', ', $set) . " WHERE id = :id");
$stmt->execute($params);
}
public function findByUserId(int $userId): ?IcloudAccount public function findByUserId(int $userId): ?IcloudAccount
{ {
$stmt = $this->db->prepare("SELECT * FROM icloud_accounts WHERE user_id = :user_id"); $stmt = $this->db->prepare("
SELECT * FROM icloud_accounts WHERE user_id = :user_id
");
$stmt->execute([':user_id' => $userId]); $stmt->execute([':user_id' => $userId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? new IcloudAccount( return $row ? new IcloudAccount(
@@ -73,10 +105,11 @@ final readonly class IcloudAccountRepository
userId: (int)$row['user_id'], userId: (int)$row['user_id'],
remoteName: $row['remote_name'], remoteName: $row['remote_name'],
appleId: $row['apple_id'], appleId: $row['apple_id'],
password: $row['password'],
trustToken: $row['trust_token'], trustToken: $row['trust_token'],
cookies: $row['cookies'], cookies: $row['cookies'],
status: $row['status'], status: $row['status'],
connectedAt: $row['connected_at'] ? (int)$row['connected_at'] : null, connectedAt: (int)$row['connected_at'],
createdAt: (int)$row['created_at'], createdAt: (int)$row['created_at'],
) : null; ) : null;
} }