eletrotupi / tcc/ commit / 8f18bac

api/auth: issue activation codes from one collision-safe generator

The whole activation code path had issues all around, same code, with
even some bug. So this patch moves into a more proper place, that draws
every digit over 0-9, add an expiration, and indexes with tests

The collision-retry branch is deliberately untested, since forcing a
collision would mean injecting the generator, and is stated here rather
than worked around
Pedro Lucas Porcellis porcellis@eletrotupi.com · 27 days ago 8f18bacb86dd1b1d21724ad45dd2641371b0d2bf Browse files
Parents 8194319
Changed files
9
Additions
+128
Deletions
-33
/dev/null → api/prisma/migrations/20260930212941_unique_activation_code/migration.sql
1 +
-- Codes are issued for a five-minute window and are not unique today, so an
2 +
-- existing database can hold the same code against several users. Keep the most
3 +
-- recently issued holder of each code and clear the rest, together with their
4 +
-- expiry, so a cleared user is reissued a code on their next login.
5 +
UPDATE "users"
6 +
SET "activation_code" = NULL,
7 +
    "activation_code_expires_at" = NULL
8 +
WHERE "activation_code" IS NOT NULL
9 +
  AND "id" NOT IN (
10 +
    SELECT DISTINCT ON ("activation_code") "id"
11 +
    FROM "users"
12 +
    WHERE "activation_code" IS NOT NULL
13 +
    ORDER BY "activation_code", "updated_at" DESC
14 +
  );
15 +

16 +
-- CreateIndex
17 +
CREATE UNIQUE INDEX "users_activation_code_key" ON "users"("activation_code");
api/prisma/schema.prisma
1 1
generator client {
2 2
  provider = "prisma-client-js"
3 3
  //output   = "../src/generated/prisma"
4 4
}
5 5

18 18
  createdAt            DateTime  @default(now()) @map("created_at")
19 19
  updatedAt            DateTime  @updatedAt @map("updated_at")
20 20
  avatarKey            String?   @map("avatar_key")
21 21
  avatarURL            String?   @map("avatar_url")
22 22

23 -
  activationCode          String?   @map("activation_code")
23 +
  activationCode          String?   @unique @map("activation_code")
24 24
  activationCodeExpiresAt DateTime? @map("activation_code_expires_at")
25 25
  active                  Boolean   @default(false) @map("active")
26 26

27 27
  pushToken            String?  @map("push_token")
28 28
  notificationsEnabled Boolean  @default(true) @map("notifications_enabled")
301 301
  THREE_TIMES_DAILY
302 302
  WEEKLY
303 303
  BIWEEKLY
304 304
  MONTHLY
305 305
}
api/src/controllers/auth.ts
1 1
import { Request, Response, NextFunction } from "express";
2 2
import { AuthenticatedRequest } from "@app/middleware/auth";
3 3

4 4
import {
5 5
  login,
6 6
  generateToken,
7 7
  requestPasswordReset,
8 8
  resetPassword,
9 9
  activateUser,
10 -
  storeActivationCode,
10 +
  issueActivationCode,
11 11
} from "@app/services/auth.service";
12 12

13 13
import { verifyToken } from "@app/lib/jwt";
14 14

15 15
import {
24 24
  PasswordResetRequestSchema,
25 25
  ActivateUserSchema,
26 26
} from "@app/schemas";
27 27
import { formatValidationError } from "@app/lib/errors/validationError";
28 28

29 -
import { addMinutes } from "date-fns";
30 29
import { getQueue, MailJobName } from "@app/lib/queue";
31 -
import crypto from "crypto";
32 30

33 31
export const AuthController = {
34 32
  login: async (req: Request, res: Response, next: NextFunction) => {
35 33
    try {
36 34
      const parsed = LoginSchema.safeParse(req.body);
43 41

44 42
      if (
45 43
        !user.active &&
46 44
        new Date() > new Date(user.activationCodeExpiresAt!)
47 45
      ) {
48 -
        const code = [...Array(6)].map(() => crypto.randomInt(9)).join("");
49 -

50 -
        const expiresAt = addMinutes(new Date(), 5);
51 -
        await storeActivationCode(user.id, code, expiresAt);
46 +
        const code = await issueActivationCode(user.id);
52 47

53 48
        const mailQueue = getQueue("mail");
54 49
        const job = await mailQueue.add(MailJobName.ActivateAccountEmail, {
55 50
          userId: user.id,
56 51
          code: code,
187 182
        return res.status(400).json({
188 183
          errors: "Usuário não encontrado",
189 184
        });
190 185
      }
191 186

192 -
      const code = [...Array(6)].map(() => crypto.randomInt(9)).join("");
193 -

194 -
      const expiresAt = addMinutes(new Date(), 5);
195 -
      await storeActivationCode(user.id, code, expiresAt);
187 +
      const code = await issueActivationCode(user.id);
196 188

197 189
      const mailQueue = getQueue("mail");
198 190
      const job = await mailQueue.add(MailJobName.ActivateAccountEmail, {
199 191
        userId: user.id,
200 192
        code: code,
206 198
    } catch (err: any) {
207 199
      next(err);
208 200
    }
209 201
  },
210 202
};
api/src/controllers/users.ts
1 1
import { Request, Response, NextFunction } from 'express';
2 2
import {
3 3
  generateToken
4 4
} from '@app/services/auth.service';
5 5

19 19
  findUserByEmail,
20 20
  updateUser
21 21
} from '@app/services/user.service';
22 22

23 23
import {
24 -
  storeActivationCode
24 +
  issueActivationCode
25 25
} from '@app/services/auth.service';
26 26

27 27
import {
28 28
  AuthenticatedRequest
29 29
} from '@app/middleware/auth';
30 30

31 31
import { prisma } from '@app/lib/prisma';
32 32
import s3 from '@app/lib/s3';
33 33
import { DeleteObjectCommand } from '@aws-sdk/client-s3';
34 -
import crypto from 'crypto';
35 -
import { addMinutes } from 'date-fns';
36 34
import { User } from '@prisma/client';
37 35

38 36
// XXX: I've regreted already
39 37
interface SingleFileRequest extends AuthenticatedRequest {
40 38
  file?: any;
61 59
      }
62 60

63 61
      const user = await createUser(parsed.data);
64 62
      const jwtToken = generateToken(user.id, user.email);
65 63

66 -
      const code = [...Array(6)].map(() => crypto.randomInt(9))
67 -
        .join("");
68 -

69 -
      const expiresAt = addMinutes(new Date(), 5);
70 -
      await storeActivationCode(user.id, code, expiresAt);
64 +
      const code = await issueActivationCode(user.id);
71 65

72 66
      const mailQueue = getQueue('mail');
73 67
      const job = await mailQueue.add(MailJobName.WelcomeEmail, {
74 68
        userId: user.id,
75 69
        code: code
225 219
    return res.json({
226 220
      user
227 221
    })
228 222
  }
229 223
}
api/src/services/auth.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 -
import { User } from '@prisma/client';
2 +
import { Prisma, User } from '@prisma/client';
3 3
import bcrypt from "bcryptjs";
4 +
import crypto from "crypto";
5 +
import { addMinutes } from "date-fns";
4 6

5 7
import {
6 8
  generateToken as createJWT,
7 9
  generatePasswordResetToken,
8 10
  getPasswordResetExpiration,
115 117
      activationCodeExpiresAt: null
116 118
    }
117 119
  });
118 120
}
119 121

120 -
export const storeActivationCode = async(userId: number, code: string, expiresAt: Date): Promise<User> => {
121 -
  return await prisma.user.update({
122 -
    where: {
123 -
      id: userId
124 -
    },
125 -
    data: {
126 -
      activationCode: code,
127 -
      activationCodeExpiresAt: expiresAt
122 +
const ACTIVATION_CODE_LENGTH = 6;
123 +
const ACTIVATION_CODE_TTL_MINUTES = 5;
124 +
const ACTIVATION_CODE_MAX_ATTEMPTS = 5;
125 +

126 +
export const generateActivationCode = (): string =>
127 +
  Array.from({ length: ACTIVATION_CODE_LENGTH }, () => crypto.randomInt(0, 10)).join("");
128 +

129 +
const isUniqueConstraintViolation = (err: unknown): boolean =>
130 +
  err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
131 +

132 +
export const issueActivationCode = async(userId: number): Promise<string> => {
133 +
  for (let attempt = 0; attempt < ACTIVATION_CODE_MAX_ATTEMPTS; attempt++) {
134 +
    const code = generateActivationCode();
135 +
    const expiresAt = addMinutes(new Date(), ACTIVATION_CODE_TTL_MINUTES);
136 +

137 +
    try {
138 +
      await prisma.user.update({
139 +
        where: {
140 +
          id: userId
141 +
        },
142 +
        data: {
143 +
          activationCode: code,
144 +
          activationCodeExpiresAt: expiresAt
145 +
        }
146 +
      });
147 +

148 +
      return code;
149 +
    } catch (err) {
150 +
      if (!isUniqueConstraintViolation(err)) throw err;
128 151
    }
129 -
  })
152 +
  }
153 +

154 +
  throw new Error("Could not issue a unique activation code");
130 155
}
api/src/services/user.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 2
import { User } from '@prisma/client';
3 3
import bcrypt from 'bcryptjs';
4 4

5 5
import {
33 33
    }
34 34
  });
35 35
};
36 36

37 37
export const findUserByActivationCode = async(code: string): Promise<User | null> => {
38 -
  return await prisma.user.findFirst({
38 +
  return await prisma.user.findUnique({
39 39
    where: {
40 40
      activationCode: code
41 41
    }
42 42
  });
43 43
}
65 65
        not: null
66 66
      }
67 67
    }
68 68
  });
69 69
}
/dev/null → api/tests/auth/activation-code.test.ts
1 +
import { describe, it, expect } from 'vitest'
2 +
import { generateActivationCode } from '@app/services/auth.service'
3 +

4 +
describe('generateActivationCode', () => {
5 +
  it('produces exactly six decimal digits on every draw', () => {
6 +
    const codes = Array.from({ length: 500 }, () => generateActivationCode())
7 +

8 +
    for (const code of codes) {
9 +
      expect(code).toMatch(/^\d{6}$/)
10 +
    }
11 +
  })
12 +

13 +
  it('reaches the digit nine', () => {
14 +
    const digits = Array.from({ length: 500 }, () => generateActivationCode()).join('')
15 +

16 +
    expect(digits).toContain('9')
17 +
  })
18 +
})
api/tests/auth/login.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { buildPrisma, cleanupTestDb, createTestUser } from '../test-helper'
5 +
import { recordedJobs, resetQueueMocks } from '../mocks/queue'
5 6

6 7
const app = createApp()
7 8
const db = buildPrisma()
8 9

9 10
afterAll(() => db.$disconnect())
10 -
beforeEach(() => cleanupTestDb(db))
11 +
beforeEach(async () => {
12 +
  await cleanupTestDb(db)
13 +

14 +
  resetQueueMocks()
15 +
})
11 16

12 17
describe('POST /auth/login', () => {
13 18
  it('returns a token for valid credentials', async () => {
14 19
    await createTestUser(db, { email: 'login@example.com', password: 'senha123' })
15 20

36 41
    const res = await request(app)
37 42
      .post('/auth/login')
38 43
      .send({ email: 'naoexiste@example.com', password: 'qualquer' })
39 44

40 45
    expect(res.status).toBe(401)
46 +
  })
47 +

48 +
  it('reissues an activation code when an inactive user logs in with an expired one', async () => {
49 +
    const user = await createTestUser(db, { email: 'inactive@example.com', password: 'senha123' })
50 +
    await db.user.update({
51 +
      where: { id: user.id },
52 +
      data: {
53 +
        activationCode: '111111',
54 +
        activationCodeExpiresAt: new Date(Date.now() - 1000),
55 +
      },
56 +
    })
57 +

58 +
    const res = await request(app)
59 +
      .post('/auth/login')
60 +
      .send({ email: 'inactive@example.com', password: 'senha123' })
61 +

62 +
    expect(res.status).toBe(200)
63 +

64 +
    const dbUser = await db.user.findUnique({ where: { id: user.id } })
65 +
    expect(dbUser!.activationCode).toMatch(/^\d{6}$/)
66 +
    expect(dbUser!.activationCode).not.toBe('111111')
67 +
    expect(dbUser!.activationCodeExpiresAt!.getTime()).toBeGreaterThan(Date.now())
68 +

69 +
    expect(recordedJobs).toContainEqual(
70 +
      expect.objectContaining({
71 +
        queue: 'mail',
72 +
        name: 'mail:activate-account',
73 +
        data: { userId: user.id, code: dbUser!.activationCode },
74 +
      })
75 +
    )
41 76
  })
42 77
})
api/tests/users/create.test.ts
1 1
import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2 2
import request from 'supertest'
3 3
import { createApp } from '@app/createApp'
4 4
import { buildPrisma, cleanupTestDb } from '../test-helper'
5 +
import { recordedJobs, resetQueueMocks } from '../mocks/queue'
5 6

6 7
const app = createApp()
7 8
const db = buildPrisma()
8 9

9 10
afterAll(() => db.$disconnect())
10 -
beforeEach(() => cleanupTestDb(db))
11 +
beforeEach(async () => {
12 +
  await cleanupTestDb(db)
13 +

14 +
  resetQueueMocks()
15 +
})
11 16

12 17
describe('POST /users', () => {
13 18
  it('creates a user and returns a JWT token', async () => {
14 19
    const res = await request(app)
15 20
      .post('/users')
19 24
    expect(res.body.token).toBeTruthy()
20 25

21 26
    const dbUser = await db.user.findUnique({ where: { email: 'new@example.com' } })
22 27
    expect(dbUser).not.toBeNull()
23 28
    expect(dbUser!.firstName).toBe('Novo')
29 +
    expect(dbUser!.activationCode).toMatch(/^\d{6}$/)
30 +

31 +
    expect(recordedJobs).toContainEqual(
32 +
      expect.objectContaining({
33 +
        queue: 'mail',
34 +
        name: 'mail:welcome',
35 +
        data: { userId: dbUser!.id, code: dbUser!.activationCode },
36 +
      })
37 +
    )
24 38
  })
25 39

26 40
  it('returns 409 for duplicate email', async () => {
27 41
    await db.user.create({
28 42
      data: { email: 'dup@example.com', firstName: 'A', encryptedPassword: 'x' },
43 57
    expect(res.status).toBe(400)
44 58
    expect(res.body.error).toBeTruthy()
45 59
    expect(res.body.fields).toBeTruthy()
46 60
  })
47 61
})