Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
245 changes: 245 additions & 0 deletions src/controllers/totp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

import { Request, Response } from 'express';

import { getSystemConfig } from '../config/getSystemConfig.js';
import { AuthEventService } from '../services/authEventService.js';
import { issueSessionAndRespond } from '../services/sessionIssuance.js';
import { recordStepUpVerification, serializeStepUpStatus } from '../services/stepUpService.js';
import {
disableTotp,
getTotpStatus,
startTotpEnrollment,
verifyEnabledTotp,
verifyTotpEnrollment,
} from '../services/totpService.js';
import { AuthenticatedRequest } from '../types/types.js';
import getLogger from '../utils/logger.js';

const logger = getLogger('totp');
const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server';

function serializeDate(value: Date | null) {
return value?.toISOString() ?? null;
}

function getAuthenticatedUser(req: Request) {
return (req as AuthenticatedRequest).user;
}

export const getCurrentTotpStatus = async (req: Request, res: Response) => {
const user = getAuthenticatedUser(req);

if (!user?.id) {
return res.status(401).json({ error: 'unauthorized' });
}

const status = await getTotpStatus(user.id);

return res.json({
enabled: status.enabled,
verifiedAt: serializeDate(status.verifiedAt),
lastUsedAt: serializeDate(status.lastUsedAt),
});
};

export const startCurrentTotpEnrollment = async (req: Request, res: Response) => {
const user = getAuthenticatedUser(req);

if (!user?.id || !user.email) {
await AuthEventService.log({
userId: user?.id ?? null,
type: 'totp_suspicious',
req,
metadata: { reason: 'Missing authenticated user or email' },
});
return res.status(401).json({ error: 'unauthorized' });
}

try {
const { app_name } = await getSystemConfig();
const enrollment = await startTotpEnrollment({
userId: user.id,
email: user.email,
issuer: app_name || 'Seamless Auth',
});

await AuthEventService.log({
userId: user.id,
type: 'totp_enrollment_started',
req,
});

return res.status(200).json({
message: 'Success',
...enrollment,
});
} catch (error) {
logger.error(`Failed to start TOTP enrollment: ${error}`);
await AuthEventService.log({
userId: user.id,
type: 'totp_failed',
req,
metadata: { reason: 'Enrollment start failed' },
});
return res.status(500).json({ error: 'Internal server error' });
}
};

export const verifyCurrentTotpEnrollment = async (req: Request, res: Response) => {
const user = getAuthenticatedUser(req);
const { code } = req.body;

if (!user?.id) {
return res.status(401).json({ error: 'unauthorized' });
}

const result = await verifyTotpEnrollment(user.id, code);

if (!result.verified) {
await AuthEventService.log({
userId: user.id,
type: 'totp_failed',
req,
metadata: { reason: result.reason },
});
return res.status(401).json({ error: 'totp_verification_failed' });
}

await AuthEventService.log({
userId: user.id,
type: 'totp_enrollment_success',
req,
});

return res.json({ message: 'Success' });
};

export const disableCurrentTotp = async (req: Request, res: Response) => {
const user = getAuthenticatedUser(req);
const { code } = req.body;

if (!user?.id) {
return res.status(401).json({ error: 'unauthorized' });
}

const result = await disableTotp(user.id, code);

if (!result.disabled) {
await AuthEventService.log({
userId: user.id,
type: 'totp_failed',
req,
metadata: { reason: result.reason },
});
return res.status(401).json({ error: 'totp_disable_failed' });
}

await AuthEventService.log({
userId: user.id,
type: 'totp_disabled',
req,
});

return res.json({ message: 'Success' });
};

export const verifyTotpLogin = async (req: Request, res: Response) => {
const user = getAuthenticatedUser(req);
const { code } = req.body;

if (!user?.id) {
await AuthEventService.log({
userId: null,
type: 'totp_suspicious',
req,
metadata: { reason: 'Missing pre-authenticated user' },
});
return res.status(401).json({ error: 'unauthorized' });
}

const result = await verifyEnabledTotp(user.id, code);

if (!result.verified) {
await AuthEventService.log({
userId: user.id,
type: 'totp_failed',
req,
metadata: { reason: result.reason, flow: 'login' },
});
return res.status(401).json({ error: 'totp_verification_failed' });
}

await AuthEventService.log({
userId: user.id,
type: 'totp_success',
req,
metadata: { flow: 'login' },
});

await issueSessionAndRespond({
user: {
id: user.id,
email: user.email,
phone: user.phone,
roles: user.roles ?? [],
},
req,
res,
authMode: AUTH_MODE,
clearExistingCookies: true,
});

await user.update({
lastLogin: new Date(),
});
};

export const verifyTotpMfa = async (req: Request, res: Response) => {
const authReq = req as AuthenticatedRequest;
const user = authReq.user;
const { code } = req.body;

if (!user?.id || !authReq.sessionId) {
return res.status(401).json({ error: 'unauthorized' });
}

const result = await verifyEnabledTotp(user.id, code);

if (!result.verified) {
await AuthEventService.log({
userId: user.id,
type: 'mfa_otp_failed',
req,
metadata: { reason: result.reason, method: 'totp' },
});
return res.status(401).json({ error: 'totp_verification_failed' });
}

const status = await recordStepUpVerification({
sessionId: authReq.sessionId,
userId: user.id,
method: 'totp',
});

if (!status) {
return res.status(401).json({ error: 'unauthorized' });
}

await AuthEventService.log({
userId: user.id,
type: 'mfa_otp_success',
req,
metadata: { method: 'totp' },
});

return res.json({
message: 'Success',
...serializeStepUpStatus(status),
method: 'totp',
});
};
62 changes: 62 additions & 0 deletions src/migrations/20260516140000-add-totp-credentials.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
*/
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE TABLE IF NOT EXISTS public.totp_credentials (
id uuid NOT NULL,
"userId" uuid NOT NULL,
"secretCiphertext" text NOT NULL,
"secretIv" character varying(255) NOT NULL,
"secretTag" character varying(255) NOT NULL,
issuer character varying(255) NOT NULL,
"accountName" character varying(255) NOT NULL,
enabled boolean DEFAULT false NOT NULL,
"verifiedAt" timestamp with time zone,
"lastUsedAt" timestamp with time zone,
"lastUsedCounter" bigint,
"createdAt" timestamp with time zone NOT NULL,
"updatedAt" timestamp with time zone NOT NULL
);

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'totp_credentials_pkey'
) THEN
ALTER TABLE ONLY public.totp_credentials
ADD CONSTRAINT totp_credentials_pkey PRIMARY KEY (id);
END IF;
END $$;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'totp_credentials_userId_fkey'
) THEN
ALTER TABLE ONLY public.totp_credentials
ADD CONSTRAINT "totp_credentials_userId_fkey"
FOREIGN KEY ("userId") REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE;
END IF;
END $$;

CREATE INDEX IF NOT EXISTS idx_totp_credentials_user_id
ON public.totp_credentials USING btree ("userId");

CREATE UNIQUE INDEX IF NOT EXISTS idx_totp_credentials_one_enabled_per_user
ON public.totp_credentials USING btree ("userId")
WHERE enabled = true;
`);
},

async down(queryInterface) {
await queryInterface.sequelize.query(`
DROP TABLE IF EXISTS public.totp_credentials;
`);
},
};
Loading
Loading