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>
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user