Authentication
Overview
Backend CLM menggunakan Keycloak sebagai Identity Provider dengan JWT (JSON Web Tokens) untuk autentikasi.
Authentication Flow
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Frontend │────▶│ Backend │────▶│ Keycloak │
│ │ │ │ │ │
│ Login │ │ Password │ │ Validate │
│ Form │ │ Grant │ │ Credentials│
└──────────┘ └──────────┘ └──────────┘
│ │
│◀───────────JWT Token─────────────│
│ │
│─────Bearer Token────────────────▶│
│ │
│◀─────────User Info───────────────│
Keycloak Configuration
Realm
{
"realm": "log-monitoring",
"enabled": true,
"registrationAllowed": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false
}
Client
{
"clientId": "backend-api",
"name": "CLM Backend API",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"secret": "backend-api-secret",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": false
}
Users
| Username | Password | Roles |
|---|---|---|
| admin | Admin123! | admin |
| operator1 | Oper@123! | it-ops |
| auditor1 | Audit@123! | compliance |
| viewer1 | View@123! | executive |
Login Process
1. Password Grant
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
// Call Keycloak Password Grant
token, err := s.client.PasswordGrant(r.Context(), req.Username, req.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
// Validate token
claims, err := s.verifier.Validate(token.AccessToken)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid token")
return
}
// Return token + user info
writeJSON(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]any{
"username": keycloak.Username(claims),
"roles": keycloak.RealmRoles(claims),
"email": claims["email"],
},
})
}
2. Keycloak Client
type Client struct {
baseURL string
realm string
clientID string
clientSecret string
}
func (c *Client) PasswordGrant(ctx context.Context, username, password string) (*Token, error) {
url := fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", c.baseURL, c.realm)
data := url.Values{
"grant_type": {"password"},
"client_id": {c.clientID},
"client_secret": {c.clientSecret},
"username": {username},
"password": {password},
}
resp, err := http.PostForm(url, data)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var token Token
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
return nil, err
}
return &token, nil
}
JWT Token Validation
Verifier
type Verifier struct {
jwksURL string
cache *jwksCache
}
func (v *Verifier) Validate(tokenString string) (map[string]any, error) {
// Parse token
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
// Get signing key from JWKS
kid := token.Header["kid"].(string)
key, err := v.getSigningKey(kid)
if err != nil {
return nil, err
}
return key, nil
})
if err != nil {
return nil, err
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
Token Structure
{
"exp": 1720000000,
"iat": 1719999700,
"jti": "uuid",
"sub": "user-id",
"typ": "Bearer",
"azp": "backend-api",
"session_state": "session-id",
"acr": "1",
"realm_access": {
"roles": ["admin", "default-roles-log-monitoring"]
},
"resource_access": {
"backend-api": {
"roles": ["admin"]
}
},
"scope": "openid profile email",
"email_verified": true,
"name": "Admin User",
"preferred_username": "admin",
"given_name": "Admin",
"family_name": "User",
"email": "admin@bjbsyariah.co.id",
"tenant_id": "bjb-syariah"
}
Auth Middleware
withAuth
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r.Header.Get("Authorization"))
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
claims, err := s.verifier.Validate(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
// Extract tenant_id from claims or use default
tenantID := s.cfg.DefaultTenant
if tid, ok := claims["tenant_id"].(string); ok && tid != "" {
tenantID = tid
}
// Add claims and tenant to context
ctx := contextWithClaims(r.Context(), claims)
ctx = context.WithValue(ctx, "tenant_id", tenantID)
ctx = context.WithValue(ctx, "tenant_slug", tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
withAPIKey
Untuk service-to-service communication:
func (s *Server) withAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
if key == "" || key != s.cfg.N8NAPIKey {
writeError(w, http.StatusUnauthorized, "invalid API key")
return
}
next.ServeHTTP(w, r)
})
}
Role Extraction
func Username(claims map[string]any) string {
if username, ok := claims["preferred_username"].(string); ok {
return username
}
return ""
}
func RealmRoles(claims map[string]any) []string {
var roles []string
if realmAccess, ok := claims["realm_access"].(map[string]any); ok {
if roleList, ok := realmAccess["roles"].([]any); ok {
for _, role := range roleList {
if r, ok := role.(string); ok {
roles = append(roles, r)
}
}
}
}
return roles
}
func HasRole(claims map[string]any, role string) bool {
roles := RealmRoles(claims)
for _, r := range roles {
if r == role {
return true
}
}
return false
}
Audit Logging
Setiap login berhasil dicatat ke audit trail:
go func() {
auditDoc := map[string]any{
"@timestamp": time.Now().UTC().Format(time.RFC3339),
"actor": req.Username,
"action": "login",
"target": "system",
"result": "success",
"ip": r.RemoteAddr,
"details": map[string]any{
"method": "password",
},
"source_type": "audit",
"app_slug": "clm-backend",
"tenant_id": s.cfg.DefaultTenant,
}
indexName := "audit-log-" + time.Now().UTC().Format("2006.01.02")
s.search.IndexDocument(context.Background(), indexName, auditDoc)
}()
Error Responses
| Status | Message | Description |
|---|---|---|
| 400 | invalid json body | Request body tidak valid |
| 400 | username and password are required | Field kosong |
| 401 | missing bearer token | Token tidak ada |
| 401 | invalid credentials | Username/password salah |
| 401 | invalid token | Token tidak valid |
| 403 | role {role} required | Role tidak sesuai |
| 403 | permission denied | Permission tidak ada |
Next Steps
- RBAC - Role-Based Access Control
- Configuration - Configuration guide
- API Endpoints - Full API documentation