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[]
|
passwordResets PasswordReset[]
|
||||||
authProviders UserAuthProvider[]
|
authProviders UserAuthProvider[]
|
||||||
|
trustedDevices TrustedDevice[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -268,3 +269,18 @@ enum PageLayout {
|
|||||||
DEFAULT
|
DEFAULT
|
||||||
WETRANSFER
|
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 {
|
export class AuthController {
|
||||||
private authService = new AuthService();
|
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) {
|
async login(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const input = LoginSchema.parse(request.body);
|
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) {
|
if ("requiresTwoFactor" in result) {
|
||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
@@ -43,7 +50,14 @@ export class AuthController {
|
|||||||
async completeTwoFactorLogin(request: FastifyRequest, reply: FastifyReply) {
|
async completeTwoFactorLogin(request: FastifyRequest, reply: FastifyReply) {
|
||||||
try {
|
try {
|
||||||
const input = CompleteTwoFactorLoginSchema.parse(request.body);
|
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({
|
const token = await request.jwtSign({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
@@ -40,6 +40,7 @@ export type ResetPasswordInput = BaseResetPasswordInput & {
|
|||||||
export const CompleteTwoFactorLoginSchema = z.object({
|
export const CompleteTwoFactorLoginSchema = z.object({
|
||||||
userId: z.string().min(1, "User ID is required").describe("User ID"),
|
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"),
|
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>;
|
export type CompleteTwoFactorLoginInput = z.infer<typeof CompleteTwoFactorLoginSchema>;
|
||||||
|
@@ -8,14 +8,16 @@ import { TwoFactorService } from "../two-factor/service";
|
|||||||
import { UserResponseSchema } from "../user/dto";
|
import { UserResponseSchema } from "../user/dto";
|
||||||
import { PrismaUserRepository } from "../user/repository";
|
import { PrismaUserRepository } from "../user/repository";
|
||||||
import { LoginInput } from "./dto";
|
import { LoginInput } from "./dto";
|
||||||
|
import { TrustedDeviceService } from "./trusted-device.service";
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private userRepository = new PrismaUserRepository();
|
private userRepository = new PrismaUserRepository();
|
||||||
private configService = new ConfigService();
|
private configService = new ConfigService();
|
||||||
private emailService = new EmailService();
|
private emailService = new EmailService();
|
||||||
private twoFactorService = new TwoFactorService();
|
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);
|
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("Invalid credentials");
|
throw new Error("Invalid credentials");
|
||||||
@@ -82,6 +84,13 @@ export class AuthService {
|
|||||||
const has2FA = await this.twoFactorService.isEnabled(user.id);
|
const has2FA = await this.twoFactorService.isEnabled(user.id);
|
||||||
|
|
||||||
if (has2FA) {
|
if (has2FA) {
|
||||||
|
if (userAgent && ipAddress) {
|
||||||
|
const isDeviceTrusted = await this.trustedDeviceService.isDeviceTrusted(user.id, userAgent, ipAddress);
|
||||||
|
if (isDeviceTrusted) {
|
||||||
|
return UserResponseSchema.parse(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requiresTwoFactor: true,
|
requiresTwoFactor: true,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -92,7 +101,13 @@ export class AuthService {
|
|||||||
return UserResponseSchema.parse(user);
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
});
|
});
|
||||||
@@ -115,6 +130,10 @@ export class AuthService {
|
|||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (rememberDevice && userAgent && ipAddress) {
|
||||||
|
await this.trustedDeviceService.addTrustedDevice(userId, userAgent, ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
return UserResponseSchema.parse(user);
|
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",
|
"verify": "Verify",
|
||||||
"verifying": "Verifying...",
|
"verifying": "Verifying...",
|
||||||
"useBackupCode": "Use backup code instead",
|
"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": {
|
"messages": {
|
||||||
"enabledSuccess": "Two-factor authentication enabled successfully!",
|
"enabledSuccess": "Two-factor authentication enabled successfully!",
|
||||||
@@ -1608,7 +1610,8 @@
|
|||||||
"backupCodesCopyFailed": "Failed to copy backup codes",
|
"backupCodesCopyFailed": "Failed to copy backup codes",
|
||||||
"statusLoadFailed": "Failed to load 2FA status",
|
"statusLoadFailed": "Failed to load 2FA status",
|
||||||
"enterVerificationCode": "Please enter the verification code",
|
"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": {
|
"errors": {
|
||||||
"invalidVerificationCode": "Invalid verification code",
|
"invalidVerificationCode": "Invalid verification code",
|
||||||
|
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp";
|
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -13,7 +14,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
interface TwoFactorVerificationProps {
|
interface TwoFactorVerificationProps {
|
||||||
twoFactorCode: string;
|
twoFactorCode: string;
|
||||||
setTwoFactorCode: (code: string) => void;
|
setTwoFactorCode: (code: string) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: (rememberDevice?: boolean) => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
}
|
}
|
||||||
@@ -27,10 +28,11 @@ export function TwoFactorVerification({
|
|||||||
}: TwoFactorVerificationProps) {
|
}: TwoFactorVerificationProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [showBackupCode, setShowBackupCode] = useState(false);
|
const [showBackupCode, setShowBackupCode] = useState(false);
|
||||||
|
const [rememberDevice, setRememberDevice] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSubmit();
|
onSubmit(rememberDevice);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCodeChange = (value: string) => {
|
const handleCodeChange = (value: string) => {
|
||||||
@@ -90,6 +92,17 @@ export function TwoFactorVerification({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
@@ -135,7 +135,7 @@ export function useLogin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTwoFactorSubmit = async () => {
|
const onTwoFactorSubmit = async (rememberDevice: boolean = false) => {
|
||||||
if (!twoFactorUserId || !twoFactorCode) {
|
if (!twoFactorUserId || !twoFactorCode) {
|
||||||
setError(t("twoFactor.messages.enterVerificationCode"));
|
setError(t("twoFactor.messages.enterVerificationCode"));
|
||||||
return;
|
return;
|
||||||
@@ -143,15 +143,16 @@ export function useLogin() {
|
|||||||
|
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await completeTwoFactorLogin({
|
const response = await completeTwoFactorLogin({
|
||||||
userId: twoFactorUserId,
|
userId: twoFactorUserId,
|
||||||
token: twoFactorCode,
|
token: twoFactorCode,
|
||||||
|
rememberDevice: rememberDevice,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isAdmin, ...userData } = response.data.user;
|
const { isAdmin, ...userData } = response.data.user;
|
||||||
|
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
setIsAdmin(isAdmin);
|
setIsAdmin(isAdmin);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
@@ -54,6 +54,7 @@ export interface TwoFactorStatus {
|
|||||||
export interface CompleteTwoFactorLoginRequest {
|
export interface CompleteTwoFactorLoginRequest {
|
||||||
userId: string;
|
userId: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
rememberDevice?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
@@ -34,8 +34,9 @@
|
|||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx"
|
"**/*.tsx",
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next/types/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user