Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-files-account.service.ts
T
schalli d00b6ff79f feat(nextcloud-files): Anmeldung per Passwort und im Browser (Zwei-Faktor), Abmelden mit Widerruf
- Anmelde-Client (getapppassword, cloud/user, Widerruf, Login Flow v2 mit fester Abfrageadresse,
  Link aus Basis und Token neu gebaut), Anmeldebremse 3/15 min je Benutzer und 8/30 min je Server,
  Ablaufspeicher für Browser-Anmeldungen (20 min, höchstens 200, eine je Benutzer)
- Kontodienst: Verbinden, Trennen mit Widerruf, Sitzung mit Zugangsschlüssel-Sperre,
  frisch ausgestellte oder ersetzte App-Passwörter bleiben nie verwaist; jeder Kontozugriff
  über forTenant mit Mandant UND Benutzer aus dem Token
- Migration 20261008183000: Spalte ncLoginName (App-Passwort gilt nur für den Anmeldenamen der
  Ausstellung, gemessen mit E-Mail-Anmeldung gegen Nextcloud 34)
- Verbindungsbildschirm und Kontoleiste, Texte de/en, RLS-Inventar fortgeschrieben,
  E2E-Skript e2e-connect.sh

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-08 18:02:45 +02:00

468 lines
16 KiB
TypeScript

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<AccountRow | null> {
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<void> {
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<void> {
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<void> {
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<NextcloudFilesStatusView> {
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<NextcloudFilesStatusView> {
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<FlowPollResult> {
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<NcSession> {
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<string> {
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<void> {
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<void> {
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<void> {
await this.revokeBestEffort(baseUrl, loginName, appPassword);
}
private async revokeBestEffort(
baseUrl: string,
loginName: string,
appPassword: string,
credentialKey?: string,
): Promise<void> {
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<AccountRow, 'ncUserId' | 'ncLoginName'>): 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;
}