feat: track unique logins

This commit is contained in:
Linus Rath
2026-04-28 08:19:46 +02:00
parent 2c419cc4fe
commit dafc8ace3c
9 changed files with 186 additions and 29 deletions
+6 -1
View File
@@ -10,6 +10,7 @@ import {
sendOnce,
reschedule,
DEFAULT_ENDPOINT,
getLoginCounts,
} from '@/lib/telemetry';
/**
@@ -23,7 +24,10 @@ export async function GET() {
if ('error' in auth) return auth.error;
const { consent, source, state } = await effectiveConsent();
const payload = await buildPayload();
const [payload, accountCounts] = await Promise.all([
buildPayload(),
getLoginCounts(),
]);
return NextResponse.json(
{
@@ -35,6 +39,7 @@ export async function GET() {
nextScheduledAt: state.nextScheduledAt,
defaultEndpoint: DEFAULT_ENDPOINT,
payloadPreview: payload,
accountCounts,
},
{ headers: { 'Cache-Control': 'no-store' } },
);
+3
View File
@@ -10,6 +10,7 @@ import {
setStalwartAuthContextInStore,
} from '@/lib/stalwart/auth-context';
import { configManager } from '@/lib/admin/config-manager';
import { recordLogin } from '@/lib/telemetry/login-tracker';
const COOKIE_OPTIONS = {
...getCookieOptions(),
@@ -50,6 +51,8 @@ export async function POST(request: NextRequest) {
authHeader,
});
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
+3
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { recordLogin } from '@/lib/telemetry/login-tracker';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
@@ -32,6 +33,8 @@ export async function POST(request: NextRequest) {
authHeader,
});
void recordLogin(username, normalizedServerUrl);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
+7 -1
View File
@@ -7,6 +7,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
/**
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
@@ -117,7 +118,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
}
return await attemptAllStrategies(tokenEndpoint, username, password, slot);
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
} catch (error) {
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -126,6 +127,7 @@ export async function POST(request: NextRequest) {
async function attemptAllStrategies(
tokenEndpoint: string,
serverUrl: string,
username: string,
password: string,
slot: number,
@@ -144,6 +146,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
@@ -155,6 +158,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params);
if (result.ok) {
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
@@ -166,6 +170,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (Basic Auth header)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'Basic Auth header', error: result.error });
@@ -177,6 +182,7 @@ async function attemptAllStrategies(
const result = await tryTokenRequest(tokenEndpoint, params, { 'Authorization': basicAuth });
if (result.ok) {
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
void recordLogin(username, serverUrl);
return await storeAndRespond(result.tokens, slot);
}
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });