Skip to content
Open
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
13 changes: 8 additions & 5 deletions lib/OC.php
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ public static function initSession(): void {
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', -1, self::$WEBROOT ? : '/');
}
Server::get(IUserSession::class)->logout();
Server::get(\OC\User\Session::class)->endInactiveSession();
}

if (!self::hasSessionRelaxedExpiry()) {
Expand Down Expand Up @@ -1298,10 +1298,13 @@ public static function handleLogin(OCP\IRequest $request): bool {
if ($userSession->tryTokenLogin($request)) {
return true;
}
if (isset($_COOKIE['nc_username'])
&& isset($_COOKIE['nc_token'])
&& isset($_COOKIE['nc_session_id'])
&& $userSession->loginWithCookie($_COOKIE['nc_username'], $_COOKIE['nc_token'], $_COOKIE['nc_session_id'])) {
// Read from $_COOKIE, where a logout or the Apache auth earlier in this request removes them. Clients can send
// the cookies as arrays.
$username = $_COOKIE['nc_username'] ?? null;
$token = $_COOKIE['nc_token'] ?? null;
$sessionId = $_COOKIE['nc_session_id'] ?? null;
if (is_string($username) && is_string($token) && is_string($sessionId)
&& $userSession->loginWithCookie($username, $token, $sessionId)) {
return true;
}
if ($userSession->tryBasicAuthLogin($request, Server::get(IThrottler::class))) {
Expand Down
92 changes: 82 additions & 10 deletions lib/private/User/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -905,18 +905,15 @@ public function tryTokenLogin(IRequest $request, bool $allowOcmAccessToken = fal
* @param string $oldSessionId
* @return bool
*/
public function loginWithCookie($uid, $currentToken, $oldSessionId) {
public function loginWithCookie(string $uid, string $currentToken, string $oldSessionId) {
$this->dispatcher->dispatchTyped(new BeforeUserLoggedInWithCookieEvent($uid));
$user = $this->manager->get($uid);
if (is_null($user)) {
// user does not exist
return false;
}

// get stored tokens
$tokens = $this->config->getUserKeys($uid, 'login_token');
// test cookies token against stored tokens
if (!in_array($currentToken, $tokens, true)) {
if (!$this->isStoredRememberMeToken($uid, $currentToken)) {
$this->logger->info('Tried to log in but could not verify token', [
'app' => 'core',
'user' => $uid,
Expand Down Expand Up @@ -1035,6 +1032,66 @@ public function logout() {
$this->dispatcher->dispatchTyped(new UserLoggedOutEvent($user));
}

/**
* End a session that was inactive for longer than session_lifetime. A request that proves a remembered
* login only loses its session data: its session token moves to the new session id.
*/
public function endInactiveSession(): void {
$cookies = $this->rememberedLoginCookies(Server::get(IRequest::class));
if ($cookies === null) {
$this->logout();
return;
}

$this->logger->debug('Ending inactive session, keeping the remembered login', [
'app' => 'core',
'user' => $cookies['username'],
]);
try {
$oldSessionId = $this->session->getId();
$this->setVolatileActiveUser(null);
$this->session->clear();
$this->tokenProvider->renewSessionToken($oldSessionId, $this->session->getId());
$this->renewMagicSessionId($oldSessionId);
} catch (InvalidTokenException|SessionNotAvailableException) {
// Without the session token the request continues as an anonymous one
}
}

/**
* The cookies of a request that proves a remembered login for the session token of the current session
*
* @return ?array{username: string, token: string}
*/
private function rememberedLoginCookies(IRequest $request): ?array {
if ($this->tokenProvider === null) {
return null;
}
try {
$sessionId = $this->session->getId();
} catch (SessionNotAvailableException) {
return null;
}
$cookies = $this->rememberMeCookiesFor($request, $sessionId);
if ($cookies === null) {
return null;
}

try {
$token = $this->tokenProvider->getToken($sessionId);
} catch (InvalidTokenException) {
return null;
}
// No user backend is set up yet when a session times out, so the session token tells whose session it is
if ($token->getType() !== IToken::TEMPORARY_TOKEN
|| $token->getRemember() !== IToken::REMEMBER
|| $token->getUID() !== $cookies['username']
|| !$this->isStoredRememberMeToken($token->getUID(), $cookies['token'])) {
return null;
}
return $cookies;
}

/**
* Set cookie value to use in next page load
*
Expand Down Expand Up @@ -1091,14 +1148,29 @@ public function setMagicInCookie($username, $token) {
* login can still find the token that was renewed along with it.
*/
public function renewMagicSessionId(string $oldSessionId): void {
$request = Server::get(IRequest::class);
$cookies = $this->rememberMeCookiesFor(Server::get(IRequest::class), $oldSessionId);
if ($cookies === null) {
return;
}
$this->setMagicInCookie($cookies['username'], $cookies['token']);
}

private function isStoredRememberMeToken(string $uid, string $token): bool {
return in_array($token, $this->config->getUserKeys($uid, 'login_token'), true);
}

/**
* The remember-me cookies of a request, if they point at the session $sessionId
*
* @return ?array{username: string, token: string}
*/
private function rememberMeCookiesFor(IRequest $request, string $sessionId): ?array {
$username = $request->getCookie('nc_username');
$token = $request->getCookie('nc_token');
$sessionId = $request->getCookie('nc_session_id');
if ($username === null || $token === null || $sessionId !== $oldSessionId) {
return;
if (!is_string($username) || !is_string($token) || $request->getCookie('nc_session_id') !== $sessionId) {
return null;
}
$this->setMagicInCookie($username, $token);
return ['username' => $username, 'token' => $token];
}

/**
Expand Down
87 changes: 87 additions & 0 deletions tests/lib/OCHandleLoginTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

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

namespace Test;

use OC\User\Session;
use OCP\IRequest;
use OCP\Security\Bruteforce\IThrottler;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;

/**
* The remember-me cookie login of OC::handleLogin()
*/
class OCHandleLoginTest extends TestCase {
private const COOKIES = ['nc_username' => 'foo', 'nc_token' => 'fooToken', 'nc_session_id' => 'sess123'];

private Session&MockObject $userSession;
private IRequest&MockObject $request;
private array $cookiesBefore;

#[\Override]
protected function setUp(): void {
parent::setUp();

$this->cookiesBefore = $_COOKIE;
$this->userSession = $this->createMock(Session::class);
$this->overwriteService(Session::class, $this->userSession);
$this->overwriteService(IThrottler::class, $this->createMock(IThrottler::class));
$this->request = $this->createMock(IRequest::class);
$this->request->method('getHeader')->willReturn('');
// The cookies as the request came in
$this->request->method('getCookie')->willReturnCallback(fn (string $name) => self::COOKIES[$name] ?? null);
}

#[\Override]
protected function tearDown(): void {
$_COOKIE = $this->cookiesBefore;

parent::tearDown();
}

public function testLoginWithRememberMeCookies(): void {
$_COOKIE = self::COOKIES;

$this->userSession->expects($this->once())
->method('loginWithCookie')
->with('foo', 'fooToken', 'sess123')
->willReturn(true);

$this->assertTrue(\OC::handleLogin($this->request));
}

public static function cookieAsArrayData(): array {
return [
'username' => ['name' => 'nc_username'],
'token' => ['name' => 'nc_token'],
'session id' => ['name' => 'nc_session_id'],
];
}

#[DataProvider(methodName: 'cookieAsArrayData')]
public function testRememberMeCookieAsArray(string $name): void {
$_COOKIE = [$name => [self::COOKIES[$name]]] + self::COOKIES;

$this->userSession->expects($this->never())
->method('loginWithCookie');

$this->assertFalse(\OC::handleLogin($this->request));
}

public function testRememberMeCookiesRemovedEarlierInTheRequest(): void {
// A logout or the Apache auth removed them from $_COOKIE, but the request still carries them
$_COOKIE = [];

$this->userSession->expects($this->never())
->method('loginWithCookie');

$this->assertFalse(\OC::handleLogin($this->request));
}
}
Loading
Loading