Skip to main content

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

UsernamePasswordRoles
adminAdmin123!admin
operator1Oper@123!it-ops
auditor1Audit@123!compliance
viewer1View@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

StatusMessageDescription
400invalid json bodyRequest body tidak valid
400username and password are requiredField kosong
401missing bearer tokenToken tidak ada
401invalid credentialsUsername/password salah
401invalid tokenToken tidak valid
403role {role} requiredRole tidak sesuai
403permission deniedPermission tidak ada

Next Steps