feat(auth): add trusted device support for 2FA

implement remember device option for two-factor authentication
add trusted device service to manage device trust
update login flow to check for trusted devices
This commit is contained in:
Daniel Luiz Alves
2025-07-09 00:34:56 -03:00
parent ffd5005c8b
commit ad689bd6d9
10 changed files with 176 additions and 15 deletions

View File

@@ -33,6 +33,7 @@ model User {
passwordResets PasswordReset[]
authProviders UserAuthProvider[]
trustedDevices TrustedDevice[]
@@map("users")
}
@@ -268,3 +269,18 @@ enum PageLayout {
DEFAULT
WETRANSFER
}
model TrustedDevice {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceHash String @unique
deviceName String?
userAgent String?
ipAddress String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("trusted_devices")
}

View File

@@ -12,10 +12,17 @@ import { AuthService } from "./service";
export class AuthController {
private authService = new AuthService();
private getClientInfo(request: FastifyRequest) {
const userAgent = request.headers["user-agent"] || "";
const ipAddress = request.ip || request.socket.remoteAddress || "";
return { userAgent, ipAddress };
}
async login(request: FastifyRequest, reply: FastifyReply) {
try {
const input = LoginSchema.parse(request.body);
const result = await this.authService.login(input);
const { userAgent, ipAddress } = this.getClientInfo(request);
const result = await this.authService.login(input, userAgent, ipAddress);
if ("requiresTwoFactor" in result) {
return reply.send(result);
@@ -43,7 +50,14 @@ export class AuthController {
async completeTwoFactorLogin(request: FastifyRequest, reply: FastifyReply) {
try {
const input = CompleteTwoFactorLoginSchema.parse(request.body);
const user = await this.authService.completeTwoFactorLogin(input.userId, input.token);
const { userAgent, ipAddress } = this.getClientInfo(request);
const user = await this.authService.completeTwoFactorLogin(
input.userId,
input.token,
input.rememberDevice,
userAgent,
ipAddress
);
const token = await request.jwtSign({
userId: user.id,

View File

@@ -40,6 +40,7 @@ export type ResetPasswordInput = BaseResetPasswordInput & {
export const CompleteTwoFactorLoginSchema = z.object({
userId: z.string().min(1, "User ID is required").describe("User ID"),
token: z.string().min(6, "Two-factor authentication code must be at least 6 characters").describe("2FA token"),
rememberDevice: z.boolean().optional().default(false).describe("Remember this device for 30 days"),
});
export type CompleteTwoFactorLoginInput = z.infer<typeof CompleteTwoFactorLoginSchema>;

View File

@@ -8,14 +8,16 @@ import { TwoFactorService } from "../two-factor/service";
import { UserResponseSchema } from "../user/dto";
import { PrismaUserRepository } from "../user/repository";
import { LoginInput } from "./dto";
import { TrustedDeviceService } from "./trusted-device.service";
export class AuthService {
private userRepository = new PrismaUserRepository();
private configService = new ConfigService();
private emailService = new EmailService();
private twoFactorService = new TwoFactorService();
private trustedDeviceService = new TrustedDeviceService();
async login(data: LoginInput) {
async login(data: LoginInput, userAgent?: string, ipAddress?: string) {
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
if (!user) {
throw new Error("Invalid credentials");
@@ -82,6 +84,13 @@ export class AuthService {
const has2FA = await this.twoFactorService.isEnabled(user.id);
if (has2FA) {
if (userAgent && ipAddress) {
const isDeviceTrusted = await this.trustedDeviceService.isDeviceTrusted(user.id, userAgent, ipAddress);
if (isDeviceTrusted) {
return UserResponseSchema.parse(user);
}
}
return {
requiresTwoFactor: true,
userId: user.id,
@@ -92,7 +101,13 @@ export class AuthService {
return UserResponseSchema.parse(user);
}
async completeTwoFactorLogin(userId: string, token: string) {
async completeTwoFactorLogin(
userId: string,
token: string,
rememberDevice: boolean = false,
userAgent?: string,
ipAddress?: string
) {
const user = await prisma.user.findUnique({
where: { id: userId },
});
@@ -115,6 +130,10 @@ export class AuthService {
where: { userId },
});
if (rememberDevice && userAgent && ipAddress) {
await this.trustedDeviceService.addTrustedDevice(userId, userAgent, ipAddress);
}
return UserResponseSchema.parse(user);
}

View File

@@ -0,0 +1,92 @@
import crypto from "node:crypto";
import { prisma } from "../../shared/prisma";
export class TrustedDeviceService {
private generateDeviceHash(userAgent: string, ipAddress: string): string {
const deviceInfo = `${userAgent}-${ipAddress}`;
return crypto.createHash("sha256").update(deviceInfo).digest("hex");
}
async isDeviceTrusted(userId: string, userAgent: string, ipAddress: string): Promise<boolean> {
const deviceHash = this.generateDeviceHash(userAgent, ipAddress);
const trustedDevice = await prisma.trustedDevice.findFirst({
where: {
userId,
deviceHash,
expiresAt: {
gt: new Date(),
},
},
});
return !!trustedDevice;
}
async addTrustedDevice(userId: string, userAgent: string, ipAddress: string, deviceName?: string): Promise<void> {
const deviceHash = this.generateDeviceHash(userAgent, ipAddress);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30); // 30 dias
await prisma.trustedDevice.upsert({
where: {
deviceHash,
},
create: {
userId,
deviceHash,
deviceName,
userAgent,
ipAddress,
expiresAt,
},
update: {
expiresAt,
userAgent,
ipAddress,
},
});
}
async cleanupExpiredDevices(): Promise<void> {
await prisma.trustedDevice.deleteMany({
where: {
expiresAt: {
lt: new Date(),
},
},
});
}
async getUserTrustedDevices(userId: string) {
return prisma.trustedDevice.findMany({
where: {
userId,
expiresAt: {
gt: new Date(),
},
},
orderBy: {
createdAt: "desc",
},
});
}
async removeTrustedDevice(userId: string, deviceId: string): Promise<void> {
await prisma.trustedDevice.deleteMany({
where: {
id: deviceId,
userId,
},
});
}
async removeAllTrustedDevices(userId: string): Promise<void> {
await prisma.trustedDevice.deleteMany({
where: {
userId,
},
});
}
}

View File

@@ -1594,7 +1594,9 @@
"verify": "Verify",
"verifying": "Verifying...",
"useBackupCode": "Use backup code instead",
"useAuthenticatorCode": "Use authenticator code instead"
"useAuthenticatorCode": "Use authenticator code instead",
"rememberDevice": "Remember this device for 30 days",
"rememberDeviceDescription": "You won't need to enter 2FA codes on this device for 30 days"
},
"messages": {
"enabledSuccess": "Two-factor authentication enabled successfully!",
@@ -1608,7 +1610,8 @@
"backupCodesCopyFailed": "Failed to copy backup codes",
"statusLoadFailed": "Failed to load 2FA status",
"enterVerificationCode": "Please enter the verification code",
"enterPassword": "Please enter your password"
"enterPassword": "Please enter your password",
"deviceTrusted": "This device has been marked as trusted for 30 days"
},
"errors": {
"invalidVerificationCode": "Invalid verification code",

View File

@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
@@ -13,7 +14,7 @@ import { Label } from "@/components/ui/label";
interface TwoFactorVerificationProps {
twoFactorCode: string;
setTwoFactorCode: (code: string) => void;
onSubmit: () => void;
onSubmit: (rememberDevice?: boolean) => void;
error?: string;
isSubmitting: boolean;
}
@@ -27,10 +28,11 @@ export function TwoFactorVerification({
}: TwoFactorVerificationProps) {
const t = useTranslations();
const [showBackupCode, setShowBackupCode] = useState(false);
const [rememberDevice, setRememberDevice] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit();
onSubmit(rememberDevice);
};
const handleCodeChange = (value: string) => {
@@ -90,6 +92,17 @@ export function TwoFactorVerification({
)}
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="rememberDevice"
checked={rememberDevice}
onCheckedChange={(checked) => setRememberDevice(checked as boolean)}
/>
<Label htmlFor="rememberDevice" className="text-sm font-normal cursor-pointer">
{t("twoFactor.verification.rememberDevice")}
</Label>
</div>
<Button
type="submit"
className="w-full"

View File

@@ -135,7 +135,7 @@ export function useLogin() {
}
};
const onTwoFactorSubmit = async () => {
const onTwoFactorSubmit = async (rememberDevice: boolean = false) => {
if (!twoFactorUserId || !twoFactorCode) {
setError(t("twoFactor.messages.enterVerificationCode"));
return;
@@ -148,6 +148,7 @@ export function useLogin() {
const response = await completeTwoFactorLogin({
userId: twoFactorUserId,
token: twoFactorCode,
rememberDevice: rememberDevice,
});
const { isAdmin, ...userData } = response.data.user;

View File

@@ -54,6 +54,7 @@ export interface TwoFactorStatus {
export interface CompleteTwoFactorLoginRequest {
userId: string;
token: string;
rememberDevice?: boolean;
}
export interface LoginResponse {

View File

@@ -34,8 +34,9 @@
".next/types/**/*.ts"
],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
]
}