diff --git a/core/Controller/ClientFlowLoginV2Controller.php b/core/Controller/ClientFlowLoginV2Controller.php
index 30fbc96898b3e..0efd0ad7e158e 100644
--- a/core/Controller/ClientFlowLoginV2Controller.php
+++ b/core/Controller/ClientFlowLoginV2Controller.php
@@ -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;
@@ -60,6 +61,7 @@ public function __construct(
private ?string $userId,
private IL10N $l10n,
private IInitialState $initialState,
+ private IFactory $ipFactory,
) {
parent::__construct($appName, $request);
}
@@ -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');
@@ -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');
@@ -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' => [
diff --git a/core/Db/LoginFlowV2.php b/core/Db/LoginFlowV2.php
index 1a56ae3237d00..d2ee4887c2440 100644
--- a/core/Db/LoginFlowV2.php
+++ b/core/Db/LoginFlowV2.php
@@ -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 */
@@ -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);
@@ -67,5 +71,6 @@ public function __construct() {
$this->addType('loginName', 'string');
$this->addType('server', 'string');
$this->addType('appPassword', 'string');
+ $this->addType('initiatorIp', 'string');
}
}
diff --git a/core/Migrations/Version36000Date20261020200000.php b/core/Migrations/Version36000Date20261020200000.php
new file mode 100644
index 0000000000000..98a5bddfd3f1c
--- /dev/null
+++ b/core/Migrations/Version36000Date20261020200000.php
@@ -0,0 +1,49 @@
+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;
+ }
+}
diff --git a/core/Service/LoginFlowV2Service.php b/core/Service/LoginFlowV2Service.php
index 47707b909ecf6..9ae817190759a 100644
--- a/core/Service/LoginFlowV2Service.php
+++ b/core/Service/LoginFlowV2Service.php
@@ -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;
@@ -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;
}
@@ -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);
@@ -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);
diff --git a/core/src/views/LoginFlowGrant.vue b/core/src/views/LoginFlowGrant.vue
index b2230bf9da69c..58e7df8b2a1dd 100644
--- a/core/src/views/LoginFlowGrant.vue
+++ b/core/src/views/LoginFlowGrant.vue
@@ -20,6 +20,7 @@ const {
actionUrl,
client,
direct,
+ initiatorAddress,
instanceName,
stateToken,
userDisplayName,
@@ -32,6 +33,7 @@ const {
actionUrl: string
client: string
direct: boolean
+ initiatorAddress?: string | null
instanceName: string
stateToken: string
userId: string
@@ -65,6 +67,10 @@ async function onSubmit(event: SubmitEvent) {
{{ t('core', 'You are about to grant "{client}" access to your {instanceName} account.', { client, instanceName }) }}
+