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,362 @@
|
||||
import type { HttpException } from '@nestjs/common';
|
||||
import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch';
|
||||
import type { NextcloudCallGate } from './nextcloud-call-gate';
|
||||
import { ncErrorDefault } from './nextcloud-files.types';
|
||||
import {
|
||||
basicAuth,
|
||||
type NcResult,
|
||||
type NextcloudTransport,
|
||||
ncRequest,
|
||||
readCappedText,
|
||||
} from './nextcloud-http';
|
||||
|
||||
/**
|
||||
* Anmelde-Client des Moduls "Nextcloud-Dateien" (quick-261008-mzu): alles, was
|
||||
* Zugangsdaten ausstellt, prueft oder widerruft. Jeder Aufruf geht durch
|
||||
* `ncRequest` und damit durch die Aufrufsperre.
|
||||
*
|
||||
* Was hier gilt und warum:
|
||||
* - Ein 401 auf `getapppassword` ist DOPPELDEUTIG: falsches Passwort ODER ein
|
||||
* Konto mit Zwei-Faktor-Anmeldung (gemessen: Nextcloud lehnt solche Konten
|
||||
* ab, bevor das Passwort geprueft wird). Der Aufrufer fragt deshalb mit
|
||||
* `credentialsOrTwoFactor` nach und bietet die Browser-Anmeldung an.
|
||||
* - Ein 429 wird NIE wiederholt. Die Aufrufsperre in `ncRequest` haelt den
|
||||
* ganzen Ursprung an; hier wird es nur als `locked` gemeldet.
|
||||
* - Mit einem App-Passwort laesst sich kein weiteres App-Passwort holen
|
||||
* (403) — daran erkennt man einen Zugang, der nicht per Passwort geht.
|
||||
* - `poll.endpoint` und der Ursprung von `login` aus der Antwort des
|
||||
* Login Flow v2 werden VERWORFEN: Nextcloud baut sie aus dem Host-Header
|
||||
* der Anfrage, ein falscher Wert waere eine Weiterleitung an einen
|
||||
* fremden Host (SSRF). Abgefragt wird immer
|
||||
* `{Basis}/index.php/login/v2/poll`; der Link fuer den Benutzer wird aus
|
||||
* der Basis und dem Token der Antwort neu gebaut.
|
||||
* - Passwoerter und App-Passwoerter stehen nur im `Authorization`-Wert eines
|
||||
* einzelnen Aufrufs; nichts davon wird geloggt oder in Fehlern genannt.
|
||||
*/
|
||||
|
||||
/** Ergebnis ohne Erfolg; Art `credentials`/`app-password-given`/`locked` sind Anmelde-spezifisch. */
|
||||
export type AuthFailureKind =
|
||||
| 'credentials'
|
||||
| 'app-password-given'
|
||||
| 'locked'
|
||||
| 'maintenance'
|
||||
| 'redirect'
|
||||
| 'timeout'
|
||||
| 'network'
|
||||
| 'tls'
|
||||
| 'invalid-response'
|
||||
| 'credential-dead'
|
||||
| 'upstream';
|
||||
|
||||
export interface AuthFailure {
|
||||
ok: false;
|
||||
kind: AuthFailureKind;
|
||||
status?: number;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
const OCS_MAX_BYTES = 1024 * 1024;
|
||||
const SMALL_MAX_BYTES = 256 * 1024;
|
||||
const REVOKE_TIMEOUT_MS = 10_000;
|
||||
const FLOW_TOKEN_RE = /^[A-Za-z0-9]{32,256}$/;
|
||||
const FLOW_LOGIN_RE = /login\/v2\/flow\/([A-Za-z0-9]{32,256})$/;
|
||||
|
||||
/** Ordnet ein Fehlergebnis der Transportschicht einer Anmelde-Fehlerart zu. */
|
||||
export function toAuthFailure(result: Extract<NcResult, { ok: false }>): AuthFailure {
|
||||
switch (result.kind) {
|
||||
case 'paused':
|
||||
return { ok: false, kind: 'locked', retryAfterSeconds: result.retryAfterSeconds };
|
||||
case 'http':
|
||||
if (result.status === 429) {
|
||||
return { ok: false, kind: 'locked', retryAfterSeconds: result.retryAfterSeconds };
|
||||
}
|
||||
return { ok: false, kind: 'upstream', status: result.status };
|
||||
case 'redirect':
|
||||
return { ok: false, kind: 'redirect' };
|
||||
case 'timeout':
|
||||
return { ok: false, kind: 'timeout' };
|
||||
case 'tls':
|
||||
return { ok: false, kind: 'tls' };
|
||||
case 'credential-dead':
|
||||
return { ok: false, kind: 'credential-dead' };
|
||||
case 'too-large':
|
||||
case 'invalid-response':
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
default:
|
||||
return { ok: false, kind: 'network' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Wandelt einen Anmelde-Fehler in die Fehlerantwort der API (D-D, nie 401/403). */
|
||||
export function authFailureToException(failure: AuthFailure): HttpException {
|
||||
switch (failure.kind) {
|
||||
case 'locked':
|
||||
return ncErrorDefault('nextcloudLocked', {
|
||||
retryAfterSeconds: failure.retryAfterSeconds ?? 900,
|
||||
});
|
||||
case 'maintenance':
|
||||
return ncErrorDefault('nextcloudMaintenance');
|
||||
case 'redirect':
|
||||
return ncErrorDefault('nextcloudRedirect');
|
||||
case 'timeout':
|
||||
case 'network':
|
||||
case 'tls':
|
||||
return ncErrorDefault('nextcloudUnavailable');
|
||||
case 'credential-dead':
|
||||
return ncErrorDefault('connectionExpired');
|
||||
case 'credentials':
|
||||
return ncErrorDefault('credentialsOrTwoFactor');
|
||||
case 'app-password-given':
|
||||
return ncErrorDefault('useBrowserLogin');
|
||||
default:
|
||||
return ncErrorDefault('nextcloudError');
|
||||
}
|
||||
}
|
||||
|
||||
/** Fehler-Code zu einem Anmelde-Fehler (fuer `{ state: 'failed', code }`). */
|
||||
export function authFailureCode(failure: AuthFailure): string {
|
||||
const body = authFailureToException(failure).getResponse() as { code: string };
|
||||
return body.code;
|
||||
}
|
||||
|
||||
interface OcsOk {
|
||||
ok: true;
|
||||
status: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
interface OcsOptions {
|
||||
method: string;
|
||||
segments: readonly string[];
|
||||
authorization: string;
|
||||
credentialKey?: string;
|
||||
headersTimeoutMs?: number;
|
||||
bodyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allgemeiner OCS-Aufruf (Etappe 2 nutzt ihn fuer Freigaben). Liefert den
|
||||
* entpackten `ocs.data`-Teil bei 2xx, sonst einen Fehler. 401 und 403 werden
|
||||
* als `credentials` / `app-password-given` gemeldet; 503 als `maintenance`.
|
||||
*/
|
||||
export async function ocsRequest(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
opts: OcsOptions,
|
||||
): Promise<OcsOk | AuthFailure> {
|
||||
const res = await ncRequest(transport, gate, {
|
||||
baseUrl,
|
||||
prefix: '/ocs/v2.php/',
|
||||
segments: opts.segments,
|
||||
method: opts.method,
|
||||
authorization: opts.authorization,
|
||||
credentialKey: opts.credentialKey,
|
||||
ocs: true,
|
||||
headersTimeoutMs: opts.headersTimeoutMs,
|
||||
bodyTimeoutMs: opts.bodyTimeoutMs,
|
||||
});
|
||||
if (!res.ok) return toAuthFailure(res);
|
||||
|
||||
if (res.status === 401) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'credentials', status: 401 };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'app-password-given', status: 403 };
|
||||
}
|
||||
if (res.status === 503) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'maintenance', status: 503 };
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await drain(res.body);
|
||||
return { ok: false, kind: 'upstream', status: res.status };
|
||||
}
|
||||
const text = await readCappedText(res.body, OCS_MAX_BYTES);
|
||||
if (!text.ok) {
|
||||
return text.kind === 'too-large'
|
||||
? { ok: false, kind: 'invalid-response' }
|
||||
: { ok: false, kind: text.kind };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text.text) as { ocs?: { data?: unknown } };
|
||||
return { ok: true, status: res.status, data: parsed?.ocs?.data ?? null };
|
||||
} catch {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(body: Parameters<typeof readCappedText>[0]): Promise<void> {
|
||||
await readCappedText(body, SMALL_MAX_BYTES);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest mit Benutzername und echtem Passwort EINEN App-Passwort-Zugang aus.
|
||||
* Das echte Passwort lebt nur in diesem Aufruf.
|
||||
*/
|
||||
export async function getAppPassword(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
password: string,
|
||||
): Promise<{ ok: true; appPassword: string } | AuthFailure> {
|
||||
const res = await ocsRequest(transport, gate, baseUrl, {
|
||||
method: 'GET',
|
||||
segments: ['core', 'getapppassword'],
|
||||
authorization: basicAuth(loginName, password),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
const appPassword = asString((res.data as { apppassword?: unknown } | null)?.apppassword);
|
||||
if (!appPassword) return { ok: false, kind: 'invalid-response' };
|
||||
return { ok: true, appPassword };
|
||||
}
|
||||
|
||||
/** Wer ist das? `id` ist die Nextcloud-Kennung fuer die Dateipfade (nicht der Anmeldename). */
|
||||
export async function getCurrentUser(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
): Promise<{ ok: true; id: string; displayName: string | null } | AuthFailure> {
|
||||
const res = await ocsRequest(transport, gate, baseUrl, {
|
||||
method: 'GET',
|
||||
segments: ['cloud', 'user'],
|
||||
authorization: basicAuth(loginName, appPassword),
|
||||
});
|
||||
if (!res.ok) return res;
|
||||
const data = (res.data ?? {}) as Record<string, unknown>;
|
||||
const id = asString(data.id);
|
||||
if (!id || id.length > 256) return { ok: false, kind: 'invalid-response' };
|
||||
const displayName = asString(data['display-name']) ?? asString(data.displayname);
|
||||
return { ok: true, id, displayName: displayName ? displayName.slice(0, 256) : null };
|
||||
}
|
||||
|
||||
/** Widerruft den App-Passwort-Zugang, mit dem der Aufruf selbst angemeldet ist. */
|
||||
export async function revokeAppPassword(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
loginName: string,
|
||||
appPassword: string,
|
||||
credentialKey?: string,
|
||||
): Promise<{ ok: true } | AuthFailure> {
|
||||
const res = await ocsRequest(transport, gate, baseUrl, {
|
||||
method: 'DELETE',
|
||||
segments: ['core', 'apppassword'],
|
||||
authorization: basicAuth(loginName, appPassword),
|
||||
credentialKey,
|
||||
headersTimeoutMs: REVOKE_TIMEOUT_MS,
|
||||
bodyTimeoutMs: REVOKE_TIMEOUT_MS,
|
||||
});
|
||||
return res.ok ? { ok: true } : res;
|
||||
}
|
||||
|
||||
/** Startet den Login Flow v2: Link fuer den Benutzer (neu gebaut) und Abfrage-Token (nur Server). */
|
||||
export async function startLoginFlow(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
): Promise<{ ok: true; loginUrl: string; pollToken: string } | AuthFailure> {
|
||||
const base = normalizeCloudUrl(baseUrl);
|
||||
if (base === null) return { ok: false, kind: 'invalid-response' };
|
||||
const res = await ncRequest(transport, gate, {
|
||||
baseUrl: base,
|
||||
prefix: '/index.php/login/v2',
|
||||
method: 'POST',
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return toAuthFailure(res);
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await drain(res.body);
|
||||
return res.status === 503
|
||||
? { ok: false, kind: 'maintenance', status: 503 }
|
||||
: { ok: false, kind: 'upstream', status: res.status };
|
||||
}
|
||||
const text = await readCappedText(res.body, SMALL_MAX_BYTES);
|
||||
if (!text.ok) {
|
||||
return text.kind === 'too-large'
|
||||
? { ok: false, kind: 'invalid-response' }
|
||||
: { ok: false, kind: text.kind };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text.text) as {
|
||||
poll?: { token?: unknown };
|
||||
login?: unknown;
|
||||
};
|
||||
const pollToken = parsed?.poll?.token;
|
||||
const login = parsed?.login;
|
||||
if (typeof pollToken !== 'string' || !FLOW_TOKEN_RE.test(pollToken)) {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
if (typeof login !== 'string') return { ok: false, kind: 'invalid-response' };
|
||||
const match = FLOW_LOGIN_RE.exec(login);
|
||||
if (!match) return { ok: false, kind: 'invalid-response' };
|
||||
return {
|
||||
ok: true,
|
||||
loginUrl: `${base}/index.php/login/v2/flow/${match[1]}`,
|
||||
pollToken,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { ok: true; state: 'pending' }
|
||||
| { ok: true; state: 'granted'; loginName: string; appPassword: string };
|
||||
|
||||
/**
|
||||
* Fragt die Browser-Anmeldung ab — IMMER `{Basis}/index.php/login/v2/poll`, nie
|
||||
* die `poll.endpoint` aus der Antwort. 404 heisst: noch nicht bestaetigt.
|
||||
*/
|
||||
export async function pollLoginFlow(
|
||||
transport: NextcloudTransport,
|
||||
gate: NextcloudCallGate,
|
||||
baseUrl: string,
|
||||
pollToken: string,
|
||||
): Promise<PollResult | AuthFailure> {
|
||||
const res = await ncRequest(transport, gate, {
|
||||
baseUrl,
|
||||
prefix: '/index.php/login/v2/poll',
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body: `token=${encodeURIComponent(pollToken)}`,
|
||||
});
|
||||
if (!res.ok) return toAuthFailure(res);
|
||||
if (res.status === 404) {
|
||||
await drain(res.body);
|
||||
return { ok: true, state: 'pending' };
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
await drain(res.body);
|
||||
return res.status === 503
|
||||
? { ok: false, kind: 'maintenance', status: 503 }
|
||||
: { ok: false, kind: 'upstream', status: res.status };
|
||||
}
|
||||
const text = await readCappedText(res.body, SMALL_MAX_BYTES);
|
||||
if (!text.ok) {
|
||||
return text.kind === 'too-large'
|
||||
? { ok: false, kind: 'invalid-response' }
|
||||
: { ok: false, kind: text.kind };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text.text) as { loginName?: unknown; appPassword?: unknown };
|
||||
const loginName = asString(parsed?.loginName);
|
||||
const appPassword = asString(parsed?.appPassword);
|
||||
if (!loginName || !appPassword || loginName.length > 256 || appPassword.length > 512) {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
// `server` aus der Antwort wird ignoriert (siehe Kopfkommentar).
|
||||
return { ok: true, state: 'granted', loginName, appPassword };
|
||||
} catch {
|
||||
return { ok: false, kind: 'invalid-response' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user