Skip to content

Commit 7ec7d83

Browse files
nickvergessenclaude
andcommitted
feat(email): Fix authentication of email users
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: Joas Schilling <coding@schilljs.com>
1 parent 375ca6b commit 7ec7d83

17 files changed

Lines changed: 260 additions & 161 deletions

lib/Authenticator.php

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Talk;
11+
12+
use OCA\Talk\Exceptions\ParticipantNotFoundException;
13+
use OCA\Talk\Exceptions\RoomNotFoundException;
14+
use OCA\Talk\Model\Attendee;
15+
use OCP\Federation\ICloudIdManager;
16+
use OCP\IRequest;
17+
18+
class Authenticator {
19+
protected bool $resolved = false;
20+
protected bool $isFederationRequest = false;
21+
protected bool $isAuthenticatedEmailGuest = false;
22+
protected string $actorType = '';
23+
protected string $actorId = '';
24+
protected string $accessToken = '';
25+
protected ?Room $room = null;
26+
protected ?Participant $participant = null;
27+
28+
public function __construct(
29+
protected readonly IRequest $request,
30+
protected readonly ICloudIdManager $cloudIdManager,
31+
protected readonly TalkSession $talkSession,
32+
) {
33+
}
34+
35+
protected function resolve(): void {
36+
if ($this->resolved) {
37+
return;
38+
}
39+
$this->resolved = true;
40+
41+
if ((bool)$this->request->getHeader('x-nextcloud-federation')) {
42+
$authUser = urldecode($this->request->server['PHP_AUTH_USER'] ?? '');
43+
try {
44+
$cloudId = $this->cloudIdManager->resolveCloudId($authUser);
45+
$this->isFederationRequest = true;
46+
$this->actorType = Attendee::ACTOR_FEDERATED_USERS;
47+
$this->actorId = $cloudId->getId();
48+
$this->accessToken = $this->request->server['PHP_AUTH_PW'] ?? '';
49+
return;
50+
} catch (\InvalidArgumentException) {
51+
// Fall through to other authentication shapes
52+
}
53+
}
54+
55+
$token = $this->request->getParam('token');
56+
if (is_string($token) && $token !== '') {
57+
$emailActorId = $this->talkSession->getAuthedEmailActorIdForRoom($token);
58+
if ($emailActorId !== null) {
59+
$this->isAuthenticatedEmailGuest = true;
60+
$this->actorType = Attendee::ACTOR_EMAILS;
61+
$this->actorId = $emailActorId;
62+
}
63+
}
64+
}
65+
66+
public function isFederationRequest(): bool {
67+
$this->resolve();
68+
return $this->isFederationRequest;
69+
}
70+
71+
public function isAuthenticatedEmailGuest(): bool {
72+
$this->resolve();
73+
return $this->isAuthenticatedEmailGuest;
74+
}
75+
76+
public function isAuthenticatedRequest(): bool {
77+
$this->resolve();
78+
return $this->isFederationRequest || $this->isAuthenticatedEmailGuest;
79+
}
80+
81+
public function getActorType(): string {
82+
$this->resolve();
83+
return $this->actorType;
84+
}
85+
86+
public function getActorId(): string {
87+
$this->resolve();
88+
return $this->actorId;
89+
}
90+
91+
/**
92+
* Federation-only alias for {@see getActorId()}. Returns an empty string
93+
* when the current request is not a federation request, even if another
94+
* authenticated actor (e.g. email guest) is present.
95+
*/
96+
public function getCloudId(): string {
97+
return $this->isFederationRequest() ? $this->getActorId() : '';
98+
}
99+
100+
public function getAccessToken(): string {
101+
$this->resolve();
102+
return $this->accessToken;
103+
}
104+
105+
public function authenticated(Room $room, Participant $participant): void {
106+
$this->room = $room;
107+
$this->participant = $participant;
108+
}
109+
110+
/**
111+
* @throws RoomNotFoundException
112+
*/
113+
public function getRoom(): Room {
114+
if ($this->room === null) {
115+
throw new RoomNotFoundException();
116+
}
117+
return $this->room;
118+
}
119+
120+
/**
121+
* @throws ParticipantNotFoundException
122+
*/
123+
public function getParticipant(): Participant {
124+
if ($this->participant === null) {
125+
throw new ParticipantNotFoundException();
126+
}
127+
return $this->participant;
128+
}
129+
}

lib/Chat/Parser/SystemMessage.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010

1111
use OCA\Circles\CirclesManager;
1212
use OCA\DAV\CardDAV\PhotoCache;
13+
use OCA\Talk\Authenticator;
1314
use OCA\Talk\Chat\ChatManager;
1415
use OCA\Talk\Events\MessageParseEvent;
1516
use OCA\Talk\Events\OverwritePublicSharePropertiesEvent;
1617
use OCA\Talk\Exceptions\ParticipantNotFoundException;
17-
use OCA\Talk\Federation\Authenticator;
1818
use OCA\Talk\Model\Attendee;
1919
use OCA\Talk\Model\Message;
2020
use OCA\Talk\Participant;

lib/Config.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
use OCA\Talk\AppInfo\Application;
1212
use OCA\Talk\Events\BeforeTurnServersGetEvent;
13-
use OCA\Talk\Federation\Authenticator;
1413
use OCA\Talk\Model\Attendee;
1514
use OCA\Talk\Service\RecordingService;
1615
use OCA\Talk\Settings\UserPreference;

lib/Controller/CallController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88

99
namespace OCA\Talk\Controller;
1010

11+
use OCA\Talk\Authenticator;
1112
use OCA\Talk\Config;
1213
use OCA\Talk\Exceptions\DialOutFailedException;
1314
use OCA\Talk\Exceptions\ParticipantNotFoundException;
14-
use OCA\Talk\Federation\Authenticator;
1515
use OCA\Talk\Manager;
1616
use OCA\Talk\Middleware\Attribute\FederationSupported;
1717
use OCA\Talk\Middleware\Attribute\RequireCallEnabled;

lib/Controller/ChatController.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
namespace OCA\Talk\Controller;
1010

1111
use OCA\Talk\AppInfo\Application;
12+
use OCA\Talk\Authenticator;
1213
use OCA\Talk\Chat\AutoComplete\SearchPlugin;
1314
use OCA\Talk\Chat\AutoComplete\Sorter;
1415
use OCA\Talk\Chat\ChatManager;
@@ -19,7 +20,6 @@
1920
use OCA\Talk\Exceptions\CannotReachRemoteException;
2021
use OCA\Talk\Exceptions\ChatSummaryException;
2122
use OCA\Talk\Exceptions\ParticipantNotFoundException;
22-
use OCA\Talk\Federation\Authenticator;
2323
use OCA\Talk\GuestManager;
2424
use OCA\Talk\Manager;
2525
use OCA\Talk\MatterbridgeManager;
@@ -251,8 +251,8 @@ private function resolveReplyTo(int $replyTo, string $replyToToken, string $acto
251251
* @return list{0: Attendee::ACTOR_*, 1: string}
252252
*/
253253
protected function getActorInfo(string $actorDisplayName = ''): array {
254-
$remoteCloudId = $this->federationAuthenticator->getCloudId();
255-
if ($remoteCloudId !== '') {
254+
if ($this->federationAuthenticator->getActorType() === Attendee::ACTOR_FEDERATED_USERS) {
255+
$remoteCloudId = $this->federationAuthenticator->getActorId();
256256
if ($actorDisplayName) {
257257
$this->participantService->updateDisplayNameForActor(Attendee::ACTOR_FEDERATED_USERS, $remoteCloudId, $actorDisplayName);
258258
}

lib/Controller/RoomController.php

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
namespace OCA\Talk\Controller;
1010

1111
use OCA\DAV\CalDAV\TimezoneService;
12+
use OCA\Talk\Authenticator;
1213
use OCA\Talk\Capabilities;
1314
use OCA\Talk\Config;
1415
use OCA\Talk\Events\AAttendeeRemovedEvent;
@@ -36,7 +37,6 @@
3637
use OCA\Talk\Exceptions\RoomProperty\SipConfigurationException;
3738
use OCA\Talk\Exceptions\RoomProperty\TypeException;
3839
use OCA\Talk\Exceptions\UnauthorizedException;
39-
use OCA\Talk\Federation\Authenticator;
4040
use OCA\Talk\Federation\FederationManager;
4141
use OCA\Talk\Federation\Proxy\TalkV1\ProxyRequest;
4242
use OCA\Talk\GuestManager;
@@ -456,15 +456,32 @@ public function getSingleRoom(string $token): DataResponse {
456456
$isTalkFederation = $this->request->getHeader('x-nextcloud-federation');
457457

458458
if (!$isTalkFederation) {
459-
$sessionId = $this->session->getSessionForRoom($token);
460-
$room = $this->manager->getRoomForUserByToken($token, $this->userId, $sessionId, $includeLastMessage, $isSIPBridgeRequest);
459+
if ($this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()) {
460+
// Authenticated email guest: the user/public-room check in
461+
// getRoomForUserByToken() would reject non-public rooms, so
462+
// trust the session-stored actor id and require the email
463+
// attendee to still exist.
464+
$room = $this->manager->getRoomByToken($token);
465+
try {
466+
$participant = $this->participantService->getParticipantByActor(
467+
$room,
468+
Attendee::ACTOR_EMAILS,
469+
$this->federationAuthenticator->getActorId(),
470+
);
471+
} catch (ParticipantNotFoundException) {
472+
throw new RoomNotFoundException();
473+
}
474+
} else {
475+
$sessionId = $this->session->getSessionForRoom($token);
476+
$room = $this->manager->getRoomForUserByToken($token, $this->userId, $sessionId, $includeLastMessage, $isSIPBridgeRequest);
461477

462-
try {
463-
$participant = $this->participantService->getParticipant($room, $this->userId, $sessionId);
464-
} catch (ParticipantNotFoundException) {
465478
try {
466-
$participant = $this->participantService->getParticipantBySession($room, $sessionId);
479+
$participant = $this->participantService->getParticipant($room, $this->userId, $sessionId);
467480
} catch (ParticipantNotFoundException) {
481+
try {
482+
$participant = $this->participantService->getParticipantBySession($room, $sessionId);
483+
} catch (ParticipantNotFoundException) {
484+
}
468485
}
469486
}
470487
} else {
@@ -2013,9 +2030,18 @@ public function markConversationAsInsensitive(): DataResponse {
20132030
])]
20142031
public function joinRoom(string $token, string $password = '', bool $force = true): DataResponse {
20152032
$sessionId = $this->session->getSessionForRoom($token);
2033+
$authenticatedEmailGuest = $this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()
2034+
? $this->federationAuthenticator->getActorId()
2035+
: null;
20162036
try {
2017-
// The participant is just joining, so enforce to not load any session
2018-
$room = $this->manager->getRoomForUserByToken($token, $this->userId, null);
2037+
// The participant is just joining, so enforce to not load any session.
2038+
// Authenticated email guests bypass the user/public-room access check;
2039+
// the email attendee lookup below is the trust anchor instead.
2040+
if ($authenticatedEmailGuest !== null) {
2041+
$room = $this->manager->getRoomByToken($token);
2042+
} else {
2043+
$room = $this->manager->getRoomForUserByToken($token, $this->userId, null);
2044+
}
20192045
} catch (RoomNotFoundException $e) {
20202046
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
20212047
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
@@ -2065,8 +2091,6 @@ public function joinRoom(string $token, string $password = '', bool $force = tru
20652091
}
20662092
}
20672093

2068-
$authenticatedEmailGuest = $this->session->getAuthedEmailActorIdForRoom($token);
2069-
20702094
$headers = [];
20712095
if ($authenticatedEmailGuest !== null || $room->isFederatedConversation()
20722096
|| ($previousParticipant instanceof Participant && $previousParticipant->isGuest())) {
@@ -2088,6 +2112,15 @@ public function joinRoom(string $token, string $password = '', bool $force = tru
20882112
try {
20892113
$previousParticipant = $this->participantService->getParticipantByActor($room, Attendee::ACTOR_EMAILS, $authenticatedEmailGuest);
20902114
} catch (ParticipantNotFoundException) {
2115+
// Stale spreed-authed-email session: the email attendee was
2116+
// removed (e.g. by a moderator) since the invite link was opened.
2117+
// Without this guard the code below would create a fresh
2118+
// ACTOR_GUESTS attendee with the password check bypassed,
2119+
// re-granting access to a possibly-private room.
2120+
$this->session->removeAuthedEmailActorIdForRoom($token);
2121+
$response = new DataResponse(null, Http::STATUS_NOT_FOUND);
2122+
$response->throttle(['token' => $token, 'action' => 'talkRoomToken']);
2123+
return $response;
20912124
}
20922125
}
20932126
$participant = $this->participantService->joinRoomAsNewGuest($this->roomService, $room, $password, $result['result'], $previousParticipant);
@@ -2537,7 +2570,11 @@ public function leaveRoom(string $token): DataResponse {
25372570
$this->session->removeSessionForRoom($token);
25382571

25392572
try {
2540-
$room = $this->manager->getRoomForUserByToken($token, $this->userId, $sessionId);
2573+
if ($this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()) {
2574+
$room = $this->manager->getRoomByToken($token);
2575+
} else {
2576+
$room = $this->manager->getRoomForUserByToken($token, $this->userId, $sessionId);
2577+
}
25412578
$participant = $this->participantService->getParticipantBySession($room, $sessionId);
25422579

25432580
if ($room->isFederatedConversation()) {

lib/Controller/SignalingController.php

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@
88

99
namespace OCA\Talk\Controller;
1010

11+
use OCA\Talk\Authenticator;
1112
use OCA\Talk\Config;
1213
use OCA\Talk\Events\BeforeSignalingResponseSentEvent;
1314
use OCA\Talk\Exceptions\ForbiddenException;
1415
use OCA\Talk\Exceptions\ParticipantNotFoundException;
1516
use OCA\Talk\Exceptions\RoomNotFoundException;
1617
use OCA\Talk\Exceptions\UnauthorizedException;
17-
use OCA\Talk\Federation\Authenticator;
1818
use OCA\Talk\Manager;
1919
use OCA\Talk\Model\Attendee;
2020
use OCA\Talk\Model\Session;
@@ -161,6 +161,14 @@ public function getSettings(string $token = ''): DataResponse {
161161
$this->federationAuthenticator->getCloudId()
162162
);
163163
$this->federationAuthenticator->authenticated($room, $participant);
164+
} elseif ($token !== '' && $this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()) {
165+
$room = $this->manager->getRoomByToken($token);
166+
$participant = $this->participantService->getParticipantByActor(
167+
$room,
168+
Attendee::ACTOR_EMAILS,
169+
$this->federationAuthenticator->getActorId(),
170+
);
171+
$this->federationAuthenticator->authenticated($room, $participant);
164172
} elseif ($token !== '') {
165173
$room = $this->manager->getRoomForUserByToken($token, $this->userId);
166174
} elseif ($this->userId !== null || $isRecordingRequest) {
@@ -600,11 +608,21 @@ public function pullMessages(string $token): DataResponse {
600608

601609
// Was the session killed or the complete conversation?
602610
try {
603-
$room = $this->manager->getRoomForUserByToken($token, $this->userId);
604-
if ($this->userId) {
605-
// For logged in users we check if they are still part of the public conversation,
606-
// if not they were removed instead of having a conflict.
607-
$this->participantService->getParticipant($room, $this->userId, false);
611+
if ($this->userId === null && $this->federationAuthenticator->isAuthenticatedEmailGuest()) {
612+
$room = $this->manager->getRoomByToken($token);
613+
// Verify the email attendee still exists; otherwise treat as removed.
614+
$this->participantService->getParticipantByActor(
615+
$room,
616+
Attendee::ACTOR_EMAILS,
617+
$this->federationAuthenticator->getActorId(),
618+
);
619+
} else {
620+
$room = $this->manager->getRoomForUserByToken($token, $this->userId);
621+
if ($this->userId) {
622+
// For logged in users we check if they are still part of the public conversation,
623+
// if not they were removed instead of having a conflict.
624+
$this->participantService->getParticipant($room, $this->userId, false);
625+
}
608626
}
609627

610628
// Session was killed, make the UI redirect to an error

0 commit comments

Comments
 (0)