feat: Implement password reset and email verification with Mailgun
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

Backend changes:
- Add password reset token database migration (V011)
- Create email service with Mailgun integration (EU/US regions)
- Implement password reset flow with secure token generation
- Add email verification endpoints and logic
- Create beautiful HTML email templates for reset and verification
- Add password reset DTOs with validation
- Update User entity with email verification fields

Frontend changes:
- Create forgot password page with email submission
- Create reset password page with token validation
- Add email verification banner component
- Integrate verification banner into main dashboard
- Add password requirements and validation UI

Features:
- Mailgun API ready for EU and US regions
- Secure token expiration (1h for reset, 24h for verification)
- Rate limiting on resend (2min interval)
- Protection against email enumeration
- IP address and user agent tracking
- Token reuse prevention

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-01 19:17:48 +00:00
parent 7ee79adcea
commit aaa239121e
16 changed files with 1487 additions and 6 deletions

View File

@@ -35,9 +35,11 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
"form-data": "^4.0.4",
"graphql": "^16.11.0",
"ioredis": "^5.8.0",
"langchain": "^0.3.35",
"mailgun.js": "^12.1.0",
"multer": "^2.0.2",
"openai": "^5.23.2",
"passport": "^0.7.0",
@@ -6978,6 +6980,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -11471,6 +11479,20 @@
"node": ">=12"
}
},
"node_modules/mailgun.js": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/mailgun.js/-/mailgun.js-12.1.0.tgz",
"integrity": "sha512-Cd6rD/aYosAuKcHI9iHdgF6n65nNB63ClQrpsvNVxDeks49SMOuh1euFXV/JUNB27n4jMZhprXSKnftcV2Bm9Q==",
"license": "MIT",
"dependencies": {
"axios": "^1.12.1",
"base-64": "^1.0.0",
"url-join": "^4.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -14840,6 +14862,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-join": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -47,9 +47,11 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
"form-data": "^4.0.4",
"graphql": "^16.11.0",
"ioredis": "^5.8.0",
"langchain": "^0.3.35",
"mailgun.js": "^12.1.0",
"multer": "^2.0.2",
"openai": "^5.23.2",
"passport": "^0.7.0",

View File

@@ -4,11 +4,12 @@ import { AuditLog } from '../database/entities';
import { AuditService } from './services/audit.service';
import { ErrorResponseService } from './services/error-response.service';
import { CacheService } from './services/cache.service';
import { EmailService } from './services/email.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
providers: [AuditService, ErrorResponseService, CacheService],
exports: [AuditService, ErrorResponseService, CacheService],
providers: [AuditService, ErrorResponseService, CacheService, EmailService],
exports: [AuditService, ErrorResponseService, CacheService, EmailService],
})
export class CommonModule {}

View File

@@ -0,0 +1,299 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Mailgun from 'mailgun.js';
import formData from 'form-data';
export interface EmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
export interface PasswordResetEmailData {
userName: string;
resetLink: string;
expiresIn: string;
}
export interface EmailVerificationData {
userName: string;
verificationLink: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private mailgunClient: any;
private readonly fromEmail: string;
private readonly fromName: string;
private readonly appUrl: string;
private readonly mailgunDomain: string;
constructor(private configService: ConfigService) {
const mailgunApiKey = this.configService.get<string>('MAILGUN_API_KEY');
const mailgunRegion = this.configService.get<string>('MAILGUN_REGION', 'us'); // 'us' or 'eu'
this.mailgunDomain = this.configService.get<string>('MAILGUN_DOMAIN', '');
this.fromEmail = this.configService.get<string>('EMAIL_FROM', 'noreply@maternal-app.com');
this.fromName = this.configService.get<string>('EMAIL_FROM_NAME', 'Maternal App');
this.appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
// Initialize Mailgun client
if (mailgunApiKey && this.mailgunDomain) {
const mailgun = new Mailgun(formData);
this.mailgunClient = mailgun.client({
username: 'api',
key: mailgunApiKey,
url: mailgunRegion === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net',
});
this.logger.log(`Mailgun initialized for ${mailgunRegion.toUpperCase()} region`);
} else {
this.logger.warn('Mailgun not configured - emails will be logged but not sent');
}
}
/**
* Send a generic email
*/
async sendEmail(options: EmailOptions): Promise<void> {
const { to, subject, html, text } = options;
if (!this.mailgunClient) {
this.logger.warn(`[EMAIL NOT SENT - No Mailgun Config] To: ${to}, Subject: ${subject}`);
this.logger.debug(`Email content: ${text || html.substring(0, 200)}...`);
return;
}
try {
const messageData = {
from: `${this.fromName} <${this.fromEmail}>`,
to,
subject,
html,
text: text || this.stripHtml(html),
};
await this.mailgunClient.messages.create(this.mailgunDomain, messageData);
this.logger.log(`Email sent successfully to ${to}: ${subject}`);
} catch (error) {
this.logger.error(`Failed to send email to ${to}: ${error.message}`, error.stack);
throw error;
}
}
/**
* Send password reset email
*/
async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise<void> {
const subject = 'Reset Your Maternal App Password';
const html = this.getPasswordResetEmailTemplate(data);
await this.sendEmail({ to, subject, html });
}
/**
* Send email verification email
*/
async sendEmailVerificationEmail(to: string, data: EmailVerificationData): Promise<void> {
const subject = 'Verify Your Maternal App Email';
const html = this.getEmailVerificationTemplate(data);
await this.sendEmail({ to, subject, html });
}
/**
* Send welcome email after successful registration
*/
async sendWelcomeEmail(to: string, userName: string): Promise<void> {
const subject = 'Welcome to Maternal App! 🎉';
const html = this.getWelcomeEmailTemplate(userName);
await this.sendEmail({ to, subject, html });
}
/**
* Password reset email template
*/
private getPasswordResetEmailTemplate(data: PasswordResetEmailData): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
.content { padding: 40px 30px; }
.content p { margin: 0 0 20px 0; font-size: 16px; }
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
.button:hover { background: #FF7B6D; }
.info-box { background: #FFF5F5; border-left: 4px solid #FFB5A0; padding: 15px; margin: 20px 0; border-radius: 4px; }
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
.footer a { color: #FF8B7D; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Reset Your Password</h1>
</div>
<div class="content">
<p>Hi ${data.userName},</p>
<p>We received a request to reset your password for your Maternal App account. Click the button below to create a new password:</p>
<div style="text-align: center;">
<a href="${data.resetLink}" class="button">Reset Password</a>
</div>
<div class="info-box">
<strong>⏱️ This link expires in ${data.expiresIn}</strong><br>
If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
</div>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666; font-size: 14px;">${data.resetLink}</p>
<p style="margin-top: 30px;">Best regards,<br>The Maternal App Team</p>
</div>
<div class="footer">
<p>If you have any questions, contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* Email verification template
*/
private getEmailVerificationTemplate(data: EmailVerificationData): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Your Email</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
.content { padding: 40px 30px; }
.content p { margin: 0 0 20px 0; font-size: 16px; }
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
.button:hover { background: #FF7B6D; }
.info-box { background: #E8F5E9; border-left: 4px solid #4CAF50; padding: 15px; margin: 20px 0; border-radius: 4px; }
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
.footer a { color: #FF8B7D; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Verify Your Email 📧</h1>
</div>
<div class="content">
<p>Hi ${data.userName},</p>
<p>Thank you for signing up for Maternal App! To complete your registration and access all features, please verify your email address by clicking the button below:</p>
<div style="text-align: center;">
<a href="${data.verificationLink}" class="button">Verify Email</a>
</div>
<div class="info-box">
<strong>✅ Why verify?</strong><br>
Email verification helps us ensure your account security and enables us to send you important updates about your child's activities and milestones.
</div>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666; font-size: 14px;">${data.verificationLink}</p>
<p style="margin-top: 30px;">Welcome to the Maternal App family! 🎉<br>The Maternal App Team</p>
</div>
<div class="footer">
<p>If you didn't create an account, you can safely ignore this email.</p>
<p>Questions? Contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* Welcome email template
*/
private getWelcomeEmailTemplate(userName: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Maternal App</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
.content { padding: 40px 30px; }
.content p { margin: 0 0 20px 0; font-size: 16px; }
.feature-box { background: #FFF5F5; padding: 20px; margin: 15px 0; border-radius: 6px; border-left: 4px solid #FFB5A0; }
.feature-box h3 { margin: 0 0 10px 0; color: #FF8B7D; font-size: 18px; }
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
.footer a { color: #FF8B7D; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Maternal App! 🎉</h1>
</div>
<div class="content">
<p>Hi ${userName},</p>
<p>We're thrilled to have you join the Maternal App family! Our mission is to help you track and understand your child's development, reduce your mental load, and provide AI-powered parenting support whenever you need it.</p>
<div class="feature-box">
<h3>📊 Track Activities</h3>
<p>Easily log feeding, sleep, diaper changes, and more with our intuitive interface.</p>
</div>
<div class="feature-box">
<h3>🤖 AI Parenting Assistant</h3>
<p>Get personalized insights and answers to your parenting questions 24/7.</p>
</div>
<div class="feature-box">
<h3>👨‍👩‍👧 Family Sync</h3>
<p>Keep everyone in the loop with real-time activity updates across all devices.</p>
</div>
<div style="text-align: center;">
<a href="${this.appUrl}" class="button">Get Started</a>
</div>
<p style="margin-top: 30px;">If you have any questions or need help getting started, our support team is here for you!</p>
<p>Happy parenting! 💕<br>The Maternal App Team</p>
</div>
<div class="footer">
<p>Questions? Contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* Strip HTML tags for plain text version
*/
private stripHtml(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
}

View File

@@ -4,6 +4,7 @@ export { Family } from './family.entity';
export { FamilyMember, FamilyRole, FamilyPermissions } from './family-member.entity';
export { Child } from './child.entity';
export { RefreshToken } from './refresh-token.entity';
export { PasswordResetToken } from './password-reset-token.entity';
export { AIConversation, MessageRole, ConversationMessage } from './ai-conversation.entity';
export { Activity, ActivityType } from './activity.entity';
export { AuditLog, AuditAction, EntityType } from './audit-log.entity';

View File

@@ -0,0 +1,57 @@
import {
Entity,
Column,
PrimaryColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
BeforeInsert,
} from 'typeorm';
import { nanoid } from 'nanoid';
import { User } from './user.entity';
@Entity('password_reset_tokens')
export class PasswordResetToken {
@PrimaryColumn({ type: 'varchar', length: 30 })
id: string;
@Column({ type: 'varchar', length: 20, name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ type: 'varchar', length: 64, unique: true })
token: string;
@Column({ type: 'timestamp without time zone', name: 'expires_at' })
expiresAt: Date;
@Column({ type: 'timestamp without time zone', name: 'used_at', nullable: true })
usedAt: Date | null;
@Column({ type: 'varchar', length: 45, name: 'ip_address', nullable: true })
ipAddress: string | null;
@Column({ type: 'text', name: 'user_agent', nullable: true })
userAgent: string | null;
@CreateDateColumn({ type: 'timestamp without time zone', name: 'created_at' })
createdAt: Date;
@BeforeInsert()
generateId() {
if (!this.id) {
this.id = `reset_${nanoid(12)}`;
}
}
isExpired(): boolean {
return new Date() > this.expiresAt;
}
isUsed(): boolean {
return this.usedAt !== null;
}
}

View File

@@ -36,6 +36,12 @@ export class User {
@Column({ name: 'email_verified', default: false })
emailVerified: boolean;
@Column({ name: 'email_verification_token', length: 64, nullable: true })
emailVerificationToken?: string | null;
@Column({ name: 'email_verification_sent_at', type: 'timestamp without time zone', nullable: true })
emailVerificationSentAt?: Date | null;
@Column({ type: 'jsonb', nullable: true })
preferences?: {
notifications?: boolean;

View File

@@ -0,0 +1,43 @@
-- Migration: V011_create_password_reset_tokens
-- Description: Create password reset tokens and email verification tracking
-- Password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id VARCHAR(30) PRIMARY KEY,
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
used_at TIMESTAMP WITHOUT TIME ZONE,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token);
CREATE INDEX idx_password_reset_tokens_expires ON password_reset_tokens(expires_at);
-- Add email verification fields to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(64),
ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMP WITHOUT TIME ZONE;
CREATE INDEX idx_users_email_verification_token ON users(email_verification_token);
-- Email verification history (for auditing)
CREATE TABLE IF NOT EXISTS email_verification_logs (
id VARCHAR(30) PRIMARY KEY,
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
verification_type VARCHAR(50) NOT NULL, -- 'signup', 'password_reset', 'email_change'
token_sent_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
verified_at TIMESTAMP WITHOUT TIME ZONE,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_email_verification_logs_user ON email_verification_logs(user_id);
CREATE INDEX idx_email_verification_logs_email ON email_verification_logs(email);
CREATE INDEX idx_email_verification_logs_created ON email_verification_logs(created_at DESC);

View File

@@ -8,19 +8,26 @@ import {
HttpStatus,
UseGuards,
ValidationPipe,
Ip,
Headers,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { PasswordResetService } from './password-reset.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { LogoutDto } from './dto/logout.dto';
import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto';
import { Public } from './decorators/public.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Controller('api/v1/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly passwordResetService: PasswordResetService,
) {}
@Public()
@Post('register')
@@ -69,4 +76,48 @@ export class AuthController {
) {
return await this.authService.updateProfile(user.userId, updateData);
}
// Password Reset Endpoints
@Public()
@Post('password/forgot')
@HttpCode(HttpStatus.OK)
async forgotPassword(
@Body(ValidationPipe) dto: RequestPasswordResetDto,
@Ip() ip: string,
@Headers('user-agent') userAgent: string,
) {
return await this.passwordResetService.requestPasswordReset(dto, ip, userAgent);
}
@Public()
@Post('password/reset')
@HttpCode(HttpStatus.OK)
async resetPassword(
@Body(ValidationPipe) dto: ResetPasswordDto,
@Ip() ip: string,
) {
return await this.passwordResetService.resetPassword(dto, ip);
}
// Email Verification Endpoints
@UseGuards(JwtAuthGuard)
@Post('email/send-verification')
@HttpCode(HttpStatus.OK)
async sendEmailVerification(@CurrentUser() user: any) {
return await this.passwordResetService.sendEmailVerification(user.userId);
}
@Public()
@Post('email/verify')
@HttpCode(HttpStatus.OK)
async verifyEmail(@Body(ValidationPipe) dto: VerifyEmailDto) {
return await this.passwordResetService.verifyEmail(dto);
}
@UseGuards(JwtAuthGuard)
@Post('email/resend-verification')
@HttpCode(HttpStatus.OK)
async resendEmailVerification(@CurrentUser() user: any) {
return await this.passwordResetService.resendEmailVerification(user.userId);
}
}

View File

@@ -5,19 +5,21 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PasswordResetService } from './password-reset.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import {
User,
DeviceRegistry,
RefreshToken,
PasswordResetToken,
Family,
FamilyMember,
} from '../../database/entities';
@Module({
imports: [
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, Family, FamilyMember]),
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember]),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
@@ -31,7 +33,7 @@ import {
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, LocalStrategy],
exports: [AuthService],
providers: [AuthService, PasswordResetService, JwtStrategy, LocalStrategy],
exports: [AuthService, PasswordResetService],
})
export class AuthModule {}

View File

@@ -0,0 +1,26 @@
import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator';
export class RequestPasswordResetDto {
@IsEmail({}, { message: 'Please provide a valid email address' })
@IsNotEmpty({ message: 'Email is required' })
email: string;
}
export class ResetPasswordDto {
@IsString({ message: 'Token is required' })
@IsNotEmpty({ message: 'Token is required' })
token: string;
@IsString({ message: 'Password must be a string' })
@MinLength(8, { message: 'Password must be at least 8 characters long' })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one number',
})
newPassword: string;
}
export class VerifyEmailDto {
@IsString({ message: 'Token is required' })
@IsNotEmpty({ message: 'Token is required' })
token: string;
}

View File

@@ -0,0 +1,265 @@
import {
Injectable,
BadRequestException,
NotFoundException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { User, PasswordResetToken } from '../../database/entities';
import { EmailService } from '../../common/services/email.service';
import { ConfigService } from '@nestjs/config';
import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto';
@Injectable()
export class PasswordResetService {
private readonly logger = new Logger(PasswordResetService.name);
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(PasswordResetToken)
private passwordResetTokenRepository: Repository<PasswordResetToken>,
private emailService: EmailService,
private configService: ConfigService,
) {}
/**
* Request a password reset - sends email with reset link
*/
async requestPasswordReset(
dto: RequestPasswordResetDto,
ipAddress?: string,
userAgent?: string,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
// Always return success to prevent email enumeration attacks
if (!user) {
this.logger.warn(`Password reset requested for non-existent email: ${dto.email}`);
return {
success: true,
message: 'If an account with that email exists, a password reset link has been sent.',
};
}
// Generate secure random token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1); // Token expires in 1 hour
// Create password reset token
const resetToken = this.passwordResetTokenRepository.create({
userId: user.id,
token,
expiresAt,
ipAddress,
userAgent,
});
await this.passwordResetTokenRepository.save(resetToken);
// Generate reset link
const appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
const resetLink = `${appUrl}/reset-password?token=${token}`;
// Send password reset email
try {
await this.emailService.sendPasswordResetEmail(user.email, {
userName: user.name,
resetLink,
expiresIn: '1 hour',
});
this.logger.log(`Password reset email sent to ${user.email}`);
} catch (error) {
this.logger.error(`Failed to send password reset email to ${user.email}:`, error);
// Don't throw error to user - they'll get generic success message
}
return {
success: true,
message: 'If an account with that email exists, a password reset link has been sent.',
};
}
/**
* Reset password using token
*/
async resetPassword(
dto: ResetPasswordDto,
ipAddress?: string,
): Promise<{ success: boolean; message: string }> {
// Find the reset token
const resetToken = await this.passwordResetTokenRepository.findOne({
where: { token: dto.token },
relations: ['user'],
});
if (!resetToken) {
throw new BadRequestException('Invalid or expired password reset token');
}
// Check if token is expired
if (resetToken.isExpired()) {
throw new BadRequestException('Password reset token has expired. Please request a new one.');
}
// Check if token was already used
if (resetToken.isUsed()) {
throw new BadRequestException('This password reset token has already been used');
}
// Hash new password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(dto.newPassword, saltRounds);
// Update user's password
resetToken.user.passwordHash = passwordHash;
await this.userRepository.save(resetToken.user);
// Mark token as used
resetToken.usedAt = new Date();
await this.passwordResetTokenRepository.save(resetToken);
this.logger.log(`Password reset successful for user ${resetToken.user.id}`);
return {
success: true,
message: 'Your password has been reset successfully. You can now log in with your new password.',
};
}
/**
* Send email verification link
*/
async sendEmailVerification(
userId: string,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.emailVerified) {
return {
success: true,
message: 'Your email is already verified',
};
}
// Generate verification token
const token = crypto.randomBytes(32).toString('hex');
// Store token in user record
user.emailVerificationToken = token;
user.emailVerificationSentAt = new Date();
await this.userRepository.save(user);
// Generate verification link
const appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
const verificationLink = `${appUrl}/verify-email?token=${token}`;
// Send verification email
try {
await this.emailService.sendEmailVerificationEmail(user.email, {
userName: user.name,
verificationLink,
});
this.logger.log(`Email verification sent to ${user.email}`);
} catch (error) {
this.logger.error(`Failed to send verification email to ${user.email}:`, error);
throw new BadRequestException('Failed to send verification email. Please try again later.');
}
return {
success: true,
message: 'Verification email sent. Please check your inbox.',
};
}
/**
* Verify email using token
*/
async verifyEmail(
dto: VerifyEmailDto,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { emailVerificationToken: dto.token },
});
if (!user) {
throw new BadRequestException('Invalid or expired verification token');
}
if (user.emailVerified) {
return {
success: true,
message: 'Your email is already verified',
};
}
// Check if token is too old (expires after 24 hours)
const tokenAge = new Date().getTime() - user.emailVerificationSentAt.getTime();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
if (tokenAge > maxAge) {
throw new BadRequestException('Verification token has expired. Please request a new one.');
}
// Mark email as verified
user.emailVerified = true;
user.emailVerificationToken = null;
user.emailVerificationSentAt = null;
await this.userRepository.save(user);
this.logger.log(`Email verified for user ${user.id}`);
return {
success: true,
message: 'Your email has been verified successfully!',
};
}
/**
* Resend email verification
*/
async resendEmailVerification(
userId: string,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.emailVerified) {
return {
success: true,
message: 'Your email is already verified',
};
}
// Check if we recently sent a verification email (rate limiting)
if (user.emailVerificationSentAt) {
const timeSinceLastSent = new Date().getTime() - user.emailVerificationSentAt.getTime();
const minInterval = 2 * 60 * 1000; // 2 minutes in milliseconds
if (timeSinceLastSent < minInterval) {
throw new BadRequestException('Please wait at least 2 minutes before requesting another verification email');
}
}
return await this.sendEmailVerification(userId);
}
}