Skip to content

feat(auth): Implement user registration and login — POST /auth/register and POST /auth/login #4

Description

@DiegoERS

Context

Authentication is the gateway to all protected features. This issue implements user registration and login using username/password credentials. On registration, a Stellar wallet is automatically generated in the background. JWT access + refresh token strategy is used.

📄 Reference docs:

  • backend/docs/standards/security.md — password hashing, JWT RS256, token flow
  • backend/docs/standards/dtos.md — DTO validation
  • backend/docs/standards/endpoints.md — route conventions
  • backend/docs/standards/error-handling.md — error codes

Objective

Implement the AuthModule with:

  • POST /api/v1/auth/register — create user + auto-generate Stellar wallet
  • POST /api/v1/auth/login — validate credentials, issue access + refresh tokens

What to implement

DTOs

src/modules/auth/dto/register.dto.ts

export class RegisterDto {
  @IsString() @IsNotEmpty() @MaxLength(50) @Matches(/^[a-z0-9_]+$/) username: string;
  @IsString() @IsNotEmpty() @MaxLength(100) firstName: string;
  @IsString() @IsNotEmpty() @MaxLength(100) lastName: string;
  @IsEmail() email: string;
  @IsString() @MinLength(8) @MaxLength(72) password: string;
  @IsString() @IsNotEmpty() confirmPassword: string;  // validated in service
}

src/modules/auth/dto/login.dto.ts

export class LoginDto {
  @IsString() @IsNotEmpty() username: string;
  @IsString() @IsNotEmpty() password: string;
}

src/modules/auth/dto/auth-tokens.dto.ts

export class AuthTokensDto {
  accessToken: string;
  refreshToken: string;
}

AuthService — src/modules/auth/auth.service.ts

register(dto: RegisterDto): Promise

  1. Validate dto.password === dto.confirmPassword → throw AppException('PASSWORDS_DO_NOT_MATCH', ..., 400) if not
  2. Check username uniqueness → AppException('USERNAME_TAKEN', ..., 409)
  3. Check email uniqueness → AppException('EMAIL_TAKEN', ..., 409)
  4. Hash password: bcrypt.hash(dto.password, 12)
  5. Insert user into users table via UsersRepository
  6. Call WalletsService.generateAndSave(userId) — fire and forget (do not await, use .catch(logger.error))
  7. Return void (HTTP 201, no tokens — user must login separately)

login(dto: LoginDto): Promise

  1. Find user by username → AppException('INVALID_CREDENTIALS', ..., 401) if not found (do not distinguish between "user not found" and "wrong password")
  2. bcrypt.compare(dto.password, user.passwordHash) → same AppException('INVALID_CREDENTIALS', ..., 401) if mismatch
  3. Generate access token: RS256 JWT, payload { sub: user.id, username: user.username }, TTL 15 min
  4. Generate refresh token: crypto.randomBytes(64).toString('hex')
  5. Hash refresh token with bcrypt.hash(rawRefreshToken, 10) and store in refresh_tokens
  6. Return { accessToken, refreshToken } (raw values — not hashed)

AuthController — src/modules/auth/auth.controller.ts

@ApiTags('auth')
@Controller('auth')
export class AuthController {
  @Post('register')
  @HttpCode(HttpStatus.CREATED)
  @ApiOperation({ summary: 'Register a new user' })
  @ApiResponse({ status: 201, description: 'User created successfully' })
  @ApiResponse({ status: 409, description: 'USERNAME_TAKEN or EMAIL_TAKEN' })
  @ApiResponse({ status: 400, description: 'VALIDATION_ERROR or PASSWORDS_DO_NOT_MATCH' })
  register(@Body() dto: RegisterDto): Promise<void> { ... }

  @Post('login')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Login and receive JWT tokens' })
  @ApiResponse({ status: 200, type: AuthTokensDto })
  @ApiResponse({ status: 401, description: 'INVALID_CREDENTIALS' })
  login(@Body() dto: LoginDto): Promise<AuthTokensDto> { ... }
}

UsersRepository — src/modules/users/users.repository.ts

Methods needed for this issue:

  • findByUsername(username: string): Promise<User | null>
  • findByEmail(email: string): Promise<User | null>
  • create(payload): Promise<User> — inserts into users table
  • Snake_case to camelCase mapping (see backend/docs/standards/database.md)

AuthRepository — src/modules/auth/auth.repository.ts

Methods:

  • saveRefreshToken({ userId, tokenHash, expiresAt }): Promise<void>
  • findRefreshToken(tokenHash: string): Promise<RefreshToken | null>
  • revokeRefreshToken(id: string): Promise<void>

Packages to install

npm install @nestjs/jwt @nestjs/passport passport passport-jwt bcrypt
npm install -D @types/bcrypt @types/passport-jwt

Swagger documentation required

Every endpoint must have:

  • @ApiTags('auth')
  • @ApiOperation({ summary: '...' })
  • @ApiBody({ type: RegisterDto | LoginDto })
  • @ApiResponse for every possible HTTP status (200/201, 400, 401, 409)
  • DTOs decorated with @ApiProperty() on every field with example values

Example:

@ApiProperty({ example: 'juan_perez', description: 'Unique username, lowercase alphanumeric and underscores' })
username: string;

Acceptance Criteria

  • POST /api/v1/auth/register with valid data returns 201
  • POST /api/v1/auth/register with duplicate username returns 409 with code USERNAME_TAKEN
  • POST /api/v1/auth/register with mismatched passwords returns 400 with code PASSWORDS_DO_NOT_MATCH
  • POST /api/v1/auth/login with valid credentials returns { accessToken, refreshToken }
  • POST /api/v1/auth/login with wrong password returns 401 with code INVALID_CREDENTIALS
  • POST /api/v1/auth/login with unknown username returns 401 with code INVALID_CREDENTIALS (same error — no user enumeration)
  • Password is never stored in plain text — verified by checking users.password_hash starts with $2b$
  • Swagger shows both endpoints under the auth tag

Unit Tests required

src/modules/auth/auth.service.spec.ts

  • register: throws PASSWORDS_DO_NOT_MATCH when passwords differ
  • register: throws USERNAME_TAKEN when usersRepository.findByUsername returns a user
  • register: throws EMAIL_TAKEN when usersRepository.findByEmail returns a user
  • register: calls bcrypt.hash with cost 12 before saving
  • register: calls walletsService.generateAndSave fire-and-forget after user creation
  • login: throws INVALID_CREDENTIALS when user not found
  • login: throws INVALID_CREDENTIALS when bcrypt.compare returns false
  • login: returns { accessToken, refreshToken } on valid credentials
  • login: access token payload contains { sub: user.id, username: user.username }

Complexity: High

Depends on: setup issue, database/migrations issues

Metadata

Metadata

Assignees

Labels

Beta-CampaignCampaign: Beta-CampaignbackendBackend (NestJS API) related taskcomplexity: highEstimated 1-2 weeks of work

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions