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): 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 { 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[0]): Promise { 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; 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 { 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' }; } }