65c3f099fa
Adds complete app password provisioning workflow for multi-user BasicAuth
deployments, allowing users to independently enable background sync by
generating and storing Nextcloud app passwords.
**New Components:**
Backend (PHP):
- CredentialsController: Validates and stores app passwords
* Validates app password format and authenticity via OCS API
* Stores encrypted passwords in oc_preferences
* Provides status and credential management endpoints
- AstrolabeAdminSettings: Admin configuration page for MCP server URL
- AstrolabeAdminSettingsListener: Event listener for admin section
- Updated McpTokenStorage: Added background sync credential methods
Frontend:
- personalSettings.js: Form handling for app password entry
* AJAX submission with error handling
* Shows success/error notifications
* Triggers page reload after successful save
- settings.css: Styling for settings pages
- Updated personal.php template: Two-option UI
* Option 1: OAuth refresh token (future, not yet available)
* Option 2: App password (works today, recommended)
* Shows "Active" badge when provisioned
* Displays credential type and provisioned timestamp
Routes:
- POST /api/v1/background-sync/credentials - Store app password
- GET /api/v1/background-sync/status - Get provisioning status
- DELETE /api/v1/background-sync/credentials - Revoke credentials
- GET /api/v1/background-sync/credentials/{userId} - Admin only
**Testing:**
- test_astrolabe_settings_buttons.py: Integration test for UI buttons
**Workflow:**
1. User generates app password in Nextcloud Security settings
2. User navigates to Astrolabe personal settings
3. User enters app password in "Option 2: App Password" form
4. Backend validates password via OCS API call
5. Password stored encrypted in oc_preferences
6. Page reloads showing "Active" badge with credential details
7. MCP server can now use stored password for background operations
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
259 lines
7.6 KiB
PHP
259 lines
7.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\Astrolabe\Controller;
|
|
|
|
use OCA\Astrolabe\Service\McpServerClient;
|
|
use OCA\Astrolabe\Service\McpTokenStorage;
|
|
use OCP\AppFramework\Controller;
|
|
use OCP\AppFramework\Http;
|
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
use OCP\AppFramework\Http\JSONResponse;
|
|
use OCP\Http\Client\IClientService;
|
|
use OCP\IConfig;
|
|
use OCP\IRequest;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUserSession;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* Controller for managing background sync credentials (app passwords).
|
|
*
|
|
* Handles storing and validating app passwords for multi-user BasicAuth mode.
|
|
*/
|
|
class CredentialsController extends Controller {
|
|
private $tokenStorage;
|
|
private $userSession;
|
|
private $logger;
|
|
private $config;
|
|
private $client;
|
|
private $httpClientService;
|
|
private $urlGenerator;
|
|
|
|
public function __construct(
|
|
string $appName,
|
|
IRequest $request,
|
|
McpTokenStorage $tokenStorage,
|
|
IUserSession $userSession,
|
|
LoggerInterface $logger,
|
|
IConfig $config,
|
|
McpServerClient $client,
|
|
IClientService $httpClientService,
|
|
IURLGenerator $urlGenerator,
|
|
) {
|
|
parent::__construct($appName, $request);
|
|
$this->tokenStorage = $tokenStorage;
|
|
$this->userSession = $userSession;
|
|
$this->logger = $logger;
|
|
$this->config = $config;
|
|
$this->client = $client;
|
|
$this->httpClientService = $httpClientService;
|
|
$this->urlGenerator = $urlGenerator;
|
|
}
|
|
|
|
/**
|
|
* Store app password for background sync.
|
|
*
|
|
* Validates the app password by making a test request to Nextcloud,
|
|
* then stores it encrypted if valid.
|
|
*
|
|
* @param string $appPassword Nextcloud app password
|
|
* @return JSONResponse
|
|
*/
|
|
#[NoAdminRequired]
|
|
public function storeAppPassword(string $appPassword): JSONResponse {
|
|
$user = $this->userSession->getUser();
|
|
if (!$user) {
|
|
$this->logger->error('storeAppPassword called without authenticated user');
|
|
return new JSONResponse([
|
|
'success' => false,
|
|
'error' => 'User not authenticated'
|
|
], Http::STATUS_UNAUTHORIZED);
|
|
}
|
|
|
|
$userId = $user->getUID();
|
|
|
|
// Validate app password format (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
|
if (!preg_match('/^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$/', $appPassword)) {
|
|
$this->logger->warning("Invalid app password format for user: $userId");
|
|
return new JSONResponse([
|
|
'success' => false,
|
|
'error' => 'Invalid app password format'
|
|
], Http::STATUS_BAD_REQUEST);
|
|
}
|
|
|
|
// Validate app password with Nextcloud
|
|
$isValid = $this->validateAppPassword($userId, $appPassword);
|
|
|
|
if (!$isValid) {
|
|
$this->logger->warning("App password validation failed for user: $userId");
|
|
return new JSONResponse([
|
|
'success' => false,
|
|
'error' => 'Invalid app password. Please check the password and try again.'
|
|
], Http::STATUS_UNAUTHORIZED);
|
|
}
|
|
|
|
// Store encrypted app password
|
|
try {
|
|
$this->tokenStorage->storeBackgroundSyncPassword($userId, $appPassword);
|
|
$this->logger->info("Successfully stored app password for user: $userId");
|
|
|
|
return new JSONResponse([
|
|
'success' => true,
|
|
'message' => 'App password saved successfully'
|
|
], Http::STATUS_OK);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error("Failed to store app password for user $userId", [
|
|
'error' => $e->getMessage()
|
|
]);
|
|
return new JSONResponse([
|
|
'success' => false,
|
|
'error' => 'Failed to save app password'
|
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate app password by making a test request to Nextcloud.
|
|
*
|
|
* @param string $userId User ID
|
|
* @param string $appPassword App password to validate
|
|
* @return bool True if valid, false otherwise
|
|
*/
|
|
private function validateAppPassword(string $userId, string $appPassword): bool {
|
|
try {
|
|
// Use 127.0.0.1 for internal validation (we're running inside Nextcloud container)
|
|
// Using IP address instead of 'localhost' to avoid Nextcloud's overwrite.cli.url rewriting
|
|
// getAbsoluteURL() returns the external URL which isn't accessible from inside the container
|
|
$baseUrl = 'http://127.0.0.1';
|
|
|
|
// Make a test request to Nextcloud API with BasicAuth
|
|
// Using OCS API user endpoint as a lightweight test
|
|
$testUrl = $baseUrl . '/ocs/v1.php/cloud/user?format=json';
|
|
|
|
$this->logger->debug("Validating app password for user: $userId against $testUrl");
|
|
|
|
// Use Nextcloud's HTTP client
|
|
$httpClient = $this->httpClientService->newClient();
|
|
|
|
$response = $httpClient->get($testUrl, [
|
|
'auth' => [$userId, $appPassword],
|
|
'headers' => [
|
|
'OCS-APIRequest' => 'true',
|
|
'Accept' => 'application/json',
|
|
],
|
|
'timeout' => 10,
|
|
]);
|
|
|
|
$statusCode = $response->getStatusCode();
|
|
|
|
// Success is 200 OK
|
|
if ($statusCode === 200) {
|
|
$this->logger->debug("App password validation successful for user: $userId");
|
|
return true;
|
|
}
|
|
|
|
$this->logger->warning("App password validation failed for user: $userId (HTTP $statusCode)");
|
|
return false;
|
|
} catch (\Exception $e) {
|
|
$this->logger->error("Exception during app password validation for user $userId", [
|
|
'error' => $e->getMessage()
|
|
]);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get background sync credentials status for the current user.
|
|
*
|
|
* @return JSONResponse
|
|
*/
|
|
#[NoAdminRequired]
|
|
public function getStatus(): JSONResponse {
|
|
$user = $this->userSession->getUser();
|
|
if (!$user) {
|
|
return new JSONResponse([
|
|
'success' => false,
|
|
'error' => 'User not authenticated'
|
|
], Http::STATUS_UNAUTHORIZED);
|
|
}
|
|
|
|
$userId = $user->getUID();
|
|
|
|
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
|
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
|
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
|
|
|
return new JSONResponse([
|
|
'success' => true,
|
|
'has_background_access' => $hasAccess,
|
|
'sync_type' => $syncType,
|
|
'provisioned_at' => $provisionedAt,
|
|
], Http::STATUS_OK);
|
|
}
|
|
|
|
/**
|
|
* Get credentials for a specific user (admin only).
|
|
*
|
|
* Note: This does NOT return the actual password, only metadata.
|
|
*
|
|
* @param string $userId User ID to check
|
|
* @return JSONResponse
|
|
*/
|
|
public function getCredentials(string $userId): JSONResponse {
|
|
// This endpoint should only be accessible by admins
|
|
// For now, just return metadata (not actual credentials)
|
|
$hasAccess = $this->tokenStorage->hasBackgroundSyncAccess($userId);
|
|
$syncType = $this->tokenStorage->getBackgroundSyncType($userId);
|
|
$provisionedAt = $this->tokenStorage->getBackgroundSyncProvisionedAt($userId);
|
|
|
|
return new JSONResponse([
|
|
'success' => true,
|
|
'user_id' => $userId,
|
|
'has_background_access' => $hasAccess,
|
|
'sync_type' => $syncType,
|
|
'provisioned_at' => $provisionedAt,
|
|
], Http::STATUS_OK);
|
|
}
|
|
|
|
/**
|
|
* Delete background sync credentials for the current user.
|
|
*
|
|
* @return JSONResponse
|
|
*/
|
|
#[NoAdminRequired]
|
|
public function deleteCredentials(): JSONResponse {
|
|
$user = $this->userSession->getUser();
|
|
if (!$user) {
|
|
return new JSONResponse([
|
|
'success' => false,
|
|
'error' => 'User not authenticated'
|
|
], Http::STATUS_UNAUTHORIZED);
|
|
}
|
|
|
|
$userId = $user->getUID();
|
|
|
|
try {
|
|
// Delete both OAuth tokens and app password (if any exist)
|
|
$this->tokenStorage->deleteUserToken($userId);
|
|
$this->tokenStorage->deleteBackgroundSyncPassword($userId);
|
|
|
|
$this->logger->info("Deleted background sync credentials for user: $userId");
|
|
|
|
return new JSONResponse([
|
|
'success' => true,
|
|
'message' => 'Credentials deleted successfully'
|
|
], Http::STATUS_OK);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error("Failed to delete credentials for user $userId", [
|
|
'error' => $e->getMessage()
|
|
]);
|
|
return new JSONResponse([
|
|
'success' => false,
|
|
'error' => 'Failed to delete credentials'
|
|
], Http::STATUS_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
}
|