Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion core/Controller/ClientFlowLoginV2Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
use OCP\ISession;
use OCP\IURLGenerator;
use OCP\IUserSession;
use OCP\Security\Ip\IFactory;
use OCP\Security\ISecureRandom;
use OCP\Server;
use OCP\Util;
Expand All @@ -60,6 +61,7 @@ public function __construct(
private ?string $userId,
private IL10N $l10n,
private IInitialState $initialState,
private IFactory $ipFactory,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -176,6 +178,7 @@ public function grantPage(?string $stateToken, int $direct = 0): StandaloneTempl
'instanceName' => $this->defaults->getName(),
'stateToken' => $stateToken,
'direct' => $direct === 1,
'initiatorAddress' => $this->isStartedFromOtherNetwork($flow) ? $flow->getInitiatorIp() : null,
]);

Util::addScript('core', 'login_flow');
Expand Down Expand Up @@ -265,6 +268,26 @@ public function generateAppPassword(?string $stateToken): Response {
return $this->handleFlowDone($result);
}

/**
* Whether the flow was started from another network than the one granting it.
* A switch between IPv4 and IPv6 is common on dual-stack devices and does not count.
*/
private function isStartedFromOtherNetwork(LoginFlowV2 $flow): bool {
$initiatorIp = $flow->getInitiatorIp();
$currentIp = $this->request->getRemoteAddress();
if ($initiatorIp === null || $initiatorIp === '' || str_contains($initiatorIp, ':') !== str_contains($currentIp, ':')) {
return false;
}

$prefix = str_contains($initiatorIp, ':') ? 64 : 24;
try {
$network = $this->ipFactory->rangeFromString($initiatorIp . '/' . $prefix);
return !$network->contains($this->ipFactory->addressFromString($currentIp));
} catch (\InvalidArgumentException) {
return false;
}
}

private function handleFlowDone(bool $result): StandaloneTemplateResponse {
if ($result) {
Util::addScript('core', 'login_flow');
Expand Down Expand Up @@ -304,7 +327,7 @@ public function init(): JSONResponse {
// Get client user agent
$userAgent = $this->request->getHeader('user-agent');

$tokens = $this->loginFlowV2Service->createTokens($userAgent);
$tokens = $this->loginFlowV2Service->createTokens($userAgent, $this->request->getRemoteAddress());

$data = [
'poll' => [
Expand Down
5 changes: 5 additions & 0 deletions core/Db/LoginFlowV2.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
* @method void setServer(string $server)
* @method string getAppPassword()
* @method void setAppPassword(string $appPassword)
* @method string|null getInitiatorIp()
* @method void setInitiatorIp(?string $initiatorIp)
*/
class LoginFlowV2 extends Entity {
/** @var int */
Expand All @@ -55,6 +57,8 @@ class LoginFlowV2 extends Entity {
protected $server;
/** @var string */
protected $appPassword;
/** @var string|null */
protected $initiatorIp;

public function __construct() {
$this->addType('timestamp', Types::INTEGER);
Expand All @@ -67,5 +71,6 @@ public function __construct() {
$this->addType('loginName', 'string');
$this->addType('server', 'string');
$this->addType('appPassword', 'string');
$this->addType('initiatorIp', 'string');
}
}
49 changes: 49 additions & 0 deletions core/Migrations/Version36000Date20261020200000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Core\Migrations;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\Attributes\AddColumn;
use OCP\Migration\Attributes\ColumnType;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* Remember the address a login flow v2 was started from
*/
#[AddColumn(table: 'login_flow_v2', name: 'initiator_ip', type: ColumnType::STRING)]
class Version36000Date20261020200000 extends SimpleMigrationStep {

/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();

if ($schema->hasTable('login_flow_v2')) {
$table = $schema->getTable('login_flow_v2');
if (!$table->hasColumn('initiator_ip')) {
$table->addColumn('initiator_ip', Types::STRING, [
'notnull' => false,
'length' => 45,
]);
return $schema;
}
}

return null;
}
}
5 changes: 4 additions & 1 deletion core/Service/LoginFlowV2Service.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use OC\Core\Exception\LoginFlowV2NotFoundException;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Authentication\Events\LoginFlowV2GrantedEvent;
use OCP\Authentication\Exceptions\InvalidTokenException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
Expand Down Expand Up @@ -179,6 +180,7 @@ public function flowDone(string $loginToken, string $sessionId, string $server,
$data->setAppPassword($this->encryptPassword($appPassword, $data->getPublicKey()));

$this->mapper->update($data);
$this->eventDispatcher->dispatchTyped(new LoginFlowV2GrantedEvent($generatedToken, $data->getInitiatorIp(), $data->getTimestamp()));
return true;
}

Expand All @@ -199,7 +201,7 @@ public function flowDoneWithAppPassword(string $loginToken, string $server, stri
return true;
}

public function createTokens(string $userAgent): LoginFlowV2Tokens {
public function createTokens(string $userAgent, string $initiatorIp): LoginFlowV2Tokens {
$flow = new LoginFlowV2();
$pollToken = $this->random->generate(128, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER);
$loginToken = $this->random->generate(128, ISecureRandom::CHAR_DIGITS . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_UPPER);
Expand All @@ -208,6 +210,7 @@ public function createTokens(string $userAgent): LoginFlowV2Tokens {
$flow->setStarted(0);
$flow->setTimestamp($this->time->getTime());
$flow->setClientName($userAgent);
$flow->setInitiatorIp($initiatorIp);

[$publicKey, $privateKey] = $this->getKeyPair();
$privateKey = $this->crypto->encrypt($privateKey, $pollToken);
Expand Down
6 changes: 6 additions & 0 deletions core/src/views/LoginFlowGrant.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
actionUrl,
client,
direct,
initiatorAddress,
instanceName,
stateToken,
userDisplayName,
Expand All @@ -32,6 +33,7 @@ const {
actionUrl: string
client: string
direct: boolean
initiatorAddress?: string | null
instanceName: string
stateToken: string
userId: string
Expand Down Expand Up @@ -65,6 +67,10 @@ async function onSubmit(event: SubmitEvent) {
{{ t('core', 'You are about to grant "{client}" access to your {instanceName} account.', { client, instanceName }) }}
</NcNoteCard>

<NcNoteCard v-if="initiatorAddress" type="warning">
{{ t('core', 'This request was started from {address}, which is not the network you are using now. Only grant access if you started it on your own device just now. Otherwise someone may be trying to get access to your account.', { address: initiatorAddress }) }}
</NcNoteCard>

<form method="POST" :action="actionUrl" @submit="onSubmit">
<input type="hidden" name="requesttoken" :value="requestToken">
<input type="hidden" name="stateToken" :value="stateToken">
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
'OCP\\App\\ManagerEvent' => $baseDir . '/lib/public/App/ManagerEvent.php',
'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php',
'OCP\\Authentication\\Events\\LoginFailedEvent' => $baseDir . '/lib/public/Authentication/Events/LoginFailedEvent.php',
'OCP\\Authentication\\Events\\LoginFlowV2GrantedEvent' => $baseDir . '/lib/public/Authentication/Events/LoginFlowV2GrantedEvent.php',
'OCP\\Authentication\\Events\\TokenInvalidatedEvent' => $baseDir . '/lib/public/Authentication/Events/TokenInvalidatedEvent.php',
'OCP\\Authentication\\Exceptions\\CredentialsUnavailableException' => $baseDir . '/lib/public/Authentication/Exceptions/CredentialsUnavailableException.php',
'OCP\\Authentication\\Exceptions\\ExpiredTokenException' => $baseDir . '/lib/public/Authentication/Exceptions/ExpiredTokenException.php',
Expand Down Expand Up @@ -1758,6 +1759,7 @@
'OC\\Core\\Migrations\\Version34000Date20260518163022' => $baseDir . '/core/Migrations/Version34000Date20260518163022.php',
'OC\\Core\\Migrations\\Version34000Date20260521110333' => $baseDir . '/core/Migrations/Version34000Date20260521110333.php',
'OC\\Core\\Migrations\\Version35000Date20260527162338' => $baseDir . '/core/Migrations/Version35000Date20260527162338.php',
'OC\\Core\\Migrations\\Version36000Date20261020200000' => $baseDir . '/core/Migrations/Version36000Date20261020200000.php',
'OC\\Core\\Notification\\CoreNotifier' => $baseDir . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => $baseDir . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\CronService' => $baseDir . '/core/Service/CronService.php',
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\App\\ManagerEvent' => __DIR__ . '/../../..' . '/lib/public/App/ManagerEvent.php',
'OCP\\Authentication\\Events\\AnyLoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/AnyLoginFailedEvent.php',
'OCP\\Authentication\\Events\\LoginFailedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/LoginFailedEvent.php',
'OCP\\Authentication\\Events\\LoginFlowV2GrantedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/LoginFlowV2GrantedEvent.php',
'OCP\\Authentication\\Events\\TokenInvalidatedEvent' => __DIR__ . '/../../..' . '/lib/public/Authentication/Events/TokenInvalidatedEvent.php',
'OCP\\Authentication\\Exceptions\\CredentialsUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Authentication/Exceptions/CredentialsUnavailableException.php',
'OCP\\Authentication\\Exceptions\\ExpiredTokenException' => __DIR__ . '/../../..' . '/lib/public/Authentication/Exceptions/ExpiredTokenException.php',
Expand Down Expand Up @@ -1799,6 +1800,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Core\\Migrations\\Version34000Date20260518163022' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260518163022.php',
'OC\\Core\\Migrations\\Version34000Date20260521110333' => __DIR__ . '/../../..' . '/core/Migrations/Version34000Date20260521110333.php',
'OC\\Core\\Migrations\\Version35000Date20260527162338' => __DIR__ . '/../../..' . '/core/Migrations/Version35000Date20260527162338.php',
'OC\\Core\\Migrations\\Version36000Date20261020200000' => __DIR__ . '/../../..' . '/core/Migrations/Version36000Date20261020200000.php',
'OC\\Core\\Notification\\CoreNotifier' => __DIR__ . '/../../..' . '/core/Notification/CoreNotifier.php',
'OC\\Core\\ResponseDefinitions' => __DIR__ . '/../../..' . '/core/ResponseDefinitions.php',
'OC\\Core\\Service\\CronService' => __DIR__ . '/../../..' . '/core/Service/CronService.php',
Expand Down
64 changes: 64 additions & 0 deletions lib/public/Authentication/Events/LoginFlowV2GrantedEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Authentication\Events;

use OCP\Authentication\Token\IToken;
use OCP\EventDispatcher\Event;

/**
* Dispatched when a user granted a login flow v2 request, after the app
* password for the new client was created.
*
* The request that grants the flow may come from another address than the
* one that started it, e.g. when someone sends the user a link to a flow
* they started themselves.
*
* @since 36.0.0
*/
class LoginFlowV2GrantedEvent extends Event {
/**
* @since 36.0.0
*/
public function __construct(
private readonly IToken $token,
private readonly ?string $initiatorIp,
private readonly int $startedAt,
) {
parent::__construct();
}

/**
* The app password created for the client
*
* @since 36.0.0
*/
public function getToken(): IToken {
return $this->token;
}

/**
* The address the flow was started from, or null for flows started
* before it was recorded
*
* @since 36.0.0
*/
public function getInitiatorIp(): ?string {
return $this->initiatorIp;
}

/**
* When the flow was started, as a Unix timestamp
*
* @since 36.0.0
*/
public function getStartedAt(): int {
return $this->startedAt;
}
}
45 changes: 45 additions & 0 deletions tests/Core/Controller/ClientFlowLoginV2ControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OC\Core\Exception\LoginFlowV2ClientForbiddenException;
use OC\Core\Exception\LoginFlowV2NotFoundException;
use OC\Core\Service\LoginFlowV2Service;
use OC\Security\Ip\Factory as IpFactory;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\StandaloneTemplateResponse;
Expand Down Expand Up @@ -73,6 +74,7 @@ protected function setUp(): void {
'user',
$this->l,
$this->initialState,
new IpFactory(),
);
}

Expand Down Expand Up @@ -280,6 +282,49 @@ public function testGrantPageValid(): void {
$this->assertSame(Http::STATUS_OK, $result->getStatus());
}

public static function dataGrantPageInitiatorAddress(): array {
return [
'not recorded' => [null, '203.0.113.5', null],
'same IPv4 network' => ['203.0.113.77', '203.0.113.5', null],
'other IPv4 network' => ['198.51.100.7', '203.0.113.5', '198.51.100.7'],
'same IPv6 network' => ['2001:db8:1:2::abcd', '2001:db8:1:2::1', null],
'other IPv6 network' => ['2001:db8:9:9::1', '2001:db8:1:2::1', '2001:db8:9:9::1'],
'dual stack' => ['198.51.100.7', '2001:db8:1:2::1', null],
];
}

#[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataGrantPageInitiatorAddress')]
public function testGrantPageInitiatorAddress(?string $initiatorIp, string $currentIp, ?string $expected): void {
$this->session->method('get')
->willReturnCallback(static fn (string $name): ?string => match ($name) {
'client.flow.v2.state.token' => 'stateToken',
'client.flow.v2.login.token' => 'loginToken',
default => null,
});
$this->userSession->method('getUser')
->willReturn($this->createMock(IUser::class));
$this->request->method('getRemoteAddress')
->willReturn($currentIp);

$flow = new LoginFlowV2();
$flow->setInitiatorIp($initiatorIp);
$this->loginFlowV2Service->method('getByLoginToken')
->with('loginToken')
->willReturn($flow);

$grantState = null;
$this->initialState->method('provideInitialState')
->willReturnCallback(static function (string $key, mixed $data) use (&$grantState): void {
if ($key === 'loginFlowGrant') {
$grantState = $data;
}
});

$this->controller->grantPage('stateToken');

$this->assertSame($expected, $grantState['initiatorAddress']);
}

public function testGenerateAppPasswordInvalidStateToken(): void {
$this->session->method('get')
->willReturnCallback(function ($name) {
Expand Down
Loading
Loading