import { createHash } from 'node:crypto'; import { HttpException, Inject, Injectable, Logger } from '@nestjs/common'; import { CryptoService } from '../crypto/crypto.service'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { type AuthFailure, authFailureCode, authFailureToException, getAppPassword, getCurrentUser, pollLoginFlow, revokeAppPassword, startLoginFlow, } from './nextcloud-auth-client'; import { NextcloudCallGate } from './nextcloud-call-gate'; import { type NcSession, type NextcloudFilesAccountView, type NextcloudFilesStatusView, ncErrorDefault, } from './nextcloud-files.types'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; import { basicAuth, NEXTCLOUD_TRANSPORT, type NextcloudTransport } from './nextcloud-http'; import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; type ConnectMethod = 'PASSWORD' | 'LOGIN_FLOW'; interface AccountRow { baseUrl: string; ncUserId: string; /** Basic-Benutzer des App-Passworts (Anmeldename bei der Ausstellung); null = ncUserId. */ ncLoginName: string | null; ncDisplayName: string | null; encryptedAppPassword: string; status: 'ACTIVE' | 'EXPIRED'; connectedVia: ConnectMethod; createdAt: Date; updatedAt: Date; } export type FlowPollResult = | { state: 'pending' } | { state: 'connected' } | { state: 'failed'; code: string; message: string }; /** Erste 16 Hex-Zeichen von sha256 ueber den verschluesselten Wert: Zugangsschluessel der Aufrufsperre. */ export function credentialKeyOf(encryptedAppPassword: string): string { return createHash('sha256').update(encryptedAppPassword).digest('hex').slice(0, 16); } /** * Konto je Benutzer (quick-261008-mzu): verbinden mit Passwort oder per * Browser-Anmeldung (Login Flow v2), trennen mit Widerruf, Sitzung fuer die * Dateiaufrufe. Gesamter Zugriff auf `nextcloudFilesAccount` mit der * Benutzerkennung aus dem Token liegt ausschliesslich hier — jede Methode * bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die * Zeilenregel laesst nur eigene Zeilen zu, und jedes `where` traegt beides. * * Geheimnisse: das echte Passwort lebt nur in `connectWithPassword`, das App- * Passwort wird mit `CryptoService.encrypt` abgelegt und nur in `getSession` * entschluesselt. Nichts davon steht in einer Antwort, einem Log oder einem * Fehler. * * App-Passwort-Hygiene (D-P): ein frisch ausgestelltes App-Passwort, das nicht * gespeichert werden konnte, wird sofort widerrufen; beim erneuten Verbinden * wird das alte (gleiche Adresse) zuerst widerrufen; ein Zugang fuer eine * andere Adresse wird nie an diese gesendet. */ @Injectable() export class NextcloudFilesAccountService { private readonly logger = new Logger(NextcloudFilesAccountService.name); constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService, private readonly settings: NextcloudFilesSettingsService, private readonly guard: NextcloudLoginGuard, private readonly flows: LoginFlowStore, private readonly gate: NextcloudCallGate, @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, ) { // Nach einem Adresswechsel gelten offene Browser-Anmeldungen nicht mehr. this.settings.onAddressChange((tenantId) => this.flows.clearTenant(tenantId)); } // --- Zeilenzugriff (jeweils eigener, an Mandant UND Benutzer gebundener Klient) ---------- private async findAccount(tenantId: string, userId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const row = await tenantPrisma.nextcloudFilesAccount.findFirst({ where: { tenantId, userId } }); return (row as AccountRow | null) ?? null; } private async upsertAccount( tenantId: string, userId: string, data: { baseUrl: string; ncUserId: string; ncLoginName: string; ncDisplayName: string | null; encryptedAppPassword: string; connectedVia: ConnectMethod; }, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await tenantPrisma.nextcloudFilesAccount.upsert({ where: { tenantId_userId: { tenantId, userId } }, create: { tenantId, userId, ...data, status: 'ACTIVE' }, update: { ...data, status: 'ACTIVE' }, }); } private async deleteAccount(tenantId: string, userId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await tenantPrisma.nextcloudFilesAccount.deleteMany({ where: { tenantId, userId } }); } /** Markiert das eigene Konto als abgelaufen (App-Passwort wurde von Nextcloud abgelehnt). */ async markExpired(tenantId: string, userId: string): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); await tenantPrisma.nextcloudFilesAccount.updateMany({ where: { tenantId, userId, status: 'ACTIVE' }, data: { status: 'EXPIRED' }, }); } // --- Stand ------------------------------------------------------------------------------ async getStatus(tenantId: string, userId: string): Promise { const base = await this.settings.getStatus(tenantId); if (!base.configured) return { ...base, account: null }; const row = await this.findAccount(tenantId, userId); if (!row) return { ...base, account: null }; const expired = row.status !== 'ACTIVE' || row.baseUrl !== base.serverUrl || this.gate.isDead(credentialKeyOf(row.encryptedAppPassword)); const account: NextcloudFilesAccountView = { connected: !expired, expired, status: expired ? 'EXPIRED' : 'ACTIVE', ncUserId: row.ncUserId, displayName: row.ncDisplayName, connectedVia: row.connectedVia, connectedAt: row.updatedAt.toISOString(), }; return { ...base, account }; } // --- Verbinden mit Passwort ------------------------------------------------------------------- async connectWithPassword( tenantId: string, userId: string, loginName: string, password: string, ): Promise { const baseUrl = await this.requireBaseUrl(tenantId); const scope = new URL(baseUrl).origin; this.guard.checkPasswordAttempt(userId, scope); const issued = await getAppPassword(this.transport, this.gate, baseUrl, loginName, password); if (!issued.ok) { // 401 ist doppeldeutig (falsches Passwort oder Zwei-Faktor) und zaehlt bei Nextcloud als Fehlanmeldung. if (issued.kind === 'credentials') this.guard.recordFailure(userId, scope); throw authFailureToException(issued); } const ncUser = await getCurrentUser( this.transport, this.gate, baseUrl, loginName, issued.appPassword, ); if (!ncUser.ok) { await this.revokeFresh(baseUrl, loginName, issued.appPassword); throw authFailureToException(unexpectedCredentials(ncUser)); } await this.storeAppPassword( tenantId, userId, baseUrl, loginName, ncUser, issued.appPassword, 'PASSWORD', ); this.guard.recordSuccess(userId); return this.getStatus(tenantId, userId); } // --- Verbinden im Browser (Login Flow v2) ------------------------------------------------------ async startFlow( tenantId: string, userId: string, ): Promise<{ flowId: string; loginUrl: string; expiresAt: string }> { const baseUrl = await this.requireBaseUrl(tenantId); this.guard.checkFlowStart(userId); const started = await startLoginFlow(this.transport, this.gate, baseUrl); if (!started.ok) throw authFailureToException(started); const entry = this.flows.create(tenantId, userId, baseUrl, started.pollToken); return { flowId: entry.flowId, loginUrl: started.loginUrl, expiresAt: new Date(entry.expiresAt).toISOString(), }; } async pollFlow(tenantId: string, userId: string, flowId: string): Promise { const found = this.flows.lookup(flowId, tenantId, userId); if (found.state === 'missing') throw ncErrorDefault('notFound'); if (found.state === 'expired') { this.flows.remove(flowId); throw ncErrorDefault('flowExpired'); } const entry = found.entry; // Die Adresse darf sich seit dem Start nicht geaendert haben. const current = await this.settings.getBaseUrl(tenantId); if (current !== entry.baseUrl) { this.flows.remove(flowId); throw ncErrorDefault('flowExpired'); } if (!this.flows.shouldPoll(entry)) return { state: 'pending' }; this.flows.markPolled(entry); const polled = await pollLoginFlow(this.transport, this.gate, entry.baseUrl, entry.pollToken); if (!polled.ok) throw authFailureToException(polled); if (polled.state === 'pending') return { state: 'pending' }; // Bestaetigt. Der Ablauf ist verbraucht (Nextcloud gibt das Ergebnis nur einmal heraus). const stillOpen = this.flows.get(flowId, tenantId, userId) !== undefined; this.flows.remove(flowId); const { loginName, appPassword } = polled; if (!stillOpen) { // Zwischenzeitlich abgebrochen: der frisch ausgestellte Zugang darf nirgends liegen bleiben. await this.revokeFresh(entry.baseUrl, loginName, appPassword); return this.failed(ncErrorDefault('flowExpired')); } const ncUser = await getCurrentUser( this.transport, this.gate, entry.baseUrl, loginName, appPassword, ); if (!ncUser.ok) { await this.revokeFresh(entry.baseUrl, loginName, appPassword); return this.failed(authFailureToException(unexpectedCredentials(ncUser))); } try { await this.storeAppPassword( tenantId, userId, entry.baseUrl, loginName, ncUser, appPassword, 'LOGIN_FLOW', ); } catch (err) { return this.failed(err); } return { state: 'connected' }; } async cancelFlow(tenantId: string, userId: string, flowId: string): Promise<{ cancelled: true }> { const found = this.flows.lookup(flowId, tenantId, userId); if (found.state === 'missing') throw ncErrorDefault('notFound'); this.flows.remove(flowId); return { cancelled: true }; } private failed(err: unknown): FlowPollResult { if (err instanceof HttpException) { const body = err.getResponse() as { code?: string; message?: string }; return { state: 'failed', code: body.code ?? 'nextcloudError', message: body.message ?? ncErrorDefault('nextcloudError').message, }; } const fallback = ncErrorDefault('nextcloudError'); return { state: 'failed', code: 'nextcloudError', message: fallback.message }; } // --- Trennen ---------------------------------------------------------------------------------- async disconnect(tenantId: string, userId: string): Promise<{ disconnected: true }> { const row = await this.findAccount(tenantId, userId); if (!row) throw ncErrorDefault('notConnected'); const current = await this.settings.getBaseUrl(tenantId); // Widerrufen nur dort, wo der Zugang gilt: aktives Konto UND gleiche Adresse (nie an einen anderen Host). if (row.status === 'ACTIVE' && current !== null && current === row.baseUrl) { let appPassword: string | null = null; try { appPassword = this.crypto.decrypt(row.encryptedAppPassword); } catch { this.logger.error( `App-Passwort eines Kontos ließ sich nicht entschlüsseln (Mandant ${tenantId}); Konto wird ohne Widerruf entfernt`, ); } if (appPassword !== null) { await this.revokeBestEffort( row.baseUrl, basicUserOf(row), appPassword, credentialKeyOf(row.encryptedAppPassword), ); } } await this.deleteAccount(tenantId, userId); return { disconnected: true }; } // --- Sitzung fuer die Dateiaufrufe -------------------------------------------------------------- async getSession(tenantId: string, userId: string): Promise { const baseUrl = await this.requireBaseUrl(tenantId); const row = await this.findAccount(tenantId, userId); if (!row) throw ncErrorDefault('notConnected'); if (row.status !== 'ACTIVE' || row.baseUrl !== baseUrl) { throw ncErrorDefault('connectionExpired'); } const credentialKey = credentialKeyOf(row.encryptedAppPassword); if (this.gate.isDead(credentialKey)) { await this.markExpired(tenantId, userId); throw ncErrorDefault('connectionExpired'); } let appPassword: string; try { appPassword = this.crypto.decrypt(row.encryptedAppPassword); } catch { this.logger.error(`Gespeichertes App-Passwort ist nicht lesbar (Mandant ${tenantId})`); throw ncErrorDefault('accountBroken'); } return { baseUrl: row.baseUrl, ncUserId: row.ncUserId, authorization: basicAuth(basicUserOf(row), appPassword), credentialKey, }; } // --- Hilfen --------------------------------------------------------------------------------------- private async requireBaseUrl(tenantId: string): Promise { const baseUrl = await this.settings.getBaseUrl(tenantId); if (baseUrl === null) throw ncErrorDefault('notConfigured'); return baseUrl; } /** * App-Passwort ablegen (D-P): zuerst das alte Passwort derselben Adresse * widerrufen, dann verschluesseln und speichern. Scheitert etwas, wird das * FRISCHE Passwort sofort widerrufen und der Fehler weitergegeben. */ private async storeAppPassword( tenantId: string, userId: string, baseUrl: string, loginName: string, ncUser: { id: string; displayName: string | null }, appPassword: string, method: ConnectMethod, ): Promise { try { await this.revokePrevious(tenantId, userId, baseUrl); const encryptedAppPassword = this.crypto.encrypt(appPassword); await this.upsertAccount(tenantId, userId, { baseUrl, ncUserId: ncUser.id, ncLoginName: loginName, ncDisplayName: ncUser.displayName, encryptedAppPassword, connectedVia: method, }); } catch (err) { await this.revokeFresh(baseUrl, loginName, appPassword); throw err; } } /** Das alte App-Passwort derselben Adresse widerrufen (best effort, nie ein Fehler nach aussen). */ private async revokePrevious(tenantId: string, userId: string, baseUrl: string): Promise { let old: AccountRow | null; try { old = await this.findAccount(tenantId, userId); } catch { return; } if (!old || old.baseUrl !== baseUrl) return; let oldPassword: string; try { oldPassword = this.crypto.decrypt(old.encryptedAppPassword); } catch { this.logger.warn(`Altes App-Passwort nicht lesbar (Mandant ${tenantId}); kein Widerruf`); return; } await this.revokeBestEffort( old.baseUrl, basicUserOf(old), oldPassword, credentialKeyOf(old.encryptedAppPassword), ); } private async revokeFresh( baseUrl: string, loginName: string, appPassword: string, ): Promise { await this.revokeBestEffort(baseUrl, loginName, appPassword); } private async revokeBestEffort( baseUrl: string, loginName: string, appPassword: string, credentialKey?: string, ): Promise { try { const res = await revokeAppPassword( this.transport, this.gate, baseUrl, loginName, appPassword, credentialKey, ); if (!res.ok) { this.logger.warn(`Widerruf eines App-Passworts nicht bestätigt (${failureLabel(res)})`); } } catch { this.logger.warn('Widerruf eines App-Passworts fehlgeschlagen'); } } } /** * Der Basic-Benutzer eines App-Passworts ist der Anmeldename der Ausstellung * (gemessen: mit der E-Mail-Adresse ausgestellt, antwortet Nextcloud auf die * Kennung mit 401). Konten vor dieser Spalte haben keinen: dann gilt die Kennung. */ function basicUserOf(row: Pick): string { return row.ncLoginName ?? row.ncUserId; } function failureLabel(failure: AuthFailure): string { return authFailureCode(failure); } /** * Ein 401 auf `cloud/user` mit einem GERADE ausgestellten App-Passwort ist kein * "falsches Passwort" fuer den Benutzer, sondern eine unerwartete Antwort. */ function unexpectedCredentials(failure: AuthFailure): AuthFailure { return failure.kind === 'credentials' ? { ...failure, kind: 'upstream' } : failure; }