Production-ready authentication base untuk Spring Boot. Dirancang stateless, aman, dan mudah dikustomisasi.
- JWT dengan RS256 — Access Token ditandatangani dengan RSA 2048-bit, bukan HMAC symmetric key
- Automatic RSA Key Rotation — Key pair dirotasi terjadwal via Quartz, token lama tetap valid selama grace period
- Private Key Encryption dengan Google Tink — Private key tidak pernah disimpan plaintext di DB; dienkripsi dengan AES-256-GCM (AEAD)
- Opaque Refresh Token — Format
sessionId.secret, hanya hash SHA-256-nya yang disimpan di DB - Replay Attack Detection — RT yang sudah dirotasi jika dipakai lagi akan langsung revoke semua session user
- Authorities dari Redis, bukan JWT — Role dan permission dimuat dari Redis per request; token tetap slim dan revocation instan
- Rate Limiting Login — Per-IP (fixed window) + per-username (exponential backoff) berbasis Redis, shared antar instance
- JWKS Endpoint —
/.well-known/jwks.jsonuntuk resource server lain memverifikasi JWT secara mandiri - i18n Error Messages — Semua pesan error mendukung multi-bahasa (en / id included)
| Komponen | Library |
|---|---|
| JWT | Nimbus JOSE JWT |
| RSA Key Generation | Nimbus JOSE (RSAKeyGenerator) |
| Private Key Encryption | Google Tink AES-256-GCM |
| Scheduler | Quartz (clustered, JDBC store) |
| Cache | Redis (StringRedisTemplate) |
| Database | PostgreSQL + Flyway |
| Security | Spring Security 6 (stateless) |
| UUID | uuid-creator (time-ordered) |
com.gepe.bayr
├── auth/
│ ├── api/
│ │ ├── event/ # Domain events (e.g., ReplayAttack)
│ │ └── type/ # Enums like AuthProviderType
│ └── internal/
│ ├── config/ # Spring Security config, startup logic
│ ├── crypto/ # RSA key services, Tink encryptor
│ ├── delivery/http/ # Controllers (Auth, JWKS) + DTOs
│ ├── entity/ # JPA entities for auth (Credential, Session)
│ ├── filter/ # JwtAuthenticationFilter
│ ├── repo/ # JPA repositories for auth
│ ├── scheduler/ # Quartz jobs for key rotation
│ └── service/ # Core auth services (Token, Login, Rate Limiting)
├── user/
│ ├── api/ # Public interface for the user module (Facade, DTOs)
│ └── internal/
│ ├── entity/ # User, Role, UserProfile entities
│ ├── repo/ # JPA repositories for user
│ └── service/ # User service implementation
└── shared/
├── config/ # Global configs (i18n, Jackson)
├── exception/ # Global exception handler
├── jpa/ # Base entities (e.g., for auditing)
└── web/ # Web configs, standard API responses
POST /app/v1/auth/register
{ "email": "...", "password": "...", "nickname": "..." }
AuthController
└── LocalAuthService.register()
├── UserService.registerUser() → buat User + UserProfile di DB
├── Credential.save() → simpan email + bcrypt(password, cost=12)
└── TokenService.issueTokenPair() → return AT + RT
POST /app/v1/auth/login
{ "email": "...", "password": "..." }
AuthController
└── LocalAuthService.login()
├── LoginRateLimiterService.checkIpAllowed(ip) → cek rate limit IP
├── LoginRateLimiterService.checkUsernameAllowed(email) → cek lockout username
├── CredentialRepo.findByProviderAndProviderId() → ambil credential
│ └── NOT FOUND → onLoginFailed() + throw 401
├── Cek UserStatus (ACTIVE / SUSPENDED / DISABLED)
├── bcrypt.matches(password, hash)
│ └── FAIL → onLoginFailed() + throw 401
├── LoginRateLimiterService.onLoginSuccess() → reset counter username
└── TokenService.issueTokenPair() → return AT + RT
Request: Authorization: Bearer <AT>
JwtAuthenticationFilter
├── TokenService.verifyAccessToken(AT)
│ ├── SignedJWT.parse() → ambil kid dari header
│ ├── SigningKeyCacheService.getSigningKey(kid)
│ │ └── Redis hit → return SigningKey (cache TTL 10 hari)
│ │ └── Redis miss → DB lookup → simpan ke Redis
│ ├── RSASSAVerifier.verify() → validasi signature RS256
│ └── cek exp claim
└── AuthorityCacheService.getAuthorities(userId)
└── Redis hit → cek status user → return roles
└── Redis miss → DB lookup → simpan ke Redis (TTL = 15 menit)
└── cek UserStatus (SUSPENDED/DISABLED → throw 403)
POST /app/v1/auth/refresh
{ "refreshToken": "<sessionId>.<rawSecret>" }
TokenService.rotateRefreshToken()
├── split RT → sessionId + rawSecret
├── findBySessionId() → ambil session dari DB
├── hashOpaque(rawSecret) → compare dengan hash di DB (constant-time)
├── cek isRevoked
│ └── TRUE → publishEvent(ReplayAttackDetectedEvent)
│ └── SecurityEventListener → revokeAllByUserId() di transaksi baru
├── cek expiresAt
├── set session lama isRevoked = true, revokeReason = "ROTATED_REFRESH_TOKEN"
└── issueTokenPair() → return AT + RT baru
KeyRotationJob (Quartz, terjadwal)
└── RsaKeyService.rotateKey()
├── generateRsaKeyPair() → RSA 2048-bit via Nimbus
├── PrivateKeyEncryptor.encrypt() → AES-256-GCM via Tink
├── signingKeyRepo.deactivateAllActiveKeys() → key lama jadi inactive
├── SigningKeyCacheService.evictAllSigningKeyCache() → bersihkan Redis
├── signingKeyRepo.save(newKey) → simpan key baru
└── signingKeyRepo.deleteExpiredKeys() → hapus key yang sudah lewat expiresAt
Grace period: Key lama tidak langsung dihapus.
expiresAt= waktu key boleh dihapus (default 30 hari setelah dibuat). Selama grace period, token lama yang masih valid tetap bisa diverifikasi. PastikanRSA_ROTATION_TTL>ACCESS_TOKEN_TTL.
Dua counter independen di Redis:
Per-IP (credential stuffing guard):
Key: rate:login:ip:{ip}
Limit: 10 request / 60 detik (fixed window)
Reset: TIDAK direset saat login sukses
Per-username (brute force guard):
Key (counter): rate:login:user:fails:{username} → TTL 24 jam, TIDAK ikut expire saat lockout habis
Key (lockout): rate:login:user:lockout:{username} → TTL = durasi lockout aktif
Gagal ke-5 → lockout 60 detik
Gagal ke-6 → lockout 120 detik (counter tetap 6 karena key fails tidak dihapus)
Gagal ke-7 → lockout 240 detik
...cap 1 jam
Reset: DIRESET saat login sukses (hapus kedua key)
# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=bayr
DB_USERNAME=bayr_user
DB_PASSWORD=secret
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=redis_secret
REDIS_SSL=false
# JWT
AUTH_ACCESS_TOKEN_TTL=15m
AUTH_REFRESH_TOKEN_TTL=15d
AUTH_RSA_ROTATION_TTL=30d # harus > ACCESS_TOKEN_TTL
# Google Tink — generate sekali, simpan di secrets manager
TINK_KEYSET_JSON=<generate dengan cara di bawah>Jalankan sekali saja (via unit test atau main method sementara):
String keysetJson = PrivateKeyEncryptor.generateNewKeysetJson();
System.out.println(keysetJson);Simpan output-nya ke secrets manager (AWS Secrets Manager, Vault, dll). Jangan pernah commit ke source control.
⚠️ Jika keyset ini hilang, semua private key di DB tidak bisa didekripsi. Backup keyset dengan aman.
Project menggunakan Flyway. Jalankan migration sebelum start:
./mvnw flyway:migrateDefault konfigurasi di KeyRotationJobConfig.java. Ubah cron expression sesuai kebutuhan:
// Contoh: setiap hari pukul 02:00
CronScheduleBuilder.cronSchedule("0 0 2 * * ?")| Method | Path | Auth | Deskripsi |
|---|---|---|---|
POST |
/app/v1/auth/register |
Public | Registrasi user baru |
POST |
/app/v1/auth/login |
Public | Login, return AT + RT |
POST |
/app/v1/auth/logout |
Bearer AT | Logout, invalidate refresh token |
POST |
/app/v1/auth/refresh |
Public | Rotate refresh token |
GET |
/app/v1/auth/me |
Bearer AT | Data user yang sedang login |
GET |
/.well-known/jwks.json |
Public | JWKS untuk verifikasi JWT eksternal |
{
"message": "Login successful",
"data": {
"accessToken": "eyJraWQ...",
"refreshToken": "01935c2a-...<sessionId>.<rawSecret>",
"user": {
"id": "01935c2a-e033-7466-9a9f-3e3b71a19903",
"nickname": "Gepe",
"email": "gepe@gepe.com",
"status": "ACTIVE",
"roles": ["USER"],
"profile": {
"fullName": "Gepe Ganteng",
"pictureUrl": "https://.../picture.jpg"
}
}
}
}{
"message": "Terlalu banyak percobaan. Coba lagi dalam 120 detik.",
"errors": null
}Menyimpan role/permission di JWT berarti:
- Token membengkak setiap request
- Tidak bisa revoke permission secara instan (harus tunggu token expire)
Solusi di project ini: JWT hanya berisi sub (userId) + exp. Role dimuat dari Redis per request dengan TTL = AT TTL. Untuk revoke permission user, cukup evict key Redis-nya.
Private key RSA tidak boleh disimpan plaintext di DB. Tink menyediakan:
- AES-256-GCM (AEAD): Authenticated Encryption — ciphertext tidak bisa dimanipulasi tanpa terdeteksi
- Associated Data (
signing_key_private): konteks tambahan yang diverifikasi saat decrypt, tanpa ikut dienkripsi - Key rotation-ready: Tink keyset bisa berisi multiple key, memudahkan rotasi keyset tanpa downtime
RT tidak perlu membawa claim apapun — tugasnya hanya sebagai "tiket" untuk dapat AT baru. Format opaque sessionId.secret:
sessionId(UUID time-ordered): untuk lookup O(1) ke DB tanpa full table scanrawSecret(16-byte random): diverifikasi dengan constant-time compare setelah di-hash SHA-256
Jika RT yang sudah dirotasi (status isRevoked = true) dipakai kembali, sistem mendeteksi ini sebagai replay attack dan langsung merevoke semua session milik user tersebut. Event ini diproses di transaksi terpisah (REQUIRES_NEW) agar tetap jalan walau transaksi utama rollback.
Di AuthorityCacheService.getAuthorities(), saat ini hanya memuat roles. Untuk menambah granular permission:
// Ganti bagian ini:
Set<String> roles = userRes.roles().stream()
.map(role -> "ROLE_" + role.code().toUpperCase())
.collect(Collectors.toSet());
// Jadi:
Set<String> authorities = new HashSet<>();
userRes.roles().forEach(r -> authorities.add("ROLE_" + r.code().toUpperCase()));
userRes.permissions().forEach(p -> authorities.add(p.code())); // tambahkan permissionVia environment variable atau langsung di application.yaml:
app:
jwt:
access-token-ttl: 30m # default 15m
refresh-token-ttl: 7d # default 15dDi LoginRateLimiterService.java:
private static final int IP_MAX_ATTEMPTS = 10; // max hit per IP per window
private static final long IP_WINDOW_SECONDS = 60L; // window dalam detik
private static final int USER_MAX_FAILS = 5; // max gagal sebelum lockout
private static final long LOCKOUT_BASE_SECONDS = 60L; // lockout pertama
private static final long LOCKOUT_MAX_SECONDS = 3600L; // cap lockoutTambahkan value baru di AuthProviderType.java dan buat service terpisah (mirip LocalAuthService) yang memanggil TokenService.issueTokenPair() setelah verifikasi token dari provider.
- Redis wajib running sebelum aplikasi start. Rate limiter dan authority cache bergantung pada Redis. Jika Redis down, rate limiter fail-open (tidak memblokir login), tapi authority cache akan fallback ke DB setiap request.
- Multi-instance: Quartz dikonfigurasi clustered (
isClustered: true) sehingga key rotation hanya dijalankan oleh satu instance. Redis dipakai bersama antar instance — tidak perlu sticky session. - Backup Tink Keyset: Simpan di secrets manager (bukan di kode atau
.envfile yang di-commit). Rotasi keyset Tink dilakukan manual jika diperlukan. RSA_ROTATION_TTLharus lebih besar dariACCESS_TOKEN_TTL: Agar token yang dibuat sesaat sebelum rotasi tetap bisa diverifikasi selama masa aktifnya.