diff --git a/README.md b/README.md index 3281398..309ff25 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,47 @@ Berikut adalah cara clone project ini: 3. **Pemrosesan Google Cloud**: Healthcare API memproses permintaan DICOM. 4. **Penanganan Respons**: Proxy meneruskan respons kembali ke klien. +## 2b. Arsitektur +```txt +go-ohif-proxy/ +│ +├── cmd/ +│ └── server/ # Application entry point +│ └── main.go # Main server initialization +│ +├── config/ # Configuration management +│ ├── config.go # Configuration loader +│ └── config.yaml # Application configuration +│ +├── credentials/ # Authentication credentials +│ └── service-account.json # Google Cloud service account +│ +├── internal/ +│ ├── api/ # API endpoints +│ │ ├── handler.go # HTTP request handlers +│ │ ├── middleware.go # Request middleware +│ │ └── routes.go # API route definitions +│ │ +│ ├── auth/ # Authentication services +│ │ └── google.go # Google Cloud authentication +│ │ +│ ├── proxy/ # Proxy service +│ │ └── dicomweb.go # DICOMweb request handling +│ │ +│ └── utils/ # Utility functions +│ ├── http.go # HTTP utilities +│ └── logger.go # Logging functionality +│ +├── test/ # Testing resources +│ └── http/ # HTTP test requests +│ +├── Dockerfile # Container definition +├── docker-compose.yml # Multi-container orchestration +├── go.mod # Go module definition +├── go.sum # Module dependency checksums +└── README.md # Project documentation +``` + ## 3. Instalasi dan Penggunaan ### Prasyarat diff --git a/config/config.go b/config/config.go index 1daeaac..5197953 100644 --- a/config/config.go +++ b/config/config.go @@ -26,6 +26,21 @@ type Config struct { CredentialsPath string `mapstructure:"credentials_path"` } `mapstructure:"google"` + Auth struct { + JWTSecret string `mapstructure:"jwt_secret"` + AccessTokenExpiry int `mapstructure:"access_token_expiry"` // in minutes + RefreshTokenExpiry int `mapstructure:"refresh_token_expiry"` // in hours + EnableDatabaseAuth bool `mapstructure:"enable_database_auth"` + } `mapstructure:"auth"` + + Database struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Name string `mapstructure:"name"` + } `mapstructure:"database"` + AllowedOrigins []string `mapstructure:"allowed_origins"` } diff --git a/config/config.yaml b/config/config.yaml index 1b6db1a..39b22f5 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -13,5 +13,18 @@ google: dicom_store: "store-1" # Your DICOM store name credentials_path: "./credentials/service-account.json" +auth: + jwt_secret: "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY=" # Change this in production! + access_token_expiry: 1440 # minutes (24 hours) + refresh_token_expiry: 168 # hours (7 days) + enable_database_auth: false # Set to true when ready to use database + +database: + host: "localhost" + port: 3306 + user: "dbuser" + password: "dbpassword" # Consider using environment variables for sensitive data + name: "ohif_proxy" + allowed_origins: - "*" # For development; restrict this in production \ No newline at end of file diff --git a/go.mod b/go.mod index 73cab5f..d44fafb 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,19 @@ module devone.aplikasi.web.id/gitea/mario/go-ohif-proxy -go 1.19 +go 1.21.0 + +toolchain go1.23.8 require ( github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/cors v1.2.1 + github.com/go-sql-driver/mysql v1.9.2 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/google/uuid v1.4.0 + github.com/jmoiron/sqlx v1.4.0 github.com/spf13/viper v1.17.0 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.14.0 golang.org/x/oauth2 v0.13.0 google.golang.org/api v0.149.0 ) @@ -14,11 +21,11 @@ require ( require ( cloud.google.com/go/compute v1.23.1 // indirect cloud.google.com/go/compute/metadata v0.2.3 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/google/uuid v1.4.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect @@ -34,7 +41,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.14.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sys v0.13.0 // indirect diff --git a/go.sum b/go.sum index c0a5bd1..a22a8bb 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -53,6 +55,7 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -60,6 +63,7 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= @@ -69,6 +73,11 @@ github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vz github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= +github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -113,6 +122,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -145,17 +155,25 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= @@ -164,9 +182,11 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9cJvm4SvQ= github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= @@ -207,6 +227,7 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -312,6 +333,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -481,7 +503,9 @@ google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k= +google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo= google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -519,6 +543,7 @@ google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go index 9071f15..5470b84 100644 --- a/internal/api/handlers/auth.go +++ b/internal/api/handlers/auth.go @@ -4,39 +4,84 @@ import ( "encoding/json" "net/http" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/service" + "go.uber.org/zap" ) // AuthHandler handles authentication requests type AuthHandler struct { - logger *zap.Logger + logger *zap.Logger + authService *service.AuthService } // NewAuthHandler creates a new auth handler -func NewAuthHandler(logger *zap.Logger) *AuthHandler { +func NewAuthHandler(logger *zap.Logger, authService *service.AuthService) *AuthHandler { return &AuthHandler{ - logger: logger, + logger: logger, + authService: authService, } } -// Login handles user login - placeholder for future implementation +// Login handles user login func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { - response := map[string]string{ - "message": "Login functionality will be implemented in a future version", + // Parse login request + var req models.LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("Failed to parse login request", zap.Error(err)) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return } + // Authenticate user + response, err := h.authService.Login(req.Email, req.Password) + if err != nil { + h.logger.Warn("Login failed", zap.Error(err), zap.String("email", req.Email)) + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + // Return tokens and user info w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) } -// Logout handles user logout - placeholder for future implementation +// RefreshToken handles token refresh +func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) { + // Parse refresh token request + var req models.RefreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.logger.Error("Failed to parse refresh token request", zap.Error(err)) + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Refresh token + accessToken, err := h.authService.RefreshToken(req.RefreshToken) + if err != nil { + h.logger.Warn("Token refresh failed", zap.Error(err)) + http.Error(w, "Invalid refresh token", http.StatusUnauthorized) + return + } + + // Return new access token + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(models.RefreshResponse{ + AccessToken: accessToken, + }) +} + +// Logout handles user logout func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { - response := map[string]string{ - "message": "Logout functionality will be implemented in a future version", - } + // In a real implementation, you would invalidate the refresh token + // For now, just return a success message w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) + json.NewEncoder(w).Encode(map[string]string{ + "message": "Successfully logged out", + }) } diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go new file mode 100644 index 0000000..ab19f6f --- /dev/null +++ b/internal/api/middleware/auth.go @@ -0,0 +1,194 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "regexp" + "strings" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/service" + + "go.uber.org/zap" +) + +type contextKey string + +const ( + UserIDKey contextKey = "user_id" + UserRoleKey contextKey = "user_role" + UserEmailKey contextKey = "user_email" +) + +// WhitelistedEndpoints contains paths that can be accessed without authentication +var WhitelistedEndpoints = []*regexp.Regexp{ + // Study by UID + regexp.MustCompile(`^/dicomWeb/studies\?.*StudyInstanceUID=.+`), + + // Frame endpoint + regexp.MustCompile(`^/dicomWeb/studies/[^/]+/series/[^/]+/instances/[^/]+/frames/\d+$`), +} + +// Auth middleware authenticates requests using JWT tokens +func Auth(authService *service.AuthService, logger *zap.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request path is whitelisted + path := r.URL.Path + if r.URL.RawQuery != "" { + path = path + "?" + r.URL.RawQuery + } + + for _, pattern := range WhitelistedEndpoints { + if pattern.MatchString(path) { + // Path is whitelisted, skip authentication + logger.Debug("Skipping authentication for whitelisted path", zap.String("path", path)) + next.ServeHTTP(w, r) + return + } + } + + // Get authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + logger.Warn("Missing Authorization header") + respondWithError(w, http.StatusUnauthorized, "missing authorization header") + return + } + + // Extract token from Bearer token + bearerToken := strings.Split(authHeader, " ") + if len(bearerToken) != 2 || strings.ToLower(bearerToken[0]) != "bearer" { + logger.Warn("Invalid Authorization header format") + respondWithError(w, http.StatusUnauthorized, "invalid authorization format") + return + } + + token := bearerToken[1] + + // Validate token + claims, err := authService.ValidateToken(token) + if err != nil { + logger.Warn("Invalid or expired token", zap.Error(err)) + respondWithError(w, http.StatusUnauthorized, "invalid or expired token") + return + } + + // Check token type + if claims.TokenType != "access" { + logger.Warn("Invalid token type", zap.String("tokenType", claims.TokenType)) + respondWithError(w, http.StatusUnauthorized, "invalid token type") + return + } + + // Add user info to request context + ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, UserRoleKey, claims.Role) + ctx = context.WithValue(ctx, UserEmailKey, claims.Email) + + // Log user info + logger.Info("Authenticated user", zap.String("userID", claims.UserID), zap.String("role", claims.Role), zap.String("email", claims.Email)) + + // Continue with the request + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RoleRequired middleware checks if user has the required role +func RoleRequired(roles ...string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request path is whitelisted first + path := r.URL.Path + if r.URL.RawQuery != "" { + path = path + "?" + r.URL.RawQuery + } + + for _, pattern := range WhitelistedEndpoints { + if pattern.MatchString(path) { + // Path is whitelisted, skip role check + next.ServeHTTP(w, r) + return + } + } + + // Get user role from context + userRole, ok := r.Context().Value(UserRoleKey).(string) + if !ok { + respondWithError(w, http.StatusUnauthorized, "user context not found") + return + } + + // Check if user has one of the required roles + hasRole := false + for _, role := range roles { + if userRole == role { + hasRole = true + break + } + } + + if !hasRole { + respondWithError(w, http.StatusForbidden, "insufficient permissions") + return + } + + // Continue with the request + next.ServeHTTP(w, r) + }) + } +} + +// PatientViewRestriction middleware restricts patients to view only their studies +func PatientViewRestriction(logger *zap.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request path is whitelisted first + path := r.URL.Path + if r.URL.RawQuery != "" { + path = path + "?" + r.URL.RawQuery + } + + for _, pattern := range WhitelistedEndpoints { + if pattern.MatchString(path) { + // Path is whitelisted, skip restrictions + logger.Debug("Skipping patient restrictions for whitelisted path", zap.String("path", path)) + next.ServeHTTP(w, r) + return + } + } + + // Get user role from context + userRole, ok := r.Context().Value(UserRoleKey).(string) + if !ok { + respondWithError(w, http.StatusUnauthorized, "user context not found") + return + } + + // Only apply restrictions to patients + if userRole != "patient" { + next.ServeHTTP(w, r) + return + } + + // TODO: Logic to restrict patients to only access their assigned studies + // For now, we're just letting the request through, but in a real + // implementation, you would check the study ID against the patient's + // assigned studies. + + // TODO: Check if the requested study is assigned to the patient + // This would likely involve parsing the URL path to extract study ID + // and checking it against a database of patient assignments + + next.ServeHTTP(w, r) + }) + } +} + +// Helper function to respond with JSON error +func respondWithError(w http.ResponseWriter, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]string{"error": message}) +} diff --git a/internal/api/models/user.go b/internal/api/models/user.go new file mode 100644 index 0000000..8982554 --- /dev/null +++ b/internal/api/models/user.go @@ -0,0 +1,61 @@ +package models + +// User represents a system user +type User struct { + ID string `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Password string `db:"password" json:"-"` // Never expose password in JSON + Role string `db:"role" json:"role"` + Name string `db:"name" json:"name"` + CreatedAt string `db:"created_at" json:"created_at"` + UpdatedAt string `db:"updated_at" json:"updated_at"` +} + +// RefreshToken represents a refresh token stored in the database +type RefreshToken struct { + ID string `db:"id" json:"id"` + UserID string `db:"user_id" json:"user_id"` + Token string `db:"token" json:"token"` + ExpiresAt string `db:"expires_at" json:"expires_at"` + IsRevoked bool `db:"is_revoked" json:"is_revoked"` + CreatedAt string `db:"created_at" json:"created_at"` +} + +// PatientDetails contains patient-specific data +type PatientDetails struct { + PatientID string `json:"patient_id"` + PatientName string `json:"patient_name"` + AccessionNumber string `json:"accession_number"` + StudyInstanceUID string `json:"study_instance_uid"` +} + +// DoctorDetails contains doctor-specific data +type DoctorDetails struct { + DoctorID string `json:"doctor_id"` + DoctorName string `json:"doctor_name"` + Type string `json:"type"` // "ref_doctor" or "expertise_doctor" +} + +// LoginRequest represents the login form data +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +// LoginResponse is the response sent after successful login +type LoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + User *User `json:"user"` + RedirectURL string `json:"redirect_url"` +} + +// RefreshRequest represents the refresh token request +type RefreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +// RefreshResponse is the response for a token refresh +type RefreshResponse struct { + AccessToken string `json:"access_token"` +} diff --git a/internal/api/routes.go b/internal/api/routes.go index b3c9a2b..89dfc3b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -2,12 +2,15 @@ package api import ( "net/http" + "time" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/config" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/handlers" apiMiddleware "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/middleware" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/service" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/auth" "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/proxy" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" @@ -18,29 +21,23 @@ import ( func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { r := chi.NewRouter() - // Built-in Chi middleware + // Base middleware r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) - r.Use(middleware.StripSlashes) - - // Custom middleware r.Use(apiMiddleware.Logger(logger)) - // CORS middleware + // CORS configuration r.Use(cors.Handler(cors.Options{ - AllowedOrigins: cfg.AllowedOrigins, + AllowedOrigins: []string{"*"}, // In production, restrict this to your frontend domains AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, ExposedHeaders: []string{"Link"}, AllowCredentials: true, - MaxAge: 300, + MaxAge: 300, // Maximum value not ignored by any of major browsers })) - // Setup health check - r.Get("/health", handlers.HealthCheck) - - // Initialize Google auth client + // Initialize Google auth client for proxy googleAuth, err := auth.NewGoogleClient(cfg.Google.CredentialsPath) if err != nil { logger.Fatal("Failed to initialize Google auth client", zap.Error(err)) @@ -49,23 +46,67 @@ func SetupRouter(cfg *config.Config, logger *zap.Logger) http.Handler { // Initialize Healthcare API client healthcareClient := proxy.NewClient(googleAuth, cfg.Google) - // DICOM Web routes - simplified approach - r.Route("/dicomWeb", func(r chi.Router) { - // Add audit logging middleware to DICOM routes - r.Use(apiMiddleware.AuditLog(logger)) + // Initialize JWT auth service + jwtSecret := cfg.Auth.JWTSecret + if jwtSecret == "" { + jwtSecret = "vQ6PQqUyh7pBNOytClgN+Nw1XBq7F8Qo6VP3VwIqvHY=" // Default from your example, should be set in config + } - // Create single handler for all DICOM requests - dicomHandler := handlers.NewDicomHandler(healthcareClient, logger) + // Convert config values to time.Duration + accessExpiry := time.Duration(cfg.Auth.AccessTokenExpiry) * time.Minute + refreshExpiry := time.Duration(cfg.Auth.RefreshTokenExpiry) * time.Hour - // Catch all routes under /dicomWeb and forward them - r.HandleFunc("/*", dicomHandler.ForwardRequest) + // Create JWT manager with config values + jwtManager := auth.NewJWTManager(jwtSecret, accessExpiry, refreshExpiry) + authService := service.NewAuthService(jwtManager) + + // Public routes that don't require authentication + r.Group(func(r chi.Router) { + // Health check + r.Get("/health", handlers.HealthCheck) + + // Authentication endpoints + r.Route("/auth", func(r chi.Router) { + authHandler := handlers.NewAuthHandler(logger, authService) + r.Post("/login", authHandler.Login) + r.Post("/refresh", authHandler.RefreshToken) + r.Post("/logout", authHandler.Logout) + }) }) - // Future auth routes for doctors - r.Route("/auth", func(r chi.Router) { - authHandler := handlers.NewAuthHandler(logger) - r.Post("/login", authHandler.Login) - r.Post("/logout", authHandler.Logout) + // Protected routes that require authentication + r.Group(func(r chi.Router) { + // Apply authentication middleware + r.Use(apiMiddleware.Auth(authService, logger)) + + // DICOM Web routes + r.Route("/dicomWeb", func(r chi.Router) { + // Add audit logging middleware to DICOM routes + r.Use(apiMiddleware.AuditLog(logger)) + + // Add patient view restriction for patient role + r.Use(apiMiddleware.PatientViewRestriction(logger)) + + // Create handler for all DICOM requests + dicomHandler := handlers.NewDicomHandler(healthcareClient, logger) + + // Common routes for studies with role-specific handling + r.Route("/studies", func(r chi.Router) { + // StudyInstanceUID parameter routes - accessible by all roles + r.Get("/{studyInstanceUID}", dicomHandler.ForwardRequest) // Study details + r.Get("/{studyInstanceUID}/series", dicomHandler.ForwardRequest) // Series list for study + + // Deep hierarchy routes - accessible by patients and all doctors + r.Get("/{studyInstanceUID}/series/{seriesUID}/metadata", dicomHandler.ForwardRequest) + r.Get("/{studyInstanceUID}/series/{seriesUID}/instances/{instanceUID}/frames/{frame}", dicomHandler.ForwardRequest) + + // Query routes - accessible by all roles + r.Get("/", dicomHandler.ForwardRequest) // Study list with filters + }) + + // Expertise doctors have full access to all DICOM endpoints + r.With(apiMiddleware.RoleRequired("expertise_doctor")).HandleFunc("/*", dicomHandler.ForwardRequest) + }) }) return r diff --git a/internal/api/service/auth_service.go b/internal/api/service/auth_service.go new file mode 100644 index 0000000..1458834 --- /dev/null +++ b/internal/api/service/auth_service.go @@ -0,0 +1,217 @@ +package service + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/auth" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserNotFound = errors.New("user not found") +) + +// AuthService handles authentication operations +type AuthService struct { + jwtManager *auth.JWTManager + // When you implement database connection, add a db client here + // db *sqlx.DB +} + +// NewAuthService creates a new authentication service +func NewAuthService(jwtManager *auth.JWTManager) *AuthService { + return &AuthService{ + jwtManager: jwtManager, + } +} + +// Login authenticates a user and generates tokens +func (s *AuthService) Login(email, password string) (*models.LoginResponse, error) { + // For now, use hardcoded credentials + // TODO: In a real implementation, you would query the database + if email == "admin" && password == "admin" { + // Create a dummy user + user := &models.User{ + ID: "1", + Email: "admin", + Role: "expertise_doctor", + Name: "Admin User", + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + } + + // Generate tokens + accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, user.Email, user.Role) + if err != nil { + return nil, err + } + + refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID, user.Email, user.Role) + if err != nil { + return nil, err + } + + // TODO: In a real implementation, you would store the refresh token in the database + // For example: + // s.storeRefreshToken(user.ID, refreshToken) + + // Determine redirect URL based on role + redirectURL := "/viewer" + if user.Role == "ref_doctor" || user.Role == "expertise_doctor" { + redirectURL = "/studylist" + } + + return &models.LoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: user, + RedirectURL: redirectURL, + }, nil + } else if email == "patient" && password == "patient" { + // Create a patient user + user := &models.User{ + ID: "2", + Email: "patient", + Role: "patient", + Name: "Patient User", + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + } + + // Generate tokens with patient-specific claims + accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, user.Email, user.Role) + if err != nil { + return nil, err + } + + refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID, user.Email, user.Role) + if err != nil { + return nil, err + } + + return &models.LoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: user, + RedirectURL: "/viewer", + }, nil + } else if email == "doctor" && password == "doctor" { + // Create a referring doctor user + user := &models.User{ + ID: "3", + Email: "doctor", + Role: "ref_doctor", + Name: "Doctor User", + CreatedAt: time.Now().Format(time.RFC3339), + UpdatedAt: time.Now().Format(time.RFC3339), + } + + // Generate tokens + accessToken, err := s.jwtManager.GenerateAccessToken(user.ID, user.Email, user.Role) + if err != nil { + return nil, err + } + + refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID, user.Email, user.Role) + if err != nil { + return nil, err + } + + return &models.LoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + User: user, + RedirectURL: "/studylist", + }, nil + } + + return nil, ErrInvalidCredentials +} + +// RefreshToken generates a new access token using a refresh token +func (s *AuthService) RefreshToken(refreshToken string) (string, error) { + // Validate the refresh token + claims, err := s.jwtManager.ValidateToken(refreshToken) + if err != nil { + return "", err + } + + // Check if token is a refresh token + if claims.TokenType != "refresh" { + return "", errors.New("invalid token type") + } + + // TODO: In a real implementation, you would check if the token is in the database and not revoked + // Here we just generate a new access token + accessToken, err := s.jwtManager.GenerateAccessToken(claims.UserID, claims.Email, claims.Role) + if err != nil { + return "", err + } + + return accessToken, nil +} + +// ValidateToken validates a token and returns the claims +func (s *AuthService) ValidateToken(token string) (*auth.CustomClaims, error) { + return s.jwtManager.ValidateToken(token) +} + +// HashPassword hashes a password using bcrypt +func HashPassword(password string) (string, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hashedPassword), nil +} + +// CheckPassword compares a password with a hash +func CheckPassword(password, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// Below functions would be implemented when connecting to a real database + +// storeRefreshToken stores a refresh token in the database +func (s *AuthService) storeRefreshToken(userID, token string) error { + // TODO: In a real implementation, this would insert a record in the database + // For example: + /* + refreshToken := &models.RefreshToken{ + ID: uuid.New().String(), + UserID: userID, + Token: token, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour).Format(time.RFC3339), + IsRevoked: false, + CreatedAt: time.Now().Format(time.RFC3339), + } + + _, err := s.db.NamedExec( + `INSERT INTO refresh_tokens (id, user_id, token, expires_at, is_revoked, created_at) + VALUES (:id, :user_id, :token, :expires_at, :is_revoked, :created_at)`, + refreshToken, + ) + return err + */ + + return nil +} + +// revokeRefreshToken marks a refresh token as revoked +func (s *AuthService) revokeRefreshToken(token string) error { + // TODO: In a real implementation, this would update a record in the database + // For example: + /* + _, err := s.db.Exec( + "UPDATE refresh_tokens SET is_revoked = true WHERE token = ?", + token, + ) + return err + */ + + return nil +} diff --git a/internal/api/service/db_repository.go b/internal/api/service/db_repository.go new file mode 100644 index 0000000..8a64cf7 --- /dev/null +++ b/internal/api/service/db_repository.go @@ -0,0 +1,130 @@ +package service + +import ( + "database/sql" + "fmt" + "time" + + "devone.aplikasi.web.id/gitea/mario/go-ohif-proxy/internal/api/models" + + _ "github.com/go-sql-driver/mysql" // Hanya menjalankan side-effectsnya saja dahulu + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// Repository provides an interface to the database +type Repository struct { + db *sqlx.DB +} + +// NewRepository creates a new database repository +func NewRepository(dsn string) (*Repository, error) { + db, err := sqlx.Connect("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Set connection pool settings + db.SetMaxOpenConns(10) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(time.Hour) + + return &Repository{ + db: db, + }, nil +} + +// GetUserByEmail retrieves a user by email +func (r *Repository) GetUserByEmail(email string) (*models.User, error) { + var user models.User + err := r.db.Get(&user, "SELECT * FROM users WHERE email = ?", email) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &user, nil +} + +// GetUserByID retrieves a user by ID +func (r *Repository) GetUserByID(id string) (*models.User, error) { + var user models.User + err := r.db.Get(&user, "SELECT * FROM users WHERE id = ?", id) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &user, nil +} + +// StoreRefreshToken saves a refresh token to the database +func (r *Repository) StoreRefreshToken(userID, token string, expiresAt time.Time) error { + refreshToken := models.RefreshToken{ + ID: uuid.New().String(), + UserID: userID, + Token: token, + ExpiresAt: expiresAt.Format(time.RFC3339), + IsRevoked: false, + CreatedAt: time.Now().Format(time.RFC3339), + } + + _, err := r.db.NamedExec( + `INSERT INTO refresh_tokens (id, user_id, token, expires_at, is_revoked, created_at) + VALUES (:id, :user_id, :token, :expires_at, :is_revoked, :created_at)`, + refreshToken, + ) + return err +} + +// GetRefreshToken retrieves a refresh token from the database +func (r *Repository) GetRefreshToken(token string) (*models.RefreshToken, error) { + var refreshToken models.RefreshToken + err := r.db.Get(&refreshToken, "SELECT * FROM refresh_tokens WHERE token = ?", token) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &refreshToken, nil +} + +// RevokeRefreshToken marks a refresh token as revoked +func (r *Repository) RevokeRefreshToken(token string) error { + _, err := r.db.Exec("UPDATE refresh_tokens SET is_revoked = true WHERE token = ?", token) + return err +} + +// GetPatientDetails retrieves patient details for a user +func (r *Repository) GetPatientDetails(userID string) (*models.PatientDetails, error) { + var patientDetails models.PatientDetails + err := r.db.Get(&patientDetails, "SELECT * FROM patient_details WHERE user_id = ?", userID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &patientDetails, nil +} + +// GetDoctorDetails retrieves doctor details for a user +func (r *Repository) GetDoctorDetails(userID string) (*models.DoctorDetails, error) { + var doctorDetails models.DoctorDetails + err := r.db.Get(&doctorDetails, "SELECT * FROM doctor_details WHERE user_id = ?", userID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &doctorDetails, nil +} + +// Close closes the database connection +func (r *Repository) Close() error { + return r.db.Close() +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..a7b9050 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,101 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token has expired") +) + +// JWTManager handles JWT token operations +type JWTManager struct { + secretKey string + accessExpiry time.Duration + refreshExpiry time.Duration +} + +// NewJWTManager creates a new JWT manager +func NewJWTManager(secretKey string, accessExpiry, refreshExpiry time.Duration) *JWTManager { + return &JWTManager{ + secretKey: secretKey, + accessExpiry: accessExpiry, + refreshExpiry: refreshExpiry, + } +} + +// CustomClaims contains the claims we want in our tokens +type CustomClaims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + TokenType string `json:"token_type"` // access or refresh + jwt.RegisteredClaims +} + +// GenerateAccessToken creates a new access token +func (m *JWTManager) GenerateAccessToken(userID, email, role string) (string, error) { + claims := CustomClaims{ + UserID: userID, + Email: email, + Role: role, + TokenType: "access", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.accessExpiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.secretKey)) +} + +// GenerateRefreshToken creates a new refresh token +func (m *JWTManager) GenerateRefreshToken(userID, email, role string) (string, error) { + claims := CustomClaims{ + UserID: userID, + Email: email, + Role: role, + TokenType: "refresh", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.refreshExpiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(m.secretKey)) +} + +// ValidateToken validates a token and returns the claims +func (m *JWTManager) ValidateToken(tokenString string) (*CustomClaims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + &CustomClaims{}, + func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(m.secretKey), nil + }, + ) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(*CustomClaims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + + return claims, nil +} diff --git a/ohif-proxy b/ohif-proxy index 1866000..201b6ef 100755 Binary files a/ohif-proxy and b/ohif-proxy differ diff --git a/ohif-proxy-old b/ohif-proxy-old new file mode 100755 index 0000000..1866000 Binary files /dev/null and b/ohif-proxy-old differ diff --git a/test/http/ohif-flow.http b/test/http/ohif-flow.http new file mode 100644 index 0000000..e84ae7c --- /dev/null +++ b/test/http/ohif-flow.http @@ -0,0 +1,57 @@ +@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMiIsImVtYWlsIjoicGF0aWVudCIsInJvbGUiOiJwYXRpZW50IiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImV4cCI6MTc0NjUwNDUzMCwiaWF0IjoxNzQ2NDE4MTMwfQ.AvSBHvy3y22Pa4M8MZS9u00fiBtHzcS_WbxukxsBcj4 +@token_exp_doctor = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSIsImVtYWlsIjoiYWRtaW4iLCJyb2xlIjoiZXhwZXJ0aXNlX2RvY3RvciIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJleHAiOjE3NDY1MDQ1MTYsImlhdCI6MTc0NjQxODExNn0.vlDrns1oPFXHE5--TmWqwzvzxnfcCPcV2UW8_4GwDwE +@baseUrl = http://localhost:5555 + +### Login Patient +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "patient", + "password": "patient" +} + +### Login Admin / Exp_doctor (ALL ACCESS) +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "admin", + "password": "admin" +} + +### * === PATIENT === * + +# Destination URL / OHIF Viewer URL: http://152.42.173.210:3000/viewer?StudyInstanceUIDs=1.2.826.0.1.3680043.9.7307.1.202503196393.01 + +### Study where StudyIUID +GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.202503196393.01 +Authorization: Bearer {{token}} + +### Series List +GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01/series?includefield=00080021,00080031,0008103E,00200011 +Authorization: Bearer {{token}} + +### Semua Study dari Patient ID +GET {{baseUrl}}/dicomWeb/studies?00100020=MR00000359&limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060 +Authorization: Bearer {{token}} + +### Metadata +GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01/series/1.2.826.0.1.3680043.2.1545.1.2.1.7.20250319.100353.734.4/metadata +Authorization: Bearer {{token}} + +### Modality and Study Description untuk Study Instance UID = ... +GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.202503196393.01 +Authorization: Bearer {{token}} + +### DICOM Frames +GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01/series/1.2.826.0.1.3680043.2.1545.1.2.1.7.20250319.100353.734.4/instances/1.2.826.0.1.3680043.2.1545.1.2.1.7.20250319.100353.1.5/frames/1 +Authorization: Bearer {{token}} + +### Modality and Study Description untuk semua studies pada PatientID = ... +GET {{baseUrl}}/dicomWeb/studies?00100020=MR00000359&limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060 +Authorization: Bearer {{token}} + +### Accession, Deskripsi Studi, Umur, dan format nama pasien where StudyInstanceUID = ... +GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=false&includefield=00080050,00081030,00101010,0010004&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.202503196393.01 +Authorization: Bearer {{token}} diff --git a/test/http/test.http b/test/http/test.http index b79ca37..aa12147 100644 --- a/test/http/test.http +++ b/test/http/test.http @@ -1,18 +1,37 @@ ### Local OHIF Proxy Test File -# @baseUrl = http://localhost:5555 -# @baseUrl = http://devone.aplikasi.web.id:5555 -@baseUrl = http://152.42.173.210:5555 +@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMiIsImVtYWlsIjoicGF0aWVudCIsInJvbGUiOiJwYXRpZW50IiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImV4cCI6MTc0NjM2NDczMiwiaWF0IjoxNzQ2MzYyOTMyfQ.4IGGV77jnewQVXOCuFWmcx4X7EMMxx341j6DeNKYcFY +@baseUrl = http://localhost:5555 +# @baseUrl = http://devone.aplikasi.web.id:5555 +# @baseUrl = http://152.42.173.210:5555 ### 1. Health Check # Verifies that the proxy server is running GET {{baseUrl}}/health Accept: application/json +### Login Success +POST {{baseUrl}}/auth/login +Content-Type: application/json + +{ + "email": "patient", + "password": "patient" +} + +### Refresh TOken +POST {{baseUrl}}/auth/refresh +Content-Type: application/json + +{ + "refresh" +} + ### 2. QIDO-RS: Search for Studies # Returns all studies (should return a list of DICOM studies if any exist) GET {{baseUrl}}/dicomWeb/studies Accept: application/dicom+json +Authorization: Bearer {{token}} ### 3. QIDO-RS: Search for Studies with Patient Name # Returns studies matching patient name (replace SMITH with a name in your dataset) @@ -49,6 +68,7 @@ Accept: application/dicom+json # Replace STUDY_INSTANCE_UID, SERIES_INSTANCE_UID and SOP_INSTANCE_UID with actual values GET {{baseUrl}}/dicomWeb/studies/1.2.826.0.1.3680043.9.7307.1.202503196393.01/series/1.2.826.0.1.3680043.2.1545.1.2.1.7.20250319.100353.734.4/metadata Accept: */* +Authorization: Bearer {{token}} ### 10. WADO-RS: Retrieve Instance # Replace STUDY_INSTANCE_UID, SERIES_INSTANCE_UID and SOP_INSTANCE_UID with actual values @@ -70,3 +90,5 @@ Accept: image/jpeg #### GET {{baseUrl}}/dicomWeb/studies?limit=101&offset=0&fuzzymatching=true&includefield=00081030,00080060&StudyInstanceUID=1.2.826.0.1.3680043.9.7307.1.202503196393.01 + +