Skip to content

Commit 54b9f10

Browse files
authored
Merge pull request #178 from nanaf6203-bit/fix/nanaf6203-security-hardening
Harden JWT validation, rate limiter, and token storage
2 parents de33972 + 690cb49 commit 54b9f10

5 files changed

Lines changed: 146 additions & 34 deletions

File tree

backend/.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Required — the application will NOT start without this.
2+
# Generate a strong random string (e.g. openssl rand -hex 32).
3+
JWT_SECRET=
4+
5+
# Optional overrides (defaults shown).
6+
# JWT_EXPIRES_IN=15m
7+
8+
# Database
9+
# DATABASE_HOST=localhost
10+
# DATABASE_PORT=5432
11+
# DATABASE_USER=postgres
12+
# DATABASE_PASSWORD=
13+
# DATABASE_NAME=stellarshunts
14+
# DATABASE_SYNC=true
15+
16+
# Stellar / Soroban
17+
# STELLAR_MODE=mock
18+
# STELLAR_NETWORK=testnet
19+
# SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
20+
# STELLAR_HUNTS_CONTRACT_ID=
21+
# STELLAR_HUNTS_NFT_CONTRACT_ID=

backend/src/auth/auth.module.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,26 @@ import { AuthController } from "./controllers/auth.controller"
88
import { AuthService } from "./services/auth.service"
99
import { JwtStrategy } from "./strategies/jwt.strategy"
1010
import { JwtAuthGuard } from "./guards/jwt-auth.guard"
11+
import * as Joi from "joi"
1112

1213
@Module({
1314
imports: [
1415
TypeOrmModule.forFeature([User]),
1516
PassportModule.register({ defaultStrategy: "jwt" }),
17+
ConfigModule.forRoot({
18+
isGlobal: true,
19+
validationSchema: Joi.object({
20+
JWT_SECRET: Joi.string().required().messages({
21+
"any.required": "JWT_SECRET is required. Set it in your .env file.",
22+
"string.empty": "JWT_SECRET cannot be empty. Set it in your .env file.",
23+
}),
24+
}),
25+
}),
1626
JwtModule.registerAsync({
1727
imports: [ConfigModule],
1828
inject: [ConfigService],
1929
useFactory: (configService: ConfigService) => ({
20-
secret: configService.get("JWT_SECRET") || "your-secret-key",
30+
secret: configService.get<string>("JWT_SECRET"),
2131
signOptions: {
2232
expiresIn: configService.get("JWT_EXPIRES_IN") || "15m",
2333
},

backend/src/rate-limiter/rate-limit.guard.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,14 @@ export class RateLimitGuard implements CanActivate {
2525
if (!config) return true;
2626

2727
const request = context.switchToHttp().getRequest();
28-
const ip = request.ip || request.connection.remoteAddress;
29-
const key = `rate:${ip}:${context.getHandler().name}`;
28+
const ip =
29+
(request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
30+
request.ip ||
31+
request.connection.remoteAddress;
32+
const userId = request.user?.id;
33+
const key = userId
34+
? `rate:${userId}:${context.getHandler().name}`
35+
: `rate:${ip}:${context.getHandler().name}`;
3036

3137
const isLimited = this.rateLimiterService.isRateLimited(
3238
key,

backend/src/rate-limiter/rate-limiter.service.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
22

33
@Injectable()
4-
export class RateLimiterService {
4+
export class RateLimiterService implements OnModuleInit, OnModuleDestroy {
55
private requestsMap = new Map<string, { count: number; expiresAt: number }>();
6+
private evictionTimer: ReturnType<typeof setInterval> | null = null;
7+
8+
onModuleInit() {
9+
this.evictionTimer = setInterval(() => this.evictExpired(), 30_000);
10+
}
11+
12+
onModuleDestroy() {
13+
if (this.evictionTimer) clearInterval(this.evictionTimer);
14+
}
615

716
isRateLimited(key: string, ttl: number, limit: number): boolean {
817
const now = Date.now();
@@ -19,4 +28,13 @@ export class RateLimiterService {
1928
this.requestsMap.set(key, entry);
2029
return false;
2130
}
31+
32+
private evictExpired() {
33+
const now = Date.now();
34+
for (const [key, entry] of this.requestsMap) {
35+
if (now > entry.expiresAt) {
36+
this.requestsMap.delete(key);
37+
}
38+
}
39+
}
2240
}

frontend/store/auth/auth-store.js

Lines changed: 86 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,149 @@
1-
import create from "zustand";
1+
import { create } from "zustand";
22
import { persist } from "zustand/middleware";
3+
import { devtools } from "zustand/middleware";
4+
5+
const ENCRYPTION_KEY_NAME = "stellar-hunts-ek";
6+
7+
async function getOrCreateEncryptionKey() {
8+
const stored = sessionStorage.getItem(ENCRYPTION_KEY_NAME);
9+
if (stored) {
10+
const raw = Uint8Array.from(atob(stored), (c) => c.charCodeAt(0));
11+
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, true, [
12+
"encrypt",
13+
"decrypt",
14+
]);
15+
}
16+
const key = await crypto.subtle.generateKey(
17+
{ name: "AES-GCM", length: 256 },
18+
true,
19+
["encrypt", "decrypt"]
20+
);
21+
const exported = await crypto.subtle.exportKey("raw", key);
22+
sessionStorage.setItem(
23+
ENCRYPTION_KEY_NAME,
24+
btoa(String.fromCharCode(...new Uint8Array(exported)))
25+
);
26+
return key;
27+
}
28+
29+
async function encryptToken(token) {
30+
if (!token) return null;
31+
const key = await getOrCreateEncryptionKey();
32+
const iv = crypto.getRandomValues(new Uint8Array(12));
33+
const encoded = new TextEncoder().encode(token);
34+
const ciphertext = await crypto.subtle.encrypt(
35+
{ name: "AES-GCM", iv },
36+
key,
37+
encoded
38+
);
39+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
40+
combined.set(iv);
41+
combined.set(new Uint8Array(ciphertext), iv.length);
42+
return btoa(String.fromCharCode(...combined));
43+
}
44+
45+
async function decryptToken(encrypted) {
46+
if (!encrypted) return null;
47+
try {
48+
const key = await getOrCreateEncryptionKey();
49+
const raw = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0));
50+
const iv = raw.slice(0, 12);
51+
const ciphertext = raw.slice(12);
52+
const decrypted = await crypto.subtle.decrypt(
53+
{ name: "AES-GCM", iv },
54+
key,
55+
ciphertext
56+
);
57+
return new TextDecoder().decode(decrypted);
58+
} catch {
59+
return null;
60+
}
61+
}
362

463
const useAuthStore = create(
564
devtools(
665
persist(
7-
(set) => ({
66+
(set, get) => ({
867
user: null,
968
token: null,
1069
isAuthenticated: false,
70+
1171
register: async (userData) => {
1272
try {
1373
const response = await fetch("/api/register", {
1474
method: "POST",
15-
headers: {
16-
"Content-Type": "application/json",
17-
},
75+
headers: { "Content-Type": "application/json" },
1876
body: JSON.stringify(userData),
1977
});
2078

21-
if (!response.ok) {
22-
throw new Error("Registration failed");
23-
}
79+
if (!response.ok) throw new Error("Registration failed");
2480

2581
const data = await response.json();
2682
const { user, token } = data;
27-
set({ user, token, isAuthenticated: true });
28-
// Optionally, store the token in localStorage or set it in headers for future requests
83+
const encrypted = await encryptToken(token);
84+
set({ user, token: encrypted, isAuthenticated: true });
2985
} catch (error) {
3086
console.error("Registration error:", error);
31-
// Handle registration error (e.g., show notification)
3287
}
3388
},
89+
3490
login: async (credentials) => {
3591
try {
3692
const response = await fetch("/api/login", {
3793
method: "POST",
38-
headers: {
39-
"Content-Type": "application/json",
40-
},
94+
headers: { "Content-Type": "application/json" },
4195
body: JSON.stringify(credentials),
4296
});
4397

44-
if (!response.ok) {
45-
throw new Error("Login failed");
46-
}
98+
if (!response.ok) throw new Error("Login failed");
4799

48100
const data = await response.json();
49101
const { user, token } = data;
50-
set({ user, token, isAuthenticated: true });
51-
// Optionally, store the token in localStorage or set it in headers for future requests
102+
const encrypted = await encryptToken(token);
103+
set({ user, token: encrypted, isAuthenticated: true });
52104
} catch (error) {
53105
console.error("Login error:", error);
54-
// Handle login error (e.g., show notification)
55106
}
56107
},
108+
57109
logout: () => {
58110
set({ user: null, token: null, isAuthenticated: false });
59-
// Optionally, remove the token from localStorage or headers
60111
},
112+
113+
getDecryptedToken: async () => {
114+
const { token } = get();
115+
return decryptToken(token);
116+
},
117+
61118
fetchUser: async () => {
62119
try {
120+
const decryptedToken = await get().getDecryptedToken();
63121
const response = await fetch("/api/user", {
64122
method: "GET",
65123
headers: {
66124
"Content-Type": "application/json",
67-
// Include authorization header with the token if required
125+
...(decryptedToken
126+
? { Authorization: `Bearer ${decryptedToken}` }
127+
: {}),
68128
},
69129
});
70130

71-
if (!response.ok) {
72-
throw new Error("Fetching user failed");
73-
}
131+
if (!response.ok) throw new Error("Fetching user failed");
74132

75133
const user = await response.json();
76134
set({ user, isAuthenticated: true });
77135
} catch (error) {
78136
console.error("Fetching user error:", error);
79-
// Handle error (e.g., redirect to login)
80137
}
81138
},
82139
}),
83140
{
84141
name: "auth-storage",
85142
getStorage: () => localStorage,
86-
},
143+
}
87144
),
88-
{ name: "AuthStore" },
89-
),
145+
{ name: "AuthStore" }
146+
)
90147
);
91148

92149
export default useAuthStore;

0 commit comments

Comments
 (0)