Skip to content

Commit 8bcf081

Browse files
committed
Secure environment & rate limiting
1 parent 39ff035 commit 8bcf081

14 files changed

Lines changed: 383 additions & 51 deletions

File tree

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ yarn-error.log*
2222
# vercel
2323
.vercel
2424

25+
# codex
26+
.codex/
27+
.codex
28+
2529
# typescript
2630
*.tsbuildinfo
27-
next-env.d.ts
31+
next-env.d.ts

app/api/auth/logout/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import {
44
readRefreshToken,
55
revokeSession,
66
} from '@/lib/auth/session'
7+
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'
78

89
export async function POST(request: NextRequest): Promise<NextResponse> {
910
try {
11+
const limited = await enforceRateLimit(request, {
12+
key: buildRateLimitKey(request, 'auth:logout'),
13+
limit: 20,
14+
windowMs: 60_000,
15+
})
16+
if (limited) return limited
17+
1018
const refreshToken = readRefreshToken(request)
1119
if (refreshToken) {
1220
await revokeSession(refreshToken)

app/api/auth/me/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { NextResponse } from 'next/server'
22
import { withAuth } from '@/lib/auth/middleware'
3+
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'
34

45
export const GET = withAuth(async (_request, auth) => {
6+
const limited = await enforceRateLimit(_request, {
7+
key: buildRateLimitKey(_request, 'auth:me', auth.walletAddress),
8+
limit: 60,
9+
windowMs: 60_000,
10+
})
11+
if (limited) return limited
12+
513
return NextResponse.json(
614
{
715
walletAddress: auth.walletAddress,

app/api/auth/nonce/route.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,32 @@ import { NextRequest, NextResponse } from 'next/server'
22
import { NONCE_TTL_SECONDS } from '@/lib/auth/constants'
33
import { randomNonce, sha256Hex } from '@/lib/auth/crypto'
44
import { saveNonce } from '@/lib/auth/store'
5+
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'
6+
import { parseJson } from '@/lib/security/validation'
57
import {
68
buildAuthMessage,
79
isValidStellarAddress,
810
normalizeWalletAddress,
911
} from '@/lib/auth/stellar'
12+
import { z } from 'zod'
1013

11-
interface NonceRequestBody {
12-
walletAddress?: string
13-
}
14+
const nonceBodySchema = z.object({
15+
walletAddress: z.string().trim().min(1).max(56),
16+
})
1417

1518
export async function POST(request: NextRequest): Promise<NextResponse> {
1619
try {
17-
const body: NonceRequestBody = await request.json()
18-
const walletAddress = body.walletAddress?.trim()
20+
const parsed = await parseJson(request, nonceBodySchema)
21+
if ('response' in parsed) return parsed.response
22+
23+
const walletAddress = parsed.data.walletAddress
24+
25+
const limited = await enforceRateLimit(request, {
26+
key: buildRateLimitKey(request, 'auth:nonce', walletAddress),
27+
limit: 5,
28+
windowMs: 60_000,
29+
})
30+
if (limited) return limited
1931

2032
if (!walletAddress || !isValidStellarAddress(walletAddress)) {
2133
return NextResponse.json(

app/api/auth/refresh/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import {
44
rotateSession,
55
setSessionCookies,
66
} from '@/lib/auth/session'
7+
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'
78

89
export async function POST(request: NextRequest): Promise<NextResponse> {
910
try {
11+
const limited = await enforceRateLimit(request, {
12+
key: buildRateLimitKey(request, 'auth:refresh'),
13+
limit: 30,
14+
windowMs: 60_000,
15+
})
16+
if (limited) return limited
17+
1018
const refreshToken = readRefreshToken(request)
1119
if (!refreshToken) {
1220
return NextResponse.json(

app/api/auth/verify/route.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,39 @@ import { NextRequest, NextResponse } from 'next/server'
22
import { createSession, setSessionCookies } from '@/lib/auth/session'
33
import { consumeNonce, hasActiveNonce } from '@/lib/auth/store'
44
import { sha256Hex } from '@/lib/auth/crypto'
5+
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'
6+
import { parseJson } from '@/lib/security/validation'
57
import {
68
buildAuthMessage,
79
isValidStellarAddress,
810
normalizeWalletAddress,
911
verifyStellarSignature,
1012
} from '@/lib/auth/stellar'
13+
import { z } from 'zod'
1114

12-
interface VerifyRequestBody {
13-
walletAddress?: string
14-
nonce?: string
15-
signature?: string
16-
message?: string
17-
}
15+
const verifyBodySchema = z.object({
16+
walletAddress: z.string().trim().min(1).max(56),
17+
nonce: z.string().trim().min(1).max(256),
18+
signature: z.string().trim().min(1).max(4096),
19+
message: z.string().trim().min(1).max(512).optional(),
20+
})
1821

1922
export async function POST(request: NextRequest): Promise<NextResponse> {
2023
try {
21-
const body: VerifyRequestBody = await request.json()
24+
const parsed = await parseJson(request, verifyBodySchema)
25+
if ('response' in parsed) return parsed.response
26+
const body = parsed.data
27+
28+
const walletAddress = body.walletAddress
29+
const nonce = body.nonce
30+
const signature = body.signature
2231

23-
const walletAddress = body.walletAddress?.trim()
24-
const nonce = body.nonce?.trim()
25-
const signature = body.signature?.trim()
32+
const limited = await enforceRateLimit(request, {
33+
key: buildRateLimitKey(request, 'auth:verify', walletAddress),
34+
limit: 10,
35+
windowMs: 60_000,
36+
})
37+
if (limited) return limited
2638

2739
if (!walletAddress || !nonce || !signature) {
2840
return NextResponse.json(

app/api/freelancer/reputation/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { withAuth } from '@/lib/auth/middleware'
3+
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'
34
import {
45
getFreelancerReputation,
56
getUserIdByWallet,
67
} from '@/lib/reputation'
78

89
export const GET = withAuth(async (request: NextRequest, auth) => {
10+
const limited = await enforceRateLimit(request, {
11+
key: buildRateLimitKey(request, 'freelancer:reputation', auth.walletAddress),
12+
limit: 60,
13+
windowMs: 60_000,
14+
})
15+
if (limited) return limited
16+
917
const userId = await getUserIdByWallet(auth.walletAddress)
1018
if (userId === null) {
1119
return NextResponse.json(

app/api/freelancers/[userId]/reputation/route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from 'next/server'
2+
import { enforceRateLimit, buildRateLimitKey } from '@/lib/security/rateLimit'
23
import {
34
getFreelancerReputation,
45
userExists,
@@ -7,6 +8,13 @@ import {
78
type RouteContext = { params: Promise<{ userId: string }> }
89

910
export async function GET(_request: NextRequest, context: RouteContext) {
11+
const limited = await enforceRateLimit(_request, {
12+
key: buildRateLimitKey(_request, 'freelancers:reputation'),
13+
limit: 120,
14+
windowMs: 60_000,
15+
})
16+
if (limited) return limited
17+
1018
const { userId: rawId } = await context.params
1119
const id = Number.parseInt(rawId, 10)
1220

lib/auth/session.ts

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ import {
88
import { sha256Hex } from '@/lib/auth/crypto'
99
import { signSessionToken, verifySessionToken } from '@/lib/auth/jwt'
1010
import {
11-
findValidRefreshToken,
1211
revokeRefreshToken,
12+
rotateRefreshToken,
1313
storeRefreshToken,
14-
touchRefreshToken,
1514
} from '@/lib/auth/store'
1615
import { normalizeWalletAddress } from '@/lib/auth/stellar'
1716

@@ -42,23 +41,20 @@ function getClientIp(request: NextRequest): string | null {
4241
return request.headers.get('x-real-ip')
4342
}
4443

45-
export async function createSession(
46-
request: NextRequest,
47-
walletAddress: string
48-
): Promise<AuthSession> {
49-
const normalizedWallet = normalizeWalletAddress(walletAddress)
50-
const secret = getJwtSecret()
51-
44+
function buildSessionTokens(
45+
walletAddress: string,
46+
secret: string
47+
): Omit<AuthSession, 'walletAddress'> {
5248
const access = signSessionToken({
53-
subject: normalizedWallet,
54-
walletAddress: normalizedWallet,
49+
subject: walletAddress,
50+
walletAddress,
5551
type: 'access',
5652
expiresInSeconds: ACCESS_TOKEN_TTL_SECONDS,
5753
secret,
5854
})
5955
const refresh = signSessionToken({
60-
subject: normalizedWallet,
61-
walletAddress: normalizedWallet,
56+
subject: walletAddress,
57+
walletAddress,
6258
type: 'refresh',
6359
expiresInSeconds: REFRESH_TOKEN_TTL_SECONDS,
6460
secret,
@@ -67,22 +63,36 @@ export async function createSession(
6763
const accessTokenExpiresAt = new Date(access.payload.exp * 1000)
6864
const refreshTokenExpiresAt = new Date(refresh.payload.exp * 1000)
6965

66+
return {
67+
accessToken: access.token,
68+
refreshToken: refresh.token,
69+
accessTokenExpiresAt,
70+
refreshTokenExpiresAt,
71+
refreshJti: refresh.payload.jti,
72+
}
73+
}
74+
75+
export async function createSession(
76+
request: NextRequest,
77+
walletAddress: string
78+
): Promise<AuthSession> {
79+
const normalizedWallet = normalizeWalletAddress(walletAddress)
80+
const secret = getJwtSecret()
81+
82+
const tokens = buildSessionTokens(normalizedWallet, secret)
83+
7084
await storeRefreshToken({
7185
walletAddress: normalizedWallet,
72-
jti: refresh.payload.jti,
73-
tokenHash: sha256Hex(refresh.token),
74-
expiresAt: refreshTokenExpiresAt,
86+
jti: tokens.refreshJti,
87+
tokenHash: sha256Hex(tokens.refreshToken),
88+
expiresAt: tokens.refreshTokenExpiresAt,
7589
userAgent: request.headers.get('user-agent'),
7690
ipAddress: getClientIp(request),
7791
})
7892

7993
return {
8094
walletAddress: normalizedWallet,
81-
accessToken: access.token,
82-
refreshToken: refresh.token,
83-
accessTokenExpiresAt,
84-
refreshTokenExpiresAt,
85-
refreshJti: refresh.payload.jti,
95+
...tokens,
8696
}
8797
}
8898

@@ -96,24 +106,27 @@ export async function rotateSession(
96106
return null
97107
}
98108

99-
const tokenHash = sha256Hex(refreshToken)
100-
const isValid = await findValidRefreshToken({
101-
walletAddress: payload.wallet,
102-
jti: payload.jti,
103-
tokenHash,
109+
const normalizedWallet = normalizeWalletAddress(payload.wallet)
110+
const nextTokens = buildSessionTokens(normalizedWallet, secret)
111+
112+
const rotated = await rotateRefreshToken({
113+
walletAddress: normalizedWallet,
114+
currentJti: payload.jti,
115+
currentTokenHash: sha256Hex(refreshToken),
116+
newJti: nextTokens.refreshJti,
117+
newTokenHash: sha256Hex(nextTokens.refreshToken),
118+
newExpiresAt: nextTokens.refreshTokenExpiresAt,
119+
userAgent: request.headers.get('user-agent'),
120+
ipAddress: getClientIp(request),
104121
})
105-
if (!isValid) {
122+
if (!rotated) {
106123
return null
107124
}
108125

109-
await touchRefreshToken(payload.jti)
110-
const session = await createSession(request, payload.wallet)
111-
await revokeRefreshToken({
112-
jti: payload.jti,
113-
replacedByJti: session.refreshJti,
114-
})
115-
116-
return session
126+
return {
127+
walletAddress: normalizedWallet,
128+
...nextTokens,
129+
}
117130
}
118131

119132
export async function revokeSession(refreshToken: string): Promise<void> {

lib/auth/store.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,66 @@ export async function findValidRefreshToken({
144144

145145
return rows.length > 0
146146
}
147+
148+
export async function rotateRefreshToken({
149+
walletAddress,
150+
currentJti,
151+
currentTokenHash,
152+
newJti,
153+
newTokenHash,
154+
newExpiresAt,
155+
userAgent,
156+
ipAddress,
157+
}: {
158+
walletAddress: string
159+
currentJti: string
160+
currentTokenHash: string
161+
newJti: string
162+
newTokenHash: string
163+
newExpiresAt: Date
164+
userAgent: string | null
165+
ipAddress: string | null
166+
}): Promise<boolean> {
167+
const rows = await sql<{ rotated: string | number }[]>`
168+
WITH old AS (
169+
SELECT id
170+
FROM auth_refresh_tokens
171+
WHERE wallet_address = ${walletAddress}
172+
AND jti = ${currentJti}
173+
AND token_hash = ${currentTokenHash}
174+
AND revoked_at IS NULL
175+
AND expires_at > NOW()
176+
FOR UPDATE
177+
),
178+
ins AS (
179+
INSERT INTO auth_refresh_tokens (
180+
wallet_address,
181+
jti,
182+
token_hash,
183+
expires_at,
184+
user_agent,
185+
ip_address
186+
)
187+
SELECT
188+
${walletAddress},
189+
${newJti},
190+
${newTokenHash},
191+
${newExpiresAt.toISOString()},
192+
${userAgent},
193+
${ipAddress}
194+
WHERE EXISTS (SELECT 1 FROM old)
195+
RETURNING jti
196+
),
197+
upd AS (
198+
UPDATE auth_refresh_tokens
199+
SET revoked_at = NOW(),
200+
replaced_by_jti = (SELECT jti FROM ins),
201+
last_used_at = NOW()
202+
WHERE id IN (SELECT id FROM old)
203+
RETURNING id
204+
)
205+
SELECT (SELECT COUNT(*) FROM upd) AS rotated
206+
`
207+
208+
return Number(rows[0]?.rotated ?? 0) === 1
209+
}

0 commit comments

Comments
 (0)