diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts index d93a32dc..6bf2aced 100644 --- a/apps/server/src/core/auth/auth.controller.ts +++ b/apps/server/src/core/auth/auth.controller.ts @@ -19,6 +19,7 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { User, Workspace } from '@docmost/db/types/entity.types'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ForgotPasswordDto } from './dto/forgot-password.dto'; @Controller('auth') export class AuthController { @@ -33,6 +34,18 @@ export class AuthController { return this.authService.login(loginInput, req.raw.workspaceId); } + @HttpCode(HttpStatus.OK) + @Post('forgot-password') + async forgotPassword( + @Req() req, + @Body() forgotPasswordDto: ForgotPasswordDto, + ) { + return this.authService.forgotPassword( + forgotPasswordDto, + req.raw.workspaceId, + ); + } + /* @HttpCode(HttpStatus.OK) @Post('register') async register(@Req() req, @Body() createUserDto: CreateUserDto) { diff --git a/apps/server/src/core/auth/dto/forgot-password.dto.ts b/apps/server/src/core/auth/dto/forgot-password.dto.ts new file mode 100644 index 00000000..92aa2ed0 --- /dev/null +++ b/apps/server/src/core/auth/dto/forgot-password.dto.ts @@ -0,0 +1,16 @@ +import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; + +export class ForgotPasswordDto { + @IsNotEmpty() + @IsEmail() + email: string; + + @IsOptional() + @IsString() + token: string; + + @IsOptional() + @IsString() + @MinLength(8) + newPassword: string; +} diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index 7619ecd5..36355eca 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -11,10 +11,13 @@ import { TokensDto } from '../dto/tokens.dto'; import { SignupService } from './signup.service'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; import { UserRepo } from '@docmost/db/repos/user/user.repo'; -import { comparePasswordHash, hashPassword } from '../../../common/helpers'; +import { comparePasswordHash, hashPassword, nanoIdGen } from '../../../common/helpers'; import { ChangePasswordDto } from '../dto/change-password.dto'; import { MailService } from '../../../integrations/mail/mail.service'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; +import { ForgotPasswordDto } from '../dto/forgot-password.dto'; +import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email'; +import { UserTokensRepo } from '@docmost/db/repos/user-tokens/user-tokens.repo'; @Injectable() export class AuthService { @@ -22,6 +25,7 @@ export class AuthService { private signupService: SignupService, private tokenService: TokenService, private userRepo: UserRepo, + private userTokensRepo: UserTokensRepo, private mailService: MailService, ) {} @@ -46,6 +50,94 @@ export class AuthService { return { tokens }; } + async forgotPassword( + forgotPasswordDto: ForgotPasswordDto, + workspaceId: string, + ) { + const user = await this.userRepo.findByEmail( + forgotPasswordDto.email, + workspaceId, + true, + ); + if (!user) { + return; + } + + if ( + forgotPasswordDto.token == null || + forgotPasswordDto.newPassword == null + ) { + // Generate 5-character user token + const code = nanoIdGen(5).toUpperCase(); + const hashedToken = await hashPassword(code); + await this.userTokensRepo.insertUserToken({ + token: hashedToken, + user_id: user.id, + workspace_id: user.workspaceId, + expires_at: new Date(new Date().getTime() + 3_600_000), // should expires in 1 hour + type: "forgot-password", + }); + + const emailTemplate = ForgotPasswordEmail({ + username: user.name, + code: code, + }); + await this.mailService.sendToQueue({ + to: user.email, + subject: 'Reset your password', + template: emailTemplate, + }); + + return; + } + + // Get all user tokens that are not expired + const userTokens = await this.userTokensRepo.findByUserId( + user.id, + user.workspaceId, + "forgot-password" + ); + // Limit to the last 3 token, so we have a total time window of 15 minutes + const validUserTokens = userTokens + .filter((token) => token.expires_at > new Date() && token.used_at == null) + .slice(0, 3); + + for (const token of validUserTokens) { + const validated = await comparePasswordHash( + forgotPasswordDto.token, + token.token, + ); + if (validated) { + await Promise.all([ + this.userTokensRepo.deleteUserToken(user.id, user.workspaceId, "forgot-password"), + this.userTokensRepo.deleteExpiredUserTokens(), + ]); + + const newPasswordHash = await hashPassword( + forgotPasswordDto.newPassword, + ); + await this.userRepo.updateUser( + { + password: newPasswordHash, + }, + user.id, + workspaceId, + ); + + const emailTemplate = ChangePasswordEmail({ username: user.name }); + await this.mailService.sendToQueue({ + to: user.email, + subject: 'Your password has been changed', + template: emailTemplate, + }); + + return; + } + } + + throw new BadRequestException('Incorrect code'); + } + async register(createUserDto: CreateUserDto, workspaceId: string) { const user = await this.signupService.signup(createUserDto, workspaceId); diff --git a/apps/server/src/database/migrations/20240903T124647-user-tokens.ts b/apps/server/src/database/migrations/20240903T124647-user-tokens.ts new file mode 100644 index 00000000..373160ba --- /dev/null +++ b/apps/server/src/database/migrations/20240903T124647-user-tokens.ts @@ -0,0 +1,27 @@ +import { sql, Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('user_tokens') + .addColumn('id', 'uuid', (col) => + col.primaryKey().defaultTo(sql`gen_uuid_v7()`), + ) + .addColumn('token', 'varchar', (col) => col.notNull()) + .addColumn('user_id', 'uuid', (col) => + col.notNull().references('users.id').onDelete('cascade'), + ) + .addColumn('workspace_id', 'uuid', (col) => + col.references('workspaces.id').onDelete('cascade'), + ) + .addColumn('type', 'varchar', (col) => col.notNull()) + .addColumn('expires_at', 'timestamptz') + .addColumn('used_at', 'timestamptz', (col) => col) + .addColumn('created_at', 'timestamptz', (col) => + col.notNull().defaultTo(sql`now()`), + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('user_tokens').execute(); +} diff --git a/apps/server/src/database/repos/user-tokens/user-tokens.repo.ts b/apps/server/src/database/repos/user-tokens/user-tokens.repo.ts new file mode 100644 index 00000000..6e3ef8fc --- /dev/null +++ b/apps/server/src/database/repos/user-tokens/user-tokens.repo.ts @@ -0,0 +1,89 @@ +import { + InsertableUserToken, + UpdatableUserToken, +} from '@docmost/db/types/entity.types'; +import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; +import { dbOrTx } from '@docmost/db/utils'; +import { Injectable } from '@nestjs/common'; +import { InjectKysely } from 'nestjs-kysely'; + +@Injectable() +export class UserTokensRepo { + constructor(@InjectKysely() private readonly db: KyselyDB) {} + + async insertUserToken( + insertableUserToken: InsertableUserToken, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .insertInto('userTokens') + .values(insertableUserToken) + .returningAll() + .executeTakeFirst(); + } + + async findByUserId( + userId: string, + workspaceId: string, + tokenType: string, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .selectFrom('userTokens') + .select([ + 'id', + 'token', + 'user_id', + 'workspace_id', + 'type', + 'expires_at', + 'used_at', + 'created_at', + ]) + .where('user_id', '=', userId) + .where('workspace_id', '=', workspaceId) + .where('type', '=', tokenType) + .orderBy('expires_at desc') + .execute(); + } + + async updateUserToken( + updatableUserToken: UpdatableUserToken, + userTokenId: string, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .updateTable('userTokens') + .set({ ...updatableUserToken }) + .where('id', '=', userTokenId) + .execute(); + } + + async deleteUserToken( + userId: string, + workspaceId: string, + tokenType: string, + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .deleteFrom('userTokens') + .where('user_id', '=', userId) + .where('workspace_id', '=', workspaceId) + .where('type', '=', tokenType) + .execute(); + } + + async deleteExpiredUserTokens( + trx?: KyselyTransaction, + ) { + const db = dbOrTx(this.db, trx); + return db + .deleteFrom('userTokens') + .where('expires_at', '<', new Date()) + .execute(); + } +} diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index ba8303e6..f6be1a73 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -1,10 +1,15 @@ -import type { ColumnType } from "kysely"; +import type { ColumnType } from 'kysely'; -export type Generated = T extends ColumnType - ? ColumnType - : ColumnType; +export type Generated = + T extends ColumnType + ? ColumnType + : ColumnType; -export type Int8 = ColumnType; +export type Int8 = ColumnType< + string, + bigint | number | string, + bigint | number | string +>; export type Json = JsonValue; @@ -185,6 +190,17 @@ export interface Workspaces { updatedAt: Generated; } +export interface UserTokens { + id: Generated; + token: string; + user_id: string; + workspace_id: string; + type: string; + expires_at: Timestamp | null; + used_at: Timestamp | null; + created_at: Generated; +} + export interface DB { attachments: Attachments; comments: Comments; @@ -197,4 +213,5 @@ export interface DB { users: Users; workspaceInvitations: WorkspaceInvitations; workspaces: Workspaces; + userTokens: UserTokens; } diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts index e553bcde..722cb7e4 100644 --- a/apps/server/src/database/types/entity.types.ts +++ b/apps/server/src/database/types/entity.types.ts @@ -11,6 +11,7 @@ import { GroupUsers, SpaceMembers, WorkspaceInvitations, + UserTokens, } from './db'; // Workspace @@ -71,3 +72,8 @@ export type UpdatableComment = Updateable>; export type Attachment = Selectable; export type InsertableAttachment = Insertable; export type UpdatableAttachment = Updateable>; + +// User Tokens +export type UserToken = Selectable; +export type InsertableUserToken = Insertable; +export type UpdatableUserToken = Updateable>; \ No newline at end of file diff --git a/apps/server/src/integrations/environment/environment.service.ts b/apps/server/src/integrations/environment/environment.service.ts index dbc39353..b6bd8cdc 100644 --- a/apps/server/src/integrations/environment/environment.service.ts +++ b/apps/server/src/integrations/environment/environment.service.ts @@ -62,9 +62,9 @@ export class EnvironmentService { getAwsS3Endpoint(): string { return this.configService.get('AWS_S3_ENDPOINT'); } - + getAwsS3ForcePathStyle(): boolean { - return this.configService.get('AWS_S3_FORCE_PATH_STYLE') + return this.configService.get('AWS_S3_FORCE_PATH_STYLE'); } getAwsS3Url(): string { diff --git a/apps/server/src/integrations/import/import.module.ts b/apps/server/src/integrations/import/import.module.ts index 097fb15a..60498808 100644 --- a/apps/server/src/integrations/import/import.module.ts +++ b/apps/server/src/integrations/import/import.module.ts @@ -4,6 +4,6 @@ import { ImportController } from './import.controller'; @Module({ providers: [ImportService], - controllers: [ImportController] + controllers: [ImportController], }) export class ImportModule {} diff --git a/apps/server/src/integrations/mail/providers/mail.provider.ts b/apps/server/src/integrations/mail/providers/mail.provider.ts index d57470a2..747bfd38 100644 --- a/apps/server/src/integrations/mail/providers/mail.provider.ts +++ b/apps/server/src/integrations/mail/providers/mail.provider.ts @@ -27,11 +27,14 @@ export const mailDriverConfigProvider = { switch (driver) { case MailOption.SMTP: let auth = undefined; - if (environmentService.getSmtpUsername() && environmentService.getSmtpPassword()) { + if ( + environmentService.getSmtpUsername() && + environmentService.getSmtpPassword() + ) { auth = { - user: environmentService.getSmtpUsername(), - pass: environmentService.getSmtpPassword(), - }; + user: environmentService.getSmtpUsername(), + pass: environmentService.getSmtpPassword(), + }; } return { driver, diff --git a/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx new file mode 100644 index 00000000..820c5f5e --- /dev/null +++ b/apps/server/src/integrations/transactional/emails/forgot-password-email.tsx @@ -0,0 +1,27 @@ +import { Section, Text } from '@react-email/components'; +import * as React from 'react'; +import { content, paragraph } from '../css/styles'; +import { MailBody } from '../partials/partials'; + +interface Props { + username: string; + code: string; +} + +export const ForgotPasswordEmail = ({ username, code }: Props) => { + return ( + +
+ Hi {username}, + + The code for resetting your password is: {code}. + + + If you did not request a password reset, please ignore this email. + +
+
+ ); +} + +export default ForgotPasswordEmail; \ No newline at end of file diff --git a/apps/server/src/integrations/transactional/utils/utils.ts b/apps/server/src/integrations/transactional/utils/utils.ts index a97aa2f4..5d3bb758 100644 --- a/apps/server/src/integrations/transactional/utils/utils.ts +++ b/apps/server/src/integrations/transactional/utils/utils.ts @@ -1,6 +1,6 @@ export const formatDate = (date: Date) => { - new Intl.DateTimeFormat("en", { - dateStyle: "medium", - timeStyle: "medium", + new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeStyle: 'medium', }).format(date); };