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
8 changes: 8 additions & 0 deletions lib/AuditLog/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ enum Operation: string {
case TeamSettingUpdated = 'circles.team_setting.updated';
case InvitationCreated = 'circles.invitation.created';
case InvitationRevoked = 'circles.invitation.revoked';
case InvitationCodeRejected = 'circles.invitation_code.rejected';
case MemberAdded = 'circles.member.added';
case MemberInvited = 'circles.member.invited';
case MemberRemoved = 'circles.member.removed';
Expand All @@ -33,8 +34,11 @@ enum Operation: string {
case RemoteInstanceAdded = 'circles.remote_instance.added';
case RemoteInstanceUpdated = 'circles.remote_instance.updated';
case RemoteInstanceRemoved = 'circles.remote_instance.removed';
case RemoteRequestRejected = 'circles.remote_request.rejected';
case ScimInstanceAdded = 'circles.scim_instance.added';
case ScimInstanceRemoved = 'circles.scim_instance.removed';
case OidcAccountLinked = 'circles.oidc_account.linked';
case OidcAccountLinkFailed = 'circles.oidc_account.link_failed';
case ConfigUpdated = 'circles.config.updated';

/**
Expand All @@ -49,6 +53,7 @@ public function description(): string {
self::TeamSettingUpdated => 'A setting of a team, for example the password of its shares, was set or removed',
self::InvitationCreated => 'An invitation link to join a team was created',
self::InvitationRevoked => 'The invitation link to join a team was revoked',
self::InvitationCodeRejected => 'An invitation code that matches no team was used to look up or join a team',
self::MemberAdded => 'A member was added to a team, or a request to join it was accepted',
self::MemberInvited => 'A user, group or team was invited to join an invite only team',
self::MemberRemoved => 'A member was removed from a team',
Expand All @@ -59,8 +64,11 @@ public function description(): string {
self::RemoteInstanceAdded => 'A remote instance was added for federated teams, with the type defining how much it is trusted',
self::RemoteInstanceUpdated => 'The identity or the type of a known remote instance for federated teams was changed',
self::RemoteInstanceRemoved => 'A remote instance for federated teams was removed',
self::RemoteRequestRejected => 'A federated teams request was rejected',
self::ScimInstanceAdded => 'A remote instance was allowed to add members to teams managed by SCIM',
self::ScimInstanceRemoved => 'A remote instance was no longer allowed to add members to teams managed by SCIM',
self::OidcAccountLinked => 'A user linked their OIDC account',
self::OidcAccountLinkFailed => 'Linking an OIDC account failed',
self::ConfigUpdated => 'An admin setting of Teams (federated teams, frontal address, team folder quotas) was changed',
};
}
Expand Down
11 changes: 11 additions & 0 deletions lib/Controller/LocalController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\Circles\Controller;

use Exception;
use OCA\Circles\AuditLog\Operation;
use OCA\Circles\Exceptions\FederatedUserException;
use OCA\Circles\Exceptions\FederatedUserNotFoundException;
use OCA\Circles\Exceptions\FrontendException;
Expand All @@ -23,6 +24,7 @@
use OCA\Circles\Model\Member;
use OCA\Circles\Model\Probes\BasicProbe;
use OCA\Circles\Model\Probes\CircleProbe;
use OCA\Circles\Service\AuditLogService;
use OCA\Circles\Service\AvatarService;
use OCA\Circles\Service\CircleService;
use OCA\Circles\Service\ConfigService;
Expand Down Expand Up @@ -66,6 +68,7 @@ public function __construct(
private SearchService $searchService,
private AvatarService $avatarService,
private ConfigService $configService,
private AuditLogService $auditLogService,
) {
parent::__construct($appName, $request);
$this->setup('app', 'circles');
Expand Down Expand Up @@ -662,6 +665,7 @@ public function getInvitation(string $invitationCode): DataResponse {

$circles = $this->circleService->getCircles($circleProbe);
if (empty($circles)) {
$this->logRejectedInvitationCode();
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
$circle = reset($circles);
Expand Down Expand Up @@ -699,6 +703,7 @@ public function joinInvitation(string $invitationCode): DataResponse {

$circles = $this->circleService->getCircles($circleProbe);
if (empty($circles)) {
$this->logRejectedInvitationCode();
return new DataResponse([], Http::STATUS_NOT_FOUND);
}
$circle = reset($circles);
Expand Down Expand Up @@ -728,4 +733,10 @@ private function setCurrentFederatedUser(): void {
$user = $this->userSession->getUser();
$this->federatedUserService->setLocalCurrentUser($user);
}

private function logRejectedInvitationCode(): void {
$this->auditLogService->log(Operation::InvitationCodeRejected, 'Invalid team invitation code used by "%s"', [
'userId' => $this->userSession->getUser()?->getUID(),
]);
}
}
20 changes: 19 additions & 1 deletion lib/Controller/OidcController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
namespace OCA\Circles\Controller;

use OCA\Circles\AppInfo\Application;
use OCA\Circles\AuditLog\Operation;
use OCA\Circles\ConfigLexicon;
use OCA\Circles\Service\AuditLogService;
use OCA\Circles\Service\OidcService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
Expand Down Expand Up @@ -40,6 +42,7 @@ public function __construct(
private readonly IClientService $clientService,
private readonly ICredentialsManager $credentialsManager,
private readonly OidcService $oidcService,
private readonly AuditLogService $auditLogService,
private readonly LoggerInterface $logger,
) {
parent::__construct(Application::APP_ID, $request);
Expand Down Expand Up @@ -83,18 +86,21 @@ public function connect(): RedirectResponse {
#[NoAdminRequired]
#[NoCSRFRequired]
public function callback(string $state = '', string $code = '', string $error = '', string $error_description = ''): RedirectResponse {
$userId = $this->userSession->getUser()?->getUID();
if ($error !== '') {
$this->logger->error('OIDC provider returned an error: ' . $error . ' - ' . $error_description);
$this->logFailedLink($userId, 'provider_error');
return $this->redirectToPersonalSettings('error');
}

if ($state === '' || $state !== $this->session->get(self::SESSION_STATE)) {
$this->logger->error('OIDC callback state mismatch');
$this->logFailedLink($userId, 'state_mismatch');
return $this->redirectToPersonalSettings('error');
}
$userId = $this->userSession->getUser()?->getUID();
if ($userId === null || $userId !== $this->session->get(self::SESSION_USER_ID)) {
$this->logger->error('OIDC callback user mismatch: started as ' . $this->session->get(self::SESSION_USER_ID) . ', completed as ' . $userId);
$this->logFailedLink($userId, 'user_mismatch');
return $this->redirectToPersonalSettings('error');
}
$this->session->remove(self::SESSION_STATE);
Expand All @@ -117,17 +123,22 @@ public function callback(string $state = '', string $code = '', string $error =
]);
} catch (\Exception $e) {
$this->logger->error('OIDC token exchange failed', ['exception' => $e]);
$this->logFailedLink($userId, 'token_exchange_failed');
return $this->redirectToPersonalSettings('error');
}

$data = json_decode($response->getBody(), true);

if (empty($data['refresh_token'])) {
$this->logger->error('OIDC provider did not return a refresh_token for user ' . $userId);
$this->logFailedLink($userId, 'missing_refresh_token');
return $this->redirectToPersonalSettings('error');
}

$this->credentialsManager->store($userId, OidcService::CREDENTIAL_REFRESH_TOKEN, $data['refresh_token']);
$this->auditLogService->log(Operation::OidcAccountLinked, 'OIDC account of "%s" linked to synchronize team memberships', [
'userId' => $userId,
]);

// initial sync
if (!empty($data['access_token'])) {
Expand All @@ -137,6 +148,13 @@ public function callback(string $state = '', string $code = '', string $error =
return $this->redirectToPersonalSettings('success');
}

private function logFailedLink(?string $userId, string $reason): void {
$this->auditLogService->log(Operation::OidcAccountLinkFailed, 'Linking the OIDC account of "%s" failed: %s', [
'userId' => $userId,
'reason' => $reason,
]);
}

private function redirectToPersonalSettings(string $result): RedirectResponse {
$url = $this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'circles']);

Expand Down
13 changes: 13 additions & 0 deletions lib/Controller/RemoteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use Exception;
use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException;
use OCA\Circles\AuditLog\Operation;
use OCA\Circles\ConfigLexicon;
use OCA\Circles\Db\CircleRequest;
use OCA\Circles\Exceptions\FederatedEventException;
Expand All @@ -26,6 +27,7 @@
use OCA\Circles\Model\Member;
use OCA\Circles\Model\Probes\BasicProbe;
use OCA\Circles\Model\Probes\CircleProbe;
use OCA\Circles\Service\AuditLogService;
use OCA\Circles\Service\CircleService;
use OCA\Circles\Service\ConfigService;
use OCA\Circles\Service\FederatedUserService;
Expand Down Expand Up @@ -78,6 +80,7 @@ public function __construct(
private readonly InterfaceService $interfaceService,
private readonly ConfigService $configService,
private readonly IUserSession $userSession,
private readonly AuditLogService $auditLogService,
) {
parent::__construct($appName, $request);

Expand Down Expand Up @@ -397,6 +400,7 @@ private function extractDataFromFromRequest(): SimpleDataStore {
/** @var FederatedUser $initiator */
$initiator = $store->gObj('initiator', FederatedUser::class);
if ($initiator->getInstance() !== $remoteInstance->getInstance() && !in_array($initiator->getInstance(), $remoteInstance->getAliases(), true)) {
$this->logRejectedRequest($remoteInstance->getInstance(), 'initiator_mismatch');
throw new InvalidRemoteInstanceException('Initiator instance does not match remote instance');
}
$this->federatedUserService->setCurrentUser($initiator);
Expand Down Expand Up @@ -432,12 +436,14 @@ private function confirmRemoteInstance(NCSignedRequest $signedRequest): RemoteIn

if (!$signatory instanceof RemoteInstance) {
$this->debug('Signatory is not a known RemoteInstance', ['signedRequest' => $signedRequest]);
$this->logRejectedRequest($signedRequest->getOrigin(), 'unknown_signatory');
throw new SignatoryException('Could not confirm identity');
}

if (!$this->configService->isLocalInstance($signedRequest->getOrigin())
&& $signatory->getType() === RemoteInstance::TYPE_UNKNOWN) {
$this->debug('Could not confirm identity', ['signedRequest' => $signedRequest]);
$this->logRejectedRequest($signedRequest->getOrigin(), 'untrusted_instance');
throw new SignatoryException('Could not confirm identity');
}

Expand All @@ -446,6 +452,13 @@ private function confirmRemoteInstance(NCSignedRequest $signedRequest): RemoteIn
return $signatory;
}

private function logRejectedRequest(?string $instance, string $reason): void {
$this->auditLogService->log(Operation::RemoteRequestRejected, 'Federated request from instance "%s" rejected: %s', [
'instance' => $instance,
'reason' => $reason,
]);
}

/**
* @param Exception $e
* @param int $httpErrorCode
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/lib/Controller/LocalControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
namespace OCA\Circles\Tests\Controller;

use OCA\Circles\AppInfo\Application;
use OCA\Circles\AuditLog\Operation;
use OCA\Circles\Controller\LocalController;
use OCA\Circles\Model\Circle;
use OCA\Circles\Model\Member;
use OCA\Circles\Service\AuditLogService;
use OCA\Circles\Service\AvatarService;
use OCA\Circles\Service\CircleService;
use OCA\Circles\Service\ConfigService;
Expand All @@ -25,6 +27,7 @@
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Container\ContainerInterface;
use Test\TestCase;

Expand All @@ -45,6 +48,7 @@ class LocalControllerTest extends TestCase {
private LocalController $localController;
private IUserManager $userManager;
private IUserSession $userSession;
private AuditLogService&MockObject $auditLogService;
private string $circleId = '';
private array $circlesToCleanup = [];
private static array $usersToCleanup = [];
Expand Down Expand Up @@ -74,6 +78,7 @@ public function setUp(): void {

$user1 = $this->userManager->get(self::TEST_USER_1);
$this->userSession->setUser($user1);
$this->auditLogService = $this->createMock(AuditLogService::class);

$this->localController = new LocalController(
Application::APP_ID,
Expand All @@ -87,6 +92,7 @@ public function setUp(): void {
$this->container->get(SearchService::class),
$this->container->get(AvatarService::class),
$this->container->get(ConfigService::class),
$this->auditLogService,
);

// Create a circle as TEST_USER_1 (owner)
Expand Down Expand Up @@ -397,4 +403,15 @@ public function testMembersAddPermissionDenied(): void {
$response = $this->localController->membersAdd($this->circleId, $members);
$this->assertEquals($response->getData()['message'], 'Insufficient permissions to perform this action');
}

public function testInvalidInvitationCodeWritesAuditLog(): void {
$this->auditLogService->expects($this->exactly(2))
->method('log')
->with(Operation::InvitationCodeRejected, 'Invalid team invitation code used by "%s"', [
'userId' => self::TEST_USER_1,
]);

$this->assertSame(Http::STATUS_NOT_FOUND, $this->localController->getInvitation('invalid-code')->getStatus());
$this->assertSame(Http::STATUS_NOT_FOUND, $this->localController->joinInvitation('invalid-code')->getStatus());
}
}
Loading
Loading