penambahan All case Satu sehat

This commit is contained in:
meninjar
2026-05-04 03:48:43 +00:00
parent 135c631021
commit af32d9cfdd
127 changed files with 617698 additions and 1168 deletions
+160
View File
@@ -0,0 +1,160 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"strings"
)
// GenerateRSAKeyPair menghasilkan private dan public key RSA dalam format string PEM.
// Umumnya Satu Sehat mewajibkan RSA dengan panjang minimal 2048 bit.
func GenerateRSAKeyPair(bits int) (privateKeyPEM string, publicKeyPEM string, err error) {
// 1. Generate RSA Private Key
privateKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
return "", "", fmt.Errorf("gagal mengenerate private key: %w", err)
}
// 2. Encode Private Key ke format PEM (PKCS#1)
privBytes := x509.MarshalPKCS1PrivateKey(privateKey)
privBlock := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privBytes,
}
privateKeyPEM = string(pem.EncodeToMemory(privBlock))
// 3. Extract dan Encode Public Key ke format PEM (SPKI)
pubBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return "", "", fmt.Errorf("gagal marshal public key: %w", err)
}
pubBlock := &pem.Block{
Type: "PUBLIC KEY",
Bytes: pubBytes,
}
// Output standard PEM dari Go (pem.EncodeToMemory) sudah identik dengan openssl_pkey_get_details di PHP.
publicKeyPEM = string(pem.EncodeToMemory(pubBlock))
return privateKeyPEM, publicKeyPEM, nil
}
// Public Key resmi Kemenkes (Production / Staging) untuk mengenkripsi kunci AES.
const KemkesPublicKeyPEM = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxLwvebfOrPLIODIxAwFp
4Qhksdtn7bEby5OhkQNLTdClGAbTe2tOO5Tiib9pcdruKxTodo481iGXTHR5033I
A5X55PegFeoY95NH5Noj6UUhyTFfRuwnhtGJgv9buTeBa4pLgHakfebqzKXr0Lce
/Ff1MnmQAdJTlvpOdVWJggsb26fD3cXyxQsbgtQYntmek2qvex/gPM9Nqa5qYrXx
8KuGuqHIFQa5t7UUH8WcxlLVRHWOtEQ3+Y6TQr8sIpSVszfhpjh9+Cag1EgaMzk+
HhAxMtXZgpyHffGHmPJ9eXbBO008tUzrE88fcuJ5pMF0LATO6ayXTKgZVU0WO/4e
iQIDAQAB
-----END PUBLIC KEY-----`
const (
beginEncryptedMsg = "-----BEGIN ENCRYPTED MESSAGE-----\r\n"
endEncryptedMsg = "-----END ENCRYPTED MESSAGE-----"
)
// EncryptSatuSehatPayload mengenkripsi JSON payload sesuai standar SatuSehat (AES-256-GCM & RSA-OAEP).
func EncryptSatuSehatPayload(message []byte) (string, error) {
block, _ := pem.Decode([]byte(KemkesPublicKeyPEM))
if block == nil {
return "", errors.New("gagal mem-parsing Kemenkes Public Key")
}
pubKeyAny, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", err
}
kemkesPubKey := pubKeyAny.(*rsa.PublicKey)
// 1. Generate 32 bytes AES Symmetric Key
aesKey := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, aesKey); err != nil {
return "", err
}
// 2. Encrypt AES Key menggunakan Kemenkes Public Key (RSA-OAEP)
wrappedAesKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, kemkesPubKey, aesKey, nil)
if err != nil {
return "", err
}
// 3. Encrypt payload menggunakan AES-256-GCM
blockCipher, err := aes.NewCipher(aesKey)
if err != nil {
return "", err
}
aesgcm, err := cipher.NewGCM(blockCipher)
if err != nil {
return "", err
}
nonce := make([]byte, 12) // 12 bytes IV / Nonce
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
// Seal menggabungkan Ciphertext + Authentication Tag (16 bytes)
ciphertextWithTag := aesgcm.Seal(nil, nonce, message, nil)
// 4. Concat: WrappedKey(256) + IV(12) + CipherText + Tag(16)
encryptedData := append(nonce, ciphertextWithTag...)
payload := append(wrappedAesKey, encryptedData...)
// 5. Base64 Encode & Chunk Split (76 characters)
b64 := base64.StdEncoding.EncodeToString(payload)
var chunked strings.Builder
for i := 0; i < len(b64); i += 76 {
end := i + 76
if end > len(b64) {
end = len(b64)
}
chunked.WriteString(b64[i:end])
chunked.WriteString("\r\n")
}
return beginEncryptedMsg + chunked.String() + endEncryptedMsg, nil
}
// DecryptSatuSehatPayload mendekripsi response Kemenkes yang telah dienkripsi.
func DecryptSatuSehatPayload(encryptedStr string, privPEM string) ([]byte, error) {
startIdx := strings.Index(encryptedStr, "-----BEGIN ENCRYPTED MESSAGE-----")
endIdx := strings.Index(encryptedStr, "-----END ENCRYPTED MESSAGE-----")
if startIdx == -1 || endIdx == -1 {
return nil, errors.New("respons tidak memiliki tag ENCRYPTED MESSAGE")
}
b64Data := encryptedStr[startIdx+len("-----BEGIN ENCRYPTED MESSAGE-----") : endIdx]
b64Data = strings.ReplaceAll(strings.ReplaceAll(b64Data, "\r", ""), "\n", "")
binaryData, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64Data))
if err != nil || len(binaryData) < 256+12 {
return nil, errors.New("gagal me-decode base64 payload / panjang payload tidak valid")
}
wrappedKey := binaryData[:256]
iv := binaryData[256 : 256+12]
ciphertextWithTag := binaryData[256+12:]
block, _ := pem.Decode([]byte(privPEM))
privKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privKey, wrappedKey, nil)
if err != nil {
return nil, fmt.Errorf("gagal me-unwrap kunci AES: %w", err)
}
blockCipher, _ := aes.NewCipher(aesKey)
aesgcm, _ := cipher.NewGCM(blockCipher)
return aesgcm.Open(nil, iv, ciphertextWithTag, nil)
}
+39 -15
View File
@@ -13,17 +13,19 @@ import (
// HTTPErrorResponse represents standardized HTTP error response
type HTTPErrorResponse struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]interface{} `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
Timestamp string `json:"timestamp"`
Retryable bool `json:"retryable,omitempty"`
Status string `json:"status"`
Error struct {
Code string `json:"code"`
Details map[string]interface{} `json:"details"`
Message string `json:"message"`
RequestID string `json:"request_id"`
Retryable bool `json:"retryable"`
StackTrace interface{} `json:"stack_trace"`
Timestamp string `json:"timestamp"`
} `json:"error"`
Meta struct {
HTTPStatus int `json:"http_status"`
Category string `json:"category"`
HTTPStatus int `json:"http_status"`
} `json:"meta,omitempty"`
}
@@ -50,18 +52,30 @@ func HandleHTTPError(c *gin.Context, err error) {
// Create error response
response := &HTTPErrorResponse{}
response.Status = "error"
response.Error.Code = appErr.Code()
response.Error.Message = appErr.GetLocalizedMessage(getLanguageFromContext(c))
response.Error.Details = appErr.Metadata()
if response.Error.Details == nil {
response.Error.Details = make(map[string]interface{})
}
response.Error.Message = appErr.GetLocalizedMessage(getLanguageFromContext(c))
response.Error.RequestID = c.GetString("request_id")
response.Error.Timestamp = appErr.Metadata()["timestamp"].(string)
response.Error.Retryable = IsRetryable(appErr.Code())
response.Meta.HTTPStatus = appErr.HTTPStatus()
response.Error.StackTrace = nil
if ts, ok := appErr.Metadata()["timestamp"].(string); ok {
response.Error.Timestamp = ts
}
response.Meta.Category = appErr.Category()
response.Meta.HTTPStatus = appErr.HTTPStatus()
// Log error
LogHTTPError(c, appErr)
// Cegah print ganda jika controller sudah print
if c.Writer.Written() {
return
}
// Send response
c.JSON(appErr.HTTPStatus(), response)
}
@@ -120,13 +134,16 @@ func ValidationErrorHandler(c *gin.Context, err error) {
if appErr.Category() == CategoryValidation {
if details, ok := appErr.Metadata()["validation_errors"]; ok {
response := gin.H{
"status": "error",
"error": gin.H{
"code": appErr.Code(),
"message": appErr.GetLocalizedMessage(getLanguageFromContext(c)),
"fields": details,
},
}
c.JSON(http.StatusBadRequest, response)
if !c.Writer.Written() {
c.JSON(http.StatusBadRequest, response)
}
return
}
}
@@ -158,13 +175,20 @@ func WriteErrorResponse(w http.ResponseWriter, err error) {
}
response := &HTTPErrorResponse{}
response.Status = "error"
response.Error.Code = appErr.Code()
response.Error.Message = appErr.GetLocalizedMessage("en")
response.Error.Details = appErr.Metadata()
response.Error.Timestamp = appErr.Metadata()["timestamp"].(string)
if response.Error.Details == nil {
response.Error.Details = make(map[string]interface{})
}
response.Error.Message = appErr.GetLocalizedMessage("en")
response.Error.Retryable = IsRetryable(appErr.Code())
response.Meta.HTTPStatus = appErr.HTTPStatus()
response.Error.StackTrace = nil
if ts, ok := appErr.Metadata()["timestamp"].(string); ok {
response.Error.Timestamp = ts
}
response.Meta.Category = appErr.Category()
response.Meta.HTTPStatus = appErr.HTTPStatus()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(appErr.HTTPStatus())
+26 -9
View File
@@ -247,7 +247,9 @@ func handleErrors(c *gin.Context, err error, cfg *ErrorMiddlewareConfig) {
response := createErrorResponse(c, appErr, cfg)
// Send response
c.JSON(appErr.HTTPStatus(), response)
if !c.Writer.Written() {
c.JSON(appErr.HTTPStatus(), response)
}
}
// createErrorResponse creates error response
@@ -255,18 +257,30 @@ func createErrorResponse(c *gin.Context, err Error, cfg *ErrorMiddlewareConfig)
lang := c.GetString("language")
requestID := c.GetString("request_id")
details := err.Metadata()
if details == nil {
details = make(map[string]interface{})
}
timestamp := time.Now().UTC().Format(time.RFC3339)
if ts, ok := details["timestamp"].(string); ok {
timestamp = ts
}
response := gin.H{
"status": "error",
"error": gin.H{
"code": err.Code(),
"message": err.GetLocalizedMessage(lang),
"details": err.Metadata(),
"request_id": requestID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"retryable": IsRetryable(err.Code()),
"code": err.Code(),
"details": details,
"message": err.GetLocalizedMessage(lang),
"request_id": requestID,
"retryable": IsRetryable(err.Code()),
"stack_trace": nil,
"timestamp": timestamp,
},
"meta": gin.H{
"http_status": err.HTTPStatus(),
"category": err.Category(),
"http_status": err.HTTPStatus(),
},
}
@@ -302,13 +316,16 @@ func ValidationHandler(c *gin.Context, err error) {
if appErr.Category() == CategoryValidation {
if details, ok := appErr.Metadata()["validation_errors"]; ok {
response := gin.H{
"status": "error",
"error": gin.H{
"code": appErr.Code(),
"message": appErr.GetLocalizedMessage(getLanguageFromContext(c)),
"fields": details,
},
}
c.JSON(http.StatusBadRequest, response)
if !c.Writer.Written() {
c.JSON(http.StatusBadRequest, response)
}
return
}
}
+43
View File
@@ -0,0 +1,43 @@
package errors
import (
"strings"
)
// ParseSatuSehatError mengekstrak dan menerjemahkan balasan OperationOutcome
// dari Kemenkes (SatuSehat) menjadi standard AppError aplikasi.
func ParseSatuSehatError(result map[string]interface{}) Error {
errMsg := "Gagal memproses data ke SatuSehat"
var errMessages []string
// Ekstrak pesan dari array "issue"
if issues, ok := result["issue"].([]interface{}); ok && len(issues) > 0 {
for _, issueItem := range issues {
if issue, ok := issueItem.(map[string]interface{}); ok {
if diagnostics, ok := issue["diagnostics"].(string); ok {
diagLower := strings.ToLower(diagnostics)
// Translasi pesan error spesifik SatuSehat
if strings.Contains(diagLower, "reference target(s) not found") {
errMessages = append(errMessages, "Data referensi (seperti ID) tidak ditemukan di SatuSehat")
} else if strings.Contains(diagLower, "nik") {
errMessages = append(errMessages, "NIK tidak valid atau tidak terdaftar di SatuSehat")
} else {
errMessages = append(errMessages, diagnostics) // Tampilkan pesan aslinya jika belum ada di kamus translasi
}
}
}
}
}
// Gabungkan semua error (jika > 1) dengan pemisah koma atau titik koma
if len(errMessages) > 0 {
errMsg = "Validasi SatuSehat: " + strings.Join(errMessages, "; ")
}
// Gunakan NewValidationError agar status HTTP menjadi 400 (Bad Request)
return NewValidationError().
Message(errMsg).
Metadata("operation_outcome", result).
Build()
}
+75 -9
View File
@@ -1,6 +1,10 @@
package response
import (
"fmt"
"net/http"
"service/pkg/errors"
"github.com/gin-gonic/gin"
)
@@ -13,6 +17,26 @@ type Response struct {
Meta interface{} `json:"meta,omitempty"`
}
// getErrorCodeAndCategory memetakan HTTP Status Code ke Error Code terpusat
func getErrorCodeAndCategory(statusCode int) (string, string) {
switch statusCode {
case http.StatusBadRequest:
return errors.ErrCodeInvalidInput, errors.CategoryValidation
case http.StatusUnauthorized:
return errors.ErrCodeUnauthorized, errors.CategoryUnauthorized
case http.StatusForbidden:
return errors.ErrCodeForbidden, errors.CategoryForbidden
case http.StatusNotFound:
return errors.ErrCodeNotFound, errors.CategoryNotFound
case http.StatusConflict:
return errors.ErrCodeConflict, errors.CategoryConflict
case http.StatusTooManyRequests:
return errors.ErrCodeRateLimitExceeded, errors.CategoryRateLimit
default:
return errors.ErrCodeInternalError, errors.CategoryInternal
}
}
// Success sends a success response
func Success(c *gin.Context, statusCode int, message string, data interface{}) {
c.JSON(statusCode, Response{
@@ -24,16 +48,35 @@ func Success(c *gin.Context, statusCode int, message string, data interface{}) {
// Error sends an error response
func Error(c *gin.Context, statusCode int, message string, err interface{}) {
// Handle jika tipe yang dikirim adalah native error Go agar tidak menjadi objek kosong '{}' di JSON
if e, ok := err.(error); ok {
err = e.Error()
var e error
if err != nil {
if errVal, ok := err.(error); ok {
e = errVal
} else {
e = fmt.Errorf("%v", err)
}
} else {
e = fmt.Errorf("%s", message)
}
c.JSON(statusCode, Response{
Status: "error",
Message: message,
Error: err,
})
// Jika error sudah berupa AppError dari pkg/errors, cetak langsung formatnya
if errors.IsAppError(e) {
errors.HandleHTTPError(c, e.(errors.Error))
return
}
// Jika berupa error standard/native biasa, konversi menjadi format terpusat AppError
code, category := getErrorCodeAndCategory(statusCode)
wrappedErr := errors.NewBuilder().
Code(code).
Category(category).
HTTPStatus(statusCode).
Message(message).
Cause(e).
Metadata("reason", e.Error()).
Build()
errors.HandleHTTPError(c, wrappedErr)
}
// ErrorWithLog mengirimkan respons error HTTP sekaligus menyisipkan error asli
@@ -41,8 +84,31 @@ func Error(c *gin.Context, statusCode int, message string, err interface{}) {
func ErrorWithLog(c *gin.Context, err error, statusCode int, message string, details interface{}) {
if err != nil {
c.Error(err) // Meneruskan error asli ke Gin Context untuk dicatat logger
} else {
err = fmt.Errorf("%s", message)
}
Error(c, statusCode, message, details) // Panggil fungsi Error() standar
// Sama seperti fungsi Error(), kita teruskan ke format sentral
if errors.IsAppError(err) {
errors.HandleHTTPError(c, err.(errors.Error))
return
}
code, category := getErrorCodeAndCategory(statusCode)
builder := errors.NewBuilder().
Code(code).
Category(category).
HTTPStatus(statusCode).
Message(message).
Cause(err).
Metadata("reason", err.Error())
// Sisipkan details tambahan ke dalam metadata error builder
if details != nil {
builder.Metadata("additional_details", details)
}
errors.HandleHTTPError(c, builder.Build())
}
// Meta contains pagination metadata
+31
View File
@@ -0,0 +1,31 @@
package custom
import (
"strings"
"time"
)
// CustomTime membungkus time.Time untuk mendukung format custom dan RFC3339
type CustomTime struct {
time.Time
}
func (c *CustomTime) UnmarshalJSON(b []byte) error {
s := strings.Trim(string(b), "\"")
if s == "null" || s == "" {
return nil
}
// Coba parse dengan format "2006-01-02 15:04:05" dan set timezone
t, err := time.Parse("2006-01-02 15:04:05", s)
if err == nil {
loc, _ := time.LoadLocation("Asia/Jakarta")
c.Time = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc)
return nil
}
// Fallback ke format RFC3339 bawaan
t, err = time.Parse(time.RFC3339, s)
c.Time = t
return err
}