Skip to main content

07 — Security

Dokumen ini menjelaskan strategi keamanan backend ELJoy — dari authentication, data protection, sampai abuse prevention.


1. Authentication & Authorization​

Auth Flow: Email + Password​

User Frontend API Server Database
│ │ │ │
│ ── Input email+pass ──►│ │ │
│ │ ── POST /auth/ │ │
│ │ sign-up/email ────►│ │
│ │ │ ── Hash password │
│ │ │ (Argon2id) ──────►│
│ │ │ │
│ │ │ ── Create session │
│ │ │ token (random │
│ │ │ 256-bit) ─────────►│
│ │ │ │
│ │ ◄── Set-Cookie: │ │
│ │ eljoy_session= │ │
│ │ {token} │ │
│ │ HttpOnly │ │
│ │ Secure │ │
│ │ SameSite=Lax │ │
│ ◄──────────────────────│ Path=/ │ │
│ │ Max-Age=604800 │ │

Auth Flow: Google OAuth​

User Frontend Google API Server Database
│ │ │ │ │
│ ── Klik ─────►│ │ │ │
│ "Login │ ── Redirect ──►│ │ │
│ Google" │ │ │ │
│ │ │ │ │
│ ◄─ Login di Google ───────────►│ │ │
│ │ │ │ │
│ │ ◄── id_token ──│ │ │
│ │ │ │ │
│ │ ── POST /auth/ │ │ │
│ │ sign-in/ │ │ │
│ │ social │ │ │
│ │ {provider: │ │ │
│ │ "google", │ │ │
│ │ idToken} ──────────────────►│ │
│ │ │ │ │
│ │ │ │ ── Verify token │
│ │ │ │ with Google ──►│
│ │ │ │ ◄── Valid ────────│
│ │ │ │ │
│ │ │ │ ── Find/Create │
│ │ │ │ user ──────────►
│ │ │ │ ── Create session ►
│ │ │ │ │
│ │ ◄── Set-Cookie ─────────────────│ │
│ ◄─────────────│ │ │ │

Session Security​

PropertyValueAlasan
HttpOnlytrueCegah JavaScript access (XSS protection)
SecuretrueHanya dikirim via HTTPS
SameSiteLaxCegah CSRF, tapi tetap allow normal navigation
Path/Available di semua route
Max-Age604800 (7 hari)Session expiry
Token formatRandom 256-bitTidak predictable, tidak bisa brute-force

Authorization Levels​

// Middleware chain

// Level 1: Public — siapa saja bisa akses
app.get('/api/v1/content/domains', publicHandler);

// Level 2: Authenticated — harus login
app.get('/api/v1/users/me', authMiddleware, userHandler);

// Level 3: Member — harus login + subscription aktif
app.post('/api/v1/conversations', authMiddleware, memberMiddleware, chatHandler);

// Level 4: Admin — harus login + role admin
app.post('/api/v1/admin/content', authMiddleware, adminMiddleware, adminHandler);
// Member middleware
const memberMiddleware = async (c: Context, next: Next) => {
const user = c.get('user');

// Check active subscription
const subscription = await db.query.subscriptions.findFirst({
where: and(
eq(subscriptions.userId, user.id),
eq(subscriptions.status, 'active'),
gt(subscriptions.expiresAt, new Date()),
),
});

if (!subscription) {
throw new ForbiddenError('Fitur ini hanya untuk Member. Upgrade sekarang!');
}

await next();
};

2. Data Protection​

Password Hashing​

Better Auth menggunakan Argon2id secara default — algoritma hashing paling aman saat ini:

PropertyValue
AlgorithmArgon2id
Memory64MB
Iterations3
Parallelism1

Password yang di-hash tidak bisa di-reverse (one-way), bahkan oleh admin.

Encryption​

DataAt RestIn Transit
PasswordArgon2id hashHTTPS (TLS 1.3)
Session tokensPlain in DB (random, no info)HTTPS + HttpOnly cookie
User dataPostgreSQL encryptionHTTPS
Audio filesR2 server-side encryptionHTTPS
API keysEnvironment variablesNever in code/logs
Payment dataTidak disimpan (handled by Midtrans)HTTPS + PCI DSS

Sensitive Data Rules​

  1. Password — Hanya disimpan sebagai hash, tidak pernah di-log
  2. API keys — Hanya di environment variables, tidak di kode atau database
  3. Payment info — Tidak disimpan di server kita, semua dihandle Midtrans (PCI compliant)
  4. Audio recordings — Disimpan di R2 dengan TTL, dihapus setelah 90 hari
  5. AI conversations — Disimpan di database, bisa dihapus user (right to erasure)

3. Input Validation & Sanitization​

Request Validation (Zod)​

Semua input user di-validasi sebelum diproses:

import { zValidator } from '@hono/zod-validator';

// Contoh: Register endpoint
app.post(
'/auth/sign-up/email',
zValidator('json', z.object({
email: z.string()
.email('Email tidak valid')
.max(255)
.transform(v => v.toLowerCase().trim()),
password: z.string()
.min(8, 'Password minimal 8 karakter')
.max(128)
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
'Password harus mengandung huruf besar, huruf kecil, dan angka'),
name: z.string()
.min(2, 'Nama minimal 2 karakter')
.max(100)
.trim(),
guestId: z.string().optional(),
})),
handler,
);

SQL Injection Prevention​

Drizzle ORM menggunakan parameterized queries secara default — SQL injection tidak mungkin:

// ✅ AMAN — Drizzle auto-parameterize
const user = await db.query.users.findFirst({
where: eq(users.email, userInput), // userInput di-parameterize
});

// ✅ AMAN — Raw SQL tetap parameterized
const result = await db.execute(
sql`SELECT * FROM users WHERE email = ${userInput}`
);

// ❌ JANGAN PERNAH — String concatenation
// const result = await db.execute(`SELECT * FROM users WHERE email = '${userInput}'`);

XSS Prevention​

  • Semua output di-encode oleh React (frontend) secara default
  • API hanya return JSON (bukan HTML) → XSS di API layer tidak mungkin
  • Content-Type header selalu application/json
  • Tidak ada server-side rendering (SSR)

4. Rate Limiting & Abuse Prevention​

Rate Limit Tiers​

const rateLimits = {
// Auth endpoints — ketat untuk cegah brute force
'auth:sign-in': { limit: 5, windowMs: 15 * 60 * 1000 }, // 5/15min
'auth:sign-up': { limit: 3, windowMs: 60 * 60 * 1000 }, // 3/1hr
'auth:forgot': { limit: 3, windowMs: 60 * 60 * 1000 }, // 3/1hr

// Assessment — mencegah spam assessment
'assessment': { limit: 5, windowMs: 60 * 1000 }, // 5/1min

// Conversation — normal speaking pace
'conversation': { limit: 30, windowMs: 60 * 1000 }, // 30/1min

// General API — generous for normal use
'general': { limit: 100, windowMs: 60 * 1000 }, // 100/1min

// File upload — prevent abuse
'upload': { limit: 10, windowMs: 60 * 1000 }, // 10/1min
};

IP-based vs User-based​

// Untuk public endpoints (belum login): rate limit per IP
const key = `rl:${path}:ip:${getClientIP(c)}`;

// Untuk authenticated endpoints: rate limit per user
const key = `rl:${path}:user:${userId}`;

Anti-Abuse Measures​

  1. Audio file size limit — Max 10MB per upload
  2. Assessment cooldown — Min 1 menit antara assessment sessions
  3. Free tier quotas — 1x Ice Breaker + 1x Universal Assessment
  4. Webhook signature verification — Midtrans webhooks diverifikasi
  5. CORS restriction — Hanya allow dari domain frontend yang dikenal

5. CORS Configuration​

import { cors } from 'hono/cors';

app.use(cors({
origin: [
'https://app.eljoy.id', // Production frontend
'https://staging.eljoy.id', // Staging frontend
...(process.env.NODE_ENV === 'development'
? ['http://localhost:5173', 'http://localhost:3000']
: []),
],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
credentials: true, // Untuk cookies
maxAge: 86400, // Preflight cache 24 jam
}));

6. Security Headers​

app.use(async (c, next) => {
await next();

// Security headers
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('X-XSS-Protection', '0'); // Disabled, rely on CSP instead
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), geolocation=(), microphone=()');

// Strict-Transport-Security (HSTS)
if (process.env.NODE_ENV === 'production') {
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});

7. Audit Logging​

Semua aksi penting dicatat di audit_logs:

async function auditLog(params: {
actorId: string | null;
actorType: 'user' | 'admin' | 'system';
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
ipAddress?: string;
}) {
await db.insert(auditLogs).values({
id: nanoid(),
...params,
createdAt: new Date(),
});
}

// Contoh usage
await auditLog({
actorId: userId,
actorType: 'user',
action: 'user.register',
resource: 'user',
resourceId: newUser.id,
details: { method: 'email', guestLinked: true },
ipAddress: c.req.header('x-forwarded-for'),
});

Aksi yang di-audit:

  • user.register, user.login, user.logout, user.delete
  • assessment.start, assessment.complete
  • subscription.create, subscription.cancel
  • payment.received, payment.failed
  • content.create, content.update, content.delete (admin)
  • admin.login, admin.user_action

8. Privacy & Data Retention​

Data Retention Policy​

DataRetentionAlasan
User accountSampai dihapus userCore data
Auth sessions7 hari (auto-expire)Session lifecycle
Assessment resultsUnlimitedTracking progress
Conversation messages1 tahunReview & improvement
Audio recordings (user)90 hariStorage cost
Audio recordings (TTS)30 hari cacheRe-generate jika perlu
Audit logs2 tahunCompliance
Deleted accounts30 hari (grace period), lalu purgeRight to erasure

User Data Rights​

  1. Right to Access — User bisa export semua data mereka via GET /users/me/export
  2. Right to Erasure — User bisa hapus akun via DELETE /users/me
  3. Right to Rectification — User bisa update data mereka via PATCH /users/me
  4. Data Portability — Export dalam format JSON

Account Deletion Flow​

User request delete → Soft delete (30 days grace) → Hard delete (purge)

Soft Delete:
- User login disabled
- Data tetap ada (recoverable)
- Email: "Akunmu akan dihapus permanen dalam 30 hari"

Hard Delete (30 hari kemudian):
- Semua user data dihapus dari DB
- Audio files dihapus dari R2
- Audit log tetap (anonymized)
- Subscription di-cancel

9. Security Checklist​

#ItemStatus
1Passwords hashed with Argon2id☐
2Sessions stored server-side (not JWT)☐
3Cookies: HttpOnly + Secure + SameSite☐
4HTTPS enforced (HSTS)☐
5CORS restricted to known origins☐
6Input validation on all endpoints (Zod)☐
7Parameterized queries (Drizzle ORM)☐
8Rate limiting on auth endpoints☐
9Rate limiting on AI endpoints☐
10File upload size limits☐
11Webhook signature verification☐
12Security headers set☐
13Audit logging implemented☐
14No secrets in code/logs☐
15Data retention policy enforced☐
16Account deletion (soft + hard)☐
17Error messages don't leak internals☐
18Dependencies regularly updated☐