d00b6ff79f
- 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>
468 lines
16 KiB
TypeScript
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;
|
|
}
|