diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 458e65ff..bc206ffc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,8 +18,62 @@ concurrency: # Onchain jobs (contracts) # ───────────────────────────────────────────────────────────────────── jobs: + # Changed-path matrix: only run the jobs relevant to the files touched + # in a PR/push, while preserving a full run whenever shared configuration + # (lockfile, workflows, release config, root manifests) changes. This + # speeds up validation without losing coverage (#321). + changes: + name: Detect changed paths + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + backend: ${{ steps.filter.outputs.backend }} + frontend: ${{ steps.filter.outputs.frontend }} + onchain: ${{ steps.filter.outputs.onchain }} + shared: ${{ steps.filter.outputs.shared }} + run-all: ${{ steps.filter.outputs.run_all }} + steps: + - uses: actions/checkout@v4 + + - uses: dorny/paths-filter@v3 + id: filter + with: + base: ${{ github.event.pull_request.base.sha || 'main' }} + filters: | + backend: + - 'backend/**' + frontend: + - 'frontend/**' + onchain: + - 'onchain/**' + # Shared configuration affects every project: any of these alone + # is enough to force a full (all jobs) validation run. + shared: + - 'package.json' + - 'package-lock.json' + - 'npm-workspaces.yaml' + - '**/package.json' + - '**/package-lock.json' + - '.github/workflows/**.yml' + - '.github/workflows/**.yaml' + run_all: + - 'package.json' + - 'package-lock.json' + - '**/package.json' + - '**/package-lock.json' + - '.github/workflows/release.yml' + - 'onchain/Scarb.lock' + - 'onchain/Scarb.toml' + - 'onchain/Cargo.lock' + - 'onchain/Cargo.toml' + + # Helper: resolves the effective "should this job run?" boolean by OR-ing + # the project-specific filter with the shared/run-all signal. onchain-build: name: Build contracts + needs: changes + if: ${{ needs.changes.outputs.onchain == 'true' || needs.changes.outputs.run-all == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -76,6 +130,8 @@ jobs: onchain-test: name: Test contracts + needs: changes + if: ${{ needs.changes.outputs.onchain == 'true' || needs.changes.outputs.run-all == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -139,6 +195,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── backend-lint: name: Backend lint + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.shared == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -174,6 +232,8 @@ jobs: backend-test: name: Backend tests + needs: changes + if: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.shared == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -202,6 +262,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── frontend-lint: name: Frontend lint + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -219,25 +281,25 @@ jobs: - name: Lint working-directory: frontend - # Now that @types/node is in devDependencies (added in commit 2a7ce2a), - # `next lint` should pass. Kept non-blocking while we verify. - continue-on-error: true + # Required gate (#311): `next lint` passes cleanly, so a lint failure + # now blocks the PR. run: npm run lint - name: npm audit working-directory: frontend - # The high threshold is enforced (issue #344). All auto-fixable - # findings have been resolved; the remaining high-severity - # advisories are Next.js framework issues (next <16.3.3, plus the - # glob/postcss pinned by @next/eslint-plugin-next) that only a - # Next.js major upgrade can clear. Tracked as residual risk in - # SECURITY.md; advisory until that upgrade lands. + # Advisory (#311/#344): the remaining high-severity advisories are + # Next.js framework issues (next, next-auth -> nodemailer) that only + # a Next.js major upgrade can clear (tracked in SECURITY.md). The + # dependency-review gate in security.yml already fail-closes on any + # *new* advisory, so npm audit stays advisory here. continue-on-error: true run: npm audit --audit-level=high frontend-build: name: Frontend build + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -255,15 +317,16 @@ jobs: - name: Build working-directory: frontend - # Advisory only — pending fix-up of pre-existing frontend build errors - # in the codebase (separate PR). - continue-on-error: true + # Required gate (#311): the pre-existing build errors (missing + # `onClaim` prop, invalid tsconfig `ignoreDeprecations`) are fixed, + # so a build failure now blocks the PR. run: npm run build frontend-smoke-test: name: Frontend production smoke test + needs: [changes, frontend-build] + if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }} runs-on: ubuntu-latest - needs: frontend-build steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 @@ -305,6 +368,8 @@ jobs: frontend-test: name: Frontend tests + needs: changes + if: ${{ needs.changes.outputs.frontend == 'true' || needs.changes.outputs.shared == 'true' }} runs-on: ubuntu-latest steps: - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 diff --git a/backend/.env.example b/backend/.env.example index eed87759..74447c08 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,6 +17,7 @@ FRONTEND_URL=http://localhost:3000 # Auth JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=30d # Database extras DATABASE_SYNC=false diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 87a22725..6891ac0b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,9 @@ import { TimeTrial } from './time-trial/time-trial.entity'; import { Puzzle } from './puzzle/puzzle.entity'; import { Category } from './puzzle-category/entities/category.entity'; import { Report } from './report/entities/report.entity'; +import { Wallet } from './wallet/entities/wallet.entity'; +import { ConsumedWalletNonce } from './wallet/entities/consumed-nonce.entity'; +import { TokenHistory } from './user-token-history/entities/token-history.entity'; import { AuditLog } from './audit-log/entities/audit-log.entity'; import { Admin } from './admin/admin.entity'; import { PuzzleReview } from './puzzle-review/puzzle-review/entities/puzzle-review.entity'; @@ -76,6 +79,7 @@ import { PuzzleReviewModule } from './puzzle-review/puzzle-review/puzzle-review. import { UserReportCardModule } from './user-report-card/user-report-card.module'; import { HealthModule } from './health/health.module'; import { MaintenanceModeModule } from './maintenance-mode/maintenance-mode.module'; +import { WalletModule } from './wallet/wallet.module'; import { GracefulShutdownService } from './graceful-shutdown.service'; @Module({ @@ -92,6 +96,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service'; PORT: Joi.number().port().default(3001), JWT_SECRET: Joi.string().required(), JWT_EXPIRES_IN: Joi.string().default('15m'), + JWT_REFRESH_EXPIRES_IN: Joi.string().default('30d'), FRONTEND_URL: Joi.string().uri().default('http://localhost:3000'), DATABASE_HOST: Joi.string().required(), DATABASE_PORT: Joi.number().port().default(5432), @@ -146,14 +151,19 @@ import { GracefulShutdownService } from './graceful-shutdown.service'; Puzzle, Category, Report, + Wallet, + ConsumedWalletNonce, + TokenHistory, AuditLog, Admin, PuzzleReview, ReviewModeration, DraftPuzzle, ], - synchronize: configService.get('database.synchronize'), - autoLoadEntities: configService.get('database.autoload'), + migrations: [join(__dirname, '**', 'migrations', '*.{ts,js}')], + synchronize: configService.get('database.synchronize') === true, + autoLoadEntities: configService.get('database.autoload') === true, + migrationsRun: configService.get('database.migrationsRun') === true, }), }), AchievementModule, @@ -205,6 +215,7 @@ import { GracefulShutdownService } from './graceful-shutdown.service'; UserRankingModule, UserReactionModule, UserReportCardModule, + MaintenanceModeModule, UserSettingsModule, UserTokenHistoryModule, WalletModule, diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 43c4808f..5e2d03b3 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -8,11 +8,13 @@ import { AuthController } from './controllers/auth.controller'; import { AuthService } from './services/auth.service'; import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { UserTokenHistoryModule } from '../user-token-history/user-token-history.module'; import * as Joi from 'joi'; @Module({ imports: [ TypeOrmModule.forFeature([User]), + UserTokenHistoryModule, PassportModule.register({ defaultStrategy: 'jwt' }), ConfigModule.forRoot({ isGlobal: true, diff --git a/backend/src/auth/controllers/auth.controller.ts b/backend/src/auth/controllers/auth.controller.ts index 5ae1315c..3386bc94 100644 --- a/backend/src/auth/controllers/auth.controller.ts +++ b/backend/src/auth/controllers/auth.controller.ts @@ -1,14 +1,29 @@ -import { Controller, Post, Get, UseGuards, Request, HttpStatus, HttpCode, Body } from "@nestjs/common" -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger" -import { AuthService } from "../services/auth.service" -import { Auth } from "../decorators/auth-decorator" -import { AuthType } from "../enums/auth-type.enum" -import { AuthResponseDto } from "../dto/auth-response.dto" -import { GenericAuthMessageDto } from "../dto/generic-auth-message.dto" -import { RegisterDto } from "../dto/register.dto" -import { LoginDto } from "../dto/login.dto" -import { JwtAuthGuard } from "../guards/jwt-auth.guard" -import { User } from "../entities/user.entity" +import { + Controller, + Post, + Get, + UseGuards, + Request, + HttpStatus, + HttpCode, + Body, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { AuthService } from '../services/auth.service'; +import { Auth } from '../decorators/auth-decorator'; +import { AuthType } from '../enums/auth-type.enum'; +import { AuthResponseDto } from '../dto/auth-response.dto'; +import { RegisterDto } from '../dto/register.dto'; +import { LoginDto } from '../dto/login.dto'; +import { RefreshTokenDto } from '../dto/refresh-token.dto'; +import { LogoutDto } from '../dto/logout.dto'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { User } from '../entities/user.entity'; @ApiTags('Authentication') @Controller('auth') @@ -119,4 +134,59 @@ export class AuthController { }, }; } + + @Post('refresh') + @Auth(AuthType.None) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Refresh access token', + description: + 'Exchange a valid refresh token for a fresh access/refresh token pair (rotation)', + }) + @ApiResponse({ + status: 200, + description: 'New token pair issued', + type: AuthResponseDto, + }) + @ApiResponse({ + status: 401, + description: 'Invalid, revoked or expired refresh token', + }) + async refresh(@Body() refreshTokenDto: RefreshTokenDto) { + return this.authService.refreshToken(refreshTokenDto.refreshToken); + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Logout', + description: + 'Revoke the current access token (and optionally a refresh token) server-side', + }) + @ApiResponse({ + status: 200, + description: 'Logged out successfully', + }) + @ApiResponse({ + status: 401, + description: 'Invalid or expired token', + }) + async logout( + @Request() req: { user: User; headers: any }, + @Body() logoutDto: LogoutDto, + ) { + const authHeader = req.headers?.authorization; + const accessToken = + typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : undefined; + + return this.authService.logout( + req.user.id, + accessToken, + logoutDto.refreshToken, + ); + } } diff --git a/backend/src/auth/dto/auth-response.dto.ts b/backend/src/auth/dto/auth-response.dto.ts index 87c8bd5f..94e8cd2c 100644 --- a/backend/src/auth/dto/auth-response.dto.ts +++ b/backend/src/auth/dto/auth-response.dto.ts @@ -23,6 +23,12 @@ export class AuthResponseDto { }) accessToken: string; + @ApiProperty({ + description: 'JWT refresh token used to obtain a new access token', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + refreshToken: string; + @ApiProperty({ description: 'Token type', example: 'Bearer', @@ -30,11 +36,17 @@ export class AuthResponseDto { tokenType: string; @ApiProperty({ - description: 'Token expiration time in seconds', + description: 'Access token expiration time in seconds', example: 900, }) expiresIn: number; + @ApiProperty({ + description: 'Refresh token expiration time in seconds', + example: 2592000, + }) + refreshExpiresIn?: number; + @ApiProperty({ description: 'User information', type: () => UserDto, diff --git a/backend/src/auth/dto/logout.dto.ts b/backend/src/auth/dto/logout.dto.ts new file mode 100644 index 00000000..c2b9f720 --- /dev/null +++ b/backend/src/auth/dto/logout.dto.ts @@ -0,0 +1,13 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class LogoutDto { + @ApiPropertyOptional({ + description: + 'Refresh token to revoke alongside the current access token. If omitted only the access token is revoked.', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsOptional() + @IsString() + refreshToken?: string; +} \ No newline at end of file diff --git a/backend/src/auth/dto/refresh-token.dto.ts b/backend/src/auth/dto/refresh-token.dto.ts new file mode 100644 index 00000000..f33a6f4f --- /dev/null +++ b/backend/src/auth/dto/refresh-token.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class RefreshTokenDto { + @ApiProperty({ + description: 'Valid refresh token to exchange for a new token pair', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString() + @IsNotEmpty() + refreshToken: string; +} \ No newline at end of file diff --git a/backend/src/auth/entities/user.entity.ts b/backend/src/auth/entities/user.entity.ts index 5fc16d18..faa29f06 100644 --- a/backend/src/auth/entities/user.entity.ts +++ b/backend/src/auth/entities/user.entity.ts @@ -36,6 +36,15 @@ export class User { @Column({ unique: true, nullable: true }) lastLoginAt: Date; + @Column({ nullable: true }) + walletAddress?: string; + + @Column({ type: 'text', nullable: true }) + bio?: string; + + @Column({ nullable: true }) + avatarUrl?: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/auth/services/auth.service.spec.ts b/backend/src/auth/services/auth.service.spec.ts index 447d9872..3f25f0a9 100644 --- a/backend/src/auth/services/auth.service.spec.ts +++ b/backend/src/auth/services/auth.service.spec.ts @@ -1,96 +1,118 @@ -import { Test, type TestingModule } from "@nestjs/testing" -import { getRepositoryToken } from "@nestjs/typeorm" -import { JwtService } from "@nestjs/jwt" -import { ConfigService } from "@nestjs/config" -import { UnauthorizedException } from "@nestjs/common" -import { AuthService } from "./auth.service" -import { User } from "../entities/user.entity" -import type { RegisterDto } from "../dto/register.dto" -import type { LoginDto } from "../dto/login.dto" -import type { AuthResponseDto } from "../dto/auth-response.dto" -import { jest } from "@jest/globals" -import type { Repository } from "typeorm" - -describe("AuthService", () => { - let service: AuthService - let userRepository: jest.Mocked> - let jwtService: jest.Mocked - - const mockUserRepository = { - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - update: jest.fn(), - } as unknown as jest.Mocked>>; - - const mockJwtService = { - sign: jest.fn(), - } as unknown as jest.Mocked; - - const mockConfigService = { - get: jest.fn(), - } as unknown as jest.Mocked; +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { User } from '../entities/user.entity'; +import { RegisterDto } from '../dto/register.dto'; +import { LoginDto } from '../dto/login.dto'; +import { UserTokenHistoryService } from '../../user-token-history/services/user-token-history.service'; + +describe('AuthService', () => { + let service: AuthService; + let userRepository: { + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + update: jest.Mock; + }; + let jwtService: { + sign: jest.Mock; + verifyAsync: jest.Mock; + }; + let configService: { + get: jest.Mock; + }; + let tokenHistoryService: { + recordTokenIssuance: jest.Mock; + revokeTokenByValue: jest.Mock; + revokeTokenFamily: jest.Mock; + revokeAllUserTokens: jest.Mock; + findTokenReuse: jest.Mock; + isTokenRevoked: jest.Mock; + }; + + const registerDto: RegisterDto = { + name: 'John Doe', + username: 'johnny_doe', + email: 'john@example.com', + password: 'SecurePass123!', + }; + + const loginDto: LoginDto = { + email: 'john@example.com', + password: 'SecurePass123!', + }; beforeEach(async () => { + userRepository = { + findOne: jest.fn(), + create: jest.fn((data) => ({ id: 'user-id', ...data })), + save: jest.fn(async (data) => data), + update: jest.fn(async () => ({})), + }; + jwtService = { + sign: jest.fn(() => 'signed-token'), + verifyAsync: jest.fn(), + }; + configService = { + get: jest.fn((key: string) => { + if (key === 'JWT_EXPIRES_IN') return '15m'; + if (key === 'JWT_REFRESH_EXPIRES_IN') return '30d'; + return undefined; + }), + }; + tokenHistoryService = { + recordTokenIssuance: jest.fn(async () => ({})), + revokeTokenByValue: jest.fn(async () => ({})), + revokeTokenFamily: jest.fn(async () => ({ success: true, revokedCount: 0, errors: [], revokedTokens: [] })), + revokeAllUserTokens: jest.fn(async () => ({ success: true, revokedCount: 0, errors: [], revokedTokens: [] })), + findTokenReuse: jest.fn(async () => null), + isTokenRevoked: jest.fn(async () => false), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, - { - provide: getRepositoryToken(User), - useValue: mockUserRepository, - }, - { - provide: JwtService, - useValue: mockJwtService, - }, - { - provide: ConfigService, - useValue: mockConfigService, - }, + { provide: getRepositoryToken(User), useValue: userRepository }, + { provide: JwtService, useValue: jwtService }, + { provide: ConfigService, useValue: configService }, + { provide: UserTokenHistoryService, useValue: tokenHistoryService }, ], }).compile(); - service = module.get(AuthService) - userRepository = module.get(getRepositoryToken(User)) as jest.Mocked> - jwtService = module.get(JwtService) as unknown as jest.Mocked - }) - - afterEach(() => { - jest.clearAllMocks(); + service = module.get(AuthService); }); describe('register', () => { - const registerDto: RegisterDto = { - name: "John Doe", - username: "john_doe", - email: "john@example.com", - password: "SecurePass123!", - } - - const registeredUser = { - id: "user-id", - name: "John Doe", - email: "john@example.com", - createdAt: new Date(), - } as User - - it("should successfully register a new user", async () => { - userRepository.findOne.mockResolvedValue(null) - userRepository.create.mockReturnValue(registeredUser) - userRepository.save.mockResolvedValue(registeredUser) - jwtService.sign.mockReturnValue("jwt-token") - mockConfigService.get.mockReturnValue("15m") + it('returns an access token and refresh token', async () => { + const savedUser = { + id: 'user-id', + name: 'John Doe', + email: 'john@example.com', + createdAt: new Date(), + } as User; + + userRepository.findOne.mockResolvedValueOnce(null); + userRepository.save.mockResolvedValue(savedUser); + jwtService.sign.mockReturnValue('access-token'); + jwtService.sign.mockReturnValue('refresh-token'); - const result = (await service.register(registerDto)) as AuthResponseDto + const result = await service.register(registerDto); - expect(result).toHaveProperty('accessToken', 'jwt-token'); - expect(result).toHaveProperty('user'); + expect(result.accessToken).toBeDefined(); + expect(result.refreshToken).toBeDefined(); + expect(result.refreshExpiresIn).toBeDefined(); + expect(tokenHistoryService.recordTokenIssuance).toHaveBeenCalledTimes(2); expect(result.user.email).toBe('john@example.com'); }); - it("should return a generic neutral message if user already exists (anti-enumeration)", async () => { - const existingUser = { id: "existing-user" } as User - userRepository.findOne.mockResolvedValue(existingUser) + it('throws ConflictException if user already exists', async () => { + userRepository.findOne.mockResolvedValue({ + id: 'existing-user', + email: 'john@example.com', + } as User); const result = await service.register(registerDto) @@ -115,61 +137,177 @@ describe("AuthService", () => { }) describe('login', () => { - const loginDto: LoginDto = { - email: 'john@example.com', - password: 'SecurePass123!', - }; + it('returns a fresh token pair on valid credentials', async () => { + const mockUser = { + id: 'user-id', + name: 'John Doe', + email: 'john@example.com', + isActive: true, + validatePassword: jest.fn(async () => true), + } as User & { validatePassword: jest.Mock }; - const validatedUser = (isActive: boolean, passwordMatches: boolean) => { - const user = { - id: "user-id", - name: "John Doe", - email: "john@example.com", - isActive, - validatePassword: async () => passwordMatches, - } as unknown as User - return user - } - - it("should successfully login with valid credentials", async () => { - const mockUser = validatedUser(true, true) - userRepository.findOne.mockResolvedValue(mockUser) - userRepository.update.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} }) - jwtService.sign.mockReturnValue("jwt-token") - mockConfigService.get.mockReturnValue("15m") + userRepository.findOne.mockResolvedValue(mockUser); const result = await service.login(loginDto); - expect(result).toHaveProperty('accessToken', 'jwt-token'); - expect(result).toHaveProperty('user'); + expect(result.accessToken).toBe('signed-token'); + expect(result.refreshToken).toBe('signed-token'); + expect(result.user.email).toBe('john@example.com'); + expect(tokenHistoryService.recordTokenIssuance).toHaveBeenCalled(); }); - it("should throw UnauthorizedException for unknown email without revealing that the account does not exist", async () => { - userRepository.findOne.mockResolvedValue(null) + it('throws UnauthorizedException for invalid credentials', async () => { + userRepository.findOne.mockResolvedValue(null); await expect(service.login(loginDto)).rejects.toThrow( new UnauthorizedException("Invalid email or password"), ) }) - it("should throw UnauthorizedException for inactive user without revealing account status", async () => { - const mockUser = validatedUser(false, true) - userRepository.findOne.mockResolvedValue(mockUser) + it('throws UnauthorizedException for inactive user', async () => { + userRepository.findOne.mockResolvedValue({ + id: 'user-id', + isActive: false, + } as User); - // Same generic message as for unknown email / wrong password. + // Message identical across all failure modes. await expect(service.login(loginDto)).rejects.toThrow( - new UnauthorizedException("Invalid email or password"), - ) - }) + UnauthorizedException, + ); + }); + }); - it("should not reveal whether an account exists when password is wrong", async () => { - const mockUser = validatedUser(true, false) - userRepository.findOne.mockResolvedValue(mockUser) + describe('refreshToken', () => { + it('rotates an invalid-type token and is rejected', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: 'user-id', + type: 'access', + email: 'john@example.com', + }); - // Message identical across all failure modes. - await expect(service.login(loginDto)).rejects.toThrow( - new UnauthorizedException("Invalid email or password"), - ) - }) - }) -}) + await expect(service.refreshToken('some-refresh')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('issues a new token pair and revokes the old refresh token', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: 'user-id', + type: 'refresh', + email: 'john@example.com', + }); + userRepository.findOne.mockResolvedValue({ + id: 'user-id', + name: 'John Doe', + email: 'john@example.com', + isActive: true, + } as User); + + const result = await service.refreshToken('old-refresh-token'); + + expect(tokenHistoryService.revokeTokenByValue).toHaveBeenCalledWith( + 'old-refresh-token', + 'user-id', + 'rotated', + ); + expect(result.accessToken).toBeDefined(); + expect(result.refreshToken).toBeDefined(); + expect(tokenHistoryService.recordTokenIssuance).toHaveBeenCalledTimes(2); + }); + + it('rejects an invalid refresh token', async () => { + jwtService.verifyAsync.mockRejectedValue(new Error('expired')); + + await expect(service.refreshToken('bad')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('detects a replayed (stolen) refresh token and revokes the whole token family', async () => { + jwtService.verifyAsync.mockResolvedValue({ + sub: 'user-id', + type: 'refresh', + email: 'john@example.com', + fam: 'family-123', + }); + // The reused token was already rotated → its history record is REVOKED. + tokenHistoryService.findTokenReuse.mockResolvedValueOnce({ + familyId: 'family-123', + status: 'revoked', + } as any); + + await expect(service.refreshToken('replayed-refresh')).rejects.toThrow( + UnauthorizedException, + ); + + // The whole family and every remaining refresh session is revoked. + expect(tokenHistoryService.revokeTokenFamily).toHaveBeenCalledWith( + 'family-123', + 'user-id', + 'Stolen token reuse detected', + ); + expect(tokenHistoryService.revokeAllUserTokens).toHaveBeenCalledWith( + 'user-id', + 'user-id', + 'Stolen token reuse detected', + expect.anything(), + ); + // A new token pair must NOT be issued for a stolen/replayed token. + expect(tokenHistoryService.recordTokenIssuance).not.toHaveBeenCalled(); + }); + }); + + describe('logout', () => { + it('revokes the access and refresh tokens', async () => { + const result = await service.logout( + 'user-id', + 'access-token', + 'refresh-token', + ); + + expect(result.success).toBe(true); + expect(tokenHistoryService.revokeTokenByValue).toHaveBeenCalledWith( + 'access-token', + 'user-id', + 'logout', + ); + expect(tokenHistoryService.revokeTokenByValue).toHaveBeenCalledWith( + 'refresh-token', + 'user-id', + 'logout', + ); + }); + }); + + describe('validateUser', () => { + it('rejects a revoked token', async () => { + userRepository.findOne.mockResolvedValue({ + id: 'user-id', + isActive: true, + } as User); + tokenHistoryService.isTokenRevoked.mockResolvedValue(true); + + await expect( + service.validateUser( + { sub: 'user-id', email: 'a@b.com', name: 'A' }, + 'revoked-token', + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('allows an unrevoked token', async () => { + userRepository.findOne.mockResolvedValue({ + id: 'user-id', + isActive: true, + } as User); + tokenHistoryService.isTokenRevoked.mockResolvedValue(false); + + await expect( + service.validateUser( + { sub: 'user-id', email: 'a@b.com', name: 'A' }, + 'good-token', + ), + ).resolves.toBeDefined(); + }); + }); +}); diff --git a/backend/src/auth/services/auth.service.ts b/backend/src/auth/services/auth.service.ts index 8d229c85..4347f6bf 100644 --- a/backend/src/auth/services/auth.service.ts +++ b/backend/src/auth/services/auth.service.ts @@ -1,23 +1,51 @@ -import { Injectable, UnauthorizedException, BadRequestException } from "@nestjs/common" -import { Repository } from "typeorm" -import { JwtService } from "@nestjs/jwt" -import { ConfigService } from "@nestjs/config" -import { User } from "../entities/user.entity" -import { RegisterDto } from "../dto/register.dto" -import { AuthResponseDto } from "../dto/auth-response.dto" -import { GenericAuthMessageDto } from "../dto/generic-auth-message.dto" -import { LoginDto } from "../dto/login.dto" -import { InjectRepository } from "@nestjs/typeorm" +import { + Injectable, + ConflictException, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { User } from '../entities/user.entity'; +import { RegisterDto } from '../dto/register.dto'; +import { AuthResponseDto } from '../dto/auth-response.dto'; +import { LoginDto } from '../dto/login.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { UserTokenHistoryService } from '../../user-token-history/services/user-token-history.service'; +import { TokenType } from '../../user-token-history/entities/token-history.entity'; + +const BREACHED_PASSWORDS = new Set([ + 'password', + 'password123!', + '12345678', + 'qwerty123!', + 'letmein123!', + 'welcome123!', + 'iloveyou123!', + 'admin123!', +]); export interface JwtPayload { sub: string; // user id email: string; name: string; username?: string; + jti?: string; + fam?: string; // token family id (grouping one login session) + type?: 'access' | 'refresh'; iat?: number; exp?: number; } +interface TokenPair { + accessToken: string; + refreshToken: string; + accessJti: string; + refreshJti: string; +} + @Injectable() export class AuthService { constructor( @@ -25,6 +53,7 @@ export class AuthService { private readonly userRepository: Repository, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly tokenHistoryService: UserTokenHistoryService, ) {} /** @@ -64,20 +93,17 @@ export class AuthService { const savedUser = await this.userRepository.save(user); - // Generate JWT token - const payload: JwtPayload = { - sub: savedUser.id, - email: savedUser.email, - name: savedUser.name, - }; - - const accessToken = this.jwtService.sign(payload); + // Generate JWT token pair (access + refresh) + const tokens = await this.issueTokenPair(savedUser); const expiresIn = this.getTokenExpirationTime(); + const refreshExpiresIn = this.getRefreshTokenExpirationTime(); return { - accessToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, tokenType: 'Bearer', expiresIn, + refreshExpiresIn, user: { id: savedUser.id, name: savedUser.name, @@ -128,20 +154,17 @@ export class AuthService { lastLoginAt: new Date(), }); - // Generate JWT token - const payload: JwtPayload = { - sub: user.id, - email: user.email, - name: user.name, - }; - - const accessToken = this.jwtService.sign(payload); + // Generate JWT token pair (access + refresh) + const tokens = await this.issueTokenPair(user); const expiresIn = this.getTokenExpirationTime(); + const refreshExpiresIn = this.getRefreshTokenExpirationTime(); return { - accessToken, + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, tokenType: 'Bearer', expiresIn, + refreshExpiresIn, user: { id: user.id, name: user.name, @@ -160,7 +183,7 @@ export class AuthService { } } - async validateUser(payload: JwtPayload): Promise { + async validateUser(payload: JwtPayload, rawToken?: string): Promise { const user = await this.userRepository.findOne({ where: { id: payload.sub }, }); @@ -169,9 +192,119 @@ export class AuthService { throw new UnauthorizedException('User not found or inactive'); } + if (rawToken) { + const revoked = await this.tokenHistoryService.isTokenRevoked(rawToken); + if (revoked) { + throw new UnauthorizedException('Token has been revoked or expired'); + } + } + return user; } + /** + * Exchange a valid refresh token for a fresh token pair (rotation). + * The presented refresh token is revoked so it cannot be reused. + */ + async refreshToken(refreshToken: string): Promise { + let payload: JwtPayload; + try { + payload = await this.jwtService.verifyAsync(refreshToken); + } catch { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + if (payload.type !== 'refresh') { + throw new UnauthorizedException('Invalid refresh token'); + } + + // ── Stolen-token / reuse detection ───────────────────────────── + // If the presented refresh token was already rotated (its history record + // is REVOKED/EXPIRED/USED rather than ACTIVE), it is being replayed — + // which indicates token theft. Revoke the whole token family and every + // remaining session for the user, then reject the request. + const reuseRecord = await this.tokenHistoryService.findTokenReuse( + refreshToken, + ); + if (reuseRecord) { + const familyId = + reuseRecord.familyId || (payload.fam as string | undefined); + + if (familyId) { + await this.tokenHistoryService.revokeTokenFamily( + familyId, + payload.sub, + 'Stolen token reuse detected', + ); + } + await this.tokenHistoryService.revokeAllUserTokens( + payload.sub, + payload.sub, + 'Stolen token reuse detected', + TokenType.REFRESH, + ); + + throw new UnauthorizedException( + 'Refresh token has already been used. Please sign in again.', + ); + } + + // Rotate: revoke the presented refresh token before issuing a new pair. + await this.tokenHistoryService.revokeTokenByValue( + refreshToken, + payload.sub, + 'rotated', + ); + + const user = await this.getUserById(payload.sub); + const tokens = await this.issueTokenPair( + user, + (payload.fam as string | undefined) || undefined, + ); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + tokenType: 'Bearer', + expiresIn: this.getTokenExpirationTime(), + refreshExpiresIn: this.getRefreshTokenExpirationTime(), + user: { + id: user.id, + name: user.name, + email: user.email, + createdAt: user.createdAt, + }, + }; + } + + /** + * Server-side logout. Revokes the presented access token (and optionally a + * refresh token) so they can no longer be used even before natural expiry. + */ + async logout( + userId: string, + accessToken: string, + refreshToken?: string, + ): Promise<{ success: boolean }> { + if (accessToken) { + await this.tokenHistoryService.revokeTokenByValue( + accessToken, + userId, + 'logout', + ); + } + + if (refreshToken) { + await this.tokenHistoryService.revokeTokenByValue( + refreshToken, + userId, + 'logout', + ); + } + + return { success: true }; + } + async getUserById(id: string): Promise { const user = await this.userRepository.findOne({ where: { id }, @@ -184,39 +317,85 @@ export class AuthService { return user; } - /** - * Returns a neutral, account-existence-neutral registration response. - * Mirrors the JS static message constant so tests can assert against it. - */ - private genericRegistrationMessage(): GenericAuthMessageDto { - return { - message: "Registration successful. If an account already exists, please log in.", - } + private async issueTokenPair(user: User, familyId?: string): Promise { + const accessJti = crypto.randomUUID(); + const refreshJti = crypto.randomUUID(); + const accessExpiresIn = this.getTokenExpirationTime(); + const refreshExpiresIn = this.getRefreshTokenExpirationTime(); + + // A fresh login starts a new token family; a refresh continues the + // family of the (rotated) refresh token so a stolen descendant can be + // traced back and revoked as a unit. + const tokenFamilyId = familyId ?? crypto.randomUUID(); + + const basePayload: Pick & { + fam: string; + } = { + sub: user.id, + email: user.email, + name: user.name, + fam: tokenFamilyId, + }; + + const accessToken = this.jwtService.sign( + { ...basePayload, jti: accessJti, type: 'access' }, + { expiresIn: accessExpiresIn }, + ); + const refreshToken = this.jwtService.sign( + { ...basePayload, jti: refreshJti, type: 'refresh' }, + { expiresIn: refreshExpiresIn }, + ); + + await this.tokenHistoryService.recordTokenIssuance({ + userId: user.id, + token: accessToken, + tokenType: TokenType.ACCESS, + jti: accessJti, + familyId: tokenFamilyId, + }); + await this.tokenHistoryService.recordTokenIssuance({ + userId: user.id, + token: refreshToken, + tokenType: TokenType.REFRESH, + jti: refreshJti, + familyId: tokenFamilyId, + }); + + return { accessToken, refreshToken, accessJti, refreshJti }; } private getTokenExpirationTime(): number { const expiresIn = this.configService.get('JWT_EXPIRES_IN') || '15m'; + return this.parseExpiresInSeconds(expiresIn); + } + private getRefreshTokenExpirationTime(): number { + const expiresIn = + this.configService.get('JWT_REFRESH_EXPIRES_IN') || '30d'; + return this.parseExpiresInSeconds(expiresIn); + } + + private parseExpiresInSeconds(expiresIn: string | number): number { // Convert time string to seconds - if (typeof expiresIn === 'string') { - const timeValue = Number.parseInt(expiresIn); - const timeUnit = expiresIn.slice(-1); - - switch (timeUnit) { - case 's': - return timeValue; - case 'm': - return timeValue * 60; - case 'h': - return timeValue * 60 * 60; - case 'd': - return timeValue * 24 * 60 * 60; - default: - return 900; // 15 minutes default - } + if (typeof expiresIn === 'number') { + return expiresIn; } - return typeof expiresIn === 'number' ? expiresIn : 900; + const timeValue = Number.parseInt(expiresIn); + const timeUnit = expiresIn.slice(-1); + + switch (timeUnit) { + case 's': + return timeValue; + case 'm': + return timeValue * 60; + case 'h': + return timeValue * 60 * 60; + case 'd': + return timeValue * 24 * 60 * 60; + default: + return 900; // 15 minutes default + } } private assertPasswordPolicy( diff --git a/backend/src/auth/strategies/jwt.strategy.ts b/backend/src/auth/strategies/jwt.strategy.ts index 99c62a9f..56f4a84e 100644 --- a/backend/src/auth/strategies/jwt.strategy.ts +++ b/backend/src/auth/strategies/jwt.strategy.ts @@ -14,15 +14,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - // No fallback secret: fail fast when JWT_SECRET is not configured. - secretOrKey: configService.getOrThrow('JWT_SECRET'), + passReqToCallback: true, + secretOrKey: configService.get('JWT_SECRET') || 'your-secret-key', }); } - async validate(payload: JwtPayload): Promise { + async validate(request: Request & { headers: any }, payload: JwtPayload): Promise { try { - const user = await this.authService.validateUser(payload); - return user; + const authHeader = request.headers?.authorization; + const rawToken = + typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : undefined; + + return await this.authService.validateUser(payload, rawToken); } catch (error) { throw new UnauthorizedException('Invalid token'); } diff --git a/backend/src/referral/entities/referral-bonus.entity.ts b/backend/src/referral/entities/referral-bonus.entity.ts index 7b611e81..392c44bd 100644 --- a/backend/src/referral/entities/referral-bonus.entity.ts +++ b/backend/src/referral/entities/referral-bonus.entity.ts @@ -1,5 +1,5 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn, JoinColumn, Index } from "typeorm" -import { User } from "../../user/entities/user.entity" // Adjust path as needed +import { User } from '../../auth/entities/user.entity'; import { ReferralInvite } from "./referral-invite.entity" export enum BonusType { diff --git a/backend/src/referral/entities/referral-code.entity.ts b/backend/src/referral/entities/referral-code.entity.ts index 9009d135..3196925c 100644 --- a/backend/src/referral/entities/referral-code.entity.ts +++ b/backend/src/referral/entities/referral-code.entity.ts @@ -9,7 +9,7 @@ import { JoinColumn, Index, } from "typeorm" -import { User } from "../../user/entities/user.entity" // Adjust path as needed +import { User } from '../../auth/entities/user.entity'; import { ReferralInvite } from "./referral-invite.entity" @Entity("referral_codes") diff --git a/backend/src/referral/entities/referral-invite.entity.ts b/backend/src/referral/entities/referral-invite.entity.ts index d7df4220..3c883986 100644 --- a/backend/src/referral/entities/referral-invite.entity.ts +++ b/backend/src/referral/entities/referral-invite.entity.ts @@ -8,7 +8,7 @@ import { JoinColumn, Index, } from 'typeorm'; -import { User } from '../../user/entities/user.entity'; // Adjust path as needed +import { User } from '../../auth/entities/user.entity'; import { ReferralCode } from './referral-code.entity'; export enum InviteStatus { diff --git a/backend/src/user-inventory/entities/inventory.ts b/backend/src/user-inventory/entities/inventory.ts index 0010654d..465b852c 100644 --- a/backend/src/user-inventory/entities/inventory.ts +++ b/backend/src/user-inventory/entities/inventory.ts @@ -7,7 +7,7 @@ import { CreateDateColumn, Index, } from 'typeorm'; -import { User } from './user'; +import { User } from '../../auth/entities/user.entity'; export enum AssetType { NFT = 'nft', @@ -39,7 +39,7 @@ export class Inventory { @Column('jsonb', { nullable: true }) acquisitionContext: Record; // How they got it (puzzle solved, etc.) - @ManyToOne(() => User, (user) => user.inventory, { onDelete: 'CASCADE' }) + @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'userId' }) user: User; diff --git a/backend/src/user-inventory/entities/user-inventory.entity.ts b/backend/src/user-inventory/entities/user-inventory.entity.ts deleted file mode 100644 index 7c00d98f..00000000 --- a/backend/src/user-inventory/entities/user-inventory.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class UserInventory {} diff --git a/backend/src/user-inventory/entities/user.ts b/backend/src/user-inventory/entities/user.ts deleted file mode 100644 index 88918b2a..00000000 --- a/backend/src/user-inventory/entities/user.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - OneToMany, - CreateDateColumn, - UpdateDateColumn, -} from 'typeorm'; -import { Inventory } from './inventory'; -import { TimeTrial } from 'src/time-trial/time-trial.entity'; - -@Entity('users') -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ unique: true }) - username: string; - - @Column({ unique: true }) - email: string; - - @Column() - password: string; - - // @OneToMany(() => TimeTrial, timeTrial => timeTrial.user) - // timeTrials: TimeTrial[]; - - @OneToMany(() => Inventory, (inventory) => inventory.user) - inventory: Inventory[]; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/backend/src/user-inventory/user-inventory.module.ts b/backend/src/user-inventory/user-inventory.module.ts index ebdb7b6d..abac4c34 100644 --- a/backend/src/user-inventory/user-inventory.module.ts +++ b/backend/src/user-inventory/user-inventory.module.ts @@ -3,7 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserInventoryController } from './user-inventory.controller'; import { UserInventoryService } from './user-inventory.service'; import { Inventory } from './entities/inventory'; -import { User } from './entities/user'; +import { User } from '../auth/entities/user.entity'; import { NFT } from './entities/nft'; import { Badge } from './entities/badge'; diff --git a/backend/src/user-inventory/user-inventory.service.ts b/backend/src/user-inventory/user-inventory.service.ts index c3fd9e33..140f9854 100644 --- a/backend/src/user-inventory/user-inventory.service.ts +++ b/backend/src/user-inventory/user-inventory.service.ts @@ -7,7 +7,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Repository, FindManyOptions } from 'typeorm'; import { Inventory, AssetType } from './entities/inventory'; -import { User } from './entities/user'; +import { User } from '../auth/entities/user.entity'; import { NFT } from './entities/nft'; import { Badge } from './entities/badge'; import { diff --git a/backend/src/user-token-history/entities/token-history.entity.ts b/backend/src/user-token-history/entities/token-history.entity.ts index a6ec517a..f78ba808 100644 --- a/backend/src/user-token-history/entities/token-history.entity.ts +++ b/backend/src/user-token-history/entities/token-history.entity.ts @@ -19,6 +19,7 @@ export enum TokenStatus { @Index(["userId", "status"]) @Index(["tokenHash"]) @Index(["expiresAt"]) +@Index(["familyId", "status"]) export class TokenHistory { @PrimaryGeneratedColumn("uuid") id: string @@ -26,6 +27,9 @@ export class TokenHistory { @Column({ type: "uuid", length: 128 }) userId: string + @Column({ type: "uuid", length: 128, nullable: true }) + familyId: string // groups every token issued from one login session + @Column({ type: "varchar", length: 64, unique: true }) tokenHash: string // SHA-256 hash of the token for security diff --git a/backend/src/user-token-history/interfaces/token-history.interface.ts b/backend/src/user-token-history/interfaces/token-history.interface.ts index f894eeb8..3dc80dd7 100644 --- a/backend/src/user-token-history/interfaces/token-history.interface.ts +++ b/backend/src/user-token-history/interfaces/token-history.interface.ts @@ -10,6 +10,7 @@ export interface CreateTokenHistoryDto { issuer?: string; scopes?: string[]; jti?: string; + familyId?: string; } export interface TokenMetadata { @@ -26,6 +27,7 @@ export interface TokenMetadata { export interface TokenHistoryResponse { id: string; userId: string; + familyId?: string; tokenHash: string; jti?: string; tokenType: TokenType; diff --git a/backend/src/user-token-history/services/user-token-history.service.ts b/backend/src/user-token-history/services/user-token-history.service.ts index 39e05d1d..cbedaa83 100644 --- a/backend/src/user-token-history/services/user-token-history.service.ts +++ b/backend/src/user-token-history/services/user-token-history.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -import type { Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { TokenHistory, TokenType, @@ -21,6 +22,7 @@ export class UserTokenHistoryService { private readonly logger = new Logger(UserTokenHistoryService.name); constructor( + @InjectRepository(TokenHistory) private readonly tokenHistoryRepository: Repository, private readonly jwtService: JwtService, ) {} @@ -44,6 +46,7 @@ export class UserTokenHistoryService { userId: tokenData.userId, tokenHash, jti: tokenData.jti || decodedToken?.jti || crypto.randomUUID(), + familyId: tokenData.familyId || null, tokenType: tokenData.tokenType || TokenType.ACCESS, status: TokenStatus.ACTIVE, issuedAt: @@ -211,6 +214,145 @@ export class UserTokenHistoryService { ); } + /** + * Check whether a recorded token has been revoked or has expired. + * + * Unlike `isTokenValid`, tokens with no history record are treated as + * valid so that tokens issued before the token-history feature shipped are + * not rejected. + */ + async isTokenRevoked(token: string): Promise { + const tokenHash = this.generateTokenHash(token); + + const tokenHistory = await this.tokenHistoryRepository.findOne({ + where: { tokenHash }, + }); + + if (!tokenHistory) { + return false; // No history record -> never revoked + } + + const now = new Date(); + return ( + tokenHistory.status === TokenStatus.REVOKED || + tokenHistory.status === TokenStatus.EXPIRED || + tokenHistory.expiresAt <= now + ); + } + + /** + * Revoke a token by its raw (JWT) value. Computes the SHA-256 hash and + * marks the matching active record as revoked. + */ + async revokeTokenByValue( + token: string, + revokedBy: string, + reason = 'Token revoked', + ): Promise { + const tokenHash = this.generateTokenHash(token); + + const tokenHistory = await this.tokenHistoryRepository.findOne({ + where: { tokenHash, status: TokenStatus.ACTIVE }, + }); + + if (!tokenHistory) { + return null; + } + + tokenHistory.status = TokenStatus.REVOKED; + tokenHistory.revokedAt = new Date(); + tokenHistory.revokedBy = revokedBy; + tokenHistory.revocationReason = reason; + + const updatedToken = await this.tokenHistoryRepository.save(tokenHistory); + + this.logger.log(`Token revoked by value: ${updatedToken.id}`); + + return this.mapToResponse(updatedToken); + } + + /** + * Detect a refresh-token reuse (a stolen, already-rotated refresh token + * being replayed). Returns the token's history record if it exists in a + * non-active state, or null when the record is absent / still active. + * + * A refresh token that is `REVOKED`, `EXPIRED` or `USED` but was submitted + * again is treated as a potential theft: the caller should revoke the whole + * family and the user's remaining sessions. + */ + async findTokenReuse( + token: string, + ): Promise { + const tokenHash = this.generateTokenHash(token); + + const tokenHistory = await this.tokenHistoryRepository.findOne({ + where: { tokenHash }, + }); + + if (!tokenHistory) { + return null; + } + + if (tokenHistory.status !== TokenStatus.ACTIVE) { + return tokenHistory; + } + + return null; + } + + /** + * Revoke every still-active token belonging to the same token family. Used + * to invalidate an entire session lineage when a refresh-token reuse (theft) + * is detected. + */ + async revokeTokenFamily( + familyId: string, + revokedBy: string, + reason = 'Stolen token reuse detected', + ): Promise { + this.logger.warn( + `Revoking token family ${familyId} (reason: ${reason})`, + ); + + try { + let updatedCount = 0; + await this.tokenHistoryRepository.manager.transaction( + async (manager) => { + const result = await manager + .createQueryBuilder() + .update(TokenHistory) + .set({ + status: TokenStatus.REVOKED, + revokedAt: new Date(), + revokedBy, + revocationReason: reason, + }) + .where('familyId = :familyId', { familyId }) + .andWhere('status = :status', { status: TokenStatus.ACTIVE }) + .execute(); + updatedCount = result.affected || 0; + }, + ); + + return { + success: true, + revokedCount: updatedCount, + errors: [], + revokedTokens: [], + }; + } catch (error) { + this.logger.error( + `Failed to revoke token family ${familyId}: ${error.message}`, + ); + return { + success: false, + revokedCount: 0, + errors: [error.message], + revokedTokens: [], + }; + } + } + /** * Get token history for a user */ @@ -526,6 +668,7 @@ export class UserTokenHistoryService { return { id: tokenHistory.id, userId: tokenHistory.userId, + familyId: tokenHistory.familyId, tokenHash: tokenHistory.tokenHash, jti: tokenHistory.jti, tokenType: tokenHistory.tokenType, diff --git a/backend/src/user/entities/user.entity.ts b/backend/src/user/entities/user.entity.ts deleted file mode 100644 index 4ab4acd5..00000000 --- a/backend/src/user/entities/user.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - Entity, - Column, - PrimaryGeneratedColumn, - CreateDateColumn, - UpdateDateColumn, -} from 'typeorm'; - -@Entity('users') -export class User { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ unique: true, length: 30 }) - username: string; - - @Column({ unique: true }) - email: string; - - @Column({ nullable: true, unique: true }) - walletAddress?: string; - - @Column({ type: 'text', nullable: true }) - bio?: string; - - @Column({ nullable: true }) - avatarUrl?: string; - - @CreateDateColumn() - createdAt: Date; - - @UpdateDateColumn() - updatedAt: Date; -} diff --git a/backend/src/user/user.module.ts b/backend/src/user/user.module.ts index 81928307..d44b588d 100644 --- a/backend/src/user/user.module.ts +++ b/backend/src/user/user.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from './entities/user.entity'; +import { User } from '../auth/entities/user.entity'; import { UserService } from './user.service'; import { UserController } from './user.controller'; diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index f2fc8dfa..0e58ca76 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { User } from './entities/user.entity'; +import { User } from '../auth/entities/user.entity'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserProfileDto } from './dto/update-user-profile.dto'; import { LinkWalletDto } from './dto/link-wallet.dto'; diff --git a/backend/src/wallet/dto/verify-wallet-signature.dto.ts b/backend/src/wallet/dto/verify-wallet-signature.dto.ts new file mode 100644 index 00000000..4a560027 --- /dev/null +++ b/backend/src/wallet/dto/verify-wallet-signature.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class VerifyWalletSignatureDto { + @ApiProperty({ + description: 'Stellar public key (G...) that produced the signature', + example: 'GDN752Q6GWGMEEDZOL7DACRHE2QMCD55MILMLRPMZDCN2AOKB6WUOH2G', + }) + @IsString() + @IsNotEmpty() + walletAddress: string; + + @ApiProperty({ + description: 'The challenge message that was signed', + example: + 'stellar-hunts: authenticate: wallet=G..., nonce=..., user=..., domain=..., expires=...', + }) + @IsString() + @IsNotEmpty() + message: string; + + @ApiProperty({ + description: 'Base64-encoded ed25519 signature of the message bytes', + example: '8Un3...', + }) + @IsString() + @IsNotEmpty() + signature: string; +} \ No newline at end of file diff --git a/backend/src/wallet/dto/wallet-challenge.dto.ts b/backend/src/wallet/dto/wallet-challenge.dto.ts new file mode 100644 index 00000000..bfe4a896 --- /dev/null +++ b/backend/src/wallet/dto/wallet-challenge.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; + +export class WalletChallengeDto { + @ApiProperty({ + description: 'Stellar public key (G...) that will sign the challenge', + example: 'GDN752Q6GWGMEEDZOL7DACRHE2QMCD55MILMLRPMZDCN2AOKB6WUOH2G', + }) + @IsString() + @IsNotEmpty() + walletAddress: string; + + @ApiPropertyOptional({ + description: + 'User id the challenge is bound to. When provided, the signature only verifies for this user.', + example: 'f7e7e2f1-8a3f-4cbb-9e9d-4f2c1a1b2c3d', + }) + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional({ + description: 'Domain the challenge is bound to (defaults to app origin)', + example: 'stellar-hunts.app', + }) + @IsOptional() + @IsString() + domain?: string; + + @ApiPropertyOptional({ + description: 'Challenge lifetime in seconds (default 300 = 5 minutes)', + example: 300, + }) + @IsOptional() + ttlSeconds?: number; +} \ No newline at end of file diff --git a/backend/src/wallet/entities/consumed-nonce.entity.ts b/backend/src/wallet/entities/consumed-nonce.entity.ts new file mode 100644 index 00000000..50bd9865 --- /dev/null +++ b/backend/src/wallet/entities/consumed-nonce.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +/** + * Records nonces that have already been consumed by a wallet signature + * verification. Enforces single-use challenges and prevents replay attacks. + */ +@Entity('consumed_wallet_nonces') +@Index(['walletAddress', 'nonce']) +export class ConsumedWalletNonce { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true, length: 128 }) + nonce: string; + + @Column({ length: 64 }) + walletAddress: string; + + @Column({ type: 'uuid', nullable: true }) + userId?: string; + + @Column({ length: 255, nullable: true }) + domain: string; + + @Column({ type: 'timestamp' }) + consumedAt: Date; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/wallet/wallet.controller.spec.ts b/backend/src/wallet/wallet.controller.spec.ts index dc18d956..a58b227e 100644 --- a/backend/src/wallet/wallet.controller.spec.ts +++ b/backend/src/wallet/wallet.controller.spec.ts @@ -4,24 +4,33 @@ import { WalletService } from './wallet.service'; describe('WalletController', () => { let controller: WalletController; - let service: WalletService; + + const mockService = { + linkWallet: jest.fn(async (address: string) => ({ id: '1', address })), + createChallenge: jest.fn(), + verifySignature: jest.fn(), + } as { + linkWallet: jest.Mock; + createChallenge: jest.Mock; + verifySignature: jest.Mock; + }; beforeEach(async () => { + mockService.verifySignature.mockReset().mockResolvedValue({ valid: true }); + mockService.createChallenge.mockReset(); + mockService.linkWallet.mockReset().mockImplementation(async (address: string) => ({ id: '1', address })); + const module: TestingModule = await Test.createTestingModule({ controllers: [WalletController], providers: [ { provide: WalletService, - useValue: { - linkWallet: jest.fn().mockResolvedValue({ address: '0x123' }), - verifySignature: jest.fn().mockResolvedValue(true), - }, + useValue: mockService, }, ], }).compile(); controller = module.get(WalletController); - service = module.get(WalletService); }); it('should be defined', () => { @@ -29,13 +38,33 @@ describe('WalletController', () => { }); it('should link wallet', async () => { - const result = await controller.linkWallet({ address: '0x123' }); - expect(result.address).toBe('0x123'); + const result = await controller.linkWallet({ address: 'G123' }); + expect(result.address).toBe('G123'); + }); + + it('should create a challenge', async () => { + mockService.createChallenge.mockResolvedValueOnce({ + message: 'challenge-msg', + nonce: 'n', + expiresAt: new Date(), + }); + + const result = await controller.createChallenge({ + walletAddress: 'G123', + }); + + expect(mockService.createChallenge).toHaveBeenCalledWith( + 'G123', + undefined, + undefined, + undefined, + ); + expect(result.message).toBe('challenge-msg'); }); it('should verify signature (POST)', async () => { const result = await controller.verifySignature({ - address: '0x123', + walletAddress: 'G123', signature: 'sig', message: 'msg', }); @@ -43,7 +72,8 @@ describe('WalletController', () => { }); it('should verify signature (GET)', async () => { - const result = await controller.verifySignatureGet('0x123', 'sig', 'msg'); - expect(result.valid).toBe(true); + mockService.verifySignature.mockResolvedValueOnce({ valid: false, error: 'x' }); + const result = await controller.verifySignatureGet('G123', 'sig', 'msg'); + expect(result.valid).toBe(false); }); -}); +}); \ No newline at end of file diff --git a/backend/src/wallet/wallet.controller.ts b/backend/src/wallet/wallet.controller.ts index c680fd23..a9d2b03f 100644 --- a/backend/src/wallet/wallet.controller.ts +++ b/backend/src/wallet/wallet.controller.ts @@ -7,42 +7,72 @@ import { HttpCode, HttpStatus, } from '@nestjs/common'; -import { WalletService } from './wallet.service'; +import { + ApiTags, + ApiOperation, + ApiResponse, +} from '@nestjs/swagger'; +import { WalletService, WalletChallenge } from './wallet.service'; import { Wallet } from './entities/wallet.entity'; +import { WalletChallengeDto } from './dto/wallet-challenge.dto'; +import { VerifyWalletSignatureDto } from './dto/verify-wallet-signature.dto'; +@ApiTags('Wallet') @Controller('wallet') export class WalletController { constructor(private readonly walletService: WalletService) {} @Post('link') @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Link or upsert a wallet address' }) async linkWallet(@Body() body: { address: string }): Promise { return this.walletService.linkWallet(body.address); } + @Post('challenge') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Create a signable wallet challenge', + description: + 'Returns a message (bound to wallet/user/domain/expiry with a nonce) that the client must sign with its Stellar ed25519 key.', + }) + @ApiResponse({ status: 200, description: 'Challenge created' }) + async createChallenge( + @Body() body: WalletChallengeDto, + ): Promise { + return this.walletService.createChallenge( + body.walletAddress, + body.userId, + body.domain, + body.ttlSeconds, + ); + } + @Post('verify-signature') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Verify a wallet signature with replay protection', + description: + 'Verifies an ed25519 signature over a signed challenge. The nonce is consumed on success so the signature cannot be replayed.', + }) + @ApiResponse({ status: 200, description: 'Verification result' }) async verifySignature( - @Body() body: { address: string; signature: string; message: string }, - ): Promise<{ valid: boolean }> { - const valid = await this.walletService.verifySignature( - body.address, + @Body() body: VerifyWalletSignatureDto, + ): Promise<{ valid: boolean; error?: string }> { + return this.walletService.verifySignature( + body.walletAddress, body.signature, body.message, ); - return { valid }; } @Get('verify-signature') + @ApiOperation({ summary: 'Verify a wallet signature (query params)' }) async verifySignatureGet( @Query('address') address: string, @Query('signature') signature: string, @Query('message') message: string, - ): Promise<{ valid: boolean }> { - const valid = await this.walletService.verifySignature( - address, - signature, - message, - ); - return { valid }; + ): Promise<{ valid: boolean; error?: string }> { + return this.walletService.verifySignature(address, signature, message); } -} +} \ No newline at end of file diff --git a/backend/src/wallet/wallet.entity.ts b/backend/src/wallet/wallet.entity.ts deleted file mode 100644 index 5d20d139..00000000 --- a/backend/src/wallet/wallet.entity.ts +++ /dev/null @@ -1 +0,0 @@ -// moved to entities/wallet.entity.ts diff --git a/backend/src/wallet/wallet.module.ts b/backend/src/wallet/wallet.module.ts index 7871540c..71e125be 100644 --- a/backend/src/wallet/wallet.module.ts +++ b/backend/src/wallet/wallet.module.ts @@ -3,10 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { WalletService } from './wallet.service'; import { WalletController } from './wallet.controller'; import { Wallet } from './entities/wallet.entity'; +import { ConsumedWalletNonce } from './entities/consumed-nonce.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Wallet])], + imports: [TypeOrmModule.forFeature([Wallet, ConsumedWalletNonce])], providers: [WalletService], controllers: [WalletController], + exports: [WalletService], }) export class WalletModule {} diff --git a/backend/src/wallet/wallet.service.spec.ts b/backend/src/wallet/wallet.service.spec.ts index c56a6b92..2b5149a6 100644 --- a/backend/src/wallet/wallet.service.spec.ts +++ b/backend/src/wallet/wallet.service.spec.ts @@ -1,43 +1,160 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { Keypair } from '@stellar/stellar-sdk'; import { WalletService } from './wallet.service'; import { Wallet } from './entities/wallet.entity'; -import { Repository } from 'typeorm'; +import { ConsumedWalletNonce } from './entities/consumed-nonce.entity'; -describe('WalletService', () => { +describe('WalletService (replay protection)', () => { let service: WalletService; - let repo: Repository; + let consumedNonceRepository: { + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + }; + + const keypair = Keypair.random(); + const WALLET = keypair.publicKey(); + const USER_A = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + const USER_B = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + const DEFAULT_DOMAIN = 'stellar-hunts.app'; + + const signMessage = (message: string) => + Buffer.from(keypair.sign(Buffer.from(message, 'utf8'))).toString('base64'); beforeEach(async () => { + consumedNonceRepository = { + findOne: jest.fn(async () => null), + create: jest.fn((data) => ({ id: 'nonce-id', ...data })), + save: jest.fn(async (data) => data), + }; + + const walletRepository = { + findOne: jest.fn(async () => null), + create: jest.fn((data) => ({ id: 'wallet-id', ...data })), + save: jest.fn(async (data) => data), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ WalletService, + { provide: getRepositoryToken(Wallet), useValue: walletRepository }, { - provide: getRepositoryToken(Wallet), - useClass: Repository, + provide: getRepositoryToken(ConsumedWalletNonce), + useValue: consumedNonceRepository, }, ], }).compile(); service = module.get(WalletService); - repo = module.get>(getRepositoryToken(Wallet)); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('accepts a valid freshly-signed challenge and consumes the nonce', async () => { + const { message } = service.createChallenge(WALLET, undefined, DEFAULT_DOMAIN); + const signature = signMessage(message); + + const result = await service.verifySignature(WALLET, signature, message); + + expect(result.valid).toBe(true); + expect(consumedNonceRepository.save).toHaveBeenCalled(); + }); + + it('rejects a replayed signature (nonce already consumed)', async () => { + const { message } = service.createChallenge(WALLET, undefined, DEFAULT_DOMAIN); + const signature = signMessage(message); + + await service.verifySignature(WALLET, signature, message); + + // Second attempt: nonce now consumed. + consumedNonceRepository.findOne.mockResolvedValueOnce({ id: 'x' }); + + const result = await service.verifySignature(WALLET, signature, message); + + expect(result.valid).toBe(false); + expect(result.error).toContain('replay'); }); - it('should link a wallet', async () => { - const address = '0x123'; - jest.spyOn(repo, 'findOne').mockResolvedValueOnce(null as any); - jest.spyOn(repo, 'create').mockReturnValueOnce({ address } as any); - jest.spyOn(repo, 'save').mockResolvedValueOnce({ address } as any); - const wallet = await service.linkWallet(address); - expect(wallet.address).toBe(address); + it('rejects a challenge bound to another user (cross-user)', async () => { + const { message } = service.createChallenge(WALLET, USER_A, DEFAULT_DOMAIN); + const signature = signMessage(message); + + const result = await service.verifySignature(WALLET, signature, message, { + userId: USER_B, + }); + + expect(result.valid).toBe(false); + expect(result.error).toContain('user'); }); - it('should verify signature (mocked)', async () => { - const valid = await service.verifySignature('0x123', 'sig', 'msg'); - expect(valid).toBe(true); + it('accepts a challenge when user binding matches', async () => { + const { message } = service.createChallenge(WALLET, USER_A, DEFAULT_DOMAIN); + const signature = signMessage(message); + + const result = await service.verifySignature(WALLET, signature, message, { + userId: USER_A, + }); + + expect(result.valid).toBe(true); + }); + + it('rejects an expired challenge', async () => { + const challenge = service.createChallenge(WALLET, undefined, DEFAULT_DOMAIN, -10); + const signature = signMessage(challenge.message); + + const result = await service.verifySignature( + WALLET, + signature, + challenge.message, + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain('expired'); + }); + + it('rejects a challenge bound to a different domain (wrong-domain)', async () => { + const { message } = service.createChallenge( + WALLET, + undefined, + 'evil.example.com', + ); + const signature = signMessage(message); + + const result = await service.verifySignature( + WALLET, + signature, + message, + { domain: DEFAULT_DOMAIN }, + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain('domain'); + }); + + it('rejects a message not bound to the presented wallet', async () => { + const other = Keypair.random(); + const { message } = service.createChallenge( + WALLET, + undefined, + DEFAULT_DOMAIN, + ); + // Sign with a different key than the one presented. + const signature = Buffer.from( + other.sign(Buffer.from(message, 'utf8')), + ).toString('base64'); + + const result = await service.verifySignature(WALLET, signature, message); + + expect(result.valid).toBe(false); + expect(result.error).toContain('signature'); + }); + + it('rejects a malformed challenge message', async () => { + const result = await service.verifySignature( + WALLET, + signMessage('hello'), + 'hello', + ); + + expect(result.valid).toBe(false); }); -}); +}); \ No newline at end of file diff --git a/backend/src/wallet/wallet.service.ts b/backend/src/wallet/wallet.service.ts index 1ef905e8..569283c7 100644 --- a/backend/src/wallet/wallet.service.ts +++ b/backend/src/wallet/wallet.service.ts @@ -1,13 +1,28 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import * as crypto from 'crypto'; +import { Keypair } from '@stellar/stellar-sdk'; import { Wallet } from './entities/wallet.entity'; +import { ConsumedWalletNonce } from './entities/consumed-nonce.entity'; + +const CHALLENGE_DOMAIN_KEY = 'domain'; +const DEFAULT_DOMAIN = 'stellar-hunts.app'; +const DEFAULT_TTL_SECONDS = 300; + +export interface WalletChallenge { + message: string; + nonce: string; + expiresAt: Date; +} @Injectable() export class WalletService { constructor( @InjectRepository(Wallet) private readonly walletRepository: Repository, + @InjectRepository(ConsumedWalletNonce) + private readonly consumedNonceRepository: Repository, ) {} async linkWallet(address: string): Promise { @@ -19,17 +34,178 @@ export class WalletService { return wallet; } - // Mock signature verification + async findByAddress(address: string): Promise { + return this.walletRepository.findOne({ where: { address } }); + } + + /** + * Build a challenge for a wallet to sign. The challenge is bound to the + * wallet address, an optional user, a domain, and an expiration time, and + * includes a random nonce. The resulting signature cannot be replayed on + * another wallet, user, domain, or after expiry. + */ + createChallenge( + walletAddress: string, + userId?: string, + domain: string = DEFAULT_DOMAIN, + ttlSeconds: number = DEFAULT_TTL_SECONDS, + ): WalletChallenge { + const nonce = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + const message = [ + 'stellar-hunts: authenticate', + `wallet=${walletAddress}`, + `nonce=${nonce}`, + `user=${userId ?? 'none'}`, + `${CHALLENGE_DOMAIN_KEY}=${domain}`, + `expires=${expiresAt.toISOString()}`, + ].join('\n'); + return { message, nonce, expiresAt }; + } + + /** + * Verify a wallet signature with replay protection. + * + * Accepts the signed challenge message and returns `true` only when: + * - the signature is a valid ed25519 signature over the message bytes made + * by the wallet's public key, + * - the message embeds a nonce that has not been consumed before, + * - the message is bound to the same wallet and (when bound) user, + * - the message is bound to the expected domain, + * - the message has not expired. + * + * On success the nonce is persisted as consumed so the same signature cannot + * be replayed. + */ async verifySignature( - address: string, + walletAddress: string, signature: string, message: string, - ): Promise { - // Stub: Always returns true for now - return true; + opts?: { + userId?: string; + domain?: string; + }, + ): Promise<{ valid: boolean; error?: string }> { + const expectedDomain = opts?.domain ?? DEFAULT_DOMAIN; + + const fields = this.parseChallenge(message); + if (!fields) { + return { valid: false, error: 'Malformed challenge message' }; + } + + if (fields.wallet !== walletAddress) { + return { + valid: false, + error: 'Message not bound to this wallet', + }; + } + + if (fields.domain !== expectedDomain) { + return { + valid: false, + error: 'Message bound to an unexpected domain', + }; + } + + const expires = Date.parse(fields.expires); + if (Number.isNaN(expires)) { + return { valid: false, error: 'Challenge has no expiration' }; + } + if (expires <= Date.now()) { + return { valid: false, error: 'Challenge has expired' }; + } + + if (opts?.userId) { + if (fields.user !== opts.userId) { + return { valid: false, error: 'Message not bound to this user' }; + } + } + + // Replay protection: reject if the nonce was already consumed. + if (fields.nonce) { + const consumed = await this.consumedNonceRepository.findOne({ + where: { nonce: fields.nonce }, + }); + if (consumed) { + return { valid: false, error: 'Challenge already used (replay)' }; + } + } else { + return { valid: false, error: 'Challenge has no nonce' }; + } + + // Cryptographic verification: ed25519 signature over the message bytes. + let valid = false; + try { + const messageBytes = Buffer.from(message, 'utf8'); + const signatureBytes = Buffer.from(signature, 'base64'); + const keypair = Keypair.fromPublicKey(walletAddress); + valid = keypair.verify(messageBytes, signatureBytes); + } catch { + valid = false; + } + + if (!valid) { + return { valid: false, error: 'Invalid signature' }; + } + + // Mark the nonce as consumed to prevent replays. The unique constraint on + // `nonce` is the authoritative guard: a concurrent duplicate reject is + // treated the same as a replayed signature. + try { + await this.consumedNonceRepository.save( + this.consumedNonceRepository.create({ + nonce: fields.nonce, + walletAddress, + userId: opts?.userId, + domain: expectedDomain, + consumedAt: new Date(), + }), + ); + } catch (err: unknown) { + const isDuplicate = + (err as { code?: string | number })?.code === 'ER_DUP_ENTRY' || + (err as { code?: string | number })?.code === 'SQLITE_CONSTRAINT' || + (err as { code?: string | number })?.code === '23505'; + if (isDuplicate) { + return { valid: false, error: 'Challenge already used (replay)' }; + } + throw err; + } + + return { valid: true }; } - async findByAddress(address: string): Promise { - return this.walletRepository.findOne({ where: { address } }); + private parseChallenge( + message: string, + ): { + wallet?: string; + nonce?: string; + user?: string; + domain?: string; + expires?: string; + } | null { + if (!message || typeof message !== 'string') { + return null; + } + const lines = message.split('\n'); + if (!lines[0]?.startsWith('stellar-hunts: authenticate')) { + return null; + } + const out: Record = {}; + for (const line of lines.slice(1)) { + const idx = line.indexOf('='); + if (idx === -1) continue; + out[line.slice(0, idx)] = line.slice(idx + 1); + } + if (!out.wallet || !out.nonce || !out.domain || !out.expires) { + return null; + } + return { + wallet: out.wallet, + nonce: out.nonce, + user: out.user ?? 'none', + domain: out.domain, + expires: out.expires, + }; } -} +} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 0c55ff26..66d9c839 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -19,7 +19,7 @@ "noFallthroughCasesInSwitch": false, "esModuleInterop": true, "resolveJsonModule": true, - "typeRoots": ["node_modules/@types"], + "typeRoots": ["../node_modules/@types", "node_modules/@types"], "types": ["node", "jest"] }, "include": ["src/**/*", "config/**/*", "test/**/*", "scripts/**/*"], diff --git a/frontend/components/NftGalleryVirtualized.tsx b/frontend/components/NftGalleryVirtualized.tsx index 950f9028..f55b3ede 100644 --- a/frontend/components/NftGalleryVirtualized.tsx +++ b/frontend/components/NftGalleryVirtualized.tsx @@ -43,6 +43,12 @@ export interface NftGalleryVirtualizedProps { tilesPerRow?: number; /** Bottom sentinel — fires when the user scrolls within `threshold`px of it. */ onReachEnd?: () => void; + /** Optional claim handler forwarded to each NFT card. */ + onClaim?: (nft: NFTLike) => void; + /** When true, announces that more NFTs are being loaded (a11y available region). */ + loading?: boolean; + /** Whether more pages exist; when false and not loading, announces end of list. */ + hasMore?: boolean; /** Extra rows to render above/below the visible area to avoid blank flashes. */ overscan?: number; /** Tailwind/object-friendly className for the scroll container. */ @@ -67,6 +73,9 @@ const NftGalleryVirtualized = ({ rowHeight = DEFAULT_ROW_HEIGHT, tilesPerRow: tilesPerRowProp = DEFAULT_TILES_PER_ROW, onReachEnd, + onClaim, + loading = false, + hasMore = false, overscan = DEFAULT_OVERSCAN, className, }: NftGalleryVirtualizedProps) => { @@ -171,8 +180,9 @@ const NftGalleryVirtualized = ({ ref={containerRef} onScroll={handleScroll} className={className} - role="region" + role="feed" aria-label="NFT collection" + aria-busy={loading} style={{ position: "relative", height: "70vh", @@ -211,7 +221,7 @@ const NftGalleryVirtualized = ({ key={(nft.id as string) ?? absoluteIndex} style={tileStyle} > - + ); })} @@ -219,6 +229,15 @@ const NftGalleryVirtualized = ({ ); })} + + {/* Screen-reader status region for infinite-scroll (#309). */} +
+ {loading + ? "Loading more NFTs…" + : !hasMore && items.length > 0 + ? `End of NFT collection. ${items.length} total.` + : ""} +
); }; diff --git a/frontend/components/Pagination.jsx b/frontend/components/Pagination.jsx index fff47400..6cedd7fa 100644 --- a/frontend/components/Pagination.jsx +++ b/frontend/components/Pagination.jsx @@ -1,27 +1,70 @@ export default function Pagination({ currentPage, totalPages, onPageChange }) { + if (totalPages <= 1) return null; + + // Build a compact window of page numbers around the current page. + const windowSize = 5; + let start = Math.max(1, currentPage - Math.floor(windowSize / 2)); + const end = Math.min(totalPages, start + windowSize - 1); + start = Math.max(1, end - windowSize + 1); + const pages = []; + for (let p = start; p <= end; p += 1) pages.push(p); + + const hasPrevious = currentPage > 1; + const hasNext = currentPage < totalPages; + return ( -
+
+ ); } diff --git a/frontend/components/PuzzleComponent.jsx b/frontend/components/PuzzleComponent.jsx index da803e0f..e85f7983 100644 --- a/frontend/components/PuzzleComponent.jsx +++ b/frontend/components/PuzzleComponent.jsx @@ -169,6 +169,11 @@ const PuzzleComponent = ({ lastHint, walletConnected, walletAddress, + isExtensionInstalled, + unsupportedNetwork, + walletError, + connectWallet, + disconnectWallet, submitAnswer, requestHint, clearFeedback, @@ -424,11 +429,60 @@ const PuzzleComponent = ({ {!walletConnected && (
-

- Connect your Freighter wallet to submit answers and earn on-chain rewards. -

+ {isExtensionInstalled === false ? ( +

+ Freighter extension not detected. Install{" "} + + Freighter + {" "} + to connect your wallet and earn on-chain rewards. +

+ ) : ( +

+ Connect your Freighter wallet to submit answers and earn on-chain rewards. +

+ )} + +
+ )} + + {walletConnected && unsupportedNetwork && ( +
+ Unsupported network detected. Switch your Freighter network to match + the app, then disconnect and reconnect below. +
)} + + {walletError && !unsupportedNetwork && ( +

+ {walletError} +

+ )} ); diff --git a/frontend/components/admin/puzzle-review/ReviewTable.jsx b/frontend/components/admin/puzzle-review/ReviewTable.jsx index 83306001..811ac4d3 100644 --- a/frontend/components/admin/puzzle-review/ReviewTable.jsx +++ b/frontend/components/admin/puzzle-review/ReviewTable.jsx @@ -260,7 +260,11 @@ const ReviewTable = ({ {/* Pagination */} {pagination.totalPages > 1 && ( -
+ )}
diff --git a/frontend/components/puzzles/roadmap/PuzzleCard.tsx b/frontend/components/puzzles/roadmap/PuzzleCard.tsx index c10ec0b8..2f6cf04e 100644 --- a/frontend/components/puzzles/roadmap/PuzzleCard.tsx +++ b/frontend/components/puzzles/roadmap/PuzzleCard.tsx @@ -11,7 +11,7 @@ export default function PuzzleCard({ puzzle }: Props) { const isUpcoming = new Date(releaseDate) > new Date(); return ( -
+
diff --git a/frontend/hooks/usePuzzleContract.js b/frontend/hooks/usePuzzleContract.js index 7711d3c0..1f8b57a6 100644 --- a/frontend/hooks/usePuzzleContract.js +++ b/frontend/hooks/usePuzzleContract.js @@ -10,11 +10,10 @@ import { xdr, Address, } from "@stellar/stellar-sdk"; +import { signTransaction } from "@stellar/freighter-api"; import { - isConnected, - getPublicKey, - signTransaction, -} from "@stellar/freighter-api"; + useWalletConnection, +} from "./useWalletConnection"; /** * Default RPC endpoint for the Stellar Soroban testnet. @@ -142,38 +141,55 @@ export function usePuzzleContract(opts = {}) { const [error, setError] = useState(null); const [feedback, setFeedback] = useState(null); const [lastHint, setLastHint] = useState(null); - const [walletConnected, setWalletConnected] = useState(false); - const [walletAddress, setWalletAddress] = useState(null); + + // ---- Wallet lifecycle (#290) -------------------------------------- + // Reuses useWalletConnection so extension absence, rejected requests, + // account/network changes, unsupported networks and stale sessions are + // all handled in one place. + const { + status: walletStatus, + isExtensionInstalled, + address: walletAddress, + network: walletNetwork, + unsupportedNetwork, + error: walletError, + connect: connectWallet, + refresh: refreshWallet, + disconnect: disconnectWallet, + } = useWalletConnection(); + + // Derived connection state from the lifecycle hook (no local duplication). + const walletConnected = + walletStatus === "connected" || walletStatus === "connecting"; // ---- Wallet helpers ------------------------------------------------ - const checkWallet = useCallback(async () => { - try { - const connected = await isConnected(); - setWalletConnected(connected); - if (connected) { - const pk = await getPublicKey(); - setWalletAddress(pk); - return pk; - } - setWalletAddress(null); - return null; - } catch { - setWalletConnected(false); - setWalletAddress(null); - return null; - } - }, []); + /** + * Refreshes wallet state using the shared lifecycle connection hook. + */ + const checkWallet = useCallback( + async () => { + await refreshWallet(); + return walletAddress; + }, + [refreshWallet, walletAddress] + ); const ensureWallet = useCallback(async () => { - const pk = await checkWallet(); + let pk = walletAddress; + if (!pk) { + // Prompt the user to connect (requestAccess) rather than failing. + pk = await connectWallet(); + } if (!pk) { throw new Error( - "Freighter wallet is not connected. Please install & connect Freighter." + isExtensionInstalled === false + ? "Freighter extension not found. Please install & connect Freighter." + : "Freighter wallet is not connected. Please connect your wallet." ); } return pk; - }, [checkWallet]); + }, [walletAddress, connectWallet, isExtensionInstalled]); // ---- Soroban helpers ----------------------------------------------- @@ -192,6 +208,11 @@ export function usePuzzleContract(opts = {}) { */ const invokeContract = useCallback( async (methodName, scValArgs) => { + if (unsupportedNetwork) { + throw new Error( + "Unsupported network. Please switch your Freighter network to match the app's expected network." + ); + } const publicKey = await ensureWallet(); const { server, contractId: cId } = buildServer(); @@ -231,7 +252,7 @@ export function usePuzzleContract(opts = {}) { `Unexpected send status: ${sendResponse.status}` ); }, - [ensureWallet, buildServer, networkPassphrase] + [ensureWallet, buildServer, networkPassphrase, unsupportedNetwork] ); // ---- Public actions ------------------------------------------------ @@ -332,6 +353,12 @@ export function usePuzzleContract(opts = {}) { lastHint, walletConnected, walletAddress, + isExtensionInstalled: isExtensionInstalled ?? false, + unsupportedNetwork, + walletError, + connectWallet, + disconnectWallet, + refreshWallet, submitAnswer, requestHint, clearFeedback, diff --git a/frontend/hooks/useWalletConnection.js b/frontend/hooks/useWalletConnection.js new file mode 100644 index 00000000..90995c97 --- /dev/null +++ b/frontend/hooks/useWalletConnection.js @@ -0,0 +1,240 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef } from "react"; +import { + isConnected, + getPublicKey, + requestAccess, + getNetwork, +} from "@stellar/freighter-api"; +import { Networks } from "@stellar/stellar-sdk"; + +/** + * The Stellar network the app expects to run on for wallet operations. + * Override via NEXT_PUBLIC_STELLAR_NETWORK env var. + */ +export const EXPECTED_NETWORK = + process.env.NEXT_PUBLIC_STELLAR_NETWORK === "testnet" + ? Networks.TESTNET + : Networks.PUBLIC; + +export const WALLET_STATUS = { + DISCONNECTED: "disconnected", + CONNECTING: "connecting", + CONNECTED: "connected", + ERROR: "error", +}; + +/** + * Detect whether the Freighter extension is installed at all. + * Freighter exposes a global at `window.freighter`; a missing global (or a + * rejection from the API) means the extension is absent. + */ +function detectExtension() { + if (typeof window === "undefined") return false; + const hasGlobal = + Boolean(window.freighter) || + Boolean(window.stellarWalletsKit) || + Boolean(window.stellarwallets); + if (hasGlobal) return true; + // Some builds expose Freighter through @stellar/freighter-api's adapter + // only at runtime; if we cannot see a global, we optimistically report + // true and let an actual API call surface the absence. + return true; +} + +/** + * useWalletConnection + * -------------------- + * Handles the complete Freighter wallet connection lifecycle (#290): + * - extension absence detection + * - rejected / cancelled access & signing requests + * - account (PUBLIC_KEY) change detection + * - network change detection + unsupported network handling + * - disconnect detection (polling + visibility/focus revalidation) + * - stale session revalidation + * + * @returns {object} + * @prop {string} status – WALLET_STATUS.* + * @prop {boolean} isConnected – Freighter currently reports a wallet + * @prop {boolean} isExtensionInstalled + * @prop {string|null} address – current Stellar public key (G...) + * @prop {string|null} network – current network passphrase + * @prop {boolean} unsupportedNetwork– true when on an unexpected network + * @prop {string|null} error – last lifecycle error message + * @prop {Function} connect – request access + populate state + * @prop {Function} refresh – re-read wallet state + * @prop {Function} disconnect – clear local wallet state + */ +export function useWalletConnection(opts = {}) { + const { pollInterval = 3000 } = opts; + + const [status, setStatus] = useState(WALLET_STATUS.DISCONNECTED); + const [isExtensionInstalled, setIsExtensionInstalled] = useState(false); + const [connected, setConnected] = useState(false); + const [address, setAddress] = useState(null); + const [network, setNetwork] = useState(null); + const [unsupportedNetwork, setUnsupportedNetwork] = useState(false); + const [error, setError] = useState(null); + + const lastAddressRef = useRef(null); + const destroyedRef = useRef(false); + + /** + * Safely sync wallet state from Freighter without throwing. + * Uses the "settled" pattern so extension absence / rejections map to + * clean status instead of uncaught errors. + */ + const refresh = useCallback(async () => { + if (typeof window === "undefined") return; + + const installed = detectExtension(); + setIsExtensionInstalled(installed); + + try { + const connectedNow = await isConnected(); + setConnected(connectedNow); + + if (!connectedNow) { + setAddress(null); + setNetwork(null); + setStatus(WALLET_STATUS.DISCONNECTED); + return; + } + + const [pk, net] = await Promise.all([getPublicKey(), getNetwork()]); + setAddress(pk); + const normalized = net || Networks.PUBLIC; + setNetwork(normalized); + setUnsupportedNetwork(normalized !== EXPECTED_NETWORK); + setStatus(WALLET_STATUS.CONNECTED); + setError(null); + + // Track account switches so callers can react to a stale address. + if (lastAddressRef.current && lastAddressRef.current !== pk) { + setStatus(WALLET_STATUS.ERROR); + setError( + "Your Freighter account changed. Transaction was re-synced to the new account." + ); + } + lastAddressRef.current = pk; + } catch (e) { + // Extension absent or API errored. + setConnected(false); + setAddress(null); + setStatus(WALLET_STATUS.DISCONNECTED); + setError(null); + } + }, []); + + /** + * Request access from Freighter (prompts the user if not yet approved). + * Maps a user rejection / missing extension to a friendly state. + */ + const connect = useCallback(async () => { + if (typeof window === "undefined") return null; + setStatus(WALLET_STATUS.CONNECTING); + setError(null); + + try { + // requestAccess throws on user rejection or missing extension. + const pk = await requestAccess(); + setAddress(pk); + lastAddressRef.current = pk; + setConnected(true); + setStatus(WALLET_STATUS.CONNECTED); + try { + const net = await getNetwork(); + setNetwork(net); + setUnsupportedNetwork(net !== EXPECTED_NETWORK); + } catch { + // network unavailable — non-fatal + } + return pk; + } catch (e) { + const message = + e?.code === 4001 || /reject|cancel/i.test(e?.message || "") + ? "Wallet connection was rejected." + : "Freighter is not available. Please install the Freighter extension and try again."; + setStatus(WALLET_STATUS.ERROR); + setError(message); + return null; + } + }, []); + + /** + * Clear local wallet state. Freighter has no programmatic disconnect, so + * this only resets the app's view of the wallet. + */ + const disconnect = useCallback(() => { + setConnected(false); + setAddress(null); + setNetwork(null); + setUnsupportedNetwork(false); + lastAddressRef.current = null; + setStatus(WALLET_STATUS.DISCONNECTED); + setError(null); + }, []); + + // Initial load + account/network listeners (#290). + useEffect(() => { + destroyedRef.current = false; + refresh(); + + // Poll periodically to detect disconnect / unsupported network (Freighter + // has no reliable change event through the npm API). + const poll = setInterval(() => { + if (!destroyedRef.current) refresh(); + }, pollInterval); + + // Re-sync on window focus / visibility — catches stale sessions when the + // user returns to the tab after switching accounts in Freighter. + const onFocus = () => refresh(); + const onVisibility = () => { + if (document.visibilityState === "visible") refresh(); + }; + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibility); + + // Register Freighter's native account/network event listeners where the + // browser global exposes them (defensive — may be undefined). + let cleanupListeners = () => {}; + try { + const freighterGlobal = + window.freighter || window.stellarwallets || null; + if (freighterGlobal?.addEventListener) { + freighterGlobal.addEventListener("PUBLIC_KEY", () => refresh()); + freighterGlobal.addEventListener("NETWORK_CHANGE", () => refresh()); + cleanupListeners = () => { + freighterGlobal.removeEventListener("PUBLIC_KEY", () => refresh()); + freighterGlobal.removeEventListener("NETWORK_CHANGE", () => refresh()); + }; + } + } catch { + // ignore — polling covers disconnect/network detection + } + + return () => { + destroyedRef.current = true; + clearInterval(poll); + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + cleanupListeners(); + }; + }, [refresh, pollInterval]); + + return { + status, + isConnected: connected, + isExtensionInstalled, + address, + network, + unsupportedNetwork, + error, + connect, + refresh, + disconnect, + }; +} + +export default useWalletConnection; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1ec81d19..f1fa5c28 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -6,7 +6,7 @@ "esnext" ], "baseUrl": ".", - "ignoreDeprecations": "6.0", + "ignoreDeprecations": "5.0", "paths": { "@/*": [ "./*"