Skip to content

Commit 5b89854

Browse files
authored
Merge pull request #154 from rohan911438/feat/auth-security-rate-limit
feat: implement auth security, replay protection and API rate limiting
2 parents 8bd206c + 076a0c7 commit 5b89854

11 files changed

Lines changed: 297 additions & 48 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ PORT=3000
2424
# Valid values: development | production | test
2525
# Default: development
2626
NODE_ENV=development
27+
AUTH_CHALLENGE_LIMIT=10
28+
AUTH_CHALLENGE_WINDOW=60000
29+
PUBLIC_LIMIT=60
30+
PUBLIC_WINDOW=60000
31+
REFRESH_TOKEN_TTL=604800
32+
NONCE_TTL=900
2733

2834
# -----------------------------------------------------------------------------
2935
# Authentication & Security

SECURITY.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,12 @@ Never commit `.env` files. Use environment-specific secret management (Vault, AW
7979

8080
### API Security
8181

82-
- **Rate limiting** on escrow, shipment, and auth endpoints
82+
- All API access (except public webhooks and SEP-10 challenge generation) requires a valid JWT.
83+
- JWTs are short-lived (1 hour) and signed using HMAC (HS256) with a secret rotation policy.
84+
- **Refresh Token Rotation**: Refresh tokens are issued alongside access tokens. Upon refresh, the old token is revoked and a new pair is issued. Reuse of a revoked refresh token immediately invalidates the entire token family to prevent hijacking.
85+
- **Replay Attack Prevention**: SEP-10 challenge transactions generate a cryptographically secure nonce stored in the database. Challenges are strictly single-use and expire within 15 minutes. Replay attempts with a previously used challenge transaction are rejected.
86+
- **Rate Limiting (Throttler)**: Public endpoints are protected against abuse and DDoS attacks. The SEP-10 challenge endpoint is limited to 10 requests per minute per IP. The Escrow query endpoints are limited to 60 requests per minute per IP.
8387
- **Input validation** via `class-validator` and Stellar SDK address checks
84-
- **SEP-10 authentication** with JWT (1-hour expiry) and challenge replay protection
8588
- **Security headers** via middleware: `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`
8689

8790
### Operational Security

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@nestjs/config": "^4.0.0",
3939
"@nestjs/core": "^11.0.1",
4040
"@nestjs/platform-express": "^11.0.1",
41+
"@nestjs/throttler": "^6.3.0",
4142
"@opentelemetry/api": "^1.9.1",
4243
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
4344
"@opentelemetry/core": "^2.7.1",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
-- CreateTable
2+
CREATE TABLE "RefreshToken" (
3+
"id" TEXT NOT NULL,
4+
"userId" TEXT NOT NULL,
5+
"tokenHash" TEXT NOT NULL,
6+
"parentTokenId" TEXT,
7+
"revoked" BOOLEAN NOT NULL DEFAULT false,
8+
"expiresAt" TIMESTAMP(3) NOT NULL,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateTable
15+
CREATE TABLE "Nonce" (
16+
"id" TEXT NOT NULL,
17+
"nonce" TEXT NOT NULL,
18+
"walletAddress" TEXT NOT NULL,
19+
"challenge" TEXT NOT NULL,
20+
"used" BOOLEAN NOT NULL DEFAULT false,
21+
"expiresAt" TIMESTAMP(3) NOT NULL,
22+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
23+
24+
CONSTRAINT "Nonce_pkey" PRIMARY KEY ("id")
25+
);
26+
27+
-- CreateIndex
28+
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
29+
30+
-- CreateIndex
31+
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
32+
33+
-- CreateIndex
34+
CREATE UNIQUE INDEX "Nonce_nonce_key" ON "Nonce"("nonce");
35+
36+
-- CreateIndex
37+
CREATE INDEX "Nonce_walletAddress_idx" ON "Nonce"("walletAddress");
38+
39+
-- CreateIndex
40+
CREATE INDEX "Escrow_vendorAddress_state_idx" ON "Escrow"("vendorAddress", "state");

prisma/schema.prisma

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ model Escrow {
4040
4141
@@index([vendorAddress])
4242
@@index([buyerAddress])
43+
@@index([vendorAddress, state])
4344
}
4445

4546
model Dispute {
@@ -164,3 +165,27 @@ model ProcessedWebhookEvent {
164165
165166
@@index([processedAt])
166167
}
168+
169+
model RefreshToken {
170+
id String @id @default(cuid())
171+
userId String
172+
tokenHash String @unique
173+
parentTokenId String?
174+
revoked Boolean @default(false)
175+
expiresAt DateTime
176+
createdAt DateTime @default(now())
177+
178+
@@index([userId])
179+
}
180+
181+
model Nonce {
182+
id String @id @default(cuid())
183+
nonce String @unique
184+
walletAddress String
185+
challenge String
186+
used Boolean @default(false)
187+
expiresAt DateTime
188+
createdAt DateTime @default(now())
189+
190+
@@index([walletAddress])
191+
}

src/app.module.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
22
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
3+
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
34
import { AdminStatsModule } from './admin/stats/admin-stats.module';
45
import { DisputeModule as AdminDisputeModule } from './admin/dispute/dispute.module';
56
import { QueueDashboardModule } from './admin/queues/queue-dashboard.module';
@@ -10,7 +11,6 @@ import { AppController } from './app.controller';
1011
import { AppService } from './app.service';
1112
import { Sep10Module } from './auth/sep10/sep10.module';
1213
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
13-
import { RateLimitGuard } from './common/guards/rate-limit.guard';
1414
import { LoggerModule } from './common/logger/logger.module';
1515
import { LoggerMiddleware } from './common/middleware/logger.middleware';
1616
import { SecurityMiddleware } from './common/middleware/security.middleware';
@@ -54,6 +54,23 @@ import { CacheService } from './common/cache.service';
5454
// Webhook receivers
5555
WebhooksModule, // issue #76 – POST /webhooks/stellar
5656
StressTestModule,
57+
58+
ThrottlerModule.forRootAsync({
59+
imports: [ConfigModule],
60+
inject: [ConfigService],
61+
useFactory: (config: ConfigService) => [
62+
{
63+
name: 'auth',
64+
ttl: config.get<number>('AUTH_CHALLENGE_WINDOW') || 60000,
65+
limit: config.get<number>('AUTH_CHALLENGE_LIMIT') || 10,
66+
},
67+
{
68+
name: 'public',
69+
ttl: config.get<number>('PUBLIC_WINDOW') || 60000,
70+
limit: config.get<number>('PUBLIC_LIMIT') || 60,
71+
},
72+
],
73+
}),
5774
],
5875
controllers: [AppController],
5976
providers: [
@@ -65,7 +82,7 @@ import { CacheService } from './common/cache.service';
6582
},
6683
{
6784
provide: APP_GUARD,
68-
useClass: RateLimitGuard,
85+
useClass: ThrottlerGuard,
6986
},
7087
],
7188
})

src/auth/sep10/sep10.controller.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common';
22
import { IsString, MinLength } from 'class-validator';
3+
import { SkipThrottle } from '@nestjs/throttler';
34
import { IsStellarAddress } from '../../common/validators/stellar-address.validator';
45
import { Sep10Service } from './sep10.service';
56

@@ -15,14 +16,20 @@ class VerifyChallengeDto {
1516
transaction!: string;
1617
}
1718

19+
class RefreshTokenDto {
20+
@IsString()
21+
@MinLength(1)
22+
refreshToken!: string;
23+
}
24+
1825
@Controller('auth')
1926
export class Sep10Controller {
2027
constructor(private readonly sep10Service: Sep10Service) {}
2128

2229
/** GET /auth?account=<G...> — issue a SEP-10 challenge (legacy) */
2330
@Get()
24-
challengeGet(@Query('account') account: string) {
25-
return { transaction: this.sep10Service.buildChallenge(account) };
31+
async challengeGet(@Query('account') account: string) {
32+
return { transaction: await this.sep10Service.buildChallenge(account) };
2633
}
2734

2835
/**
@@ -32,17 +39,24 @@ export class Sep10Controller {
3239
*/
3340
@Post('challenge')
3441
@HttpCode(HttpStatus.OK)
35-
challengePost(@Body() dto: ChallengeRequestDto) {
42+
@SkipThrottle({ public: true }) // Skip the public 60 req/min limit, only apply auth 10 req/min
43+
async challengePost(@Body() dto: ChallengeRequestDto) {
3644
return {
37-
transaction: this.sep10Service.buildChallenge(dto.publicKey, 900),
45+
transaction: await this.sep10Service.buildChallenge(dto.publicKey, 900),
3846
network_passphrase: this.sep10Service.getNetworkPassphrase(),
3947
};
4048
}
4149

4250
/** POST /auth { transaction: "<signed-base64-xdr>" } — verify and issue JWT */
4351
@Post()
44-
verify(@Body() dto: VerifyChallengeDto) {
45-
const token = this.sep10Service.verifyAndIssueToken(dto.transaction);
46-
return { token };
52+
async verify(@Body() dto: VerifyChallengeDto) {
53+
return await this.sep10Service.verifyAndIssueToken(dto.transaction);
54+
}
55+
56+
/** POST /auth/refresh { refreshToken } — rotate refresh token */
57+
@Post('refresh')
58+
@HttpCode(HttpStatus.OK)
59+
async refresh(@Body() dto: RefreshTokenDto) {
60+
return await this.sep10Service.rotateRefreshToken(dto.refreshToken);
4761
}
4862
}

src/auth/sep10/sep10.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { Module } from '@nestjs/common';
22
import { Sep10Controller } from './sep10.controller';
33
import { Sep10Service } from './sep10.service';
4+
import { PrismaModule } from '../../prisma/prisma.module';
45

56
@Module({
7+
imports: [PrismaModule],
68
controllers: [Sep10Controller],
79
providers: [Sep10Service],
810
exports: [Sep10Service],

0 commit comments

Comments
 (0)