Update besar

This commit is contained in:
meninjar
2025-10-31 02:30:27 +00:00
parent 07d264c57e
commit 0002cf26be
20 changed files with 4939 additions and 1938 deletions
+14
View File
@@ -0,0 +1,14 @@
auth:
type: static # Options: jwt, keycloak, static, hybrid (for hybrid mode keycloak is primary and jwt is fallback)
static_tokens:
- token1
- token2
- token3
- token4
fallback_to: jwt # Options: keycloak, static, jwt (for hybrid mode keycloak is primary and jwt is fallback)
keycloak:
enabled: true
issuer: https://auth.rssa.top/realms/sandbox
audience: nuxtsim-pendaftaran
jwks_url: https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs
+21 -9
View File
@@ -6,7 +6,6 @@ require (
github.com/gin-gonic/gin v1.10.1
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.1
github.com/jackc/pgx/v5 v5.7.2 // Ensure pgx is a direct dependency
go.mongodb.org/mongo-driver v1.17.3
golang.org/x/crypto v0.41.0
@@ -17,18 +16,22 @@ require (
)
require (
github.com/daku10/go-lz-string v0.0.6
github.com/Masterminds/squirrel v1.5.4
github.com/gin-contrib/cors v1.7.6
github.com/go-playground/validator/v10 v10.27.0
github.com/go-redis/redis_rate/v10 v10.0.1
github.com/go-sql-driver/mysql v1.8.1
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/mashingan/smapping v0.1.19
github.com/rs/zerolog v1.34.0
github.com/redis/go-redis/v9 v9.16.0
github.com/spf13/viper v1.21.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.6
github.com/tidwall/gjson v1.18.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -38,7 +41,10 @@ require (
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
@@ -47,6 +53,7 @@ require (
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
@@ -60,9 +67,10 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/microsoft/go-mssqldb v1.8.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -70,14 +78,19 @@ require (
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.43.0 // indirect
@@ -85,6 +98,5 @@ require (
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.35.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/gorm v1.30.0 // indirect
)
+51 -25
View File
@@ -20,27 +20,40 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mx
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/daku10/go-lz-string v0.0.6 h1:aO8FFp4QPuNp7+WNyh1DyNjGF3UbZu95tUv9xOZNsYQ=
github.com/daku10/go-lz-string v0.0.6/go.mod h1:Vk++rSG3db8HXJaHEAbxiy/ukjTmPBw/iI+SrVZDzfs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -65,11 +78,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
@@ -90,8 +106,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -112,6 +126,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -132,6 +148,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -140,14 +160,10 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mashingan/smapping v0.1.19 h1:SsEtuPn2UcM1croIupPtGLgWgpYRuS0rSQMvKD9g2BQ=
github.com/mashingan/smapping v0.1.19/go.mod h1:FjfiwFxGOuNxL/OT1WcrNAwTPx0YJeg5JiXwBB1nyig=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.8.2 h1:236sewazvC8FvG6Dr3bszrVhMkAl4KYImryLkRMCd0I=
github.com/microsoft/go-mssqldb v1.8.2/go.mod h1:vp38dT33FGfVotRiTmDo3bFyaHq+p3LektQrjTULowo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -166,20 +182,31 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzL
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4=
github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -189,20 +216,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -218,6 +241,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -278,7 +303,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -326,6 +350,8 @@ golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+293 -60
View File
@@ -13,16 +13,20 @@ import (
"time"
"github.com/go-playground/validator/v10"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
type Config struct {
Server ServerConfig
Databases map[string]DatabaseConfig
ReadReplicas map[string][]DatabaseConfig // For read replicas
Auth AuthConfig
Keycloak KeycloakConfig
Bpjs BpjsConfig
SatuSehat SatuSehatConfig
Swagger SwaggerConfig
Security SecurityConfig
Validator *validator.Validate
}
@@ -63,6 +67,25 @@ type DatabaseConfig struct {
ConnMaxLifetime time.Duration // Connection max lifetime
}
type AuthConfig struct {
Type string `yaml:"type" env:"AUTH_TYPE"` // "keycloak", "jwt", "static", "hybrid"
StaticTokens []string `yaml:"static_tokens" env:"AUTH_STATIC_TOKENS"` // Support multiple static tokens
FallbackTo string `yaml:"fallback_to" env:"AUTH_FALLBACK_TO"` // fallback auth type if primary fails
}
// AuthYAMLConfig represents the auth section in config.yaml
type AuthYAMLConfig struct {
Type string `yaml:"type"`
StaticTokens []string `yaml:"static_tokens"`
FallbackTo string `yaml:"fallback_to"`
}
type KeycloakYAMLConfig struct {
Issuer string `yaml:"issuer"`
Audience string `yaml:"audience"`
JwksURL string `yaml:"jwks_url"`
Enabled bool `yaml:"enabled"`
}
type KeycloakConfig struct {
Issuer string
Audience string
@@ -90,27 +113,30 @@ type SatuSehatConfig struct {
Timeout time.Duration `json:"timeout"`
}
// SetHeader generates required headers for BPJS VClaim API
// func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
// timenow := time.Now().UTC()
// t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
// if err != nil {
// log.Fatal(err)
// }
// SecurityConfig berisi semua pengaturan untuk middleware keamanan
type SecurityConfig struct {
// CORS
TrustedOrigins []string `mapstructure:"trusted_origins"`
// Rate Limiting
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
// Input Validation
MaxInputLength int `mapstructure:"max_input_length"`
}
// tstamp := timenow.Unix() - t.Unix()
// secret := []byte(cfg.SecretKey)
// message := []byte(cfg.ConsID + "&" + fmt.Sprint(tstamp))
// hash := hmac.New(sha256.New, secret)
// hash.Write(message)
// RateLimitConfig berisi pengaturan untuk rate limiter
type RateLimitConfig struct {
RequestsPerMinute int `mapstructure:"requests_per_minute"`
Redis RedisConfig `mapstructure:"redis"`
}
// // to lowercase hexits
// hex.EncodeToString(hash.Sum(nil))
// // to base64
// xSignature := base64.StdEncoding.EncodeToString(hash.Sum(nil))
// RedisConfig berisi detail koneksi ke Redis
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
}
// return cfg.ConsID, cfg.SecretKey, cfg.UserKey, fmt.Sprint(tstamp), xSignature
// }
func (cfg BpjsConfig) SetHeader() (string, string, string, string, string) {
timenow := time.Now().UTC()
t, err := time.Parse(time.RFC3339, "1970-01-01T00:00:00Z")
@@ -149,6 +175,7 @@ func (cfg ConfigBpjs) SetHeader() (string, string, string, string, string) {
}
func LoadConfig() *Config {
log.Printf("DEBUG: Raw ENV for SECURITY_MAX_INPUT_LENGTH is: '%s'", os.Getenv("SECURITY_MAX_INPUT_LENGTH"))
config := &Config{
Server: ServerConfig{
Port: getEnvAsInt("PORT", 8080),
@@ -156,12 +183,8 @@ func LoadConfig() *Config {
},
Databases: make(map[string]DatabaseConfig),
ReadReplicas: make(map[string][]DatabaseConfig),
Keycloak: KeycloakConfig{
Issuer: getEnv("KEYCLOAK_ISSUER", "https://keycloak.example.com/auth/realms/yourrealm"),
Audience: getEnv("KEYCLOAK_AUDIENCE", "your-client-id"),
JwksURL: getEnv("KEYCLOAK_JWKS_URL", "https://keycloak.example.com/auth/realms/yourrealm/protocol/openid-connect/certs"),
Enabled: getEnvAsBool("KEYCLOAK_ENABLED", true),
},
Auth: loadAuthConfig(),
Keycloak: loadKeycloakConfig(),
Bpjs: BpjsConfig{
BaseURL: getEnv("BPJS_BASEURL", "https://apijkn.bpjs-kesehatan.go.id"),
ConsID: getEnv("BPJS_CONSID", ""),
@@ -194,8 +217,21 @@ func LoadConfig() *Config {
BasePath: getEnv("SWAGGER_BASE_PATH", "/api/v1"),
Schemes: parseSchemes(getEnv("SWAGGER_SCHEMES", "http,https")),
},
Security: SecurityConfig{
TrustedOrigins: parseOrigins(getEnv("SECURITY_TRUSTED_ORIGINS", "http://localhost:3000,http://localhost:8080")),
MaxInputLength: getEnvAsInt("SECURITY_MAX_INPUT_LENGTH", 500),
RateLimit: RateLimitConfig{
RequestsPerMinute: getEnvAsInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 60),
Redis: RedisConfig{
Host: getEnv("REDIS_HOST", "localhost"),
Port: getEnvAsInt("REDIS_PORT", 6379),
Password: getEnv("REDIS_PASSWORD", ""),
DB: getEnvAsInt("REDIS_DB", 0),
},
},
},
}
log.Printf("DEBUG: Final Config Object. MaxInputLength is: %d", config.Security.MaxInputLength)
// Initialize validator
config.Validator = validator.New()
@@ -205,28 +241,155 @@ func LoadConfig() *Config {
// Load read replica configurations
config.loadReadReplicaConfigs()
log.Printf("DEBUG [LoadConfig]: Config object created at address: %p", config)
log.Printf("DEBUG [LoadConfig]: Security.MaxInputLength is: %d", config.Security.MaxInputLength)
return config
}
func loadAuthConfig() AuthConfig {
// --- AWAL TAMBAHAN DEBUG ---
// Cetak direktori kerja saat ini untuk debugging
wd, err := os.Getwd()
if err != nil {
log.Printf("Error getting working directory: %v", err)
} else {
log.Printf("DEBUG: Current working directory is: %s", wd)
}
// --- AKHIR TAMBAHAN DEBUG ---
authConfig := AuthConfig{
Type: "jwt", // default to jwt for backward compatibility
FallbackTo: "",
StaticTokens: []string{},
}
// Path file yang akan dibaca
configPath := "internal/config/config.yaml"
log.Printf("DEBUG: Attempting to read auth config from: %s", configPath)
// Load auth configuration from config.yaml first
if data, err := os.ReadFile(configPath); err == nil {
log.Printf("DEBUG: Successfully read config.yaml file. Parsing...") // Tambahkan log sukses
var yamlConfig struct {
Auth AuthYAMLConfig `yaml:"auth"`
}
if err := yaml.Unmarshal(data, &yamlConfig); err == nil {
// Log nilai yang berhasil dibaca
log.Printf("DEBUG: Parsed YAML. Type: '%s', Tokens: %d", yamlConfig.Auth.Type, len(yamlConfig.Auth.StaticTokens))
authConfig.Type = yamlConfig.Auth.Type
authConfig.FallbackTo = yamlConfig.Auth.FallbackTo
authConfig.StaticTokens = yamlConfig.Auth.StaticTokens
} else {
log.Printf("ERROR: Failed to unmarshal YAML: %v", err)
}
} else {
// --- AWAL TAMBAHAN DEBUG ---
// Cetak error spesifik jika file tidak ditemukan
log.Printf("ERROR: Could not read config file at '%s': %v", configPath, err)
// --- AKHIR TAMBAHAN DEBUG ---
}
// Then override with environment variables if set
if envType := getEnv("AUTH_TYPE", ""); envType != "" {
log.Printf("DEBUG: Overriding auth type with environment variable: %s", envType)
authConfig.Type = envType
}
if envFallback := getEnv("AUTH_FALLBACK_TO", ""); envFallback != "" {
authConfig.FallbackTo = envFallback
}
envTokens := parseStaticTokens(getEnv("AUTH_STATIC_TOKENS", ""))
if len(envTokens) > 0 {
authConfig.StaticTokens = envTokens
}
// Log hasil akhir sebelum dikembalikan
log.Printf("DEBUG: Final AuthConfig before returning: Type='%s', TokenCount=%d", authConfig.Type, len(authConfig.StaticTokens))
return authConfig
}
// Lakukan hal yang sama untuk loadKeycloakConfig
func loadKeycloakConfig() KeycloakConfig {
// --- AWAL TAMBAHAN DEBUG ---
// Cetak direktori kerja saat ini untuk debugging
wd, err := os.Getwd()
if err != nil {
log.Printf("Error getting working directory for keycloak config: %v", err)
} else {
log.Printf("DEBUG (Keycloak): Current working directory is: %s", wd)
}
// --- AKHIR TAMBAHAN DEBUG ---
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("./internal/config")
// --- AWAL TAMBAHAN DEBUG ---
log.Printf("DEBUG (Keycloak): Viper is set to search for config in: '.', './config', './internal/config'")
// --- AKHIR TAMBAHAN DEBUG ---
if err := v.ReadInConfig(); err == nil {
// Log jika file berhasil ditemukan dan dibaca
log.Printf("DEBUG (Keycloak): Successfully read config file: %s", v.ConfigFileUsed())
keycloakConfig := KeycloakConfig{
Issuer: v.GetString("keycloak.issuer"),
Audience: v.GetString("keycloak.audience"),
JwksURL: v.GetString("keycloak.jwks_url"),
Enabled: v.GetBool("keycloak.enabled"),
}
// Log nilai yang berhasil dibaca dari file
log.Printf("DEBUG (Keycloak): Parsed values from file. Issuer: '%s', Enabled: %t", keycloakConfig.Issuer, keycloakConfig.Enabled)
log.Printf("Loaded keycloak config from file: enabled=%t", keycloakConfig.Enabled)
return keycloakConfig
} else {
// --- AWAL TAMBAHAN DEBUG ---
// Cetak error spesifik jika file tidak ditemukan
log.Printf("ERROR (Keycloak): Could not read config file: %v", err)
// --- AKHIR TAMBAHAN DEBUG ---
}
// Fallback ke environment variable
log.Printf("DEBUG (Keycloak): Falling back to environment variables.")
fallbackConfig := KeycloakConfig{
Issuer: getEnv("KEYCLOAK_ISSUER", ""),
Audience: getEnv("KEYCLOAK_AUDIENCE", ""),
JwksURL: getEnv("KEYCLOAK_JWKS_URL", ""),
Enabled: getEnvAsBool("KEYCLOAK_ENABLED", false),
}
// Log hasil akhir dari fallback
log.Printf("DEBUG (Keycloak): Final fallback config. Issuer: '%s', Enabled: %t", fallbackConfig.Issuer, fallbackConfig.Enabled)
return fallbackConfig
}
func (c *Config) loadDatabaseConfigs() {
// Simplified approach: Directly load from environment variables
// This ensures we get the exact values specified in .env
// Primary database configuration
c.Databases["default"] = DatabaseConfig{
Name: "default",
Type: getEnv("DB_CONNECTION", "postgres"),
Host: getEnv("DB_HOST", "localhost"),
Port: getEnvAsInt("DB_PORT", 5432),
Username: getEnv("DB_USERNAME", ""),
Password: getEnv("DB_PASSWORD", ""),
Database: getEnv("DB_DATABASE", "satu_db"),
Schema: getEnv("DB_SCHEMA", "public"),
SSLMode: getEnv("DB_SSLMODE", "disable"),
MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 25),
ConnMaxLifetime: parseDuration(getEnv("DB_CONN_MAX_LIFETIME", "5m")),
}
// // Primary database configuration
// c.Databases["default"] = DatabaseConfig{
// Name: "default",
// Type: getEnv("DB_CONNECTION", "postgres"),
// Host: getEnv("DB_HOST", "localhost"),
// Port: getEnvAsInt("DB_PORT", 5432),
// Username: getEnv("DB_USERNAME", ""),
// Password: getEnv("DB_PASSWORD", ""),
// Database: getEnv("DB_DATABASE", "satu_db"),
// Schema: getEnv("DB_SCHEMA", "public"),
// SSLMode: getEnv("DB_SSLMODE", "disable"),
// MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
// MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 25),
// ConnMaxLifetime: parseDuration(getEnv("DB_CONN_MAX_LIFETIME", "5m")),
// }
// SATUDATA database configuration
c.addPostgreSQLConfigs()
@@ -669,71 +832,141 @@ func parseSchemes(schemesStr string) []string {
return schemes
}
// parseStaticTokens parses comma-separated static tokens string into a slice
func parseStaticTokens(tokensStr string) []string {
if tokensStr == "" {
return []string{}
}
tokens := strings.Split(tokensStr, ",")
for i, token := range tokens {
tokens[i] = strings.TrimSpace(token)
// Remove empty tokens
if tokens[i] == "" {
tokens = append(tokens[:i], tokens[i+1:]...)
i--
}
}
return tokens
}
func parseOrigins(originsStr string) []string {
if originsStr == "" {
return []string{"http://localhost:8080"} // Default untuk pengembangan
}
origins := strings.Split(originsStr, ",")
for i, origin := range origins {
origins[i] = strings.TrimSpace(origin)
}
return origins
}
func (c *Config) Validate() error {
var errs []string
if len(c.Databases) == 0 {
log.Fatal("At least one database configuration is required")
errs = append(errs, "at least one database configuration is required")
}
for name, db := range c.Databases {
if db.Host == "" {
log.Fatalf("Database host is required for %s", name)
errs = append(errs, fmt.Sprintf("database host is required for %s", name))
}
if db.Username == "" {
log.Fatalf("Database username is required for %s", name)
errs = append(errs, fmt.Sprintf("database username is required for %s", name))
}
if db.Password == "" {
log.Fatalf("Database password is required for %s", name)
errs = append(errs, fmt.Sprintf("database password is required for %s", name))
}
if db.Database == "" {
log.Fatalf("Database name is required for %s", name)
errs = append(errs, fmt.Sprintf("database name is required for %s", name))
}
}
if c.Bpjs.BaseURL == "" {
log.Fatal("BPJS Base URL is required")
errs = append(errs, "BPJS Base URL is required")
}
if c.Bpjs.ConsID == "" {
log.Fatal("BPJS Consumer ID is required")
errs = append(errs, "BPJS Consumer ID is required")
}
if c.Bpjs.UserKey == "" {
log.Fatal("BPJS User Key is required")
errs = append(errs, "BPJS User Key is required")
}
if c.Bpjs.SecretKey == "" {
log.Fatal("BPJS Secret Key is required")
errs = append(errs, "BPJS Secret Key is required")
}
// Validate Keycloak configuration if enabled
if c.Keycloak.Enabled {
// Validate authentication configuration
switch c.Auth.Type {
case "keycloak":
if !c.Keycloak.Enabled {
errs = append(errs, "keycloak.enabled must be true when auth.type is 'keycloak'")
}
if c.Keycloak.Issuer == "" {
log.Fatal("Keycloak issuer is required when Keycloak is enabled")
errs = append(errs, "keycloak.issuer is required when auth.type is 'keycloak'")
}
if c.Keycloak.Audience == "" {
log.Fatal("Keycloak audience is required when Keycloak is enabled")
errs = append(errs, "keycloak.audience is required when auth.type is 'keycloak'")
}
if c.Keycloak.JwksURL == "" {
log.Fatal("Keycloak JWKS URL is required when Keycloak is enabled")
errs = append(errs, "keycloak.jwks_url is required when auth.type is 'keycloak'")
}
case "static":
if len(c.Auth.StaticTokens) == 0 {
errs = append(errs, "auth.static_tokens is required when auth.type is 'static'")
}
case "hybrid":
if c.Auth.FallbackTo == "" {
errs = append(errs, "auth.fallback_to is required when auth.type is 'hybrid'")
}
// Validate fallback configuration
switch c.Auth.FallbackTo {
case "keycloak":
if !c.Keycloak.Enabled {
errs = append(errs, "keycloak.enabled must be true when auth.fallback_to is 'keycloak'")
}
case "static":
if len(c.Auth.StaticTokens) == 0 {
errs = append(errs, "auth.static_tokens is required when auth.fallback_to is 'static'")
}
}
}
// Legacy validation for backward compatibility
if c.Auth.Type != "keycloak" && c.Keycloak.Enabled {
if c.Keycloak.Issuer == "" {
errs = append(errs, "Keycloak issuer is required when Keycloak is enabled")
}
if c.Keycloak.Audience == "" {
errs = append(errs, "Keycloak audience is required when Keycloak is enabled")
}
if c.Keycloak.JwksURL == "" {
errs = append(errs, "Keycloak JWKS URL is required when Keycloak is enabled")
}
}
// Validate SatuSehat configuration
if c.SatuSehat.OrgID == "" {
log.Fatal("SatuSehat Organization ID is required")
errs = append(errs, "SatuSehat Organization ID is required")
}
if c.SatuSehat.FasyakesID == "" {
log.Fatal("SatuSehat Fasyankes ID is required")
errs = append(errs, "SatuSehat Fasyankes ID is required")
}
if c.SatuSehat.ClientID == "" {
log.Fatal("SatuSehat Client ID is required")
errs = append(errs, "SatuSehat Client ID is required")
}
if c.SatuSehat.ClientSecret == "" {
log.Fatal("SatuSehat Client Secret is required")
errs = append(errs, "SatuSehat Client Secret is required")
}
if c.SatuSehat.AuthURL == "" {
log.Fatal("SatuSehat Auth URL is required")
errs = append(errs, "SatuSehat Auth URL is required")
}
if c.SatuSehat.BaseURL == "" {
log.Fatal("SatuSehat Base URL is required")
errs = append(errs, "SatuSehat Base URL is required")
}
if len(errs) > 0 {
return fmt.Errorf("configuration validation failed: %s", strings.Join(errs, "; "))
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
auth:
type: static # Options: jwt, keycloak, static, hybrid (for hybrid mode keycloak is primary and jwt is fallback)
static_tokens:
- token1
- token2
- token3
- token4
fallback_to: jwt # Options: keycloak, static, jwt (for hybrid mode keycloak is primary and jwt is fallback)
keycloak:
enabled: true
issuer: https://auth.rssa.top/realms/sandbox
audience: nuxtsim-pendaftaran
jwks_url: https://auth.rssa.top/realms/sandbox/protocol/openid-connect/certs
+111 -14
View File
@@ -1,6 +1,7 @@
package handlers
import (
"api-service/internal/models/auth"
models "api-service/internal/models/auth"
services "api-service/internal/services/auth"
"net/http"
@@ -62,9 +63,22 @@ func (h *AuthHandler) Login(c *gin.Context) {
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /api/v1/auth/refresh [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
// For now, this is a placeholder for refresh token functionality
// In a real implementation, you would handle refresh tokens here
c.JSON(http.StatusNotImplemented, gin.H{"error": "refresh token not implemented"})
var refreshReq auth.RefreshTokenRequest
// Bind JSON request
if err := c.ShouldBindJSON(&refreshReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Refresh token
tokenResponse, err := h.authService.RefreshToken(refreshReq.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// Register godoc
@@ -78,12 +92,7 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
// @Failure 400 {object} map[string]string "Bad request"
// @Router /api/v1/auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
var registerReq struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Role string `json:"role" binding:"required"`
}
var registerReq auth.RegisterRequest
if err := c.ShouldBindJSON(&registerReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -123,10 +132,98 @@ func (h *AuthHandler) Me(c *gin.Context) {
}
// In a real implementation, you would fetch user details from database
c.JSON(http.StatusOK, gin.H{
"id": userID,
"username": c.GetString("username"),
"email": c.GetString("email"),
"role": c.GetString("role"),
c.JSON(http.StatusOK, auth.UserResponse{
ID: userID.(string),
Username: c.GetString("username"),
Email: c.GetString("email"),
Role: c.GetString("role"),
})
}
// TokenHandler handles token generation endpoints
type TokenHandler struct {
authService *services.AuthService
}
// NewTokenHandler creates a new token handler
func NewTokenHandler(authService *services.AuthService) *TokenHandler {
return &TokenHandler{
authService: authService,
}
}
// GenerateToken godoc
// @Summary Generate JWT token
// @Description Generate a JWT token for testing purposes
// @Tags Token
// @Accept json
// @Produce json
// @Param token body map[string]interface{} true "Token generation data"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Router /api/v1/token/generate [post]
func (h *TokenHandler) GenerateToken(c *gin.Context) {
var req map[string]interface{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Extract user data from request
userID, ok := req["user_id"].(string)
if !ok || userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
username, _ := req["username"].(string)
email, _ := req["email"].(string)
role, _ := req["role"].(string)
// Generate token
tokenResponse, err := h.authService.GenerateToken(userID, username, email, role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// GenerateTokenDirect godoc
// @Summary Generate JWT token directly
// @Description Generate a JWT token directly with provided data
// @Tags Token
// @Accept json
// @Produce json
// @Param token body map[string]interface{} true "Token generation data"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Router /api/v1/token/generate-direct [post]
func (h *TokenHandler) GenerateTokenDirect(c *gin.Context) {
var req map[string]interface{}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Extract user data from request
userID, ok := req["user_id"].(string)
if !ok || userID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
return
}
username, _ := req["username"].(string)
email, _ := req["email"].(string)
role, _ := req["role"].(string)
// Generate token directly
tokenResponse, err := h.authService.GenerateTokenDirect(userID, username, email, role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokenResponse)
}
-95
View File
@@ -1,95 +0,0 @@
package handlers
import (
models "api-service/internal/models/auth"
services "api-service/internal/services/auth"
"net/http"
"github.com/gin-gonic/gin"
)
// TokenHandler handles token generation endpoints
type TokenHandler struct {
authService *services.AuthService
}
// NewTokenHandler creates a new token handler
func NewTokenHandler(authService *services.AuthService) *TokenHandler {
return &TokenHandler{
authService: authService,
}
}
// GenerateToken godoc
// @Summary Generate JWT token
// @Description Generate a JWT token for a user
// @Tags Token
// @Accept json
// @Produce json
// @Param token body models.LoginRequest true "User credentials"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 401 {object} map[string]string "Unauthorized"
// @Router /api/v1/token/generate [post]
func (h *TokenHandler) GenerateToken(c *gin.Context) {
var loginReq models.LoginRequest
// Bind JSON request
if err := c.ShouldBindJSON(&loginReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Generate token
tokenResponse, err := h.authService.Login(loginReq.Username, loginReq.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// GenerateTokenDirect godoc
// @Summary Generate token directly
// @Description Generate a JWT token directly without password verification (for testing)
// @Tags Token
// @Accept json
// @Produce json
// @Param user body map[string]string true "User info"
// @Success 200 {object} models.TokenResponse
// @Failure 400 {object} map[string]string "Bad request"
// @Router /api/v1/token/generate-direct [post]
func (h *TokenHandler) GenerateTokenDirect(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Create a temporary user for token generation
user := &models.User{
ID: "temp-" + req.Username,
Username: req.Username,
Email: req.Email,
Role: req.Role,
}
// Generate token directly
token, err := h.authService.GenerateTokenForUser(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, models.TokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: 3600,
})
}
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
package middleware
import (
"api-service/internal/config"
"api-service/internal/models/auth"
service "api-service/internal/services/auth"
"api-service/pkg/logger"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
var (
ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
ErrInvalidSignature = errors.New("invalid token signature")
ErrInvalidIssuer = errors.New("invalid token issuer")
ErrInvalidAudience = errors.New("invalid token audience")
ErrMissingClaims = errors.New("required claims missing")
ErrInvalidAuthHeader = errors.New("invalid authorization header format")
ErrMissingAuthHeader = errors.New("authorization header missing")
)
// TokenCache interface for token caching
type TokenCache interface {
Get(tokenString string) (*auth.JWTClaims, bool)
Set(tokenString string, claims *auth.JWTClaims, expiration time.Duration)
Delete(tokenString string)
}
// InMemoryTokenCache implements TokenCache with in-memory storage
type InMemoryTokenCache struct {
tokens map[string]cacheEntry
mu sync.RWMutex
}
type cacheEntry struct {
claims *auth.JWTClaims
expiration time.Time
}
func NewInMemoryTokenCache() *InMemoryTokenCache {
cache := &InMemoryTokenCache{
tokens: make(map[string]cacheEntry),
}
// Start cleanup goroutine
go cache.cleanup()
return cache
}
func (c *InMemoryTokenCache) Get(tokenString string) (*auth.JWTClaims, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.tokens[tokenString]
if !exists || time.Now().After(entry.expiration) {
return nil, false
}
return entry.claims, true
}
func (c *InMemoryTokenCache) Set(tokenString string, claims *auth.JWTClaims, expiration time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.tokens[tokenString] = cacheEntry{
claims: claims,
expiration: time.Now().Add(expiration),
}
}
func (c *InMemoryTokenCache) Delete(tokenString string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.tokens, tokenString)
}
func (c *InMemoryTokenCache) cleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
c.mu.Lock()
now := time.Now()
for token, entry := range c.tokens {
if now.After(entry.expiration) {
delete(c.tokens, token)
}
}
c.mu.Unlock()
}
}
// AuthMiddleware provides authentication with rate limiting and caching
type AuthMiddleware struct {
providers []AuthProvider
tokenCache TokenCache
rateLimiter *rate.Limiter
config *config.Config
}
func NewAuthMiddleware(
cfg *config.Config,
authService *service.AuthService,
tokenCache TokenCache,
) *AuthMiddleware {
factory := NewProviderFactory(authService, cfg)
providers := factory.CreateProviders()
// Rate limit: 10 requests per second with burst of 20
limiter := rate.NewLimiter(10, 20)
// Use default cache if none provided
if tokenCache == nil {
tokenCache = NewInMemoryTokenCache()
}
return &AuthMiddleware{
providers: providers,
tokenCache: tokenCache,
rateLimiter: limiter,
config: cfg,
}
}
// RequireAuth enforces authentication
func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc {
return m.authenticate(false)
}
// OptionalAuth allows both authenticated and unauthenticated requests
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
return m.authenticate(true)
}
// authenticate is the core authentication logic
func (m *AuthMiddleware) authenticate(optional bool) gin.HandlerFunc {
return func(c *gin.Context) {
reqLogger := logger.Default().WithService("auth-middleware")
reqLogger.Info("Starting authentication", map[string]interface{}{
"path": c.Request.URL.Path,
"optional": optional,
})
// Apply rate limiting
if !m.rateLimiter.Allow() {
reqLogger.Warn("Rate limit exceeded")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded",
})
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
if optional {
c.Next()
return
}
reqLogger.Warn("Authorization header missing")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": ErrMissingAuthHeader.Error(),
})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
if optional {
c.Next()
return
}
reqLogger.Warn("Invalid authorization header format")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": ErrInvalidAuthHeader.Error(),
})
return
}
tokenString := parts[1]
// Check cache first
if claims, found := m.tokenCache.Get(tokenString); found {
reqLogger.Info("Token retrieved from cache", map[string]interface{}{
"user_id": claims.UserID,
})
m.setUserInfo(c, claims, "cache")
c.Next()
return
}
// Try each provider until one succeeds
var validatedClaims *auth.JWTClaims
var err error
var providerName string
var providerErrors []string
for _, provider := range m.providers {
providerLog := reqLogger.WithField("provider", provider.Name())
providerLog.Info("Trying provider")
validatedClaims, err = provider.ValidateToken(tokenString)
if err == nil {
providerName = provider.Name()
providerLog.Info("Authentication successful", map[string]interface{}{
"user_id": validatedClaims.UserID,
})
break
}
providerLog.Warn("Provider validation failed", map[string]interface{}{
"error": err.Error(),
})
providerErrors = append(providerErrors, fmt.Sprintf("provider %s: %v", provider.Name(), err))
}
if err != nil {
if optional {
c.Next()
return
}
reqLogger.Error("All providers failed", map[string]interface{}{
"errors": strings.Join(providerErrors, "; "),
})
// Return specific error message based on the error type
errorMessage := "Token tidak valid"
if errors.Is(err, ErrTokenExpired) {
errorMessage = "Token telah kadaluarsa"
} else if errors.Is(err, ErrInvalidSignature) {
errorMessage = "Signature token tidak valid"
} else if errors.Is(err, ErrInvalidIssuer) {
errorMessage = "Issuer token tidak valid"
} else if errors.Is(err, ErrInvalidAudience) {
errorMessage = "Audience token tidak valid"
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": errorMessage,
"details": strings.Join(providerErrors, "; "),
})
return
}
// Cache the validated token
m.tokenCache.Set(tokenString, validatedClaims, 5*time.Minute)
// Set user info in context
m.setUserInfo(c, validatedClaims, providerName)
c.Next()
}
}
// setUserInfo sets user information in the Gin context
func (m *AuthMiddleware) setUserInfo(c *gin.Context, claims *auth.JWTClaims, providerName string) {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("auth_provider", providerName)
}
// RequireRole creates a middleware that requires a specific role
func (m *AuthMiddleware) RequireRole(requiredRole string) gin.HandlerFunc {
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "user role not found",
})
return
}
userRole, ok := role.(string)
if !ok {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "invalid role format",
})
return
}
if userRole != requiredRole {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": fmt.Sprintf("requires %s role", requiredRole),
})
return
}
c.Next()
}
}
-59
View File
@@ -1,59 +0,0 @@
package middleware
import (
"fmt"
"net/http"
"api-service/internal/config"
"github.com/gin-gonic/gin"
)
// ConfigurableAuthMiddleware provides flexible authentication based on configuration
func ConfigurableAuthMiddleware(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip authentication for development/testing if explicitly disabled
if !cfg.Keycloak.Enabled {
fmt.Println("Authentication is disabled - allowing all requests")
c.Next()
return
}
// Use Keycloak authentication when enabled
AuthMiddleware()(c)
}
}
// StrictAuthMiddleware enforces authentication regardless of Keycloak.Enabled setting
func StrictAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil {
fmt.Println("AuthMiddleware: Config not initialized")
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
return
}
// Always enforce authentication
AuthMiddleware()(c)
}
}
// OptionalKeycloakAuthMiddleware allows requests but adds authentication info if available
func OptionalKeycloakAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil || !appConfig.Keycloak.Enabled {
c.Next()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// No token provided, but continue
c.Next()
return
}
// Try to validate token, but don't fail if invalid
AuthMiddleware()(c)
}
}
-16
View File
@@ -36,19 +36,3 @@ func ErrorHandler() gin.HandlerFunc {
}
}
}
// CORS middleware configuration
func CORSConfig() gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
}
-254
View File
@@ -1,254 +0,0 @@
package middleware
/** Keycloak Auth Middleware **/
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
"api-service/internal/config"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)
var (
ErrInvalidToken = errors.New("invalid token")
)
// JwksCache caches JWKS keys with expiration
type JwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
expiresAt time.Time
sfGroup singleflight.Group
config *config.Config
}
func NewJwksCache(cfg *config.Config) *JwksCache {
return &JwksCache{
keys: make(map[string]*rsa.PublicKey),
config: cfg,
}
}
func (c *JwksCache) GetKey(kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
if key, ok := c.keys[kid]; ok && time.Now().Before(c.expiresAt) {
c.mu.RUnlock()
return key, nil
}
c.mu.RUnlock()
// Fetch keys with singleflight to avoid concurrent fetches
v, err, _ := c.sfGroup.Do("fetch_jwks", func() (interface{}, error) {
return c.fetchKeys()
})
if err != nil {
return nil, err
}
keys := v.(map[string]*rsa.PublicKey)
c.mu.Lock()
c.keys = keys
c.expiresAt = time.Now().Add(1 * time.Hour) // cache for 1 hour
c.mu.Unlock()
key, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("key with kid %s not found", kid)
}
return key, nil
}
func (c *JwksCache) fetchKeys() (map[string]*rsa.PublicKey, error) {
if !c.config.Keycloak.Enabled {
return nil, fmt.Errorf("keycloak authentication is disabled")
}
jwksURL := c.config.Keycloak.JwksURL
if jwksURL == "" {
// Construct JWKS URL from issuer if not explicitly provided
jwksURL = c.config.Keycloak.Issuer + "/protocol/openid-connect/certs"
}
resp, err := http.Get(jwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var jwksData struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwksData); err != nil {
return nil, err
}
keys := make(map[string]*rsa.PublicKey)
for _, key := range jwksData.Keys {
if key.Kty != "RSA" {
continue
}
pubKey, err := parseRSAPublicKey(key.N, key.E)
if err != nil {
continue
}
keys[key.Kid] = pubKey
}
return keys, nil
}
// parseRSAPublicKey parses RSA public key components from base64url strings
func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64UrlDecode(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64UrlDecode(eStr)
if err != nil {
return nil, err
}
var eInt int
for _, b := range eBytes {
eInt = eInt<<8 + int(b)
}
pubKey := &rsa.PublicKey{
N: new(big.Int).SetBytes(nBytes),
E: eInt,
}
return pubKey, nil
}
func base64UrlDecode(s string) ([]byte, error) {
// Add padding if missing
if m := len(s) % 4; m != 0 {
s += strings.Repeat("=", 4-m)
}
return base64.URLEncoding.DecodeString(s)
}
// Global config instance
var appConfig *config.Config
var jwksCacheInstance *JwksCache
// InitializeAuth initializes the auth middleware with config
func InitializeAuth(cfg *config.Config) {
appConfig = cfg
jwksCacheInstance = NewJwksCache(cfg)
}
// AuthMiddleware validates Bearer token as Keycloak JWT token
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if appConfig == nil {
fmt.Println("AuthMiddleware: Config not initialized")
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
return
}
if !appConfig.Keycloak.Enabled {
// Skip authentication if Keycloak is disabled but log for debugging
fmt.Println("AuthMiddleware: Keycloak authentication is disabled - allowing all requests")
c.Next()
return
}
fmt.Println("AuthMiddleware: Checking Authorization header") // Debug log
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
fmt.Println("AuthMiddleware: Authorization header missing") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
fmt.Println("AuthMiddleware: Invalid Authorization header format") // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
tokenString := parts[1]
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
fmt.Printf("AuthMiddleware: Unexpected signing method: %v\n", token.Header["alg"]) // Debug log
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
kid, ok := token.Header["kid"].(string)
if !ok {
fmt.Println("AuthMiddleware: kid header not found") // Debug log
return nil, errors.New("kid header not found")
}
return jwksCacheInstance.GetKey(kid)
}, jwt.WithIssuer(appConfig.Keycloak.Issuer), jwt.WithAudience(appConfig.Keycloak.Audience))
if err != nil || !token.Valid {
fmt.Printf("AuthMiddleware: Invalid or expired token: %v\n", err) // Debug log
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
fmt.Println("AuthMiddleware: Token valid, proceeding") // Debug log
// Token is valid, proceed
c.Next()
}
}
/** JWT Bearer authentication middleware */
// import (
// "net/http"
// "strings"
// "github.com/gin-gonic/gin"
// )
// AuthMiddleware validates Bearer token in Authorization header
func AuthJWTMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header missing"})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"})
return
}
token := parts[1]
// For now, use a static token for validation. Replace with your logic.
const validToken = "your-static-token"
if token != validToken {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
c.Next()
}
}
+615
View File
@@ -0,0 +1,615 @@
package middleware
import (
"api-service/internal/config"
"api-service/internal/models/auth"
models "api-service/internal/models/auth"
service "api-service/internal/services/auth"
"api-service/pkg/logger"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/sync/singleflight"
)
// AuthProvider interface for different authentication methods
type AuthProvider interface {
ValidateToken(tokenString string) (*models.JWTClaims, error)
Name() string
}
// ProviderFactory creates authentication providers based on configuration
type ProviderFactory struct {
authService *service.AuthService
config *config.Config
}
func NewProviderFactory(authService *service.AuthService, config *config.Config) *ProviderFactory {
return &ProviderFactory{
authService: authService,
config: config,
}
}
func (f *ProviderFactory) CreateProviders() []AuthProvider {
var providers []AuthProvider
reqLogger := logger.Default().WithService("provider-factory")
reqLogger.Info("Creating authentication providers", map[string]interface{}{
"auth_type": f.config.Auth.Type,
"keycloak_enabled": f.config.Keycloak.Enabled,
"keycloak_issuer": f.config.Keycloak.Issuer,
"static_tokens_len": len(f.config.Auth.StaticTokens),
"fallback_to": f.config.Auth.FallbackTo,
})
switch f.config.Auth.Type {
case "static":
reqLogger.Info("Configuring static token provider")
if len(f.config.Auth.StaticTokens) > 0 {
providers = append(providers, NewStaticTokenProvider(f.config.Auth.StaticTokens))
reqLogger.Info("Static token provider added", map[string]interface{}{
"token_count": len(f.config.Auth.StaticTokens),
})
} else {
reqLogger.Warn("No static tokens configured for static auth type")
}
case "jwt":
reqLogger.Info("Configuring JWT provider")
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT provider added")
case "keycloak":
reqLogger.Info("Configuring Keycloak provider")
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak provider added")
} else {
reqLogger.Warn("Keycloak issuer not configured for keycloak auth type")
}
case "hybrid":
reqLogger.Info("Configuring hybrid providers")
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak provider added for hybrid")
} else {
reqLogger.Warn("Keycloak issuer not configured for hybrid auth type")
}
switch f.config.Auth.FallbackTo {
case "static":
reqLogger.Info("Configuring static fallback for hybrid")
if len(f.config.Auth.StaticTokens) > 0 {
providers = append(providers, NewStaticTokenProvider(f.config.Auth.StaticTokens))
reqLogger.Info("Static fallback provider added", map[string]interface{}{
"token_count": len(f.config.Auth.StaticTokens),
})
} else {
reqLogger.Warn("No static tokens configured for hybrid fallback")
}
case "jwt":
reqLogger.Info("Configuring JWT fallback for hybrid")
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT fallback provider added")
case "keycloak":
reqLogger.Info("Configuring Keycloak fallback for hybrid")
if f.config.Keycloak.Issuer != "" {
providers = append(providers, NewKeycloakAuthProvider(f.config))
reqLogger.Info("Keycloak fallback provider added")
} else {
reqLogger.Warn("Keycloak issuer not configured for hybrid fallback")
}
default:
reqLogger.Warn("Unknown fallback type for hybrid, using JWT", map[string]interface{}{
"fallback_to": f.config.Auth.FallbackTo,
})
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT fallback provider added as default")
}
default:
reqLogger.Warn("Unknown auth type, defaulting to JWT", map[string]interface{}{
"auth_type": f.config.Auth.Type,
})
providers = append(providers, NewJWTAuthProvider(f.authService))
reqLogger.Info("JWT provider added as default")
}
reqLogger.Info("Provider creation completed", map[string]interface{}{
"provider_count": len(providers),
})
return providers
}
// StaticTokenProvider handles static token authentication
type StaticTokenProvider struct {
tokens map[string]bool
}
func NewStaticTokenProvider(tokens []string) *StaticTokenProvider {
tokenMap := make(map[string]bool)
for _, token := range tokens {
if token != "" {
tokenMap[token] = true
}
}
return &StaticTokenProvider{tokens: tokenMap}
}
func (s *StaticTokenProvider) ValidateToken(tokenString string) (*models.JWTClaims, error) {
reqLogger := logger.Default().WithService("static-auth")
if !s.tokens[tokenString] {
reqLogger.Warn("Invalid static token provided")
return nil, ErrInvalidToken
}
reqLogger.Info("Static token validation successful")
return &models.JWTClaims{
UserID: "static-user",
Username: "static-user",
Email: "static@example.com",
Role: "user",
}, nil
}
func (s *StaticTokenProvider) Name() string {
return "static"
}
// JWTAuthProvider handles JWT authentication using AuthService
type JWTAuthProvider struct {
authService *service.AuthService
}
func NewJWTAuthProvider(authService *service.AuthService) *JWTAuthProvider {
return &JWTAuthProvider{authService: authService}
}
func (j *JWTAuthProvider) ValidateToken(tokenString string) (*models.JWTClaims, error) {
reqLogger := logger.Default().WithService("jwt-auth")
reqLogger.Info("Starting JWT token validation")
claims, err := j.authService.ValidateToken(tokenString)
if err != nil {
reqLogger.Error("JWT validation failed", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
reqLogger.Info("JWT validation successful", map[string]interface{}{
"user_id": claims.UserID,
})
return claims, nil
}
func (j *JWTAuthProvider) Name() string {
return "jwt"
}
// KeycloakAuthProvider handles Keycloak JWT authentication
type KeycloakAuthProvider struct {
jwksCache *JwksCache
config *config.Config
}
func NewKeycloakAuthProvider(cfg *config.Config) *KeycloakAuthProvider {
return &KeycloakAuthProvider{
jwksCache: NewJwksCache(cfg),
config: cfg,
}
}
func (k *KeycloakAuthProvider) ValidateToken(tokenString string) (*auth.JWTClaims, error) {
reqLogger := logger.Default().WithService("keycloak-auth")
reqLogger.Info("Starting Keycloak token validation")
// Parse token without verification first to get claims for logging
parsedToken, _, err := jwt.NewParser().ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
reqLogger.Error("Failed to parse token", map[string]interface{}{
"error": err.Error(),
})
return nil, ErrInvalidToken
}
// Extract claims for logging
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
reqLogger.Error("Invalid claims format")
return nil, ErrMissingClaims
}
// Check if token is expired
if exp, ok := claims["exp"].(float64); ok {
if time.Now().Unix() > int64(exp) {
reqLogger.Warn("Token expired", map[string]interface{}{
"exp": exp,
"now": time.Now().Unix(),
})
return nil, ErrTokenExpired
}
}
// Now parse with verification
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify signing method
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
reqLogger.Warn("Unexpected signing method", map[string]interface{}{
"alg": token.Header["alg"],
})
return nil, ErrInvalidSignature
}
kid, ok := token.Header["kid"].(string)
if !ok {
reqLogger.Warn("kid header not found in token")
return nil, errors.New("kid header not found")
}
reqLogger.Info("Looking for key", map[string]interface{}{
"kid": kid,
})
key, err := k.jwksCache.GetKey(kid)
if err != nil {
reqLogger.Error("Failed to get key", map[string]interface{}{
"kid": kid,
"error": err.Error(),
})
return nil, err
}
reqLogger.Info("Key retrieved successfully", map[string]interface{}{
"kid": kid,
})
return key, nil
}, jwt.WithIssuer(k.config.Keycloak.Issuer), jwt.WithAudience(k.config.Keycloak.Audience))
if err != nil {
reqLogger.Error("JWT parse error", map[string]interface{}{
"error": err.Error(),
})
// Return specific error based on the error type
if strings.Contains(err.Error(), "expired") {
return nil, ErrTokenExpired
} else if strings.Contains(err.Error(), "signature") {
return nil, ErrInvalidSignature
} else if strings.Contains(err.Error(), "issuer") {
return nil, ErrInvalidIssuer
} else if strings.Contains(err.Error(), "audience") {
return nil, ErrInvalidAudience
}
return nil, fmt.Errorf("invalid token: %v", err)
}
if !token.Valid {
reqLogger.Warn("Token is not valid")
return nil, ErrInvalidToken
}
reqLogger.Info("Token validation successful")
// Extract claims
claims, ok = token.Claims.(jwt.MapClaims)
if !ok {
reqLogger.Error("Invalid claims format")
return nil, ErrMissingClaims
}
// Validate required claims
userID := getClaimString(claims, "sub")
if userID == "" {
reqLogger.Error("Missing required claim: sub")
return nil, ErrMissingClaims
}
return &auth.JWTClaims{
UserID: userID,
Username: getClaimString(claims, "preferred_username"),
Email: getClaimString(claims, "email"),
Role: getClaimString(claims, "role"),
}, nil
}
func (k *KeycloakAuthProvider) Name() string {
return "keycloak"
}
// UnifiedAuthMiddleware provides flexible authentication based on configuration
func UnifiedAuthMiddleware(cfg *config.Config, authService *service.AuthService) gin.HandlerFunc {
factory := NewProviderFactory(authService, cfg)
providers := factory.CreateProviders()
// Validate that we have at least one provider
if len(providers) == 0 {
logger.Default().Error("No authentication providers configured", map[string]interface{}{
"auth_type": cfg.Auth.Type,
})
return func(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "authentication service not configured"})
}
}
logger.Default().Info("UnifiedAuthMiddleware initialized", map[string]interface{}{
"provider_count": len(providers),
"auth_type": cfg.Auth.Type,
})
return func(c *gin.Context) {
reqLogger := logger.Default().WithService("unified-auth")
reqLogger.Info("Memulai proses autentikasi", map[string]interface{}{
"auth_type": cfg.Auth.Type,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
reqLogger.Warn("Header Authorization tidak ditemukan", map[string]interface{}{
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": ErrMissingAuthHeader.Error()})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
reqLogger.Warn("Format header Authorization tidak valid", map[string]interface{}{
"header_value": authHeader[:min(20, len(authHeader))], // Log first 20 chars for debugging
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": ErrInvalidAuthHeader.Error()})
return
}
tokenString := parts[1]
reqLogger.Info("Token diterima", map[string]interface{}{
"token_length": len(tokenString),
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
// Coba setiap provider sampai salah satu berhasil
var claims *auth.JWTClaims
var err error
var providerName string
var providerErrors []string
var triedProviders []string
reqLogger.Info("Starting provider validation loop", map[string]interface{}{
"provider_count": len(providers),
})
for _, provider := range providers {
providerLog := reqLogger.WithField("provider", provider.Name())
triedProviders = append(triedProviders, provider.Name())
providerLog.Info("Mencoba validasi dengan provider", map[string]interface{}{
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
claims, err = provider.ValidateToken(tokenString)
if err == nil {
providerName = provider.Name()
providerLog.Info("Autentikasi berhasil", map[string]interface{}{
"user_id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
break // Berhenti jika ada yang berhasil
}
providerLog.Warn("Validasi provider gagal", map[string]interface{}{
"error": err.Error(),
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
providerErrors = append(providerErrors, fmt.Sprintf("provider %s: %v", provider.Name(), err))
}
if err != nil {
reqLogger.Error("Semua provider gagal memvalidasi token", map[string]interface{}{
"errors": strings.Join(providerErrors, "; "),
"tried_providers": strings.Join(triedProviders, ", "),
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
// Return specific error message based on the error type
errorMessage := "Token tidak valid"
if errors.Is(err, ErrTokenExpired) {
errorMessage = "Token telah kadaluarsa"
} else if errors.Is(err, ErrInvalidSignature) {
errorMessage = "Signature token tidak valid"
} else if errors.Is(err, ErrInvalidIssuer) {
errorMessage = "Issuer token tidak valid"
} else if errors.Is(err, ErrInvalidAudience) {
errorMessage = "Audience token tidak valid"
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": errorMessage,
"details": strings.Join(providerErrors, "; "),
})
return
}
// Set informasi pengguna di konteks
if claims != nil {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("auth_provider", providerName)
reqLogger.Info("User context set successfully", map[string]interface{}{
"user_id": claims.UserID,
"username": claims.Username,
"role": claims.Role,
"auth_provider": providerName,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
} else {
reqLogger.Warn("Claims is nil after successful authentication", map[string]interface{}{
"provider": providerName,
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
}
reqLogger.Info("Authentication completed successfully, proceeding to next handler", map[string]interface{}{
"path": c.Request.URL.Path,
"method": c.Request.Method,
})
c.Next()
}
}
// InitializeAuth initializes authentication configuration
func InitializeAuth(cfg *config.Config) {
// This function can be used to initialize global auth settings if needed
logger.Default().Info("Authentication initialized", map[string]interface{}{
"auth_type": cfg.Auth.Type,
})
}
// Helper functions
func getClaimString(claims jwt.MapClaims, key string) string {
if value, ok := claims[key]; ok && value != nil {
if str, ok := value.(string); ok {
return str
}
}
return ""
}
// JwksCache and related functions
type JwksCache struct {
mu sync.RWMutex
keys map[string]*rsa.PublicKey
expiresAt time.Time
sfGroup singleflight.Group
config *config.Config
}
func NewJwksCache(cfg *config.Config) *JwksCache {
return &JwksCache{
keys: make(map[string]*rsa.PublicKey),
config: cfg,
}
}
func (c *JwksCache) GetKey(kid string) (*rsa.PublicKey, error) {
c.mu.RLock()
if key, ok := c.keys[kid]; ok && time.Now().Before(c.expiresAt) {
c.mu.RUnlock()
return key, nil
}
c.mu.RUnlock()
// Fetch keys with singleflight to avoid concurrent fetches
v, err, _ := c.sfGroup.Do("fetch_jwks", func() (interface{}, error) {
return c.fetchKeys()
})
if err != nil {
return nil, err
}
keys := v.(map[string]*rsa.PublicKey)
c.mu.Lock()
c.keys = keys
c.expiresAt = time.Now().Add(1 * time.Hour) // cache for 1 hour
c.mu.Unlock()
key, ok := keys[kid]
if !ok {
return nil, fmt.Errorf("key with kid %s not found", kid)
}
return key, nil
}
func (c *JwksCache) fetchKeys() (map[string]*rsa.PublicKey, error) {
if c.config.Keycloak.Issuer == "" {
return nil, fmt.Errorf("keycloak issuer is not configured")
}
jwksURL := c.config.Keycloak.JwksURL
if jwksURL == "" {
// Construct JWKS URL from issuer if not explicitly provided
jwksURL = c.config.Keycloak.Issuer + "/protocol/openid-connect/certs"
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(jwksURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch JWKS: HTTP %d", resp.StatusCode)
}
var jwksData struct {
Keys []struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
} `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&jwksData); err != nil {
return nil, err
}
keys := make(map[string]*rsa.PublicKey)
for _, key := range jwksData.Keys {
if key.Kty != "RSA" {
continue
}
pubKey, err := parseRSAPublicKey(key.N, key.E)
if err != nil {
continue
}
keys[key.Kid] = pubKey
}
return keys, nil
}
// parseRSAPublicKey parses RSA public key components from base64url strings
func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
if err != nil {
return nil, err
}
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
if err != nil {
return nil, err
}
n := new(big.Int).SetBytes(nBytes)
e := int(new(big.Int).SetBytes(eBytes).Int64())
return &rsa.PublicKey{
N: n,
E: e,
}, nil
}
+316
View File
@@ -0,0 +1,316 @@
// middleware/security.go
package middleware
import (
"api-service/internal/config"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis_rate/v10" // Tambahkan library ini: go get github.com/go-redis/redis_rate/v10
"github.com/redis/go-redis/v9"
)
// Config menyimpan konfigurasi untuk middleware keamanan
type Config struct {
// CORS
TrustedOrigins []string
// Rate Limiting
RedisClient *redis.Client
RequestsPerMin int
// Input Validation
MaxInputLength int
}
// SwaggerSecurityHeaders adalah middleware khusus untuk route dokumentasi.
// CSP-nya dilonggarkan untuk mengizinkan skrip dan gaya inline yang dibutuhkan Swagger UI.
func SwaggerSecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Header lainnya tetap bisa diterapkan
c.Header("X-Frame-Options", "DENY")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()")
// CSP yang lebih longgar untuk Swagger UI
// 'unsafe-inline' dibutuhkan untuk skrip dan gaya yang ada di dalam HTML
// data: dibutuhkan jika ada gambar atau resource yang di-encode base64
cspHeader := "default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " + // <--- PERUBAHAN UTAMA
"style-src 'self' 'unsafe-inline'; " + // <--- Juga sering dibutuhkan
"img-src 'self' data:; " + // <--- Untuk gambar base64
"object-src 'none'; " +
"base-uri 'self'; " +
"frame-ancestors 'none';"
c.Header("Content-Security-Policy", cspHeader)
// HSTS juga bisa diterapkan jika menggunakan HTTPS
if c.Request.TLS != nil {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
c.Next()
}
}
// SecurityHeaders menambahkan header keamanan standar ke semua respons
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Mencegah clickjacking
c.Header("X-Frame-Options", "DENY")
// Mencegah MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")
// Mengaktifkan proteksi XSS (sudah usang di browser modern tapi tetap baik)
c.Header("X-XSS-Protection", "1; mode=block")
// Kebijakan referrer
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Kebijakan Keamanan Konten (CSP) - Lebih ketat
// Hindari 'unsafe-inline' di produksi. Gunakan nonce atau hash jika memungkinkan.
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none';")
// Kebijakan Izin (Permissions Policy) - Menonaktifkan fitur browser yang tidak dibutuhkan
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()")
// HSTS (HTTP Strict Transport Security) - Hanya untuk HTTPS
if c.Request.TLS != nil {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
c.Next()
}
}
// SecureCORSConfig menyediakan konfigurasi CORS yang aman dan fleksibel
func SecureCORSConfig(cfg config.SecurityConfig) gin.HandlerFunc {
return cors.New(cors.Config{
AllowOrigins: cfg.TrustedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Length", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true, // Hanya gunakan 'true' jika Anda benar-benar membutuhkannya (cookie, auth)
MaxAge: 12 * time.Hour,
})
}
// RateLimitByIPRedis membatasi permintaan per IP menggunakan Redis untuk skalabilitas
func RateLimitByIPRedis(cfg config.SecurityConfig) gin.HandlerFunc {
// Buat koneksi Redis dari konfigurasi
rdb := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.RateLimit.Redis.Host, cfg.RateLimit.Redis.Port),
Password: cfg.RateLimit.Redis.Password,
DB: cfg.RateLimit.Redis.DB,
})
// Cek koneksi ke Redis
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := rdb.Ping(ctx).Result(); err != nil {
// Jika gagal konek, gunakan fallback di memori dan log error
fmt.Printf("WARNING: Could not connect to Redis: %v. Falling back to in-memory rate limiter.\n", err)
return rateLimitByIPFallback(cfg.RateLimit.RequestsPerMinute)
}
limiter := redis_rate.NewLimiter(rdb)
return func(c *gin.Context) {
res, err := limiter.Allow(c.Request.Context(), c.ClientIP(), redis_rate.PerMinute(cfg.RateLimit.RequestsPerMinute))
if err != nil {
fmt.Printf("Rate limiter error: %v\n", err)
c.Next()
return
}
h := c.Writer.Header()
h.Set("X-RateLimit-Limit", fmt.Sprintf("%d", cfg.RateLimit.RequestsPerMinute))
h.Set("X-RateLimit-Remaining", fmt.Sprintf("%d", res.Remaining))
if res.Allowed == 0 {
h.Set("X-RateLimit-Reset", fmt.Sprintf("%d", time.Now().Add(res.RetryAfter).Unix()))
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "Rate limit exceeded",
})
return
}
c.Next()
}
}
// rateLimitByIPFallback adalah rate limiter sederhana di memori, HANYA untuk pengembangan
func rateLimitByIPFallback(requestsPerMinute int) gin.HandlerFunc {
type client struct {
count int
resetTime int64
}
clients := make(map[string]*client)
return func(c *gin.Context) {
ip := c.ClientIP()
now := time.Now().Unix()
if _, exists := clients[ip]; !exists {
clients[ip] = &client{count: 0, resetTime: now + 60}
}
cl := clients[ip]
if now > cl.resetTime {
cl.count = 0
cl.resetTime = now + 60
}
if cl.count >= requestsPerMinute {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "Rate limit exceeded"})
return
}
cl.count++
c.Next()
}
}
// InputValidation memvalidasi input untuk mencegah serangan injeksi dan buffer overflow
func InputValidation(cfg config.SecurityConfig) gin.HandlerFunc {
// Pola-pola yang mencurigakan. Ini adalah lapisan pertahanan tambahan (WAF), bukan pengganti prepared statements.
suspiciousPatterns := []string{
"union select", "union all select", "select.*from", "insert.*into", "update.*set", "delete.*from",
"drop table", "drop database", "alter table", "create table", "exec(", "execute(", "xp_", "sp_",
"information_schema", "sysobjects", "syscolumns", "mysql.", "pg_", "sqlite_", ";--", "/*", "*/",
"@@", "script>", "<script", "javascript:", "vbscript:", "onload=", "onerror=", "eval(", "alert(",
}
return func(c *gin.Context) {
// 1. Validasi Panjang Input
log.Printf("DEBUG: InputValidation middleware called. MaxInputLength is set to: %d", cfg.MaxInputLength)
if !validateInputLength(c, cfg.MaxInputLength) {
return
}
// 2. Deteksi Pola Injeksi
if hasInjectionPatterns(c, suspiciousPatterns) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "Invalid input detected",
"message": "Request contains potentially malicious content",
})
return
}
c.Next()
}
}
// validateInputLength memeriksa panjang input pada query dan form
func validateInputLength(c *gin.Context, maxLength int) bool {
log.Printf("DEBUG: Full Raw Query Received: %s", c.Request.URL.RawQuery)
// Periksa query parameters
for key, values := range c.Request.URL.Query() {
for _, value := range values {
log.Printf("DEBUG: Checking param '%s' with value '%s' (length: %d)", key, value, len(value))
if len(value) > maxLength {
log.Printf("ERROR: Parameter '%s' with value '%s' (length: %d) exceeds max length %d", key, value, len(value), maxLength)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "Input too long",
"message": fmt.Sprintf("Query parameter '%s' exceeds maximum length", key),
})
return false
}
}
}
// Periksa form data (jika sudah di-parse)
if c.Request.PostForm != nil {
for key, values := range c.Request.PostForm {
for _, value := range values {
if len(value) > maxLength {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{
"error": "Input too long",
"message": fmt.Sprintf("Form parameter '%s' exceeds maximum length", key),
})
return false
}
}
}
}
return true
}
// hasInjectionPatterns memeriksa pola injeksi pada query, form, dan body JSON
func hasInjectionPatterns(c *gin.Context, patterns []string) bool {
// Periksa query string
query := strings.ToLower(c.Request.URL.RawQuery)
for _, pattern := range patterns {
if strings.Contains(query, pattern) {
return true
}
}
// Periksa form data
if err := c.Request.ParseForm(); err == nil {
for _, values := range c.Request.Form {
for _, value := range values {
lowerValue := strings.ToLower(value)
for _, pattern := range patterns {
if strings.Contains(lowerValue, pattern) {
return true
}
}
}
}
}
// Periksa body JSON
if c.ContentType() == "application/json" {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return false
}
// **PENTING**: Kembalikan body agar bisa dibaca lagi oleh handler (misalnya c.ShouldBindJSON)
c.Request.Body = io.NopCloser(strings.NewReader(string(bodyBytes)))
var jsonData map[string]interface{}
if err := json.Unmarshal(bodyBytes, &jsonData); err == nil {
if checkMapForPatterns(jsonData, patterns) {
return true
}
}
}
return false
}
// checkMapForPatterns memeriksa nilai-nilai di dalam map JSON secara rekursif
func checkMapForPatterns(data map[string]interface{}, patterns []string) bool {
for _, value := range data {
if checkValueForPatterns(value, patterns) {
return true
}
}
return false
}
func checkValueForPatterns(value interface{}, patterns []string) bool {
switch v := value.(type) {
case string:
lowerValue := strings.ToLower(v)
for _, pattern := range patterns {
if strings.Contains(lowerValue, pattern) {
return true
}
}
case map[string]interface{}:
return checkMapForPatterns(v, patterns)
case []interface{}:
for _, item := range v {
if checkValueForPatterns(item, patterns) {
return true
}
}
}
return false
}
+36 -9
View File
@@ -1,4 +1,8 @@
package models
package auth
import (
"github.com/golang-jwt/jwt/v5"
)
// LoginRequest represents the login request payload
type LoginRequest struct {
@@ -8,17 +12,32 @@ type LoginRequest struct {
// TokenResponse represents the token response
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"` // Biasanya "Bearer"
ExpiresIn int64 `json:"expires_in"` // Durasi dalam detik
}
// JWTClaims represents the JWT claims
type JWTClaims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
UserID string `json:"sub"` // Gunakan "sub" (subject) sebagai standar untuk ID pengguna
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims // Menanamkan klaim standar (exp, iat, iss, aud, dll.)
}
// RegisterRequest represents the register request payload
type RegisterRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Role string `json:"role" binding:"required,oneof=admin user"` // Contoh validasi role
}
// RefreshTokenRequest represents the refresh token request payload
type RefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
// User represents a user for authentication
@@ -26,6 +45,14 @@ type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"`
Password string `json:"-"` // Tidak disertakan saat di-serialize ke JSON
Role string `json:"role"`
}
// UserResponse represents user data that can be safely returned to the client
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
+114 -83
View File
@@ -9,40 +9,67 @@ import (
"api-service/internal/middleware"
services "api-service/internal/services/auth"
"api-service/pkg/logger"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
)
func RegisterRoutes(cfg *config.Config) *gin.Engine {
// Atur mode Gin berdasarkan konfigurasi
gin.SetMode(cfg.Server.Mode)
router := gin.New()
// Initialize auth middleware configuration
// =============================================================================
// GLOBAL MIDDLEWARE STACK (Middleware yang diperlukan SEMUA route)
// =============================================================================
middleware.InitializeAuth(cfg)
// Add global middleware
router.Use(middleware.CORSConfig())
router.Use(middleware.ErrorHandler())
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
router.Use(gin.Recovery())
// 1. CORS (Paling awal)
router.Use(middleware.SecureCORSConfig(cfg.Security))
// 2. Rate Limiting
router.Use(middleware.RateLimitByIPRedis(cfg.Security))
// 3. Logging & Recovery
router.Use(logger.RequestLoggerMiddleware(logger.Default()))
// 4. Error Handling (Terakhir, untuk menangkap error dari middleware di atasnya)
router.Use(middleware.ErrorHandler())
// =============================================================================
// INISIALISASI SERVIS & HANDLER
// =============================================================================
// Initialize services with error handling
authService := services.NewAuthService(cfg)
if authService == nil {
logger.Fatal("Failed to initialize auth service")
}
// Initialize database service
dbService := database.New(cfg)
// =============================================================================
// HEALTH CHECK & SYSTEM ROUTES
// SWAGGER DOCUMENTATION (Publik - TANPA SecurityHeaders)
// =============================================================================
// Route ini didefinisikan SEBELUM grup API agar tidak terkena middleware keamanan.
router.GET("/swagger/*any", ginSwagger.WrapHandler(
swaggerFiles.Handler,
ginSwagger.DefaultModelsExpandDepth(-1),
ginSwagger.DeepLinking(true),
))
// =============================================================================
// API GROUPS (Dengan Keamanan Ketat)
// =============================================================================
// Terapkan middleware keamanan dan validasi input HANYA ke grup API.
// Ini adalah perubahan utama.
apiGroup := router.Group("/api")
apiGroup.Use(middleware.SecurityHeaders()) // <--- PINDAHKAN KE SINI
apiGroup.Use(middleware.InputValidation(cfg.Security)) // <--- PINDAHKAN KE SINI
// --- HEALTH CHECK & SYSTEM ROUTES ---
healthCheckHandler := healthcheckHandlers.NewHealthCheckHandler(dbService)
sistem := router.Group("/api/sistem")
sistem := apiGroup.Group("/sistem")
{
sistem.GET("/health", healthCheckHandler.CheckHealth)
sistem.GET("/databases", func(c *gin.Context) {
@@ -62,89 +89,93 @@ func RegisterRoutes(cfg *config.Config) *gin.Engine {
})
}
// =============================================================================
// SWAGGER DOCUMENTATION
// =============================================================================
router.GET("/swagger/*any", ginSwagger.WrapHandler(
swaggerFiles.Handler,
ginSwagger.DefaultModelsExpandDepth(-1),
ginSwagger.DeepLinking(true),
))
// =============================================================================
// API v1 GROUP
// =============================================================================
v1 := router.Group("/api/v1")
// =============================================================================
// PUBLIC ROUTES (No Authentication Required)
// =============================================================================
// Authentication routes
authHandler := authHandlers.NewAuthHandler(authService)
tokenHandler := authHandlers.NewTokenHandler(authService)
// Basic auth routes
v1.POST("/auth/login", authHandler.Login)
v1.POST("/auth/register", authHandler.Register)
v1.POST("/auth/refresh", authHandler.RefreshToken)
// Token generation routes
v1.POST("/token/generate", tokenHandler.GenerateToken)
v1.POST("/token/generate-direct", tokenHandler.GenerateTokenDirect)
// =============================================================================
// PUBLISHED ROUTES
// Retribusi endpoints with
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
retribusiGroup := v1.Group("/retribusi")
// --- API v1 GROUP ---
v1 := apiGroup.Group("/v1")
{
retribusiGroup.GET("", retribusiHandler.GetRetribusi)
retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
retribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced)
retribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
retribusiGroup.POST("", func(c *gin.Context) {
retribusiHandler.CreateRetribusi(c)
})
// =============================================================================
// PUBLIC ROUTES (No Authentication Required)
// =============================================================================
authHandler := authHandlers.NewAuthHandler(authService)
tokenHandler := authHandlers.NewTokenHandler(authService)
retribusiGroup.PUT("/id/:id", func(c *gin.Context) {
retribusiHandler.UpdateRetribusi(c)
})
v1.POST("/auth/login", authHandler.Login)
v1.POST("/auth/register", authHandler.Register)
v1.POST("/auth/refresh", authHandler.RefreshToken)
retribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
retribusiHandler.DeleteRetribusi(c)
})
v1.POST("/token/generate", tokenHandler.GenerateToken)
v1.POST("/token/generate-direct", tokenHandler.GenerateTokenDirect)
retribusiHandler := retribusiHandlers.NewRetribusiHandler()
retribusiGroup := v1.Group("/retribusi")
{
retribusiGroup.GET("", retribusiHandler.GetRetribusi)
retribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
retribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
retribusiGroup.POST("", func(c *gin.Context) {
retribusiHandler.CreateRetribusi(c)
})
retribusiGroup.PUT("/id/:id", func(c *gin.Context) {
retribusiHandler.UpdateRetribusi(c)
})
retribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
retribusiHandler.DeleteRetribusi(c)
})
}
// =============================================================================
// PROTECTED ROUTES (Authentication Required)
// =============================================================================
protected := v1.Group("/")
protected.Use(middleware.UnifiedAuthMiddleware(cfg, authService))
// farmasiObatHandler := farmasiObatHandlers.NewObatHandler()
// protectedFarmasiGroup := protected.Group("/farmasi/obat")
// {
// protectedFarmasiGroup.GET("", farmasiObatHandler.GetObat)
// protectedFarmasiGroup.GET("/kode/:kode", farmasiObatHandler.GetObatByID)
// }
// protectedAuthGroup := protected.Group("/auth")
// {
// protectedAuthGroup.GET("/me", authHandler.Me)
// }
}
// =============================================================================
// PROTECTED ROUTES (Authentication Required)
// DEBUG ROUTES (Publik - Tanpa keamanan ketat)
// =============================================================================
router.GET("/debug/token", func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Header Authorization hilang"})
return
}
protected := v1.Group("/")
protected.Use(middleware.ConfigurableAuthMiddleware(cfg))
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Format header harus Bearer {token}"})
return
}
// Protected retribusi endpoints (Authentication Required)
// protectedRetribusiGroup := protected.Group("/retribusi")
// {
// protectedRetribusiGroup.GET("", retribusiHandler.GetRetribusi)
// protectedRetribusiGroup.GET("/dynamic", retribusiHandler.GetRetribusiDynamic)
// protectedRetribusiGroup.GET("/search", retribusiHandler.SearchRetribusiAdvanced)
// protectedRetribusiGroup.GET("/id/:id", retribusiHandler.GetRetribusiByID)
// protectedRetribusiGroup.POST("", func(c *gin.Context) {
// retribusiHandler.CreateRetribusi(c)
// })
tokenString := parts[1]
// protectedRetribusiGroup.PUT("/id/:id", func(c *gin.Context) {
// retribusiHandler.UpdateRetribusi(c)
// })
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Gagal parsing token: " + err.Error()})
return
}
// protectedRetribusiGroup.DELETE("/id/:id", func(c *gin.Context) {
// retribusiHandler.DeleteRetribusi(c)
// })
// }
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "Format claim tidak valid"})
return
}
c.JSON(http.StatusOK, gin.H{
"header": token.Header,
"claims": claims,
})
})
return router
}
+160 -33
View File
@@ -1,9 +1,11 @@
package services
// services/auth/service.go
package auth
import (
"api-service/internal/config"
models "api-service/internal/models/auth"
"errors"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -12,8 +14,9 @@ import (
// AuthService handles authentication logic
type AuthService struct {
config *config.Config
users map[string]*models.User // In-memory user store for demo
config *config.Config
users map[string]*models.User // In-memory user store for demo
jwtSecret []byte
}
// NewAuthService creates a new authentication service
@@ -38,9 +41,16 @@ func NewAuthService(cfg *config.Config) *AuthService {
Role: "user",
}
// Get JWT secret from environment or use default
jwtSecret := []byte(os.Getenv("JWT_SECRET"))
if len(jwtSecret) == 0 {
jwtSecret = []byte("your-secret-key-change-this-in-production")
}
return &AuthService{
config: cfg,
users: users,
config: cfg,
users: users,
jwtSecret: jwtSecret,
}
}
@@ -58,65 +68,148 @@ func (s *AuthService) Login(username, password string) (*models.TokenResponse, e
}
// Generate JWT token
token, err := s.generateToken(user)
token, expiresIn, err := s.generateToken(user)
if err != nil {
return nil, err
}
// Generate refresh token
refreshToken, err := s.generateRefreshToken(user)
if err != nil {
return nil, err
}
return &models.TokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: 3600, // 1 hour
AccessToken: token,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: expiresIn,
}, nil
}
// generateToken creates a new JWT token for the user
func (s *AuthService) generateToken(user *models.User) (string, error) {
// RefreshToken generates a new access token using a valid refresh token
func (s *AuthService) RefreshToken(refreshTokenString string) (*models.TokenResponse, error) {
// Parse and validate the refresh token
token, err := jwt.Parse(refreshTokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.jwtSecret, nil
})
if err != nil {
return nil, errors.New("invalid refresh token")
}
if !token.Valid {
return nil, errors.New("invalid refresh token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("invalid token claims")
}
// Check if it's a refresh token
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "refresh" {
return nil, errors.New("not a refresh token")
}
// Get user ID from claims
userID, ok := claims["user_id"].(string)
if !ok {
return nil, errors.New("invalid user ID in token")
}
// Find user
var user *models.User
for _, u := range s.users {
if u.ID == userID {
user = u
break
}
}
if user == nil {
return nil, errors.New("user not found")
}
// Generate new access token
accessToken, expiresIn, err := s.generateToken(user)
if err != nil {
return nil, err
}
// Generate new refresh token
newRefreshToken, err := s.generateRefreshToken(user)
if err != nil {
return nil, err
}
return &models.TokenResponse{
AccessToken: accessToken,
RefreshToken: newRefreshToken,
TokenType: "Bearer",
ExpiresIn: expiresIn,
}, nil
}
// generateToken creates a new JWT access token for the user
func (s *AuthService) generateToken(user *models.User) (string, int64, error) {
// Create claims
now := time.Now()
expiresAt := now.Add(time.Hour * 1) // 1 hour expiration
claims := jwt.MapClaims{
"user_id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
"exp": time.Now().Add(time.Hour * 1).Unix(),
"iat": time.Now().Unix(),
"type": "access",
"exp": expiresAt.Unix(),
"iat": now.Unix(),
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign token with secret key
secretKey := []byte(s.getJWTSecret())
return token.SignedString(secretKey)
tokenString, err := token.SignedString(s.jwtSecret)
if err != nil {
return "", 0, err
}
return tokenString, int64(time.Hour.Seconds()), nil
}
// GenerateTokenForUser generates a JWT token for a specific user
func (s *AuthService) GenerateTokenForUser(user *models.User) (string, error) {
// generateRefreshToken creates a new JWT refresh token for the user
func (s *AuthService) generateRefreshToken(user *models.User) (string, error) {
// Create claims
now := time.Now()
expiresAt := now.Add(time.Hour * 24 * 7) // 7 days expiration
claims := jwt.MapClaims{
"user_id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
"exp": time.Now().Add(time.Hour * 1).Unix(),
"iat": time.Now().Unix(),
"user_id": user.ID,
"type": "refresh",
"exp": expiresAt.Unix(),
"iat": now.Unix(),
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Sign token with secret key
secretKey := []byte(s.getJWTSecret())
return token.SignedString(secretKey)
return token.SignedString(s.jwtSecret)
}
// ValidateToken validates the JWT token
// ValidateToken validates the JWT access token
func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(s.getJWTSecret()), nil
return s.jwtSecret, nil
})
if err != nil {
@@ -132,6 +225,12 @@ func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, erro
return nil, errors.New("invalid claims")
}
// Check if it's an access token
tokenType, ok := claims["type"].(string)
if !ok || tokenType != "access" {
return nil, errors.New("not an access token")
}
return &models.JWTClaims{
UserID: claims["user_id"].(string),
Username: claims["username"].(string),
@@ -140,12 +239,6 @@ func (s *AuthService) ValidateToken(tokenString string) (*models.JWTClaims, erro
}, nil
}
// getJWTSecret returns the JWT secret key
func (s *AuthService) getJWTSecret() string {
// In production, this should come from environment variables
return "your-secret-key-change-this-in-production"
}
// RegisterUser registers a new user (for demo purposes)
func (s *AuthService) RegisterUser(username, email, password, role string) error {
if _, exists := s.users[username]; exists {
@@ -167,3 +260,37 @@ func (s *AuthService) RegisterUser(username, email, password, role string) error
return nil
}
// GenerateToken generates a JWT token for the given user data (public method)
func (s *AuthService) GenerateToken(userID, username, email, role string) (*models.TokenResponse, error) {
user := &models.User{
ID: userID,
Username: username,
Email: email,
Role: role,
}
// Generate access token
token, expiresIn, err := s.generateToken(user)
if err != nil {
return nil, err
}
// Generate refresh token
refreshToken, err := s.generateRefreshToken(user)
if err != nil {
return nil, err
}
return &models.TokenResponse{
AccessToken: token,
RefreshToken: refreshToken,
TokenType: "Bearer",
ExpiresIn: expiresIn,
}, nil
}
// GenerateTokenDirect generates a JWT token directly for the given user data (public method)
func (s *AuthService) GenerateTokenDirect(userID, username, email, role string) (*models.TokenResponse, error) {
return s.GenerateToken(userID, username, email, role)
}
File diff suppressed because it is too large Load Diff
+204 -116
View File
@@ -2,140 +2,228 @@ package validation
import (
"context"
"database/sql"
"fmt"
"time"
queryUtils "api-service/internal/utils/query"
"github.com/jmoiron/sqlx"
)
// ValidationConfig holds configuration for duplicate validation
type ValidationConfig struct {
TableName string
IDColumn string
StatusColumn string
DateColumn string
ActiveStatuses []string
AdditionalFields map[string]interface{}
// =============================================================================
// DYNAMIC VALIDATION RULE
// =============================================================================
// ValidationRule mendefinisikan aturan untuk memeriksa duplikat atau kondisi lain.
// Struct ini membuat validator dapat digunakan kembali untuk tabel apa pun.
type ValidationRule struct {
// TableName adalah nama tabel yang akan diperiksa.
TableName string
// UniqueColumns adalah daftar kolom yang, jika digabungkan, harus unik.
// Contoh: []string{"email"} atau []string{"first_name", "last_name", "dob"}
UniqueColumns []string
// Conditions adalah filter tambahan yang harus dipenuhi.
// Ini sangat berguna untuk aturan bisnis, seperti "status != 'deleted'".
// Gunakan queryUtils.DynamicFilter untuk fleksibilitas penuh.
Conditions []queryUtils.DynamicFilter
// ExcludeIDColumn dan ExcludeIDValue digunakan untuk operasi UPDATE,
// untuk memastikan bahwa record tidak membandingkan dirinya sendiri.
ExcludeIDColumn string
ExcludeIDValue interface{}
}
// DuplicateValidator provides methods for validating duplicate entries
type DuplicateValidator struct {
db *sql.DB
// NewUniqueFieldRule adalah helper untuk membuat aturan validasi unik untuk satu kolom.
// Ini adalah cara cepat untuk membuat aturan yang paling umum.
func NewUniqueFieldRule(tableName, uniqueColumn string, additionalConditions ...queryUtils.DynamicFilter) ValidationRule {
return ValidationRule{
TableName: tableName,
UniqueColumns: []string{uniqueColumn},
Conditions: additionalConditions,
}
}
// NewDuplicateValidator creates a new instance of DuplicateValidator
func NewDuplicateValidator(db *sql.DB) *DuplicateValidator {
return &DuplicateValidator{db: db}
// =============================================================================
// DYNAMIC VALIDATOR
// =============================================================================
// DynamicValidator menyediakan metode untuk menjalankan validasi berdasarkan ValidationRule.
// Ini sepenuhnya generik dan tidak terikat pada tabel atau model tertentu.
type DynamicValidator struct {
qb *queryUtils.QueryBuilder
}
// ValidateDuplicate checks for duplicate entries based on the provided configuration
func (dv *DuplicateValidator) ValidateDuplicate(ctx context.Context, config ValidationConfig, identifier interface{}) error {
query := fmt.Sprintf(`
SELECT COUNT(*)
FROM %s
WHERE %s = $1
AND %s = ANY($2)
AND DATE(%s) = CURRENT_DATE
`, config.TableName, config.IDColumn, config.StatusColumn, config.DateColumn)
var count int
err := dv.db.QueryRowContext(ctx, query, identifier, config.ActiveStatuses).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check duplicate: %w", err)
}
if count > 0 {
return fmt.Errorf("data with ID %v already exists with active status today", identifier)
}
return nil
// NewDynamicValidator membuat instance DynamicValidator baru.
func NewDynamicValidator(qb *queryUtils.QueryBuilder) *DynamicValidator {
return &DynamicValidator{qb: qb}
}
// ValidateDuplicateWithCustomFields checks for duplicates with additional custom fields
func (dv *DuplicateValidator) ValidateDuplicateWithCustomFields(ctx context.Context, config ValidationConfig, fields map[string]interface{}) error {
whereClause := fmt.Sprintf("%s = ANY($1) AND DATE(%s) = CURRENT_DATE", config.StatusColumn, config.DateColumn)
args := []interface{}{config.ActiveStatuses}
argIndex := 2
// Add additional field conditions
for fieldName, fieldValue := range config.AdditionalFields {
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
args = append(args, fieldValue)
argIndex++
// Validate menjalankan validasi terhadap aturan yang diberikan.
// `data` adalah map yang berisi nilai untuk kolom yang akan diperiksa (biasanya dari request body).
// Mengembalikan `true` jika ada duplikat yang ditemukan (validasi gagal), `false` jika tidak ada duplikat (validasi berhasil).
func (dv *DynamicValidator) Validate(ctx context.Context, db *sqlx.DB, rule ValidationRule, data map[string]interface{}) (bool, error) {
if len(rule.UniqueColumns) == 0 {
return false, fmt.Errorf("ValidationRule must have at least one UniqueColumn")
}
// Add dynamic fields
for fieldName, fieldValue := range fields {
whereClause += fmt.Sprintf(" AND %s = $%d", fieldName, argIndex)
args = append(args, fieldValue)
argIndex++
}
// 1. Kumpulkan semua filter dari aturan
var allFilters []queryUtils.DynamicFilter
query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s", config.TableName, whereClause)
// Tambahkan kondisi tambahan (misalnya, status != 'deleted')
allFilters = append(allFilters, rule.Conditions...)
var count int
err := dv.db.QueryRowContext(ctx, query, args...).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check duplicate with custom fields: %w", err)
}
if count > 0 {
return fmt.Errorf("duplicate entry found with the specified criteria")
}
return nil
}
// ValidateOncePerDay ensures only one submission per day for a given identifier
func (dv *DuplicateValidator) ValidateOncePerDay(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) error {
query := fmt.Sprintf(`
SELECT COUNT(*)
FROM %s
WHERE %s = $1
AND DATE(%s) = CURRENT_DATE
`, tableName, idColumn, dateColumn)
var count int
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&count)
if err != nil {
return fmt.Errorf("failed to check daily submission: %w", err)
}
if count > 0 {
return fmt.Errorf("only one submission allowed per day for ID %v", identifier)
}
return nil
}
// GetLastSubmissionTime returns the last submission time for a given identifier
func (dv *DuplicateValidator) GetLastSubmissionTime(ctx context.Context, tableName, idColumn, dateColumn string, identifier interface{}) (*time.Time, error) {
query := fmt.Sprintf(`
SELECT %s
FROM %s
WHERE %s = $1
ORDER BY %s DESC
LIMIT 1
`, dateColumn, tableName, idColumn, dateColumn)
var lastTime time.Time
err := dv.db.QueryRowContext(ctx, query, identifier).Scan(&lastTime)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil // No previous submission
// 2. Bangun filter untuk kolom unik berdasarkan data yang diberikan
for _, colName := range rule.UniqueColumns {
value, exists := data[colName]
if !exists {
// Jika data untuk kolom unik tidak ada, ini adalah kesalahan pemrograman.
return false, fmt.Errorf("data for unique column '%s' not found in provided data map", colName)
}
return nil, fmt.Errorf("failed to get last submission time: %w", err)
allFilters = append(allFilters, queryUtils.DynamicFilter{
Column: colName,
Operator: queryUtils.OpEqual,
Value: value,
})
}
return &lastTime, nil
// 3. Tambahkan filter pengecualian ID (untuk operasi UPDATE)
if rule.ExcludeIDColumn != "" {
allFilters = append(allFilters, queryUtils.DynamicFilter{
Column: rule.ExcludeIDColumn,
Operator: queryUtils.OpNotEqual,
Value: rule.ExcludeIDValue,
})
}
// 4. Bangun dan eksekusi query untuk menghitung jumlah record yang cocok
query := queryUtils.DynamicQuery{
From: rule.TableName,
Filters: []queryUtils.FilterGroup{{Filters: allFilters, LogicOp: "AND"}},
}
count, err := dv.qb.ExecuteCount(ctx, db, query)
if err != nil {
return false, fmt.Errorf("failed to execute validation query for table %s: %w", rule.TableName, err)
}
// 5. Kembalikan hasil
return count > 0, nil
}
// DefaultRetribusiConfig returns default configuration for retribusi validation
func DefaultRetribusiConfig() ValidationConfig {
return ValidationConfig{
TableName: "data_retribusi",
IDColumn: "id",
StatusColumn: "status",
DateColumn: "date_created",
ActiveStatuses: []string{"active", "draft"},
}
// =============================================================================
// CONTOH PENGGUNAAN (UNTUK DITEMPATKAN DI HANDLER ANDA)
// =============================================================================
/*
// --- Cara Penggunaan di RetribusiHandler ---
// 1. Tambahkan DynamicValidator ke struct handler
type RetribusiHandler struct {
// ...
validator *validation.DynamicValidator
}
// 2. Inisialisasi di constructor
func NewRetribusiHandler() *RetribusiHandler {
qb := queryUtils.NewQueryBuilder(queryUtils.DBTypePostgreSQL).SetAllowedColumns(...)
return &RetribusiHandler{
// ...
validator: validation.NewDynamicValidator(qb),
}
}
// 3. Gunakan di CreateRetribusi
func (h *RetribusiHandler) CreateRetribusi(c *gin.Context) {
var req retribusi.RetribusiCreateRequest
// ... bind dan validasi request ...
// Siapkan aturan validasi: KodeTarif harus unik di antara record yang tidak dihapus.
rule := validation.NewUniqueFieldRule(
"data_retribusi", // Nama tabel
"Kode_tarif", // Kolom yang harus unik
queryUtils.DynamicFilter{ // Kondisi tambahan
Column: "status",
Operator: queryUtils.OpNotEqual,
Value: "deleted",
},
)
// Siapkan data dari request untuk divalidasi
dataToValidate := map[string]interface{}{
"Kode_tarif": req.KodeTarif,
}
// Eksekusi validasi
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
if err != nil {
h.logAndRespondError(c, "Failed to validate Kode Tarif", err, http.StatusInternalServerError)
return
}
if isDuplicate {
h.respondError(c, "Kode Tarif already exists", fmt.Errorf("duplicate Kode Tarif: %s", req.KodeTarif), http.StatusConflict)
return
}
// ... lanjutkan proses create ...
}
// 4. Gunakan di UpdateRetribusi
func (h *RetribusiHandler) UpdateRetribusi(c *gin.Context) {
id := c.Param("id")
var req retribusi.RetribusiUpdateRequest
// ... bind dan validasi request ...
// Siapkan aturan validasi: KodeTarif harus unik, kecuali untuk record dengan ID ini.
rule := validation.ValidationRule{
TableName: "data_retribusi",
UniqueColumns: []string{"Kode_tarif"},
Conditions: []queryUtils.DynamicFilter{
{Column: "status", Operator: queryUtils.OpNotEqual, Value: "deleted"},
},
ExcludeIDColumn: "id", // Kecualikan berdasarkan kolom 'id'
ExcludeIDValue: id, // ...dengan nilai ID dari parameter
}
dataToValidate := map[string]interface{}{
"Kode_tarif": req.KodeTarif,
}
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
if err != nil {
h.logAndRespondError(c, "Failed to validate Kode Tarif", err, http.StatusInternalServerError)
return
}
if isDuplicate {
h.respondError(c, "Kode Tarif already exists", fmt.Errorf("duplicate Kode Tarif: %s", req.KodeTarif), http.StatusConflict)
return
}
// ... lanjutkan proses update ...
}
// --- Contoh Penggunaan untuk Kasus Lain ---
// Contoh: Validasi kombinasi unik untuk tabel 'users'
// (email dan company_id harus unik bersama-sama)
func (h *UserHandler) CreateUser(c *gin.Context) {
// ...
rule := validation.ValidationRule{
TableName: "users",
UniqueColumns: []string{"email", "company_id"}, // Unik komposit
}
dataToValidate := map[string]interface{}{
"email": req.Email,
"company_id": req.CompanyID,
}
isDuplicate, err := h.validator.Validate(ctx, dbConn, rule, dataToValidate)
// ... handle error dan duplicate
}
*/
-260
View File
@@ -5,266 +5,6 @@ global:
enable_logging: true
services:
retribusi:
name: "Retribusi"
category: "retribusi"
package: "retribusi"
description: "Retribusi service for tariff and billing management"
base_url: ""
timeout: 30
retry_count: 3
endpoints:
retribusi:
description: "Retribusi tariff management"
handler_folder: "retribusi"
handler_file: "retribusi.go"
handler_name: "Retribusi"
table_name: "data_retribusi"
functions:
list:
methods: ["GET"]
path: "/"
get_routes: "/"
get_path: "/"
model: "Retribusi"
response_model: "RetribusiGetResponse"
description: "Get retribusi list with pagination and filters"
summary: "Get Retribusi List"
tags: ["Retribusi"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 300
has_pagination: true
has_filter: true
has_search: true
has_stats: true
get:
methods: ["GET"]
path: "/:id"
get_routes: "/:id"
get_path: "/:id"
model: "Retribusi"
response_model: "RetribusiGetByIDResponse"
description: "Get retribusi by ID"
summary: "Get Retribusi by ID"
tags: ["Retribusi"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 300
dynamic:
methods: ["GET"]
path: "/dynamic"
get_routes: "/dynamic"
get_path: "/dynamic"
model: "Retribusi"
response_model: "RetribusiGetResponse"
description: "Get retribusi with dynamic filtering"
summary: "Get Retribusi Dynamic"
tags: ["Retribusi"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 300
has_dynamic: true
search:
methods: ["GET"]
path: "/search"
get_routes: "/search"
get_path: "/search"
model: "Retribusi"
response_model: "RetribusiGetResponse"
description: "Search retribusi"
summary: "Search Retribusi"
tags: ["Retribusi"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 300
has_search: true
create:
methods: ["POST"]
path: "/"
post_routes: "/"
post_path: "/"
model: "RetribusiCreateRequest"
response_model: "RetribusiCreateResponse"
request_model: "RetribusiCreateRequest"
description: "Create new retribusi"
summary: "Create Retribusi"
tags: ["Retribusi"]
require_auth: true
cache_enabled: false
enable_database: true
cache_ttl: 0
update:
methods: ["PUT"]
path: "/:id"
put_routes: "/:id"
put_path: "/:id"
model: "RetribusiUpdateRequest"
response_model: "RetribusiUpdateResponse"
request_model: "RetribusiUpdateRequest"
description: "Update retribusi"
summary: "Update Retribusi"
tags: ["Retribusi"]
require_auth: true
cache_enabled: false
enable_database: true
cache_ttl: 0
delete:
methods: ["DELETE"]
path: "/:id"
delete_routes: "/:id"
delete_path: "/:id"
model: "Retribusi"
response_model: "RetribusiDeleteResponse"
description: "Delete retribusi"
summary: "Delete Retribusi"
tags: ["Retribusi"]
require_auth: true
cache_enabled: false
enable_database: true
cache_ttl: 0
stats:
methods: ["GET"]
path: "/stats"
get_routes: "/stats"
get_path: "/stats"
model: "AggregateData"
response_model: "AggregateData"
description: "Get retribusi statistics"
summary: "Get Retribusi Stats"
tags: ["Retribusi"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 180
has_stats: true
# Example of another service
user:
name: "User"
category: "user"
package: "user"
description: "User management service"
base_url: ""
timeout: 30
retry_count: 3
endpoints:
user:
description: "User management endpoints"
handler_folder: "retribusi"
handler_file: "user.go"
handler_name: "User"
table_name: "data_user"
functions:
list:
methods: ["GET"]
path: "/"
get_routes: "/"
get_path: "/"
model: "User"
response_model: "UserGetResponse"
description: "Get user list with pagination"
summary: "Get User List"
tags: ["User"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 300
has_pagination: true
has_filter: true
has_search: true
get:
methods: ["GET"]
path: "/:id"
get_routes: "/:id"
get_path: "/:id"
model: "User"
response_model: "UserGetByIDResponse"
description: "Get user by ID"
summary: "Get User by ID"
tags: ["User"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 300
create:
methods: ["POST"]
path: "/"
post_routes: "/"
post_path: "/"
model: "UserCreateRequest"
response_model: "UserCreateResponse"
request_model: "UserCreateRequest"
description: "Create new user"
summary: "Create User"
tags: ["User"]
require_auth: true
cache_enabled: false
enable_database: true
cache_ttl: 0
update:
methods: ["PUT"]
path: "/:id"
put_routes: "/:id"
put_path: "/:id"
model: "UserUpdateRequest"
response_model: "UserUpdateResponse"
request_model: "UserUpdateRequest"
description: "Update user"
summary: "Update User"
tags: ["User"]
require_auth: true
cache_enabled: false
enable_database: true
cache_ttl: 0
delete:
methods: ["DELETE"]
path: "/:id"
delete_routes: "/:id"
delete_path: "/:id"
model: "User"
response_model: "UserDeleteResponse"
description: "Delete user"
summary: "Delete User"
tags: ["User"]
require_auth: true
cache_enabled: false
enable_database: true
cache_ttl: 0
search:
methods: ["GET"]
path: "/search"
get_routes: "/search"
get_path: "/search"
model: "User"
response_model: "UserGetResponse"
description: "Search user"
summary: "Search User"
tags: ["User"]
require_auth: true
cache_enabled: true
enable_database: true
cache_ttl: 300
has_search: true
schedule:
name: "Jadwal Dokter"
category: "schedule"