mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-23 06:11:58 +00:00
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:
@@ -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")
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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>;
|
||||
|
@@ -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);
|
||||
}
|
||||
|
||||
|
92
apps/server/src/modules/auth/trusted-device.service.ts
Normal file
92
apps/server/src/modules/auth/trusted-device.service.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
@@ -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",
|
||||
|
@@ -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"
|
||||
|
@@ -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;
|
||||
@@ -143,15 +143,16 @@ export function useLogin() {
|
||||
|
||||
setError(undefined);
|
||||
setIsSubmitting(true);
|
||||
|
||||
|
||||
try {
|
||||
const response = await completeTwoFactorLogin({
|
||||
userId: twoFactorUserId,
|
||||
token: twoFactorCode,
|
||||
rememberDevice: rememberDevice,
|
||||
});
|
||||
|
||||
|
||||
const { isAdmin, ...userData } = response.data.user;
|
||||
|
||||
|
||||
setUser(userData);
|
||||
setIsAdmin(isAdmin);
|
||||
setIsAuthenticated(true);
|
||||
|
@@ -54,6 +54,7 @@ export interface TwoFactorStatus {
|
||||
export interface CompleteTwoFactorLoginRequest {
|
||||
userId: string;
|
||||
token: string;
|
||||
rememberDevice?: boolean;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
|
@@ -34,8 +34,9 @@
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
"**/*.tsx",
|
||||
"next-env.d.ts",
|
||||
".next/types/**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user