Skip to content

Commit 4ecaea9

Browse files
committed
feat: add api for magic links
1 parent cb2e37c commit 4ecaea9

File tree

16 files changed

+339
-11
lines changed

16 files changed

+339
-11
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ next-env.d.ts
1111
.env.prod
1212
*.pem
1313
*.mjs
14-
*.xml
14+
*.xml
15+
*.patch

api/.env.dist

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,12 @@ SALT_ROUNDS=10
1212

1313
CRYPTO_PUBLIC_KEY=
1414
BALANCE_MIN_VALUE=1
15-
LOCKER_SERVICE_KEY=
15+
LOCKER_SERVICE_KEY=
16+
17+
SMTP_HOST=
18+
SMTP_PORT=
19+
SMTP_USER=
20+
SMTP_PASS=
21+
SMTP_FROM=
22+
FRONT_URL=https://buck.utt.fr/
23+
MAGIC_LINK_VALIDITY=3600000

api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"fast-xml-parser": "^5.0.9",
3535
"file-type": "^20.4.1",
3636
"multer": "1.4.5-lts.2",
37+
"nodemailer": "^6.10.0",
3738
"pactum-matchers": "^1.1.6",
3839
"passport-jwt": "^4.0.1",
3940
"prisma": "^6.5.0",
@@ -51,6 +52,7 @@
5152
"@types/multer": "^1.4.11",
5253
"@types/mysql": "^2.15.27",
5354
"@types/node": "22.13.13",
55+
"@types/nodemailer": "^6.4.17",
5456
"@types/passport-jwt": "^4.0.1",
5557
"@types/pdfkit": "^0.13.9",
5658
"@typescript-eslint/eslint-plugin": "^8.28.0",

api/pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/prisma/schema.prisma

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,28 @@ datasource db {
77
url = env("DATABASE_URL")
88
}
99

10+
model MagicLink {
11+
token String @id @default(uuid())
12+
createdAt DateTime @default(now())
13+
usedAt DateTime?
14+
userId String
15+
originatingIp String
16+
user User @relation(fields: [userId], references: [id])
17+
}
18+
1019
model User {
11-
id String @id
12-
type UserType @default(USER)
13-
email String @unique
20+
id String @id
21+
type UserType @default(USER)
22+
email String @unique
1423
pwdHash String
1524
firstName String
1625
lastName String
1726
balance Int
1827
processed DateTime?
19-
iban String? @db.Text
20-
ibanFoolproof String? @db.Char(4)
21-
locker String? @db.Text
28+
iban String? @db.Text
29+
ibanFoolproof String? @db.Char(4)
30+
locker String? @db.Text
31+
magicLinks MagicLink[]
2232
}
2333

2434
model ReportProperty {

api/src/app.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { JwtGuard } from './auth/guard';
77
import { ConfigModule } from './config/config.module';
88
import { HttpModule } from './http/http.module';
99
import { AdminModule } from './admin/admin.module';
10+
import { MailModule } from './mails/mail.module';
1011

1112
@Module({
12-
imports: [ConfigModule, HttpModule, PrismaModule, AuthModule, UsersModule, AdminModule],
13+
imports: [ConfigModule, HttpModule, PrismaModule, MailModule, AuthModule, UsersModule, AdminModule],
1314
// The providers below are used for all the routes of the api.
1415
// For example, the JwtGuard is used for all the routes and checks whether the user is authenticated.
1516
providers: [

api/src/auth/auth.controller.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Body, Controller, Get, Headers, HttpCode, HttpStatus, Post } from '@nestjs/common';
1+
import { Body, Controller, Delete, Get, Headers, HttpCode, HttpStatus, Post } from '@nestjs/common';
22
import { AuthService } from './auth.service';
33
import AuthSignInReqDto from './dto/req/auth-sign-in-req.dto';
44
import { IsPublic } from './decorator';
@@ -8,6 +8,8 @@ import AccessTokenResponse from './dto/res/access-token-res.dto';
88
import TokenValidityResDto from './dto/res/token-validity-res.dto';
99
import { ApiAppErrorResponse } from '../app.dto';
1010
import { ConfigModule } from '../config/config.module';
11+
import AuthCreateMagicDto from './dto/req/auth-create-magic.dto';
12+
import AuthDeleteMagicDto from './dto/req/auth-delete-magic.dto';
1113

1214
@Controller('auth')
1315
@ApiTags('Authentication')
@@ -84,4 +86,39 @@ export class AuthController {
8486
operation: user ? (user.type === 'ADMIN' ? 'administrate' : 'refund') : false,
8587
};
8688
}
89+
90+
@HttpCode(HttpStatus.OK)
91+
@IsPublic()
92+
@Post('magic')
93+
@ApiOperation({
94+
description: 'Generates a magic link for the user. This link should be sent to the user by email.',
95+
})
96+
@ApiBody({ type: AuthCreateMagicDto })
97+
async generateMagicLink(@Body() dto: AuthCreateMagicDto, @Headers() { 'X-Forwarded-for': ip }): Promise<void> {
98+
const linkData = await this.authService.generateMagicLink(dto.login, ip);
99+
if (!linkData) throw new AppException(ERROR_CODE.INVALID_CREDENTIALS);
100+
await this.authService.sendMagicLink(dto.login, linkData.code, linkData.name);
101+
}
102+
103+
@HttpCode(HttpStatus.OK)
104+
@IsPublic()
105+
@Delete('magic')
106+
@ApiOperation({
107+
description: 'Consumes/Deletes the magic link.',
108+
})
109+
@ApiBody({ type: AuthDeleteMagicDto })
110+
async consumeMagicLink(@Body() dto: AuthDeleteMagicDto): Promise<AccessTokenResponse> {
111+
const { token, id } = (await this.authService.consumeMagicLink(dto.spell)) ?? {};
112+
if (!token) throw new AppException(ERROR_CODE.INVALID_CREDENTIALS);
113+
const user = id ? await this.authService.getUser(id) : undefined;
114+
return {
115+
access_token: token,
116+
currentBalance: user.balance,
117+
firstName: user.firstName,
118+
paymentMethodRegistered: user.iban ? user.ibanFoolproof : null,
119+
processed: !!user.processed,
120+
eligible: user.balance >= this.config.BALANCE_MIN_VALUE,
121+
operation: user.type === 'ADMIN' ? 'administrate' : 'refund',
122+
};
123+
}
87124
}

api/src/auth/auth.service.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ import * as bcrypt from 'bcryptjs';
44
import { JwtService } from '@nestjs/jwt';
55
import { ConfigModule } from '../config/config.module';
66
import AuthSignInReqDto from './dto/req/auth-sign-in-req.dto';
7+
import { MailService } from '../mails/mail.service';
78

89
@Injectable()
910
export class AuthService {
10-
constructor(private prisma: PrismaService, private jwt: JwtService, private config: ConfigModule) {}
11+
constructor(
12+
private prisma: PrismaService,
13+
private jwt: JwtService,
14+
private mail: MailService,
15+
private config: ConfigModule,
16+
) {}
1117

1218
/**
1319
* Verifies the credentials are right.
@@ -75,4 +81,64 @@ export class AuthService {
7581
},
7682
});
7783
}
84+
85+
async generateMagicLink(email: string, ip: string): Promise<{ code: string; name: string } | null> {
86+
try {
87+
const link = await this.prisma.magicLink.create({
88+
data: {
89+
user: {
90+
connect: {
91+
email,
92+
},
93+
},
94+
originatingIp: ip,
95+
},
96+
select: {
97+
token: true,
98+
user: {
99+
select: {
100+
firstName: true,
101+
},
102+
},
103+
},
104+
});
105+
return { code: link.token.replaceAll('-', '').toUpperCase(), name: link.user.firstName };
106+
} catch {
107+
// User does not exist, silent the error
108+
return null;
109+
}
110+
}
111+
112+
async sendMagicLink(email: string, token: string, name: string): Promise<void> {
113+
return this.mail.sendGeneric({
114+
recipient: email,
115+
title: 'Accès à ton compte BuckUTT',
116+
content: `<h2>Bonjour ${name},</h2><p>Voici le lien pour accéder à ton compte BuckUTT : <a href="${this.config.FRONT_URL}/magic?spell=${token}">${this.config.FRONT_URL}/magic?spell=${token}</a></p><p>Le lien est valide pendant ${this.config.MAGIC_LINK_VALIDITY / 60000} minutes. Si tu n'as pas demandé ce lien, ignore ce message.</p><p>À bientôt !</p>`,
117+
});
118+
}
119+
120+
async consumeMagicLink(token: string): Promise<{ token: string; id: string } | null> {
121+
const link = await this.prisma.magicLink.update({
122+
data: {
123+
usedAt: new Date(),
124+
},
125+
where: {
126+
usedAt: null,
127+
token,
128+
createdAt: {
129+
gte: new Date(Date.now() - this.config.MAGIC_LINK_VALIDITY),
130+
},
131+
},
132+
select: {
133+
user: {
134+
select: {
135+
id: true,
136+
email: true,
137+
},
138+
},
139+
},
140+
});
141+
if (!link) return null;
142+
return { token: await this.signToken(link.user.id, link.user.email), id: link.user.id };
143+
}
78144
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { IsEmail, IsNotEmpty } from 'class-validator';
2+
3+
export default class AuthCreateMagicDto {
4+
@IsNotEmpty()
5+
@IsEmail()
6+
login: string;
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { IsAscii, IsNotEmpty } from 'class-validator';
2+
3+
export default class AuthDeleteMagicDto {
4+
@IsNotEmpty()
5+
@IsAscii()
6+
spell: string;
7+
}

0 commit comments

Comments
 (0)