-
Notifications
You must be signed in to change notification settings - Fork 4
[PB-1960]: feat/implement mail account provisioning and recovery email functionality #1019
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e96b83c
dd84afb
3a2e65e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| 'use strict'; | ||
|
|
||
| const { v4 } = require('uuid'); | ||
|
|
||
| const LIMIT_LABEL = 'mail-access'; | ||
|
|
||
| /** @type {import('sequelize-cli').Migration} */ | ||
| module.exports = { | ||
| async up(queryInterface) { | ||
| const transaction = await queryInterface.sequelize.transaction(); | ||
| try { | ||
| const disabledLimitId = v4(); | ||
| const enabledLimitId = v4(); | ||
|
|
||
| await queryInterface.bulkInsert( | ||
| 'limits', | ||
| [ | ||
| { | ||
| id: disabledLimitId, | ||
| label: LIMIT_LABEL, | ||
| type: 'boolean', | ||
| value: 'false', | ||
| created_at: new Date(), | ||
| updated_at: new Date(), | ||
| }, | ||
| { | ||
| id: enabledLimitId, | ||
| label: LIMIT_LABEL, | ||
| type: 'boolean', | ||
| value: 'true', | ||
| created_at: new Date(), | ||
| updated_at: new Date(), | ||
| }, | ||
| ], | ||
| { transaction }, | ||
| ); | ||
|
|
||
| const [tiers] = await queryInterface.sequelize.query( | ||
| `SELECT id, label FROM tiers`, | ||
| { transaction }, | ||
| ); | ||
|
|
||
| const tierLimitRelations = tiers.map((tier) => ({ | ||
| id: v4(), | ||
| tier_id: tier.id, | ||
| limit_id: disabledLimitId, | ||
| created_at: new Date(), | ||
| updated_at: new Date(), | ||
| })); | ||
|
|
||
| await queryInterface.bulkInsert('tiers_limits', tierLimitRelations, { | ||
| transaction, | ||
| }); | ||
|
|
||
| await transaction.commit(); | ||
| } catch (error) { | ||
| await transaction.rollback(); | ||
| throw error; | ||
| } | ||
| }, | ||
|
|
||
| async down(queryInterface) { | ||
| const transaction = await queryInterface.sequelize.transaction(); | ||
| try { | ||
| const [limits] = await queryInterface.sequelize.query( | ||
| `SELECT id FROM limits WHERE label = :limitLabel`, | ||
| { replacements: { limitLabel: LIMIT_LABEL }, transaction }, | ||
| ); | ||
|
|
||
| const limitIds = limits.map((l) => l.id); | ||
|
|
||
| if (limitIds.length > 0) { | ||
| await queryInterface.sequelize.query( | ||
| `DELETE FROM tiers_limits WHERE limit_id IN (:limitIds)`, | ||
| { replacements: { limitIds }, transaction }, | ||
| ); | ||
| } | ||
|
|
||
| await queryInterface.sequelize.query( | ||
| `DELETE FROM limits WHERE label = :limitLabel`, | ||
| { replacements: { limitLabel: LIMIT_LABEL }, transaction }, | ||
| ); | ||
|
|
||
| await transaction.commit(); | ||
| } catch (error) { | ||
| await transaction.rollback(); | ||
| throw error; | ||
| } | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { Module } from '@nestjs/common'; | ||
| import { ConfigModule } from '@nestjs/config'; | ||
| import { HttpClientModule } from '../http/http.module'; | ||
| import { MailService } from './mail.service'; | ||
|
|
||
| @Module({ | ||
| imports: [ConfigModule, HttpClientModule], | ||
| controllers: [], | ||
| providers: [MailService], | ||
| exports: [MailService], | ||
| }) | ||
| export class MailServiceModule {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import { ConflictException, Inject, Injectable } from '@nestjs/common'; | ||
| import { sign } from 'jsonwebtoken'; | ||
| import { ConfigService } from '@nestjs/config'; | ||
| import { HttpClient } from '../http/http.service'; | ||
|
|
||
| interface CreateAccountPayload { | ||
| userId: string; | ||
| address: string; | ||
| domain: string; | ||
| displayName: string; | ||
| } | ||
|
|
||
| interface CreateAccountResponse { | ||
| address: string; | ||
| domain: string; | ||
| } | ||
|
|
||
| function signToken(duration: string, secret: string, isDevelopment?: boolean) { | ||
| return sign({}, Buffer.from(secret, 'base64').toString('utf8'), { | ||
| algorithm: 'RS256', | ||
| expiresIn: duration, | ||
| ...(isDevelopment ? { allowInsecureKeySizes: true } : null), | ||
| }); | ||
| } | ||
|
|
||
| @Injectable() | ||
| export class MailService { | ||
| constructor( | ||
| @Inject(ConfigService) | ||
| private readonly configService: ConfigService, | ||
| @Inject(HttpClient) | ||
| private readonly httpClient: HttpClient, | ||
| ) {} | ||
|
|
||
| private getAuthHeaders() { | ||
| const isDevelopment = this.configService.get('isDevelopment'); | ||
| const jwt = signToken( | ||
| '5m', | ||
| this.configService.get('secrets.gateway'), | ||
| isDevelopment, | ||
| ); | ||
|
|
||
| return { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${jwt}`, | ||
| }; | ||
| } | ||
|
|
||
| async findUserIdByAddress(address: string): Promise<string | null> { | ||
| const baseUrl = this.configService.get('apis.mail.url'); | ||
| const headers = this.getAuthHeaders(); | ||
|
|
||
| try { | ||
| const res = await this.httpClient.get( | ||
| `${baseUrl}/gateway/addresses/${encodeURIComponent(address)}`, | ||
| { headers }, | ||
| ); | ||
|
|
||
| return res.data?.userId ?? null; | ||
| } catch (error) { | ||
| if (error?.response?.status === 404) { | ||
| return null; | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| async createAccount( | ||
| payload: CreateAccountPayload, | ||
| ): Promise<CreateAccountResponse> { | ||
| const baseUrl = this.configService.get('apis.mail.url'); | ||
| const headers = this.getAuthHeaders(); | ||
|
|
||
| try { | ||
| const res = await this.httpClient.post( | ||
| `${baseUrl}/gateway/accounts`, | ||
| payload, | ||
| { headers }, | ||
| ); | ||
|
|
||
| return res.data; | ||
| } catch (error) { | ||
| if (error?.response?.status === 409) { | ||
| throw new ConflictException( | ||
| error.response.data?.message || 'Mail account already exists', | ||
| ); | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,6 +48,8 @@ import { FeatureLimitService } from '../feature-limit/feature-limit.service'; | |
| import { PaymentRequiredException } from '../feature-limit/exceptions/payment-required.exception'; | ||
| import { Client } from '../../common/decorators/client.decorator'; | ||
| import { type ClientEnum } from '../../common/enums/platform.enum'; | ||
| import { MailService } from '../../externals/mail/mail.service'; | ||
| import { isManagedMailDomain } from './managed-mail-domains'; | ||
|
|
||
| @ApiTags('Auth') | ||
| @Controller('auth') | ||
|
|
@@ -60,8 +62,19 @@ export class AuthController { | |
| private readonly twoFactorAuthService: TwoFactorAuthService, | ||
| private readonly authUseCases: AuthUsecases, | ||
| private readonly featureLimitService: FeatureLimitService, | ||
| private readonly mailService: MailService, | ||
| ) {} | ||
|
|
||
| private async resolveLoginEmail(email: string): Promise<string> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is part of the login logic, I will add it to the auth.service, not in the controller, as this applies to the login itself, with independence of the web login or the CLI one |
||
| if (!isManagedMailDomain(email)) return email; | ||
|
|
||
| const userId = await this.mailService.findUserIdByAddress(email); | ||
| if (!userId) return email; | ||
|
|
||
| const user = await this.userUseCases.findByUuid(userId).catch(() => null); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't hide errors, log or let them propagate |
||
| return user?.email ?? email; | ||
| } | ||
|
|
||
| @Post('/login') | ||
| @HttpCode(HttpStatus.OK) | ||
| @ApiOperation({ | ||
|
|
@@ -70,7 +83,7 @@ export class AuthController { | |
| @ApiOkResponse({ description: 'Retrieve details', type: LoginResponseDto }) | ||
| @Public() | ||
| async login(@Body() body: LoginDto): Promise<LoginResponseDto> { | ||
| const email = body.email.toLowerCase(); | ||
| const email = await this.resolveLoginEmail(body.email.toLowerCase()); | ||
|
|
||
| const user = await this.userUseCases.findByEmail(email); | ||
|
|
||
|
|
@@ -128,8 +141,11 @@ export class AuthController { | |
| revocationKey: body.revocateKey, | ||
| }); | ||
|
|
||
| const email = await this.resolveLoginEmail(body.email.toLowerCase()); | ||
|
|
||
| const result = await this.userUseCases.loginAccess({ | ||
| ...body, | ||
| email, | ||
| keys: { kyber, ecc }, | ||
| }); | ||
|
|
||
|
|
@@ -299,8 +315,11 @@ export class AuthController { | |
| revocationKey: body.revocateKey, | ||
| }); | ||
|
|
||
| const email = await this.resolveLoginEmail(body.email.toLowerCase()); | ||
|
|
||
| const result = await this.userUseCases.loginAccess({ | ||
| ...body, | ||
| email, | ||
| keys: { kyber, ecc }, | ||
| platform, | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| export const MANAGED_MAIL_DOMAINS: ReadonlySet<string> = new Set([ | ||
| 'inxt.eu', | ||
| 'inxt.me', | ||
| ]); | ||
|
|
||
| export function isManagedMailDomain(email: string): boolean { | ||
| const domain = email.split('@')[1]?.toLowerCase(); | ||
| return !!domain && MANAGED_MAIL_DOMAINS.has(domain); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { ApiProperty } from '@nestjs/swagger'; | ||
| import { IsNotEmpty, IsString } from 'class-validator'; | ||
|
|
||
| export class CreateMailAccountDto { | ||
| @ApiProperty({ example: 'john' }) | ||
| @IsNotEmpty() | ||
| @IsString() | ||
| address: string; | ||
|
|
||
| @ApiProperty({ example: 'inxt.eu' }) | ||
| @IsNotEmpty() | ||
| @IsString() | ||
| domain: string; | ||
|
|
||
| @ApiProperty({ example: 'John Doe' }) | ||
| @IsNotEmpty() | ||
| @IsString() | ||
| displayName: string; | ||
|
|
||
| @ApiProperty({ description: 'Encrypted password for re-authentication' }) | ||
| @IsNotEmpty() | ||
| @IsString() | ||
| password: string; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should not be part of Drive, rather than from payments.