From 9a057528a11315e4264697ed6d9ad55b6d5d93e8 Mon Sep 17 00:00:00 2001 From: Lucien Date: Fri, 7 Nov 2025 23:26:38 +0800 Subject: [PATCH 01/15] chore(email): Simplify email signup --- src/auth/auth.controller.ts | 60 +++++++--- src/auth/auth.module.ts | 2 + src/auth/auth.service.ts | 153 +++++++++++++++++++++++++ src/auth/dto/email-otp.dto.ts | 43 +++++++ src/auth/otp.service.ts | 188 +++++++++++++++++++++++++++++++ src/i18n/en/mail.json | 10 ++ src/i18n/zh/mail.json | 10 ++ src/mail/mail.service.ts | 29 +++++ src/mail/templates/email-otp.hbs | 66 +++++++++++ src/user/dto/set-password.dto.ts | 17 +++ src/user/user.controller.ts | 9 ++ 11 files changed, 574 insertions(+), 13 deletions(-) create mode 100644 src/auth/dto/email-otp.dto.ts create mode 100644 src/auth/otp.service.ts create mode 100644 src/mail/templates/email-otp.hbs create mode 100644 src/user/dto/set-password.dto.ts diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 732a706c..7fef2228 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -11,9 +11,15 @@ import { UseGuards, Controller, HttpCode, + Query, } from '@nestjs/common'; import { ResourcePermission } from 'omniboxd/permissions/resource-permission.enum'; import { NamespaceRole } from 'omniboxd/namespaces/entities/namespace-member.entity'; +import { + SendEmailOtpDto, + VerifyEmailOtpDto, + SendEmailOtpResponseDto, +} from './dto/email-otp.dto'; @Controller('api/v1') export class AuthController { @@ -45,31 +51,59 @@ export class AuthController { } @Public() - @Post('sign-up') - async signUp(@Body('url') url: string, @Body('email') email: string) { - return await this.authService.signUp(url, email); + @Post('auth/send-otp') + @HttpCode(200) + async sendEmailOtp( + @Body() dto: SendEmailOtpDto, + @Body('url') url: string, + ): Promise { + return await this.authService.sendOTP(dto.email, url); } @Public() - @Post('sign-up/confirm') - async signUpConfirm( - @Body('token') token: string, - @Body('username') username: string, - @Body('password') password: string, + @Post('auth/verify-otp') + @HttpCode(200) + async verifyEmailOtp( + @Body() dto: VerifyEmailOtpDto, @Res() res: Response, @Body('lang') lang?: string, ) { - const signUpData = await this.authService.signUpConfirm(token, { - username, - password, + const authData = await this.authService.verifyOTP( + dto.email, + dto.code, lang, + ); + + const jwtExpireSeconds = parseInt( + this.configService.get('OBB_JWT_EXPIRE', '2678400'), + 10, + ); + res.cookie('token', authData.access_token, { + httpOnly: true, + secure: true, + sameSite: 'none', + path: '/', + maxAge: jwtExpireSeconds * 1000, }); + return res.json(authData); + } + + @Public() + @Post('auth/verify-magic') + @HttpCode(200) + async verifyMagicLink( + @Query('token') token: string, + @Res() res: Response, + @Body('lang') lang?: string, + ) { + const authData = await this.authService.verifyMagicLink(token, lang); + const jwtExpireSeconds = parseInt( this.configService.get('OBB_JWT_EXPIRE', '2678400'), 10, ); - res.cookie('token', signUpData.access_token, { + res.cookie('token', authData.access_token, { httpOnly: true, secure: true, sameSite: 'none', @@ -77,7 +111,7 @@ export class AuthController { maxAge: jwtExpireSeconds * 1000, }); - return res.json(signUpData); + return res.json(authData); } @Public() diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9a17df0a..fb0193e0 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -20,6 +20,7 @@ import { WechatController } from 'omniboxd/auth/wechat/wechat.controller'; import { GoogleService } from 'omniboxd/auth/google/google.service'; import { GoogleController } from 'omniboxd/auth/google/google.controller'; import { SocialService } from 'omniboxd/auth/social.service'; +import { OtpService } from 'omniboxd/auth/otp.service'; import { APIKeyModule } from 'omniboxd/api-key/api-key.module'; import { APIKeyAuthGuard } from 'omniboxd/auth/api-key/api-key-auth.guard'; import { CookieAuthGuard } from 'omniboxd/auth/cookie/cookie-auth.guard'; @@ -36,6 +37,7 @@ import { CacheService } from 'omniboxd/common/cache.service'; providers: [ AuthService, SocialService, + OtpService, WechatService, GoogleService, JwtStrategy, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index cf0b67f4..0d14afcd 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -17,6 +17,9 @@ import { SignUpPayloadDto } from './dto/signup-payload.dto'; import { LoginPayloadDto } from './dto/login-payload.dto'; import { NamespaceRole } from 'omniboxd/namespaces/entities/namespace-member.entity'; import { isEmail } from 'class-validator'; +import { OtpService } from './otp.service'; +import { SocialService } from './social.service'; +import { SendEmailOtpResponseDto } from './dto/email-otp.dto'; @Injectable() export class AuthService { @@ -32,6 +35,8 @@ export class AuthService { private readonly permissionsService: PermissionsService, private readonly dataSource: DataSource, private readonly i18n: I18nService, + private readonly otpService: OtpService, + private readonly socialService: SocialService, ) {} async verify(email: string, password: string): Promise { @@ -72,6 +77,154 @@ export class AuthService { }; } + /** + * Send OTP to email for registration or login + */ + async sendOTP( + email: string, + baseUrl: string, + ): Promise { + const account = await this.userService.findByEmail(email); + const exists = !!account; + + // Generate OTP code and magic link token + const { code, magicToken } = this.otpService.generateOtp(email); + + // Build magic link URL + const magicLink = `${baseUrl}?token=${magicToken}`; + + // Send email with both code and link + await this.mailService.sendOTPEmail(email, code, magicLink); + + return { exists, sent: true }; + } + + /** + * Verify OTP and complete registration or login + */ + async verifyOTP(email: string, code: string, lang?: string) { + // Verify the OTP code + this.otpService.verifyOtp(email, code); + + // Check if user already exists + const existingUser = await this.userService.findByEmail(email); + + if (existingUser) { + // User exists - login + return { + id: existingUser.id, + access_token: this.jwtService.sign({ + sub: existingUser.id, + username: existingUser.username, + }), + }; + } + + // User doesn't exist - register + return await this.dataSource.transaction(async (manager) => { + // Extract username from email (e.g., foo@example.com -> foo) + const emailUsername = email.split('@')[0]; + + // Generate valid username (handles conflicts) + const username = await this.socialService.getValidUsername( + emailUsername, + manager, + ); + + // Generate a random password for OTP-registered users + const randomPassword = Math.random().toString(36).slice(-12) + 'Aa1'; + + // Create user with generated username and random password + const user = await this.userService.create( + { + email, + username, + password: randomPassword, + lang, + }, + manager, + ); + + // Create user namespace + await this.namespaceService.createUserNamespace( + user.id, + user.username, + manager, + ); + + return { + id: user.id, + access_token: this.jwtService.sign({ + sub: user.id, + username: user.username, + }), + }; + }); + } + + /** + * Verify magic link token and complete registration or login + */ + async verifyMagicLink(token: string, lang?: string) { + // Verify the magic link token and get email + const email = this.otpService.verifyMagicToken(token); + + // Check if user already exists + const existingUser = await this.userService.findByEmail(email); + + if (existingUser) { + // User exists - login + return { + id: existingUser.id, + access_token: this.jwtService.sign({ + sub: existingUser.id, + username: existingUser.username, + }), + }; + } + + // User doesn't exist - register + return await this.dataSource.transaction(async (manager) => { + // Extract username from email + const emailUsername = email.split('@')[0]; + + // Generate valid username (handles conflicts) + const username = await this.socialService.getValidUsername( + emailUsername, + manager, + ); + + // Generate a random password for OTP-registered users + const randomPassword = Math.random().toString(36).slice(-12) + 'Aa1'; + + // Create user + const user = await this.userService.create( + { + email, + username, + password: randomPassword, + lang, + }, + manager, + ); + + // Create user namespace + await this.namespaceService.createUserNamespace( + user.id, + user.username, + manager, + ); + + return { + id: user.id, + access_token: this.jwtService.sign({ + sub: user.id, + username: user.username, + }), + }; + }); + } + private async getSignUpToken(email: string) { const account = await this.userService.findByEmail(email); if (account) { diff --git a/src/auth/dto/email-otp.dto.ts b/src/auth/dto/email-otp.dto.ts new file mode 100644 index 00000000..778fe73c --- /dev/null +++ b/src/auth/dto/email-otp.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsString, Length } from 'class-validator'; + +export class SendEmailOtpDto { + @ApiProperty({ + description: 'Email address to send OTP to', + example: 'user@example.com', + }) + @IsEmail() + @IsNotEmpty() + email: string; +} + +export class VerifyEmailOtpDto { + @ApiProperty({ + description: 'Email address', + example: 'user@example.com', + }) + @IsEmail() + @IsNotEmpty() + email: string; + + @ApiProperty({ + description: '6-digit verification code', + example: '123456', + }) + @IsString() + @IsNotEmpty() + @Length(6, 6) + code: string; +} + +export class SendEmailOtpResponseDto { + @ApiProperty({ + description: 'Whether the email exists (registered user)', + }) + exists: boolean; + + @ApiProperty({ + description: 'Whether the OTP was sent successfully', + }) + sent: boolean; +} diff --git a/src/auth/otp.service.ts b/src/auth/otp.service.ts new file mode 100644 index 00000000..53224682 --- /dev/null +++ b/src/auth/otp.service.ts @@ -0,0 +1,188 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +interface OtpRecord { + code: string; + email: string; + expiresAt: number; + attempts: number; +} + +interface RateLimitRecord { + count: number; + resetAt: number; +} + +@Injectable() +export class OtpService { + private otpStore = new Map(); + private rateLimitStore = new Map(); + + // Configuration + private readonly OTP_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes + private readonly MAX_ATTEMPTS = 5; + private readonly RATE_LIMIT_MAX = 3; // max sends per window + private readonly RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes + private readonly MAGIC_LINK_EXPIRY = '5m'; // JWT expiry + + constructor(private jwtService: JwtService) { + // Clean up expired entries every minute + setInterval(() => this.cleanup(), 60 * 1000); + } + + /** + * Generate a 6-digit numeric OTP code + */ + private generateCode(): string { + return Math.floor(100000 + Math.random() * 900000).toString(); + } + + /** + * Check rate limiting for email + */ + private checkRateLimit(email: string): void { + const now = Date.now(); + const record = this.rateLimitStore.get(email); + + if (!record || now > record.resetAt) { + // Create new rate limit window + this.rateLimitStore.set(email, { + count: 1, + resetAt: now + this.RATE_LIMIT_WINDOW_MS, + }); + return; + } + + if (record.count >= this.RATE_LIMIT_MAX) { + const remainingMinutes = Math.ceil((record.resetAt - now) / 60000); + throw new BadRequestException( + `Too many OTP requests. Please try again in ${remainingMinutes} minutes.`, + ); + } + + record.count++; + } + + /** + * Generate and store OTP for email + * Returns the OTP code and magic link token + */ + generateOtp(email: string): { code: string; magicToken: string } { + this.checkRateLimit(email); + + const code = this.generateCode(); + const now = Date.now(); + + // Store OTP + this.otpStore.set(email, { + code, + email, + expiresAt: now + this.OTP_EXPIRY_MS, + attempts: 0, + }); + + // Generate magic link JWT token + const magicToken = this.jwtService.sign( + { email, code, type: 'otp-magic' }, + { expiresIn: this.MAGIC_LINK_EXPIRY }, + ); + + return { code, magicToken }; + } + + /** + * Verify OTP code for email + * Returns true if valid, throws error if invalid + */ + verifyOtp(email: string, code: string): boolean { + const record = this.otpStore.get(email); + + if (!record) { + throw new BadRequestException('Invalid or expired verification code'); + } + + const now = Date.now(); + + // Check expiration + if (now > record.expiresAt) { + this.otpStore.delete(email); + throw new BadRequestException('Verification code has expired'); + } + + // Check max attempts + if (record.attempts >= this.MAX_ATTEMPTS) { + this.otpStore.delete(email); + throw new BadRequestException( + 'Too many failed attempts. Please request a new code.', + ); + } + + // Verify code + if (record.code !== code) { + record.attempts++; + throw new BadRequestException( + `Invalid verification code. ${this.MAX_ATTEMPTS - record.attempts} attempts remaining.`, + ); + } + + // Success - remove the OTP (one-time use) + this.otpStore.delete(email); + return true; + } + + /** + * Verify magic link token + * Returns email if valid, throws error if invalid + */ + verifyMagicToken(token: string): string { + const payload = this.jwtService.verify(token); + + if (payload.type !== 'otp-magic') { + throw new BadRequestException('Invalid magic link'); + } + + // Verify the code still exists and matches + const record = this.otpStore.get(payload.email); + if (!record || record.code !== payload.code) { + throw new BadRequestException( + 'Magic link has already been used or expired', + ); + } + + // Success - remove the OTP (one-time use) + this.otpStore.delete(payload.email); + return payload.email; + } + + /** + * Clean up expired OTP records and rate limits + */ + private cleanup(): void { + const now = Date.now(); + + // Clean expired OTPs + for (const [email, record] of this.otpStore.entries()) { + if (now > record.expiresAt) { + this.otpStore.delete(email); + } + } + + // Clean expired rate limits + for (const [email, record] of this.rateLimitStore.entries()) { + if (now > record.resetAt) { + this.rateLimitStore.delete(email); + } + } + } + + /** + * Get remaining time for OTP in seconds + */ + getRemainingTime(email: string): number { + const record = this.otpStore.get(email); + if (!record) return 0; + + const remaining = Math.max(0, record.expiresAt - Date.now()); + return Math.ceil(remaining / 1000); + } +} diff --git a/src/i18n/en/mail.json b/src/i18n/en/mail.json index f4268c7d..b008e1a4 100644 --- a/src/i18n/en/mail.json +++ b/src/i18n/en/mail.json @@ -6,6 +6,7 @@ "signUp": "Continue completing account registration", "passwordReset": "Password Reset Request", "emailVerification": "Email Verification", + "emailOtp": "Your verification code", "invite": "Invite you to join the space" }, "templates": { @@ -33,6 +34,15 @@ "ignoreText": "If you did not request this verification, please ignore this email.", "expireText": "This code will expire in 10 minutes." }, + "emailOtp": { + "title": "Email Verification", + "heading": "Verify your email", + "body": "Enter this code to complete your sign in:", + "expireText": "This code expires in 5 minutes", + "dividerText": "or", + "buttonText": "Sign in with one click", + "ignoreText": "If you didn't request this code, you can safely ignore this email." + }, "invite": { "title": "Space Invitation", "heading": "You're Invited to Join a Space", diff --git a/src/i18n/zh/mail.json b/src/i18n/zh/mail.json index 87e11630..625e89b3 100644 --- a/src/i18n/zh/mail.json +++ b/src/i18n/zh/mail.json @@ -6,6 +6,7 @@ "signUp": "继续完成账号注册", "passwordReset": "密码重置请求", "emailVerification": "邮箱验证", + "emailOtp": "您的验证码", "invite": "邀请您加入空间" }, "templates": { @@ -33,6 +34,15 @@ "ignoreText": "如果您没有请求此验证,请忽略此邮件。", "expireText": "此验证码将在10分钟后过期。" }, + "emailOtp": { + "title": "邮箱验证", + "heading": "验证您的邮箱", + "body": "输入此验证码以完成登录:", + "expireText": "此验证码将在5分钟后过期", + "dividerText": "或", + "buttonText": "一键登录", + "ignoreText": "如果您没有请求此验证码,可以安全地忽略此邮件。" + }, "invite": { "title": "空间邀请", "heading": "您被邀请加入一个空间", diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index 6c7667ef..ef359308 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -12,6 +12,35 @@ export class MailService { private readonly i18n: I18nService, ) {} + async sendOTPEmail( + email: string, + code: string, + magicLink: string, + ): Promise { + const subject = this.i18n.t('mail.subjects.emailOtp'); + + try { + await this.mailerService.sendMail({ + to: email, + subject, + template: 'email-otp', + context: { + code, + magicLink, + i18nLang: I18nContext.current()?.lang, + }, + }); + } catch (error) { + this.logger.error({ error }); + const message = this.i18n.t('mail.errors.unableToSendEmail'); + throw new AppException( + message, + 'UNABLE_TO_SEND_EMAIL', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async sendSignUpEmail(email: string, resetUrl: string): Promise { const subject = this.i18n.t('mail.subjects.signUp'); diff --git a/src/mail/templates/email-otp.hbs b/src/mail/templates/email-otp.hbs new file mode 100644 index 00000000..2eae64e6 --- /dev/null +++ b/src/mail/templates/email-otp.hbs @@ -0,0 +1,66 @@ + + + + {{t "mail.templates.emailOtp.title"}} + + + +

{{t "mail.templates.emailOtp.heading"}}

+

{{t "mail.templates.emailOtp.body"}}

+ +
{{code}}
+ +

{{t "mail.templates.emailOtp.expireText"}}

+ +
{{t "mail.templates.emailOtp.dividerText"}}
+ + + +

{{t "mail.templates.emailOtp.ignoreText"}}

+ + diff --git a/src/user/dto/set-password.dto.ts b/src/user/dto/set-password.dto.ts new file mode 100644 index 00000000..a518f17f --- /dev/null +++ b/src/user/dto/set-password.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; + +export class SetPasswordDto { + @ApiProperty({ + description: 'New password for the user', + example: 'NewPassword123', + }) + @IsString() + @IsNotEmpty() + @MinLength(8) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, { + message: + 'Password must contain at least one uppercase letter, one lowercase letter, and one digit', + }) + password: string; +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 685c0a52..e0723891 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,5 +1,6 @@ import { UserService } from 'omniboxd/user/user.service'; import { UpdateUserDto } from 'omniboxd/user/dto/update-user.dto'; +import { SetPasswordDto } from 'omniboxd/user/dto/set-password.dto'; import { UserId } from 'omniboxd/decorators/user-id.decorator'; import { CreateUserOptionDto } from 'omniboxd/user/dto/create-user-option.dto'; import { @@ -13,6 +14,7 @@ import { Post, Controller, ParseIntPipe, + HttpCode, } from '@nestjs/common'; @Controller('api/v1/user') @@ -48,6 +50,13 @@ export class UserController { return await this.userService.update(id, account); } + @Post('set-password') + @HttpCode(200) + async setPassword(@UserId() userId: string, @Body() dto: SetPasswordDto) { + await this.userService.updatePassword(userId, dto.password); + return { success: true }; + } + @Delete(':id') async remove(@Param('id') id: string) { return await this.userService.remove(id); From bdf1d37a52914b731c4c3ea49716d76fbf5c9744 Mon Sep 17 00:00:00 2001 From: Lucien Date: Sat, 8 Nov 2025 00:30:27 +0800 Subject: [PATCH 02/15] chore(email): Use redis for otp, fix magic link --- src/auth/auth.controller.ts | 10 ++++ src/auth/auth.service.ts | 42 ++++++++++++-- src/auth/otp.service.ts | 108 ++++++++++++++++++------------------ 3 files changed, 100 insertions(+), 60 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 7fef2228..55ced496 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -60,6 +60,16 @@ export class AuthController { return await this.authService.sendOTP(dto.email, url); } + @Public() + @Post('auth/send-signup-otp') + @HttpCode(200) + async sendSignupOtp( + @Body() dto: SendEmailOtpDto, + @Body('url') url: string, + ): Promise { + return await this.authService.sendSignupOTP(dto.email, url); + } + @Public() @Post('auth/verify-otp') @HttpCode(200) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 0d14afcd..2064f1ca 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -78,7 +78,8 @@ export class AuthService { } /** - * Send OTP to email for registration or login + * Send OTP to email for login only + * Does NOT send email if user doesn't exist */ async sendOTP( email: string, @@ -87,8 +88,39 @@ export class AuthService { const account = await this.userService.findByEmail(email); const exists = !!account; + // Don't send email for unregistered users (login only) + if (!exists) { + return { exists: false, sent: false }; + } + // Generate OTP code and magic link token - const { code, magicToken } = this.otpService.generateOtp(email); + const { code, magicToken } = await this.otpService.generateOtp(email); + + // Build magic link URL + const magicLink = `${baseUrl}?token=${magicToken}`; + + // Send email with both code and link + await this.mailService.sendOTPEmail(email, code, magicLink); + + return { exists: true, sent: true }; + } + + /** + * Send OTP to email for signup only + */ + async sendSignupOTP( + email: string, + baseUrl: string, + ): Promise { + const account = await this.userService.findByEmail(email); + + if (account) { + // User already exists, should login instead + return { exists: true, sent: false }; + } + + // Generate OTP code and magic link token for new user + const { code, magicToken } = await this.otpService.generateOtp(email); // Build magic link URL const magicLink = `${baseUrl}?token=${magicToken}`; @@ -96,7 +128,7 @@ export class AuthService { // Send email with both code and link await this.mailService.sendOTPEmail(email, code, magicLink); - return { exists, sent: true }; + return { exists: false, sent: true }; } /** @@ -104,7 +136,7 @@ export class AuthService { */ async verifyOTP(email: string, code: string, lang?: string) { // Verify the OTP code - this.otpService.verifyOtp(email, code); + await this.otpService.verifyOtp(email, code); // Check if user already exists const existingUser = await this.userService.findByEmail(email); @@ -167,7 +199,7 @@ export class AuthService { */ async verifyMagicLink(token: string, lang?: string) { // Verify the magic link token and get email - const email = this.otpService.verifyMagicToken(token); + const email = await this.otpService.verifyMagicToken(token); // Check if user already exists const existingUser = await this.userService.findByEmail(email); diff --git a/src/auth/otp.service.ts b/src/auth/otp.service.ts index 53224682..e99aa13b 100644 --- a/src/auth/otp.service.ts +++ b/src/auth/otp.service.ts @@ -1,5 +1,6 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import { CacheService } from 'omniboxd/common/cache.service'; interface OtpRecord { code: string; @@ -15,8 +16,9 @@ interface RateLimitRecord { @Injectable() export class OtpService { - private otpStore = new Map(); - private rateLimitStore = new Map(); + // Namespaces for cache keys + private readonly otpNamespace = '/otp/codes'; + private readonly rateLimitNamespace = '/otp/rate-limits'; // Configuration private readonly OTP_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes @@ -25,10 +27,10 @@ export class OtpService { private readonly RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes private readonly MAGIC_LINK_EXPIRY = '5m'; // JWT expiry - constructor(private jwtService: JwtService) { - // Clean up expired entries every minute - setInterval(() => this.cleanup(), 60 * 1000); - } + constructor( + private jwtService: JwtService, + private cacheService: CacheService, + ) {} /** * Generate a 6-digit numeric OTP code @@ -40,16 +42,25 @@ export class OtpService { /** * Check rate limiting for email */ - private checkRateLimit(email: string): void { + private async checkRateLimit(email: string): Promise { const now = Date.now(); - const record = this.rateLimitStore.get(email); + const record = await this.cacheService.get( + this.rateLimitNamespace, + email, + ); if (!record || now > record.resetAt) { // Create new rate limit window - this.rateLimitStore.set(email, { + const newRecord: RateLimitRecord = { count: 1, resetAt: now + this.RATE_LIMIT_WINDOW_MS, - }); + }; + await this.cacheService.set( + this.rateLimitNamespace, + email, + newRecord, + this.RATE_LIMIT_WINDOW_MS, + ); return; } @@ -60,26 +71,37 @@ export class OtpService { ); } + // Increment count record.count++; + const ttl = record.resetAt - now; + await this.cacheService.set(this.rateLimitNamespace, email, record, ttl); } /** * Generate and store OTP for email * Returns the OTP code and magic link token */ - generateOtp(email: string): { code: string; magicToken: string } { - this.checkRateLimit(email); + async generateOtp( + email: string, + ): Promise<{ code: string; magicToken: string }> { + await this.checkRateLimit(email); const code = this.generateCode(); const now = Date.now(); - // Store OTP - this.otpStore.set(email, { + // Store OTP with TTL + const otpRecord: OtpRecord = { code, email, expiresAt: now + this.OTP_EXPIRY_MS, attempts: 0, - }); + }; + await this.cacheService.set( + this.otpNamespace, + email, + otpRecord, + this.OTP_EXPIRY_MS, + ); // Generate magic link JWT token const magicToken = this.jwtService.sign( @@ -94,8 +116,11 @@ export class OtpService { * Verify OTP code for email * Returns true if valid, throws error if invalid */ - verifyOtp(email: string, code: string): boolean { - const record = this.otpStore.get(email); + async verifyOtp(email: string, code: string): Promise { + const record = await this.cacheService.get( + this.otpNamespace, + email, + ); if (!record) { throw new BadRequestException('Invalid or expired verification code'); @@ -105,13 +130,13 @@ export class OtpService { // Check expiration if (now > record.expiresAt) { - this.otpStore.delete(email); + await this.cacheService.delete(this.otpNamespace, email); throw new BadRequestException('Verification code has expired'); } // Check max attempts if (record.attempts >= this.MAX_ATTEMPTS) { - this.otpStore.delete(email); + await this.cacheService.delete(this.otpNamespace, email); throw new BadRequestException( 'Too many failed attempts. Please request a new code.', ); @@ -120,13 +145,15 @@ export class OtpService { // Verify code if (record.code !== code) { record.attempts++; + const ttl = record.expiresAt - now; + await this.cacheService.set(this.otpNamespace, email, record, ttl); throw new BadRequestException( `Invalid verification code. ${this.MAX_ATTEMPTS - record.attempts} attempts remaining.`, ); } // Success - remove the OTP (one-time use) - this.otpStore.delete(email); + await this.cacheService.delete(this.otpNamespace, email); return true; } @@ -134,7 +161,7 @@ export class OtpService { * Verify magic link token * Returns email if valid, throws error if invalid */ - verifyMagicToken(token: string): string { + async verifyMagicToken(token: string): Promise { const payload = this.jwtService.verify(token); if (payload.type !== 'otp-magic') { @@ -142,7 +169,10 @@ export class OtpService { } // Verify the code still exists and matches - const record = this.otpStore.get(payload.email); + const record = await this.cacheService.get( + this.otpNamespace, + payload.email, + ); if (!record || record.code !== payload.code) { throw new BadRequestException( 'Magic link has already been used or expired', @@ -150,39 +180,7 @@ export class OtpService { } // Success - remove the OTP (one-time use) - this.otpStore.delete(payload.email); + await this.cacheService.delete(this.otpNamespace, payload.email); return payload.email; } - - /** - * Clean up expired OTP records and rate limits - */ - private cleanup(): void { - const now = Date.now(); - - // Clean expired OTPs - for (const [email, record] of this.otpStore.entries()) { - if (now > record.expiresAt) { - this.otpStore.delete(email); - } - } - - // Clean expired rate limits - for (const [email, record] of this.rateLimitStore.entries()) { - if (now > record.resetAt) { - this.rateLimitStore.delete(email); - } - } - } - - /** - * Get remaining time for OTP in seconds - */ - getRemainingTime(email: string): number { - const record = this.otpStore.get(email); - if (!record) return 0; - - const remaining = Math.max(0, record.expiresAt - Date.now()); - return Math.ceil(remaining / 1000); - } } From 3b3417db4ff80b4a70bf16a9053ad7ebb0bdbe7d Mon Sep 17 00:00:00 2001 From: Lucien Date: Sat, 8 Nov 2025 21:10:54 +0800 Subject: [PATCH 03/15] chore(email): Bump version to 0.1.10, fix magic link query parameter --- package.json | 2 +- src/auth/auth.service.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a6f83e1e..2975712d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniboxd", - "version": "0.1.4", + "version": "0.1.10", "private": true, "scripts": { "build": "nest build", diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 2064f1ca..5ee4f473 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -97,7 +97,8 @@ export class AuthService { const { code, magicToken } = await this.otpService.generateOtp(email); // Build magic link URL - const magicLink = `${baseUrl}?token=${magicToken}`; + const separator = baseUrl.includes('?') ? '&' : '?'; + const magicLink = `${baseUrl}${separator}token=${magicToken}`; // Send email with both code and link await this.mailService.sendOTPEmail(email, code, magicLink); @@ -123,7 +124,8 @@ export class AuthService { const { code, magicToken } = await this.otpService.generateOtp(email); // Build magic link URL - const magicLink = `${baseUrl}?token=${magicToken}`; + const separator = baseUrl.includes('?') ? '&' : '?'; + const magicLink = `${baseUrl}${separator}token=${magicToken}`; // Send email with both code and link await this.mailService.sendOTPEmail(email, code, magicLink); From ecd342b08cd10abcc51995009419248bdf1977d3 Mon Sep 17 00:00:00 2001 From: Lucien Date: Sat, 8 Nov 2025 22:11:30 +0800 Subject: [PATCH 04/15] chore(email): Simplify invitation flow with auto account creation --- src/auth/auth.controller.ts | 25 +++++++++ src/auth/auth.e2e-spec.ts | 27 +-------- src/auth/auth.service.ts | 87 ++++++++++++++++++++++++++--- src/i18n/en/mail.json | 2 +- src/i18n/zh/mail.json | 2 +- src/interceptor/user.interceptor.ts | 2 +- src/mail/mail.service.ts | 24 -------- src/mail/templates/invite.hbs | 37 +++++++++++- src/mail/templates/sign-up.hbs | 13 ----- 9 files changed, 142 insertions(+), 77 deletions(-) delete mode 100644 src/mail/templates/sign-up.hbs diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 55ced496..24d7caec 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -188,6 +188,31 @@ export class AuthController { return await this.authService.inviteConfirm(token); } + @Public() + @Post('auth/accept-invite') + @HttpCode(200) + async acceptInvite( + @Query('token') token: string, + @Res() res: Response, + @Body('lang') lang?: string, + ) { + const authData = await this.authService.acceptInvite(token, lang); + + const jwtExpireSeconds = parseInt( + this.configService.get('OBB_JWT_EXPIRE', '2678400'), + 10, + ); + res.cookie('token', authData.access_token, { + httpOnly: true, + secure: true, + sameSite: 'none', + path: '/', + maxAge: jwtExpireSeconds * 1000, + }); + + return res.json(authData); + } + @Post('logout') logout(@Res() res: Response) { res.clearCookie('token', { diff --git a/src/auth/auth.e2e-spec.ts b/src/auth/auth.e2e-spec.ts index 5a1a9991..21cc5857 100644 --- a/src/auth/auth.e2e-spec.ts +++ b/src/auth/auth.e2e-spec.ts @@ -485,32 +485,7 @@ describe('AuthModule (e2e)', () => { }); }); - describe('Sign-up Flow', () => { - describe('POST /api/v1/sign-up/confirm', () => { - it('should fail with invalid token', async () => { - await client - .request() - .post('/api/v1/sign-up/confirm') - .send({ - token: 'invalid-token', - username: 'testuser', - password: 'testpassword', - }) - .expect(HttpStatus.UNAUTHORIZED); - }); - - it('should fail with missing parameters', async () => { - await client - .request() - .post('/api/v1/sign-up/confirm') - .send({ - token: 'some-token', - // Missing username and password - }) - .expect(HttpStatus.UNAUTHORIZED); // Invalid token gets processed first - }); - }); - + describe('Password Reset Flow', () => { describe('POST /api/v1/password', () => { it('should initiate password reset for existing user', async () => { await client diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 5ee4f473..df37bdca 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -269,12 +269,6 @@ export class AuthService { return this.jwtService.sign(payload, { expiresIn: '1h' }); } - async signUp(url: string, email: string) { - const token: string = await this.getSignUpToken(email); - const mailSendUri = `${url}?token=${token}`; - await this.mailService.sendSignUpEmail(email, mailSendUri); - } - async signUpConfirm( token: string, data: { @@ -406,11 +400,10 @@ export class AuthService { invitation, }; const token = this.jwtService.sign(payload, { - expiresIn: '1h', + expiresIn: '7d', }); - const mailSendUri = `${data.registerUrl}?user=${userId}&namespace=${data.namespaceId}&token=${token}`; + const mailSendUri = `${data.registerUrl}?token=${token}`; await this.mailService.sendInviteEmail(email, mailSendUri); - // return { url: mailSendUri }; } async inviteConfirm(token: string): Promise { @@ -421,6 +414,82 @@ export class AuthService { }); } + async acceptInvite(token: string, lang?: string) { + // Verify and decode the JWT token + const payload: SignUpPayloadDto = this.jwtVerify(token); + + if (!payload.email || !payload.invitation) { + const message = this.i18n.t('auth.errors.tokenInvalid'); + throw new AppException(message, 'INVALID_TOKEN', HttpStatus.UNAUTHORIZED); + } + + const { email, invitation } = payload; + + // Check if user already exists + const existingUser = await this.userService.findByEmail(email); + + if (existingUser) { + // User exists - just add to namespace + await this.dataSource.transaction(async (manager) => { + await this.handleUserInvitation(existingUser.id, invitation, manager); + }); + + return { + id: existingUser.id, + access_token: this.jwtService.sign({ + sub: existingUser.id, + username: existingUser.username, + }), + namespaceId: invitation.namespaceId, + }; + } + + // User doesn't exist - create account and add to namespace + return await this.dataSource.transaction(async (manager) => { + // Extract username from email (e.g., foo@example.com -> foo) + const emailUsername = email.split('@')[0]; + + // Generate valid username (handles conflicts) + const username = await this.socialService.getValidUsername( + emailUsername, + manager, + ); + + // Generate a random password for invited users + const randomPassword = Math.random().toString(36).slice(-12) + 'Aa1'; + + // Create user with generated username and random password + const user = await this.userService.create( + { + email, + username, + password: randomPassword, + lang, + }, + manager, + ); + + // Create user's personal namespace + await this.namespaceService.createUserNamespace( + user.id, + user.username, + manager, + ); + + // Add user to the invited namespace + await this.handleUserInvitation(user.id, invitation, manager); + + return { + id: user.id, + access_token: this.jwtService.sign({ + sub: user.id, + username: user.username, + }), + namespaceId: invitation.namespaceId, + }; + }); + } + async inviteGroup( namespaceId: string, resourceId: string, diff --git a/src/i18n/en/mail.json b/src/i18n/en/mail.json index b008e1a4..3805ca24 100644 --- a/src/i18n/en/mail.json +++ b/src/i18n/en/mail.json @@ -46,7 +46,7 @@ "invite": { "title": "Space Invitation", "heading": "You're Invited to Join a Space", - "body": "You have been invited to join a space. Please click the link below to accept the invitation:", + "body": "You have been invited to join a space. Click the link below to create your account and join the space:", "buttonText": "Accept Invitation", "ignoreText": "If you do not want to join this space, please ignore this email.", "expireText": "This invitation will expire in 7 days." diff --git a/src/i18n/zh/mail.json b/src/i18n/zh/mail.json index 625e89b3..56aef3e7 100644 --- a/src/i18n/zh/mail.json +++ b/src/i18n/zh/mail.json @@ -46,7 +46,7 @@ "invite": { "title": "空间邀请", "heading": "您被邀请加入一个空间", - "body": "您已被邀请加入一个空间。请点击下方链接接受邀请:", + "body": "您已被邀请加入一个空间。点击下方链接创建账户并加入空间:", "buttonText": "接受邀请", "ignoreText": "如果您不想加入此空间,请忽略此邮件。", "expireText": "此邀请将在7天后过期。" diff --git a/src/interceptor/user.interceptor.ts b/src/interceptor/user.interceptor.ts index c559c9ec..2a0659ff 100644 --- a/src/interceptor/user.interceptor.ts +++ b/src/interceptor/user.interceptor.ts @@ -9,7 +9,7 @@ import { tap, finalize } from 'rxjs/operators'; import { trace, context } from '@opentelemetry/api'; import { Socket } from 'socket.io'; -const LOGIN_URLS = ['/api/v1/login', '/api/v1/sign-up/confirm']; +const LOGIN_URLS = ['/api/v1/login', '/api/v1/auth/accept-invite']; @Injectable() export class UserInterceptor implements NestInterceptor { diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index ef359308..3fbbb7a0 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -41,30 +41,6 @@ export class MailService { } } - async sendSignUpEmail(email: string, resetUrl: string): Promise { - const subject = this.i18n.t('mail.subjects.signUp'); - - try { - await this.mailerService.sendMail({ - to: email, - subject, - template: 'sign-up', - context: { - resetUrl, - i18nLang: I18nContext.current()?.lang, - }, - }); - } catch (error) { - this.logger.error({ error }); - const message = this.i18n.t('mail.errors.unableToSendEmail'); - throw new AppException( - message, - 'UNABLE_TO_SEND_EMAIL', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - async sendPasswordEmail(email: string, resetUrl: string): Promise { const subject = this.i18n.t('mail.subjects.passwordReset'); try { diff --git a/src/mail/templates/invite.hbs b/src/mail/templates/invite.hbs index 6a597bfc..6281c57f 100644 --- a/src/mail/templates/invite.hbs +++ b/src/mail/templates/invite.hbs @@ -2,12 +2,45 @@ {{t "mail.templates.invite.title"}} +

{{t "mail.templates.invite.heading"}}

{{t "mail.templates.invite.body"}}

-

{{t "mail.templates.invite.buttonText"}}

+ + + +

{{t "mail.templates.invite.expireText"}}

+

{{t "mail.templates.invite.ignoreText"}}

-

{{t "mail.templates.invite.expireText"}}

\ No newline at end of file diff --git a/src/mail/templates/sign-up.hbs b/src/mail/templates/sign-up.hbs deleted file mode 100644 index d05f4d8e..00000000 --- a/src/mail/templates/sign-up.hbs +++ /dev/null @@ -1,13 +0,0 @@ - - - - {{t "mail.templates.signUp.title"}} - - -

{{t "mail.templates.signUp.heading"}}

-

{{t "mail.templates.signUp.body"}}

-

{{t "mail.templates.signUp.buttonText"}}

-

{{t "mail.templates.signUp.ignoreText"}}

-

{{t "mail.templates.signUp.expireText"}}

- - \ No newline at end of file From 28adc43dbcc95d7d7e52476f3910cb58fd0411c0 Mon Sep 17 00:00:00 2001 From: Lucien Date: Sat, 8 Nov 2025 22:52:21 +0800 Subject: [PATCH 05/15] chore(email): Personalize invitation emails with sender and namespace details --- src/auth/auth.service.ts | 36 ++++++++++++++++++++++++++++++++++- src/i18n/en/mail.json | 7 ++++++- src/i18n/zh/mail.json | 7 ++++++- src/mail/mail.service.ts | 13 ++++++++++++- src/mail/templates/invite.hbs | 11 ++++++++++- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index df37bdca..ac044dba 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -370,6 +370,28 @@ export class AuthService { permission: data.permission, groupId: data.groupId, }; + + // Fetch sender and namespace information + const senders = await this.userService.findByIds([userId]); + const sender = senders[0]; + const namespace = await this.namespaceService.getNamespace( + data.namespaceId, + ); + + if (!sender || !namespace) { + this.logger.error( + `Failed to fetch sender or namespace: sender=${!!sender}, namespace=${!!namespace}`, + ); + throw new AppException( + 'Failed to fetch sender or namespace information', + 'INVITE_INFO_FETCH_FAILED', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const senderUsername = sender.username; + const namespaceName = namespace.name; + const account = await this.userService.findByEmail(email); if (account) { const namespaceMembers = await this.namespaceService.listMembers( @@ -389,9 +411,14 @@ export class AuthService { const token = this.jwtService.sign(payload, { expiresIn: '1h', }); + await this.mailService.sendInviteEmail( email, `${data.inviteUrl}?user=${userId}&namespace=${data.namespaceId}&token=${token}`, + senderUsername!, + namespaceName, + account.username!, + true, ); return; } @@ -403,7 +430,14 @@ export class AuthService { expiresIn: '7d', }); const mailSendUri = `${data.registerUrl}?token=${token}`; - await this.mailService.sendInviteEmail(email, mailSendUri); + await this.mailService.sendInviteEmail( + email, + mailSendUri, + senderUsername!, + namespaceName, + undefined, + false, + ); } async inviteConfirm(token: string): Promise { diff --git a/src/i18n/en/mail.json b/src/i18n/en/mail.json index 3805ca24..678cadfb 100644 --- a/src/i18n/en/mail.json +++ b/src/i18n/en/mail.json @@ -46,7 +46,12 @@ "invite": { "title": "Space Invitation", "heading": "You're Invited to Join a Space", - "body": "You have been invited to join a space. Click the link below to create your account and join the space:", + "bodyNewUserPrefix": "has invited you to join the space", + "bodyNewUserSuffix": "Click the link below to create your account and join the space:", + "bodyExistingUserPrefix": "Hi", + "bodyExistingUserMiddle": "has invited you to join the space", + "bodyExistingUserAction": "Click the link below to accept the invitation:", + "bodyExistingUserNoUsernamePrefix": "has invited you to join the space", "buttonText": "Accept Invitation", "ignoreText": "If you do not want to join this space, please ignore this email.", "expireText": "This invitation will expire in 7 days." diff --git a/src/i18n/zh/mail.json b/src/i18n/zh/mail.json index 56aef3e7..c838a7c8 100644 --- a/src/i18n/zh/mail.json +++ b/src/i18n/zh/mail.json @@ -46,7 +46,12 @@ "invite": { "title": "空间邀请", "heading": "您被邀请加入一个空间", - "body": "您已被邀请加入一个空间。点击下方链接创建账户并加入空间:", + "bodyNewUserPrefix": "邀请您加入空间", + "bodyNewUserSuffix": "点击下方链接创建账户并加入空间:", + "bodyExistingUserPrefix": "您好", + "bodyExistingUserMiddle": "邀请您加入空间", + "bodyExistingUserAction": "点击下方链接接受邀请:", + "bodyExistingUserNoUsernamePrefix": "邀请您加入空间", "buttonText": "接受邀请", "ignoreText": "如果您不想加入此空间,请忽略此邮件。", "expireText": "此邀请将在7天后过期。" diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index 3fbbb7a0..f05e5a96 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -88,7 +88,14 @@ export class MailService { } } - async sendInviteEmail(email: string, resetUrl: string): Promise { + async sendInviteEmail( + email: string, + resetUrl: string, + senderUsername: string, + namespaceName: string, + receiverUsername?: string, + isExistingUser?: boolean, + ): Promise { const subject = this.i18n.t('mail.subjects.invite'); try { @@ -98,6 +105,10 @@ export class MailService { template: 'invite', context: { resetUrl, + senderUsername, + namespaceName, + receiverUsername, + isExistingUser: isExistingUser || false, i18nLang: I18nContext.current()?.lang, }, }); diff --git a/src/mail/templates/invite.hbs b/src/mail/templates/invite.hbs index 6281c57f..1eb4cd2c 100644 --- a/src/mail/templates/invite.hbs +++ b/src/mail/templates/invite.hbs @@ -33,7 +33,16 @@

{{t "mail.templates.invite.heading"}}

-

{{t "mail.templates.invite.body"}}

+ + {{#if isExistingUser}} + {{#if receiverUsername}} +

{{t "mail.templates.invite.bodyExistingUserPrefix"}} {{receiverUsername}}, {{senderUsername}} {{t "mail.templates.invite.bodyExistingUserMiddle"}} {{namespaceName}}. {{t "mail.templates.invite.bodyExistingUserAction"}}

+ {{else}} +

{{senderUsername}} {{t "mail.templates.invite.bodyExistingUserNoUsernamePrefix"}} {{namespaceName}}. {{t "mail.templates.invite.bodyExistingUserAction"}}

+ {{/if}} + {{else}} +

{{senderUsername}} {{t "mail.templates.invite.bodyNewUserPrefix"}} {{namespaceName}}. {{t "mail.templates.invite.bodyNewUserSuffix"}}

+ {{/if}}