diff --git a/.env.example b/.env.example deleted file mode 100644 index 02810734..00000000 --- a/.env.example +++ /dev/null @@ -1,34 +0,0 @@ -# server configuration -# Env value : prod || dev -VERSION=0.0.1 -APP_ENV=dev -APP_HOST=0.0.0.0 -APP_PORT=8081 -APP_URL=http://localhost:8081 - -# database configuration -DB_HOST=postgresdb -DB_USER=postgres -DB_PASSWORD=changeme -DB_NAME=db_lti_erp -DB_PORT=5432 -DB_PORT_HOST=5542 - -# JWT -JWT_SECRET=changeme -JWT_ACCESS_EXP_MINUTES=30 -JWT_REFRESH_EXP_DAYS=30 -JWT_RESET_PASSWORD_EXP_MINUTES=10 -JWT_VERIFY_EMAIL_EXP_MINUTES=10 - -# CORS -CORS_ALLOW_ORIGINS=changeme -CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS -CORS_ALLOW_HEADERS=Authorization,Content-Type,X-Requested-With -CORS_EXPOSE_HEADERS=Link,Location -CORS_ALLOW_CREDENTIALS=true -CORS_MAX_AGE=600 - -# Redis -REDIS_URL=redis://redis:6379/0 -REDIS_PORT_HOST=6381 diff --git a/.gitignore b/.gitignore index 90267937..82524f71 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,13 @@ bin/ *.exe *.out +Makefile +docker-compose.local.yml +docker-compose.yaml +Dockerfile.local # Go build cache .gocache/ +vendor # Logs & reports *.log diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..3aa6389b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,69 @@ +stages: + - deploy + +deploy-dev: + stage: deploy + image: alpine:3.20 + variables: + DEPLOY_APP: "LTI-MBUGROUP" + + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval $(ssh-agent -s) + - ssh-add ~/.ssh/id_rsa + - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" + - > + if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " + cd /home/devops/docker/deployment/development/lti-api && + git fetch origin development && + git reset --hard origin/development && + docker compose restart dev-api-lti || docker compose up -d dev-api-lti + "; then + STATUS='success'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development \ No newline at end of file diff --git a/Dockerfile.local b/Dockerfile similarity index 100% rename from Dockerfile.local rename to Dockerfile diff --git a/Makefile b/Makefile deleted file mode 100644 index 5533dc7f..00000000 --- a/Makefile +++ /dev/null @@ -1,120 +0,0 @@ -# --- Load .env kalau ada, dan export ke shell child --- -ifneq (,$(wildcard .env)) -include .env -export -endif - -# --- Konfigurasi umum --- -COMPOSE ?= docker compose -f docker-compose.local.yml -NETWORK ?= lti-api_go-network -MIGRATE_IMAGE ?= migrate/migrate -MIGRATIONS_DIR := $(PWD)/internal/database/migrations - -# Fallback agar tetap jalan meski .env kosong -DB_HOST ?= postgresdb -DB_PORT ?= 5432 -DB_USER ?= postgres -DB_PASSWORD ?= postgres -DB_NAME ?= db_lti_erp - -DB_URL := postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable - -# Tunggu DB ready memakai pg_isready dari image postgres -WAIT_DB := docker run --rm --network $(NETWORK) postgres:alpine \ - sh -c 'until pg_isready -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d $(DB_NAME); do echo "waiting for postgres..."; sleep 1; done' - -# Default target -.DEFAULT_GOAL := start - -# --- Daftar phony targets --- -.PHONY: start build test lint gen \ - db-up wait-db \ - migration-% migrate-up migrate-down migrate-fresh \ - seed \ - docker-local docker-down docker-nuke docker-cache psql - -# --- Go workflow --- -start: - @go run cmd/api/main.go - -build: - @go build -o tmp/app ./cmd/api - -test: - @go test ./test/... - -lint: - @golangci-lint run - -# --- Compose / DB helpers --- -db-up: - @$(COMPOSE) up -d postgresdb - -wait-db: - @$(WAIT_DB) - -# --- Migration (pembuatan file) --- -# Contoh: make migration-create_users_table -# ":" akan diubah ke "_" (biar aman untuk nama file) -migration-%: - @migrate create -ext sql -dir $(MIGRATIONS_DIR) $(subst :,_,$*) - -# --- Migration (apply via docker image 'migrate') --- -migrate-up: db-up wait-db - @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ - $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" up - -# Contoh: -# make migrate-down step=2 → rollback 2 step -# make migrate-down → rollback semua - -migrate-down: db-up wait-db - @if [ -n "$(step)" ]; then \ - echo "âŦ‡ī¸ Migrating down $(step) step(s)..."; \ - docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ - $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down $(step); \ - else \ - echo "âŦ‡ī¸ Migrating down ALL steps..."; \ - docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ - $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" down -all; \ - fi - -migrate-fresh: migrate-down migrate-up - @true - -# Pakai: make migrate-force v=20250917120000 -migrate-force: - @docker run --rm -v $(MIGRATIONS_DIR):/migrations --network $(NETWORK) \ - $(MIGRATE_IMAGE) -path=/migrations/ -database "$(DB_URL)" force $(v) - - -# --- Seeder --- -seed: db-up wait-db - @$(COMPOSE) run --rm app go run cmd/seed/main.go - -# --- Docker orchestration convenience --- -docker-local: - @$(COMPOSE) up --build -d - -docker-down: - @$(COMPOSE) down --remove-orphans - -# âš ī¸ Akan menghapus container, images dan volumes. -docker-nuke: - @$(COMPOSE) down --rmi all --volumes --remove-orphans - -docker-cache: - @docker builder prune -f - -# --- PSQL shell ke DB di container --- -psql: db-up - @$(COMPOSE) exec -it postgresdb psql -U $(DB_USER) -d $(DB_NAME) - -# Single feature -# example: make gen feat=product-category - -# Sub feature -# make gen feat=master/area -gen: - @go run tools/gen.go $(feat) -# @goimports -w internal diff --git a/cmd/api/main.go b/cmd/api/main.go index 0bcbaa86..a7c278d7 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -9,10 +9,13 @@ import ( "syscall" "time" + "gitlab.com/mbugroup/lti-api.git/internal/cache" "gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/middleware" + "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/route" + "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/gofiber/fiber/v2" @@ -33,6 +36,7 @@ func main() { defer closeDatabase(db) rdb := setupRedis() defer rdb.Close() + setupSSO(ctx, rdb) setupRoutes(app, db, rdb) address := fmt.Sprintf("%s:%d", config.AppHost, config.AppPort) @@ -52,10 +56,47 @@ func setupRedis() *redis.Client { if err := rdb.Ping(context.Background()).Err(); err != nil { utils.Log.Fatalf("Redis ping failed: %v", err) } + cache.SetRedis(rdb) utils.Log.Infof("Redis connected: %s", config.RedisURL) return rdb } +func setupSSO(ctx context.Context, rdb *redis.Client) { + const ( + maxAttempts = 12 + retryDelay = 5 * time.Second + ) + + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + if err := sso.Init(ctx, config.SSOJWKSURL, config.SSOIssuer, config.SSOAllowedAudiences); err != nil { + lastErr = err + utils.Log.WithError(err).Warnf("SSO initialization attempt %d/%d failed", attempt, maxAttempts) + select { + case <-ctx.Done(): + utils.Log.Fatalf("SSO initialization aborted: %v", ctx.Err()) + case <-time.After(retryDelay): + } + continue + } + lastErr = nil + if attempt > 1 { + utils.Log.Infof("SSO initialization succeeded after %d attempts", attempt) + } + break + } + + if lastErr != nil { + utils.Log.Fatalf("SSO initialization failed: %v", lastErr) + } + + if rdb != nil { + session.SetRevocationStore(session.NewRevocationStore(rdb, config.SSOTokenBlacklistPrefix)) + } else { + session.SetRevocationStore(nil) + } +} + func setupFiberApp() *fiber.App { app := fiber.New(config.FiberConfig()) diff --git a/credential/.env.db b/credential/.env.db new file mode 100644 index 00000000..d2bed6b7 --- /dev/null +++ b/credential/.env.db @@ -0,0 +1,3 @@ +POSTGRES_USER=postgres +POSTGRES_PASSWORD=Postgres@Secure2025! +POSTGRES_DB=db_lti_erp \ No newline at end of file diff --git a/credential/01_init_app_user.sql b/credential/01_init_app_user.sql new file mode 100644 index 00000000..0587d4c1 --- /dev/null +++ b/credential/01_init_app_user.sql @@ -0,0 +1,47 @@ +-- ============================================================ +-- 🧩 INIT SCRIPT: CREATE LIMITED APP USER FOR LTI API +-- ============================================================ + +-- Buat user aplikasi jika belum ada +DO +$$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_lti_user') THEN + CREATE ROLE app_lti_user WITH LOGIN PASSWORD 'AppLti@Secure2025!' NOINHERIT NOCREATEROLE NOCREATEDB NOSUPERUSER; + RAISE NOTICE '✅ Role app_lti_user created successfully.'; + ELSE + RAISE NOTICE 'â„šī¸ Role app_lti_user already exists.'; + END IF; +END +$$; + +-- Buat database jika belum ada +DO +$$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'db_lti_erp') THEN + CREATE DATABASE db_lti_erp OWNER app_lti_user; + RAISE NOTICE '✅ Database db_lti_erp created and owned by app_lti_user.'; + ELSE + RAISE NOTICE 'â„šī¸ Database db_lti_erp already exists.'; + END IF; +END +$$; + +\connect db_lti_erp + +-- Beri hak CRUD untuk app_lti_user +GRANT CONNECT ON DATABASE db_lti_erp TO app_lti_user; +GRANT USAGE ON SCHEMA public TO app_lti_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_lti_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_lti_user; + +-- Set default privileges agar tabel baru juga bisa diakses +ALTER DEFAULT PRIVILEGES IN SCHEMA public +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_lti_user; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public +GRANT USAGE, SELECT ON SEQUENCES TO app_lti_user; + +-- Tampilkan hasil +\du app_lti_user diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..ab6daeba --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,98 @@ +services: + dev-api-lti: + build: + context: . + dockerfile: Dockerfile + container_name: dev-api-lti + working_dir: /lti-api + command: ["/bin/sh", "scripts/entrypoint.sh"] + ports: + - "8081:8081" + env_file: + - .env + environment: + # override agar koneksi ke container internal + DB_HOST: dev-postgres-lti + DB_PORT: 5432 + REDIS_URL: redis://dev-redis-lti:6379/0 + volumes: + - .:/lti-api + - ./.air.toml:/lti-api/.air.toml:ro + - ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key + - ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub + depends_on: + - dev-postgres-lti + - dev-redis-lti + networks: + - lti-network + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"] + interval: 10s + timeout: 3s + retries: 10 + start_period: 10s + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + reservations: + cpus: "1.0" + memory: 512M + + dev-postgres-lti: + image: postgres:15-alpine + container_name: dev-postgres-lti + restart: always + env_file: + - credential/.env.db + ports: + - "5433:5432" + volumes: + - dev-postgres-lti-data:/var/lib/postgresql/data + - ./credential:/docker-entrypoint-initdb.d:ro + networks: + - lti-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + deploy: + resources: + limits: + cpus: "1.0" + memory: 2G + reservations: + cpus: "0.5" + memory: 512M + + dev-redis-lti: + image: redis:7-alpine + container_name: dev-redis-lti + restart: always + ports: + - "6380:6379" + networks: + - lti-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + deploy: + resources: + limits: + cpus: "0.5" + memory: 512M + reservations: + cpus: "0.2" + memory: 256M + +networks: + lti-network: + driver: bridge + +volumes: + dev-postgres-lti-data: diff --git a/go.mod b/go.mod index 3d7b91ba..078bcfe0 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module gitlab.com/mbugroup/lti-api.git go 1.23 require ( + github.com/MicahParks/keyfunc/v2 v2.1.0 github.com/bytedance/sonic v1.12.1 github.com/glebarez/sqlite v1.11.0 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/contrib/jwt v1.0.10 github.com/gofiber/fiber/v2 v2.52.5 github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgconn v1.14.1 github.com/redis/go-redis/v9 v9.14.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.19.0 @@ -18,7 +21,6 @@ require ( ) require ( - github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -32,9 +34,11 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/jackc/chunkreader/v2 v2.0.1 // indirect + github.com/jackc/pgio v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgproto3/v2 v2.3.2 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index 448287fc..ea477c5d 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,10 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= 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= @@ -43,6 +47,7 @@ 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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofiber/contrib/jwt v1.0.10 h1:/ilGepl6i0Bntl0Zcd+lAzagY8BiS1+fEiAj32HMApk= github.com/gofiber/contrib/jwt v1.0.10/go.mod h1:1qBENE6sZ6PPT4xIpBzx1VxeyROQO7sj48OlM1I9qdU= github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= @@ -57,12 +62,47 @@ 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/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/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o= +github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY= +github.com/jackc/pgconn v1.14.1 h1:smbxIaZA08n6YuxEX1sDyjV/qkbtUtkH20qLkR9MUR4= +github.com/jackc/pgconn v1.14.1/go.mod h1:9mBNlny0UvkgJdCDvdVHYSjI+8tD2rnKK69Wz8ti++E= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc= +github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.3.2 h1:7eY55bdBeCz1F2fTzSz69QC+pG46jYq9/jtSPiJ5nn0= +github.com/jackc/pgproto3/v2 v2.3.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -75,16 +115,28 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +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/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +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/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.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 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.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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= @@ -96,6 +148,7 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= @@ -109,10 +162,17 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -126,10 +186,15 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 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.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -150,24 +215,41 @@ github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRV github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -175,7 +257,14 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -184,28 +273,43 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc 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.3.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= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cache/cache.go b/internal/cache/cache.go new file mode 100644 index 00000000..9474d3d7 --- /dev/null +++ b/internal/cache/cache.go @@ -0,0 +1,38 @@ +package cache + +import ( + "errors" + "sync" + + "github.com/redis/go-redis/v9" +) + +var ( + redisClient *redis.Client + mu sync.RWMutex +) + +// SetRedis assigns the global redis client used across the application. +func SetRedis(client *redis.Client) { + mu.Lock() + defer mu.Unlock() + redisClient = client +} + +// Redis returns the configured redis client. It may be nil if not yet initialised. +func Redis() *redis.Client { + mu.RLock() + defer mu.RUnlock() + return redisClient +} + +// MustRedis returns the redis client or panics if it has not been set. +func MustRedis() *redis.Client { + mu.RLock() + client := redisClient + mu.RUnlock() + if client == nil { + panic(errors.New("redis client not initialised")) + } + return client +} diff --git a/internal/capabilities/capabilities.go b/internal/capabilities/capabilities.go new file mode 100644 index 00000000..742d7acb --- /dev/null +++ b/internal/capabilities/capabilities.go @@ -0,0 +1,44 @@ +package capabilities + +import ( + "strings" + + recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" +) + +// FromPermissions returns a filtered map of capabilities that the frontend can use +// to toggle features. Only permissions recognized by the application are exposed. +func FromPermissions(perms []string) map[string]bool { + if len(perms) == 0 { + return nil + } + + out := make(map[string]bool) + for _, perm := range perms { + if key, ok := normalizeAndAllow(perm); ok { + out[key] = true + } + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeAndAllow(perm string) (string, bool) { + perm = strings.ToLower(strings.TrimSpace(perm)) + if perm == "" { + return "", false + } + if _, ok := allowed[perm]; !ok { + return "", false + } + return perm, true +} + +var allowed = map[string]struct{}{ + recordings.PermissionRecordingRead: {}, + recordings.PermissionRecordingCreate: {}, + recordings.PermissionRecordingUpdate: {}, + recordings.PermissionRecordingDelete: {}, +} diff --git a/internal/common/repository/common.base.repository.go b/internal/common/repository/common.base.repository.go index 6605f95f..fa58fcd7 100644 --- a/internal/common/repository/common.base.repository.go +++ b/internal/common/repository/common.base.repository.go @@ -11,6 +11,7 @@ type BaseRepository[T any] interface { GetAll(ctx context.Context, offset, limit int, modifier func(*gorm.DB) *gorm.DB) ([]T, int64, error) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*T, error) GetByIDs(ctx context.Context, ids []uint, modifier func(*gorm.DB) *gorm.DB) ([]T, error) + First(ctx context.Context, modifier func(*gorm.DB) *gorm.DB) (*T, error) CreateOne(ctx context.Context, entity *T, modifier func(*gorm.DB) *gorm.DB) error CreateMany(ctx context.Context, entities []*T, modifier func(*gorm.DB) *gorm.DB) error @@ -96,6 +97,21 @@ func (r *BaseRepositoryImpl[T]) GetByIDs( return entities, nil } +func (r *BaseRepositoryImpl[T]) First( + ctx context.Context, + modifier func(*gorm.DB) *gorm.DB, +) (*T, error) { + entity := new(T) + q := r.db.WithContext(ctx) + if modifier != nil { + q = modifier(q) + } + if err := q.First(entity).Error; err != nil { + return nil, err + } + return entity, nil +} + // ---- CREATE ---- func (r *BaseRepositoryImpl[T]) CreateOne( ctx context.Context, diff --git a/internal/common/validation/validation.go b/internal/common/validation/validation.go index 426974f3..330009e6 100644 --- a/internal/common/validation/validation.go +++ b/internal/common/validation/validation.go @@ -3,6 +3,8 @@ package validation import ( "errors" "fmt" + "reflect" + "strings" "github.com/go-playground/validator/v10" ) @@ -21,34 +23,41 @@ var customMessages = map[string]string{ "alphanum": "Field %s must contain only alphanumeric characters", "oneof": "Invalid value for field %s", "password": "Field %s must be at least 8 characters, contain uppercase, lowercase, number, and special character", + "gt": "Invalid %s, must be greater than %s", } -func CustomErrorMessages(err error) map[string]string { +func CustomErrorMessages(err error) (string, map[string]string) { var validationErrors validator.ValidationErrors if errors.As(err, &validationErrors) { return generateErrorMessages(validationErrors) } - return nil + return "", nil } -func generateErrorMessages(validationErrors validator.ValidationErrors) map[string]string { +func generateErrorMessages(validationErrors validator.ValidationErrors) (string, map[string]string) { errorsMap := make(map[string]string) - for _, err := range validationErrors { + var firstMessage string + for i, err := range validationErrors { fieldName := err.StructNamespace() tag := err.Tag() customMessage := customMessages[tag] + var msg string if customMessage != "" { - errorsMap[fieldName] = formatErrorMessage(customMessage, err, tag) + msg = formatErrorMessage(customMessage, err, tag) } else { - errorsMap[fieldName] = defaultErrorMessage(err) + msg = defaultErrorMessage(err) + } + errorsMap[fieldName] = msg + if i == 0 { + firstMessage = msg } } - return errorsMap + return firstMessage, errorsMap } func formatErrorMessage(customMessage string, err validator.FieldError, tag string) string { - if tag == "min" || tag == "max" || tag == "len" { + if tag == "min" || tag == "max" || tag == "len" || tag == "gt" { return fmt.Sprintf(customMessage, err.Field(), err.Param()) } return fmt.Sprintf(customMessage, err.Field()) @@ -61,6 +70,16 @@ func defaultErrorMessage(err validator.FieldError) string { func Validator() *validator.Validate { validate := validator.New() + validate.RegisterTagNameFunc(func(fld reflect.StructField) string { + if jsonTag := getTagName(fld, "json"); jsonTag != "" { + return jsonTag + } + if queryTag := getTagName(fld, "query"); queryTag != "" { + return queryTag + } + return fld.Name + }) + if err := validate.RegisterValidation("password", Password); err != nil { return nil } @@ -72,3 +91,16 @@ func Validator() *validator.Validate { } return validate } + +func getTagName(fld reflect.StructField, tag string) string { + value, ok := fld.Tag.Lookup(tag) + if !ok || value == "-" { + return "" + } + + name := strings.Split(value, ",")[0] + if name == "" || name == "-" { + return "" + } + return name +} diff --git a/internal/config/.DS_Store b/internal/config/.DS_Store index 5008ddfc..6dacdd03 100644 Binary files a/internal/config/.DS_Store and b/internal/config/.DS_Store differ diff --git a/internal/config/config.go b/internal/config/config.go index 2cd4987e..2554bf57 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,36 +2,69 @@ package config import ( "encoding/json" + "fmt" "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/spf13/viper" ) +type SSOClientConfig struct { + PublicID string `json:"public_id"` + RedirectURI string `json:"redirect_uri"` + Scope string `json:"scope"` + // Prompt string `json:"prompt"` + DefaultReturnURI string `json:"default_return_uri"` + AllowedReturnOrigins []string `json:"allowed_return_origins"` + SyncSecret string `json:"sync_secret"` +} + var ( - IsProd bool - AppHost string - Version string - LogLevel string - AppPort int - DBHost string - DBUser string - DBPassword string - DBName string - DBPort int - JWTSecret string - JWTAccessExp int - JWTRefreshExp int - JWTResetPasswordExp int - JWTVerifyEmailExp int - RedisURL string - CORSAllowOrigins []string - CORSAllowMethods []string - CORSAllowHeaders []string - CORSExposeHeaders []string - CORSAllowCredentials bool - CORSMaxAge int + IsProd bool + AppHost string + Version string + LogLevel string + AppPort int + DBHost string + DBUser string + DBPassword string + DBName string + DBPort int + DBSSLMode string + DBSSLRootCert string + DBSSLCert string + DBSSLKey string + JWTSecret string + JWTAccessExp int + JWTRefreshExp int + JWTResetPasswordExp int + JWTVerifyEmailExp int + RedisURL string + CORSAllowOrigins []string + CORSAllowMethods []string + CORSAllowHeaders []string + CORSExposeHeaders []string + CORSAllowCredentials bool + CORSMaxAge int + SSOIssuer string + SSOJWKSURL string + SSOAllowedAudiences []string + SSOAuthorizeURL string + SSOTokenURL string + SSOGetMeURL string + SSOClients map[string]SSOClientConfig + SSOAccessCookieName string + SSORefreshCookieName string + SSOCookieDomain string + SSOCookieSecure bool + SSOCookieSameSite string + SSOTokenBlacklistPrefix string + SSOPKCETTL time.Duration + SSOUserSyncDrift time.Duration + SSOUserSyncNonceTTL time.Duration + SSOUserSyncMaxBodyBytes int ) func init() { @@ -50,6 +83,10 @@ func init() { DBPassword = viper.GetString("DB_PASSWORD") DBName = viper.GetString("DB_NAME") DBPort = viper.GetInt("DB_PORT") + DBSSLMode = defaultString(viper.GetString("DB_SSLMODE"), "disable") + DBSSLRootCert = strings.TrimSpace(viper.GetString("DB_SSLROOTCERT")) + DBSSLCert = strings.TrimSpace(viper.GetString("DB_SSLCERT")) + DBSSLKey = strings.TrimSpace(viper.GetString("DB_SSLKEY")) // jwt configuration JWTSecret = viper.GetString("JWT_SECRET") @@ -68,6 +105,44 @@ func init() { // Redis RedisURL = viper.GetString("REDIS_URL") + + // SSO integration + SSOIssuer = viper.GetString("SSO_ISSUER") + SSOJWKSURL = viper.GetString("SSO_JWKS_URL") + SSOAllowedAudiences = parseList("SSO_ALLOWED_AUDIENCES") + SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL") + SSOTokenURL = viper.GetString("SSO_TOKEN_URL") + SSOGetMeURL = viper.GetString("SSO_GETME_URL") + SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access") + SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh") + SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") + SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") + SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax") + SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist") + if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 { + SSOPKCETTL = time.Duration(ttl) * time.Second + } else { + SSOPKCETTL = 5 * time.Minute + } + SSOClients = loadSSOClients("SSO_CLIENTS") + if drift := viper.GetInt("SSO_USER_SYNC_SIGNATURE_DRIFT_SECONDS"); drift > 0 { + SSOUserSyncDrift = time.Duration(drift) * time.Second + } else { + SSOUserSyncDrift = 2 * time.Minute + } + if ttl := viper.GetInt("SSO_USER_SYNC_NONCE_TTL_SECONDS"); ttl > 0 { + SSOUserSyncNonceTTL = time.Duration(ttl) * time.Second + } else { + SSOUserSyncNonceTTL = 10 * time.Minute + } + SSOUserSyncMaxBodyBytes = viper.GetInt("SSO_USER_SYNC_MAX_BODY_BYTES") + if SSOUserSyncMaxBodyBytes <= 0 { + SSOUserSyncMaxBodyBytes = 32 * 1024 + } + + if IsProd { + ensureProdConfig() + } } func loadConfig() { @@ -117,3 +192,70 @@ func parseListWithDefault(key, def string) []string { } return parts } + +func loadSSOClients(key string) map[string]SSOClientConfig { + clients := make(map[string]SSOClientConfig) + raw := strings.TrimSpace(viper.GetString(key)) + if raw == "" { + return clients + } + if err := json.Unmarshal([]byte(raw), &clients); err != nil { + utils.Log.Errorf("Failed to parse %s: %v", key, err) + return make(map[string]SSOClientConfig) + } + result := make(map[string]SSOClientConfig, len(clients)) + for alias, cfg := range clients { + alias = strings.ToLower(strings.TrimSpace(alias)) + for i, origin := range cfg.AllowedReturnOrigins { + cfg.AllowedReturnOrigins[i] = strings.TrimSpace(origin) + } + cfg.SyncSecret = strings.TrimSpace(cfg.SyncSecret) + result[alias] = cfg + } + return result +} + +func defaultString(v, def string) string { + if strings.TrimSpace(v) == "" { + return def + } + return v +} + +func ensureProdConfig() { + if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") { + panic("SSO_AUTHORIZE_URL must be https in production") + } + if SSOTokenURL == "" || !strings.HasPrefix(SSOTokenURL, "https://") { + panic("SSO_TOKEN_URL must be https in production") + } + if SSOGetMeURL == "" || !strings.HasPrefix(SSOGetMeURL, "https://") { + panic("SSO_GETME_URL must be https in production") + } + if !SSOCookieSecure { + panic("SSO_COOKIE_SECURE must be true in production") + } + if SSOCookieDomain == "" { + panic("SSO_COOKIE_DOMAIN must be configured in production") + } + if len(SSOAllowedAudiences) == 0 { + panic("SSO_ALLOWED_AUDIENCES must contain at least one audience in production") + } + for alias, cfg := range SSOClients { + if strings.TrimSpace(cfg.SyncSecret) == "" { + panic(fmt.Sprintf("SSO_CLIENTS[%s].sync_secret must be configured in production", alias)) + } + if len(cfg.SyncSecret) < 16 { + panic(fmt.Sprintf("SSO_CLIENTS[%s].sync_secret must be at least 16 characters", alias)) + } + } + if SSOUserSyncDrift <= 0 { + panic("SSO_USER_SYNC_SIGNATURE_DRIFT_SECONDS must be greater than zero in production") + } + if SSOUserSyncNonceTTL <= 0 { + panic("SSO_USER_SYNC_NONCE_TTL_SECONDS must be greater than zero in production") + } + if SSOUserSyncMaxBodyBytes <= 0 { + panic("SSO_USER_SYNC_MAX_BODY_BYTES must be greater than zero in production") + } +} diff --git a/internal/database/database.go b/internal/database/database.go index 4d6b2551..95991810 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -2,6 +2,7 @@ package database import ( "fmt" + "strings" "time" "gitlab.com/mbugroup/lti-api.git/internal/config" @@ -13,10 +14,25 @@ import ( ) func Connect(dbHost, dbName string) *gorm.DB { - dsn := fmt.Sprintf( - "host=%s user=%s password=%s dbname=%s port=%d sslmode=disable TimeZone=Asia/Shanghai", - dbHost, config.DBUser, config.DBPassword, dbName, config.DBPort, - ) + parts := []string{ + fmt.Sprintf("host=%s", dbHost), + fmt.Sprintf("user=%s", config.DBUser), + fmt.Sprintf("password=%s", config.DBPassword), + fmt.Sprintf("dbname=%s", dbName), + fmt.Sprintf("port=%d", config.DBPort), + fmt.Sprintf("sslmode=%s", config.DBSSLMode), + "TimeZone=Asia/Shanghai", + } + if config.DBSSLRootCert != "" { + parts = append(parts, fmt.Sprintf("sslrootcert=%s", config.DBSSLRootCert)) + } + if config.DBSSLCert != "" { + parts = append(parts, fmt.Sprintf("sslcert=%s", config.DBSSLCert)) + } + if config.DBSSLKey != "" { + parts = append(parts, fmt.Sprintf("sslkey=%s", config.DBSSLKey)) + } + dsn := strings.Join(parts, " ") db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index 09b1c46e..eabc78b5 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -9,13 +9,9 @@ CREATE TABLE users ( deleted_at TIMESTAMPTZ ); -CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) -WHERE - deleted_at IS NULL; +CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) WHERE deleted_at IS NULL; -CREATE UNIQUE INDEX users_email_unique ON users (email) -WHERE - deleted_at IS NULL; +CREATE UNIQUE INDEX users_email_unique ON users (email) WHERE deleted_at IS NULL; -- FLAGS CREATE TABLE flags ( @@ -334,4 +330,4 @@ CREATE INDEX stock_logs_created_by_idx ON stock_logs (created_by); CREATE INDEX stock_logs_created_at_idx ON stock_logs (created_at); -CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at); \ No newline at end of file +CREATE INDEX stock_logs_deleted_at_idx ON stock_logs (deleted_at); diff --git a/internal/database/migrations/20251007091700_add_unique_constraint_users_id_user.down.sql b/internal/database/migrations/20251007091700_add_unique_constraint_users_id_user.down.sql new file mode 100644 index 00000000..fe32dd77 --- /dev/null +++ b/internal/database/migrations/20251007091700_add_unique_constraint_users_id_user.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_id_user_key; diff --git a/internal/database/migrations/20251007091700_add_unique_constraint_users_id_user.up.sql b/internal/database/migrations/20251007091700_add_unique_constraint_users_id_user.up.sql new file mode 100644 index 00000000..3c931dfa --- /dev/null +++ b/internal/database/migrations/20251007091700_add_unique_constraint_users_id_user.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + ADD CONSTRAINT users_id_user_key UNIQUE (id_user); diff --git a/internal/database/migrations/20251114084320_update_kandang_capacity.down.sql b/internal/database/migrations/20251114084320_update_kandang_capacity.down.sql new file mode 100644 index 00000000..4afc4f12 --- /dev/null +++ b/internal/database/migrations/20251114084320_update_kandang_capacity.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE kandangs + DROP COLUMN IF EXISTS capacity; diff --git a/internal/database/migrations/20251114084320_update_kandang_capacity.up.sql b/internal/database/migrations/20251114084320_update_kandang_capacity.up.sql new file mode 100644 index 00000000..e1ea4410 --- /dev/null +++ b/internal/database/migrations/20251114084320_update_kandang_capacity.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE kandangs + ADD COLUMN capacity NUMERIC(15,3) NOT NULL; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 7a9bad27..7c1f8a1e 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -235,13 +235,14 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users seeds := []struct { Name string Status utils.KandangStatus + Capacity float64 Location string PicKey string }{ - {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, - {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, - {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, - {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Location: "Cikaum", PicKey: "admin"}, + {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, + {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Singaparna", PicKey: "admin"}, + {Name: "Cikaum 1", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, + {Name: "Cikaum 2", Status: utils.KandangStatusNonActive, Capacity: 50000, Location: "Cikaum", PicKey: "admin"}, } result := make(map[string]uint, len(seeds)) diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 178681f0..882184b3 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -7,17 +7,18 @@ import ( ) type Kandang struct { - Id uint `gorm:"primaryKey"` - Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` - Status string `gorm:"type:varchar(50);not null"` - LocationId uint `gorm:"not null"` - PicId uint `gorm:"not null"` - CreatedBy uint `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` - Location Location `gorm:"foreignKey:LocationId;references:Id"` - Pic User `gorm:"foreignKey:PicId;references:Id"` + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` + Status string `gorm:"type:varchar(50);not null"` + LocationId uint `gorm:"not null"` + Capacity float64 `gorm:"not null"` + PicId uint `gorm:"not null"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Location Location `gorm:"foreignKey:LocationId;references:Id"` + Pic User `gorm:"foreignKey:PicId;references:Id"` ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` } diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index d89dcb31..10f9a3f8 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,101 +1,193 @@ package middleware -// import ( -// "strings" +import ( + "strings" -// "gitlab.com/mbugroup/lti-api.git/internal/config" -// service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" -// "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + "gitlab.com/mbugroup/lti-api.git/internal/sso" + "gitlab.com/mbugroup/lti-api.git/internal/utils" -// "github.com/gofiber/fiber/v2" -// ) + "github.com/gofiber/fiber/v2" +) -// func Auth(userService service.UserService, requiredRights ...string) fiber.Handler { -// return func(c *fiber.Ctx) error { -// authHeader := c.Get("Authorization") -// token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) +const ( + authContextLocalsKey = "auth.context" + authUserLocalsKey = "auth.user" +) -// if token == "" { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } +// AuthContext keeps authentication details captured by the middleware. +type AuthContext struct { + Token string + Verification *sso.VerificationResult + User *entity.User + Roles []sso.Role + Permissions map[string]struct{} +} -// userID, err := utils.VerifyToken(token, config.JWTSecret, config.TokenTypeAccess) -// if err != nil { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } +// Auth validates the incoming request against the central SSO access token and +// loads the corresponding local user. Optional scopes can be provided to enforce +// fine-grained authorization using the SSO access token scopes. +func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { + return func(c *fiber.Ctx) error { + token := bearerToken(c) + if token == "" { + token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + } + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } -// // Only end-user subjects are allowed by this middleware. Service tokens -// if verification.UserID == 0 { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } + verification, err := sso.VerifyAccessToken(token) + if err != nil { + utils.Log.WithError(err).Warn("auth: token verification failed") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } -// // Fail-closed on revocation check errors for stricter security posture. -// if revoker := session.GetRevocationStore(); revoker != nil { -// if fingerprint := session.TokenFingerprint(token); fingerprint != "" { -// revoked, err := revoker.IsRevoked(c.Context(), fingerprint) -// if err != nil { -// utils.Log.WithError(err).Warn("failed to check token revocation") -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } -// if revoked { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } -// } -// } + if verification.UserID == 0 { + return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") + } -// user, err := userService.GetBySSOUserID(c, verification.UserID) -// if err != nil || user == nil { -// return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } + if err := ensureNotRevoked(c, token, verification); err != nil { + return err + } -// if len(requiredRights) > 0 && verification.Claims != nil { -// if !hasAllScopes(verification.Claims.Scopes(), requiredRights) { -// return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") -// } -// } + user, err := userService.GetBySSOUserID(c, verification.UserID) + if err != nil || user == nil { + utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } -// c.Locals("user", user) + if len(requiredScopes) > 0 { + if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { + return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") + } + } -// // if len(requiredRights) > 0 { -// // userRights, hasRights := config.RoleRights[user.Role] -// // if (!hasRights || !hasAllRights(userRights, requiredRights)) && c.Params("userId") != userID { -// // return fiber.NewError(fiber.StatusForbidden, "You don't have permission to access this resource") -// // } -// // } + var roles []sso.Role + permissions := make(map[string]struct{}) + if verification.UserID != 0 { + if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { + utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") + } else if profile != nil { + roles = profile.Roles + for _, perm := range profile.PermissionNames() { + if perm != "" { + permissions[perm] = struct{}{} + } + } + } + } -// return c.Next() -// } -// } + ctx := &AuthContext{ + Token: token, + Verification: verification, + User: user, + Roles: roles, + Permissions: permissions, + } -// // bearerToken extracts a Bearer token from the Authorization header using -// // case-insensitive scheme matching and tolerant whitespace handling. -// func bearerToken(c *fiber.Ctx) string { -// parts := strings.Fields(c.Get("Authorization")) -// if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { -// return strings.TrimSpace(parts[1]) -// } -// return "" -// } + c.Locals(authContextLocalsKey, ctx) + c.Locals(authUserLocalsKey, user) -// func hasAllScopes(have, required []string) bool { -// if len(required) == 0 { -// return true -// } -// set := make(map[string]struct{}, len(have)) -// for _, s := range have { -// s = strings.ToLower(strings.TrimSpace(s)) -// if s != "" { -// set[s] = struct{}{} -// } -// } -// for _, r := range required { -// r = strings.ToLower(strings.TrimSpace(r)) -// if r == "" { -// continue -// } -// if _, ok := set[r]; !ok { -// return false -// } -// } -// return true -// } + return c.Next() + } +} + +// AuthenticatedUser returns the authenticated user populated by Auth. +func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { + value := c.Locals(authUserLocalsKey) + if user, ok := value.(*entity.User); ok && user != nil { + return user, true + } + return nil, false +} + +// AuthDetails returns the full authentication context (token, claims, user). +func AuthDetails(c *fiber.Ctx) (*AuthContext, bool) { + value := c.Locals(authContextLocalsKey) + if ctx, ok := value.(*AuthContext); ok && ctx != nil { + return ctx, true + } + return nil, false +} + +// ensureNotRevoked ensures the token is not revoked or superseded by a forced logout. +func ensureNotRevoked(c *fiber.Ctx, token string, verification *sso.VerificationResult) error { + revoker := session.GetRevocationStore() + if revoker == nil { + return nil + } + + if fingerprint := session.TokenFingerprint(token); fingerprint != "" { + revoked, err := revoker.IsRevoked(c.Context(), fingerprint) + if err != nil { + utils.Log.WithError(err).Warn("auth: token revocation check failed") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + if revoked { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + } + + if verification.UserID == 0 { + return nil + } + + logoutAt, err := revoker.UserLogoutTime(c.Context(), verification.UserID) + if err != nil { + utils.Log.WithError(err).Warn("auth: failed to load user logout marker") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + if logoutAt.IsZero() { + return nil + } + + claims := verification.Claims + if claims == nil || claims.IssuedAt == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + issuedAt := claims.IssuedAt.Time + // Treat tokens issued at or before the forced logout timestamp as invalid. + if !issuedAt.After(logoutAt) { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + return nil +} + +// bearerToken extracts a Bearer token from the Authorization header using +// case-insensitive scheme matching and tolerant whitespace handling. +func bearerToken(c *fiber.Ctx) string { + parts := strings.Fields(c.Get("Authorization")) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return strings.TrimSpace(parts[1]) + } + return "" +} + +func hasAllScopes(have, required []string) bool { + if len(required) == 0 { + return true + } + set := make(map[string]struct{}, len(have)) + for _, s := range have { + s = strings.ToLower(strings.TrimSpace(s)) + if s != "" { + set[s] = struct{}{} + } + } + for _, r := range required { + r = strings.ToLower(strings.TrimSpace(r)) + if r == "" { + continue + } + if _, ok := set[r]; !ok { + return false + } + } + return true +} diff --git a/internal/middleware/limiter.go b/internal/middleware/limiter.go index 205facd1..2b9471ce 100644 --- a/internal/middleware/limiter.go +++ b/internal/middleware/limiter.go @@ -24,3 +24,24 @@ func LimiterConfig() fiber.Handler { SkipSuccessfulRequests: true, }) } + +func NewLimiter(max int, expiration time.Duration) fiber.Handler { + if max <= 0 { + max = 10 + } + if expiration <= 0 { + expiration = time.Minute + } + return limiter.New(limiter.Config{ + Max: max, + Expiration: expiration, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests). + JSON(response.Common{ + Code: fiber.StatusTooManyRequests, + Status: "error", + Message: "Too many requests, please try again later", + }) + }, + }) +} diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go new file mode 100644 index 00000000..3ebe6866 --- /dev/null +++ b/internal/middleware/permissions.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "strings" + + "github.com/gofiber/fiber/v2" +) + +// RequirePermissions ensures the authenticated user possesses all specified permissions. +func RequirePermissions(perms ...string) fiber.Handler { + required := canonicalPermissions(perms) + return func(c *fiber.Ctx) error { + if len(required) == 0 { + return c.Next() + } + + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + + userPerms := ctx.permissionSet() + if len(userPerms) == 0 { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + + for _, perm := range required { + if _, has := userPerms[perm]; !has { + return fiber.NewError(fiber.StatusForbidden, "Insufficient permission") + } + } + + return c.Next() + } +} + +// HasPermission reports whether the current request context includes the given permission. +func HasPermission(c *fiber.Ctx, perm string) bool { + ctx, ok := AuthDetails(c) + if !ok || ctx == nil { + return false + } + perm = canonicalPermission(perm) + if perm == "" { + return false + } + _, has := ctx.permissionSet()[perm] + return has +} + +func (a *AuthContext) permissionSet() map[string]struct{} { + if a == nil || a.Permissions == nil { + return nil + } + return a.Permissions +} + +func canonicalPermissions(perms []string) []string { + out := make([]string, 0, len(perms)) + seen := make(map[string]struct{}, len(perms)) + for _, perm := range perms { + if canonical := canonicalPermission(perm); canonical != "" { + if _, ok := seen[canonical]; ok { + continue + } + seen[canonical] = struct{}{} + out = append(out, canonical) + } + } + return out +} + +func canonicalPermission(perm string) string { + return strings.ToLower(strings.TrimSpace(perm)) +} diff --git a/internal/middleware/trim/json_body.go b/internal/middleware/trim/json_body.go index 0d9f9502..83610153 100644 --- a/internal/middleware/trim/json_body.go +++ b/internal/middleware/trim/json_body.go @@ -16,6 +16,10 @@ func JSONBody() fiber.Handler { return c.Next() } + if strings.EqualFold(c.Path(), "/api/sso/users/sync") { + return c.Next() + } + body := c.Body() if len(body) == 0 { return c.Next() diff --git a/internal/modules/inventory/product-warehouses/route.go b/internal/modules/inventory/product-warehouses/route.go index 429c1d16..9c6c8e2b 100644 --- a/internal/modules/inventory/product-warehouses/route.go +++ b/internal/modules/inventory/product-warehouses/route.go @@ -1,7 +1,7 @@ package productWarehouses import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/controllers" productWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ProductWarehouseRoutes(v1 fiber.Router, u user.UserService, s productWareho ctrl := controller.NewProductWarehouseController(s) route := v1.Group("/product-warehouses") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Get("/:id", ctrl.GetOne) diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index fcb7881a..a0e98154 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -7,8 +7,8 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" - productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" + productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS ) diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go index 544a0674..f608af42 100644 --- a/internal/modules/inventory/transfers/route.go +++ b/internal/modules/inventory/transfers/route.go @@ -1,7 +1,7 @@ package transfers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/controllers" transfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ ctrl := controller.NewTransferController(s) route := v1.Group("/transfers") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go index 13a46580..397bf0ef 100644 --- a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go +++ b/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go @@ -328,7 +328,6 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri return groups } -// getVehicleNumber mengambil vehicle number dari DeliveryProduct jika ada func getVehicleNumber(e entity.MarketingProduct) string { if e.DeliveryProduct != nil && e.DeliveryProduct.VehicleNumber != "" { return e.DeliveryProduct.VehicleNumber diff --git a/internal/modules/master/areas/module.go b/internal/modules/master/areas/module.go index 0d9d4f4e..8ef790e8 100644 --- a/internal/modules/master/areas/module.go +++ b/internal/modules/master/areas/module.go @@ -23,4 +23,3 @@ func (AreaModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *val AreaRoutes(router, userService, areaService) } - diff --git a/internal/modules/master/areas/route.go b/internal/modules/master/areas/route.go index 71d4980d..755a542e 100644 --- a/internal/modules/master/areas/route.go +++ b/internal/modules/master/areas/route.go @@ -1,7 +1,7 @@ package areas import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/controllers" area "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func AreaRoutes(v1 fiber.Router, u user.UserService, s area.AreaService) { ctrl := controller.NewAreaController(s) route := v1.Group("/areas") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/banks/module.go b/internal/modules/master/banks/module.go index cb2f4540..c7283d93 100644 --- a/internal/modules/master/banks/module.go +++ b/internal/modules/master/banks/module.go @@ -23,4 +23,3 @@ func (BankModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *val BankRoutes(router, userService, bankService) } - diff --git a/internal/modules/master/banks/route.go b/internal/modules/master/banks/route.go index 00b7694d..2e5bed3b 100644 --- a/internal/modules/master/banks/route.go +++ b/internal/modules/master/banks/route.go @@ -1,7 +1,7 @@ package banks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/controllers" bank "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func BankRoutes(v1 fiber.Router, u user.UserService, s bank.BankService) { ctrl := controller.NewBankController(s) route := v1.Group("/banks") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/customers/module.go b/internal/modules/master/customers/module.go index 21262bfa..6d541539 100644 --- a/internal/modules/master/customers/module.go +++ b/internal/modules/master/customers/module.go @@ -23,4 +23,3 @@ func (CustomerModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate CustomerRoutes(router, userService, customerService) } - diff --git a/internal/modules/master/customers/route.go b/internal/modules/master/customers/route.go index 54df1345..d361e167 100644 --- a/internal/modules/master/customers/route.go +++ b/internal/modules/master/customers/route.go @@ -1,7 +1,7 @@ package customers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/controllers" customer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func CustomerRoutes(v1 fiber.Router, u user.UserService, s customer.CustomerServ ctrl := controller.NewCustomerController(s) route := v1.Group("/customers") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/fcrs/route.go b/internal/modules/master/fcrs/route.go index 27863784..60633f16 100644 --- a/internal/modules/master/fcrs/route.go +++ b/internal/modules/master/fcrs/route.go @@ -1,7 +1,7 @@ package fcrs import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/controllers" fcr "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func FcrRoutes(v1 fiber.Router, u user.UserService, s fcr.FcrService) { ctrl := controller.NewFcrController(s) route := v1.Group("/fcrs") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/flocks/dto/flock.dto.go b/internal/modules/master/flocks/dto/flock.dto.go index 10e6f555..8038ddb0 100644 --- a/internal/modules/master/flocks/dto/flock.dto.go +++ b/internal/modules/master/flocks/dto/flock.dto.go @@ -43,9 +43,9 @@ func ToFlockListDTO(e entity.Flock) FlockListDTO { return FlockListDTO{ FlockBaseDTO: ToFlockBaseDTO(e), - CreatedAt: e.CreatedAt, - UpdatedAt: e.UpdatedAt, - CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, } } diff --git a/internal/modules/master/flocks/route.go b/internal/modules/master/flocks/route.go index 6d93827d..429d8dcd 100644 --- a/internal/modules/master/flocks/route.go +++ b/internal/modules/master/flocks/route.go @@ -1,7 +1,7 @@ package flocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/controllers" flock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func FlockRoutes(v1 fiber.Router, u user.UserService, s flock.FlockService) { ctrl := controller.NewFlockController(s) route := v1.Group("/flocks") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/flocks/validations/floc.validation.go b/internal/modules/master/flocks/validations/floc.validation.go index 95505746..56bbd601 100644 --- a/internal/modules/master/flocks/validations/floc.validation.go +++ b/internal/modules/master/flocks/validations/floc.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index deed483c..284ca166 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -14,6 +14,7 @@ type KandangBaseDTO struct { Id uint `json:"id"` Name string `json:"name"` Status string `json:"status"` + Capacity float64 `json:"capacity"` Location *locationDTO.LocationBaseDTO `json:"location"` Pic *userDTO.UserBaseDTO `json:"pic"` } @@ -48,6 +49,7 @@ func ToKandangBaseDTO(e entity.Kandang) KandangBaseDTO { Id: e.Id, Name: e.Name, Status: e.Status, + Capacity: e.Capacity, Location: location, Pic: pic, } diff --git a/internal/modules/master/kandangs/module.go b/internal/modules/master/kandangs/module.go index b831e322..005cc1a8 100644 --- a/internal/modules/master/kandangs/module.go +++ b/internal/modules/master/kandangs/module.go @@ -23,4 +23,3 @@ func (KandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * KandangRoutes(router, userService, kandangService) } - diff --git a/internal/modules/master/kandangs/route.go b/internal/modules/master/kandangs/route.go index bf41b4ee..1e384b1f 100644 --- a/internal/modules/master/kandangs/route.go +++ b/internal/modules/master/kandangs/route.go @@ -13,12 +13,7 @@ func KandangRoutes(v1 fiber.Router, u user.UserService, s kandang.KandangService ctrl := controller.NewKandangController(s) route := v1.Group("/kandangs") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + // route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index 9cad90f3..e65348fc 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -41,7 +41,7 @@ func NewKandangService(repo repository.KandangRepository, validate *validator.Va func (s kandangService) withRelations(db *gorm.DB) *gorm.DB { return db.Preload("CreatedUser").Preload("Location").Preload("Pic").Preload("ProjectFlockKandangs.ProjectFlock") - + } func (s kandangService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Kandang, int64, error) { @@ -132,11 +132,12 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit //TODO: created by dummy createBody := &entity.Kandang{ - Name: req.Name, - LocationId: req.LocationId, - Status: status, - PicId: req.PicId, - CreatedBy: 1, + Name: req.Name, + LocationId: req.LocationId, + Capacity: req.Capacity, + Status: status, + PicId: req.PicId, + CreatedBy: 1, } if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { @@ -194,6 +195,10 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["pic_id"] = *req.PicId } + if req.Capacity != nil { + updateBody["capacity"] = *req.Capacity + } + finalStatus := strings.ToUpper(existing.Status) if req.Status != nil { status := strings.ToUpper(*req.Status) diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index f6886991..6d7c090b 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -1,19 +1,21 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` - Status string `json:"status,omitempty" validate:"omitempty,min=3"` - LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` - PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` - ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"` + Name string `json:"name" validate:"required_strict,min=3"` + Status string `json:"status,omitempty" validate:"omitempty,min=3"` + Capacity float64 `json:"capacity" validate:"required_strict,gt=0"` + LocationId uint `json:"location_id" validate:"required_strict,number,gt=0"` + PicId uint `json:"pic_id" validate:"required_strict,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id" validate:"omitempty,number,gt=0"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` - Status *string `json:"status,omitempty" validate:"omitempty,min=3"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` - ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty,number,gt=0"` + Name *string `json:"name,omitempty" validate:"omitempty"` + Status *string `json:"status,omitempty" validate:"omitempty,min=3"` + Capacity *float64 `json:"capacity" validate:"omitempty,gt=0"` + LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` + PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` + ProjectFlockId *uint `json:"project_flock_id,omitempty" validate:"omitempty,number,gt=0"` } type Query struct { diff --git a/internal/modules/master/locations/module.go b/internal/modules/master/locations/module.go index c8a9303f..3e8c658d 100644 --- a/internal/modules/master/locations/module.go +++ b/internal/modules/master/locations/module.go @@ -23,4 +23,3 @@ func (LocationModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate LocationRoutes(router, userService, locationService) } - diff --git a/internal/modules/master/locations/route.go b/internal/modules/master/locations/route.go index 99d22289..68bce594 100644 --- a/internal/modules/master/locations/route.go +++ b/internal/modules/master/locations/route.go @@ -1,7 +1,7 @@ package locations import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/controllers" location "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func LocationRoutes(v1 fiber.Router, u user.UserService, s location.LocationServ ctrl := controller.NewLocationController(s) route := v1.Group("/locations") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/nonstocks/module.go b/internal/modules/master/nonstocks/module.go index 167d432b..148c9c16 100644 --- a/internal/modules/master/nonstocks/module.go +++ b/internal/modules/master/nonstocks/module.go @@ -23,4 +23,3 @@ func (NonstockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate NonstockRoutes(router, userService, nonstockService) } - diff --git a/internal/modules/master/nonstocks/route.go b/internal/modules/master/nonstocks/route.go index 155096f0..2aa7b838 100644 --- a/internal/modules/master/nonstocks/route.go +++ b/internal/modules/master/nonstocks/route.go @@ -1,7 +1,7 @@ package nonstocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/controllers" nonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func NonstockRoutes(v1 fiber.Router, u user.UserService, s nonstock.NonstockServ ctrl := controller.NewNonstockController(s) route := v1.Group("/nonstocks") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/product-categories/route.go b/internal/modules/master/product-categories/route.go index 349fcb78..4a2262f9 100644 --- a/internal/modules/master/product-categories/route.go +++ b/internal/modules/master/product-categories/route.go @@ -1,7 +1,7 @@ package productcategories import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/controllers" productCategory "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ProductCategoryRoutes(v1 fiber.Router, u user.UserService, s productCategor ctrl := controller.NewProductCategoryController(s) route := v1.Group("/product-categories") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/products/module.go b/internal/modules/master/products/module.go index 87c6fb46..f42182d6 100644 --- a/internal/modules/master/products/module.go +++ b/internal/modules/master/products/module.go @@ -23,4 +23,3 @@ func (ProductModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * ProductRoutes(router, userService, productService) } - diff --git a/internal/modules/master/products/route.go b/internal/modules/master/products/route.go index ffa75dfa..369d6ea8 100644 --- a/internal/modules/master/products/route.go +++ b/internal/modules/master/products/route.go @@ -1,7 +1,7 @@ package products import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/controllers" product "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ProductRoutes(v1 fiber.Router, u user.UserService, s product.ProductService ctrl := controller.NewProductController(s) route := v1.Group("/products") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 88e17a98..44702e1a 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -11,6 +11,7 @@ import ( banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks" customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers" fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs" + flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks" @@ -19,7 +20,6 @@ import ( suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" - flocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks" // MODULE IMPORTS ) diff --git a/internal/modules/master/suppliers/module.go b/internal/modules/master/suppliers/module.go index f4619a0d..4d9e67e4 100644 --- a/internal/modules/master/suppliers/module.go +++ b/internal/modules/master/suppliers/module.go @@ -23,4 +23,3 @@ func (SupplierModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate SupplierRoutes(router, userService, supplierService) } - diff --git a/internal/modules/master/suppliers/route.go b/internal/modules/master/suppliers/route.go index b176c40c..17271d4a 100644 --- a/internal/modules/master/suppliers/route.go +++ b/internal/modules/master/suppliers/route.go @@ -1,7 +1,7 @@ package suppliers import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/controllers" supplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func SupplierRoutes(v1 fiber.Router, u user.UserService, s supplier.SupplierServ ctrl := controller.NewSupplierController(s) route := v1.Group("/suppliers") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/uoms/dto/uom.dto.go b/internal/modules/master/uoms/dto/uom.dto.go index 476309b2..2e614de0 100644 --- a/internal/modules/master/uoms/dto/uom.dto.go +++ b/internal/modules/master/uoms/dto/uom.dto.go @@ -15,7 +15,8 @@ type UomBaseDTO struct { } type UomListDTO struct { - UomBaseDTO + Id uint `json:"id"` + Name string `json:"name"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -42,7 +43,8 @@ func ToUomListDTO(e entity.Uom) UomListDTO { } return UomListDTO{ - UomBaseDTO: ToUomBaseDTO(e), + Id: e.Id, + Name: e.Name, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, diff --git a/internal/modules/master/uoms/module.go b/internal/modules/master/uoms/module.go index 25919045..2c02ea7f 100644 --- a/internal/modules/master/uoms/module.go +++ b/internal/modules/master/uoms/module.go @@ -23,4 +23,3 @@ func (UomModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *vali UomRoutes(router, userService, uomService) } - diff --git a/internal/modules/master/uoms/route.go b/internal/modules/master/uoms/route.go index 6c8b29cc..53faa239 100644 --- a/internal/modules/master/uoms/route.go +++ b/internal/modules/master/uoms/route.go @@ -1,7 +1,7 @@ package uoms import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/controllers" uom "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func UomRoutes(v1 fiber.Router, u user.UserService, s uom.UomService) { ctrl := controller.NewUomController(s) route := v1.Group("/uoms") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/master/warehouses/module.go b/internal/modules/master/warehouses/module.go index bb331862..92ad45b2 100644 --- a/internal/modules/master/warehouses/module.go +++ b/internal/modules/master/warehouses/module.go @@ -23,4 +23,3 @@ func (WarehouseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate WarehouseRoutes(router, userService, warehouseService) } - diff --git a/internal/modules/master/warehouses/route.go b/internal/modules/master/warehouses/route.go index b19657cb..8acf4452 100644 --- a/internal/modules/master/warehouses/route.go +++ b/internal/modules/master/warehouses/route.go @@ -1,7 +1,7 @@ package warehouses import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/controllers" warehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func WarehouseRoutes(v1 fiber.Router, u user.UserService, s warehouse.WarehouseS ctrl := controller.NewWarehouseController(s) route := v1.Group("/warehouses") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/production/chickins/route.go b/internal/modules/production/chickins/route.go index e2b64846..a558dd29 100644 --- a/internal/modules/production/chickins/route.go +++ b/internal/modules/production/chickins/route.go @@ -1,7 +1,7 @@ package chickins import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/controllers" chickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func ChickinRoutes(v1 fiber.Router, u user.UserService, s chickin.ChickinService ctrl := controller.NewChickinController(s) route := v1.Group("/chickins") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) // route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 9ec64799..39e283ab 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -1,7 +1,7 @@ package project_flocks import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/controllers" projectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -19,6 +19,7 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj // route.Get("/:id", m.Auth(u), ctrl.GetOne) // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Post("/", ctrl.CreateOne) @@ -28,5 +29,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) + route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary) } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 715fb1fb..5b92b0db 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -10,6 +10,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + authmiddleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" @@ -235,6 +236,11 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } + actorID, err := actorIDFromContext(c) + if err != nil { + return nil, err + } + cat := strings.ToUpper(req.Category) if !utils.IsValidProjectFlockCategory(cat) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") @@ -259,7 +265,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* canonicalBase := baseName if s.FlockRepo != nil { - baseFlock, err := s.ensureFlockByName(c.Context(), baseName) + baseFlock, err := s.ensureFlockByName(c.Context(), actorID, baseName) if err != nil { return nil, err } @@ -289,7 +295,7 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* Category: cat, FcrId: req.FcrId, LocationId: req.LocationId, - CreatedBy: 1, + CreatedBy: actorID, } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -314,7 +320,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return err } - actorID := uint(1) //TODO: Change From Auth action := entity.ApprovalActionCreated approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) _, err = approvalSvcTx.CreateApproval( @@ -348,6 +353,11 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, err } + actorID, err := actorIDFromContext(c) + if err != nil { + return nil, err + } + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") @@ -370,7 +380,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } canonicalBase := trimmed if s.FlockRepo != nil { - flockEntity, err := s.ensureFlockByName(c.Context(), trimmed) + flockEntity, err := s.ensureFlockByName(c.Context(), actorID, trimmed) if err != nil { return nil, err } @@ -529,7 +539,6 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id } if hasChanges { - actorID := uint(1) //TODO: Change From Auth approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) if approvalSvc != nil { latestBeforeReset, err := approvalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, nil) @@ -583,7 +592,11 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] return nil, err } - actorID := uint(1) // TODO: change from auth context + actorID, err := actorIDFromContext(c) + if err != nil { + return nil, err + } + var action entity.ApprovalAction switch strings.ToUpper(strings.TrimSpace(req.Action)) { case string(entity.ApprovalActionRejected): @@ -604,7 +617,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] step = utils.ProjectFlockStepAktif } - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) projectRepoTx := repository.NewProjectflockRepository(dbTransaction) @@ -891,7 +904,7 @@ func (s projectflockService) generateSequentialFlockName(ctx context.Context, re } } -func (s projectflockService) ensureFlockByName(ctx context.Context, name string) (*entity.Flock, error) { +func (s projectflockService) ensureFlockByName(ctx context.Context, actorID uint, name string) (*entity.Flock, error) { trimmed := strings.TrimSpace(name) if trimmed == "" { return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") @@ -908,7 +921,7 @@ func (s projectflockService) ensureFlockByName(ctx context.Context, name string) newFlock := &entity.Flock{ Name: trimmed, - CreatedBy: 1, // TODO: replace with authenticated user + CreatedBy: actorID, } if err := s.FlockRepo.CreateOne(ctx, newFlock, nil); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { @@ -1027,3 +1040,11 @@ func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.Ka } return kandangRepository.NewKandangRepository(s.Repository.DB()) } + +func actorIDFromContext(c *fiber.Ctx) (uint, error) { + user, ok := authmiddleware.AuthenticatedUser(c) + if !ok || user == nil || user.Id == 0 { + return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + return user.Id, nil +} diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 99afc013..21fccd41 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -69,12 +69,21 @@ type RecordingStockDTO struct { } type RecordingEggDTO struct { + Id uint `json:"id"` ProductWarehouseId uint `json:"product_warehouse_id"` Qty int `json:"qty"` ProductWarehouse productWarehouseDTO.ProductWarehouseDTO `json:"product_warehouse"` Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` } +type RecordingProductWarehouseDTO struct { + Id uint `json:"id"` + ProductId uint `json:"product_id"` + ProductName string `json:"product_name"` + WarehouseId uint `json:"warehouse_id"` + WarehouseName string `json:"warehouse_name"` +} + type RecordingEggGradingDTO struct { Grade string `json:"grade,omitempty"` Qty float64 `json:"qty"` @@ -241,6 +250,7 @@ func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { result := make([]RecordingEggDTO, len(eggs)) for i, egg := range eggs { result[i] = RecordingEggDTO{ + Id: egg.Id, ProductWarehouseId: egg.ProductWarehouseId, Qty: egg.Qty, ProductWarehouse: mapProductWarehouseDTO(&egg.ProductWarehouse), diff --git a/internal/modules/production/recordings/permissions.go b/internal/modules/production/recordings/permissions.go new file mode 100644 index 00000000..00f9bd48 --- /dev/null +++ b/internal/modules/production/recordings/permissions.go @@ -0,0 +1,8 @@ +package recordings + +const ( + PermissionRecordingRead = "recording.read" + PermissionRecordingCreate = "recording.write" + PermissionRecordingUpdate = "recording.update" + PermissionRecordingDelete = "recording.delete" +) diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index 0d088998..c492c39f 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -1,7 +1,7 @@ package recordings import ( - // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/controllers" recording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -13,12 +13,7 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS ctrl := controller.NewRecordingController(s) route := v1.Group("/recordings") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + route.Use(m.Auth(u)) route.Get("/", ctrl.GetAll) route.Get("/next-day", ctrl.GetNextDay) diff --git a/internal/modules/sso/controllers/sso.controller.go b/internal/modules/sso/controllers/sso.controller.go new file mode 100644 index 00000000..f11a31c8 --- /dev/null +++ b/internal/modules/sso/controllers/sso.controller.go @@ -0,0 +1,706 @@ +package controllers + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + "gitlab.com/mbugroup/lti-api.git/internal/sso" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gitlab.com/mbugroup/lti-api.git/internal/utils/secure" +) + +// Controller manages the SSO start & callback flow using PKCE. +type Controller struct { + httpClient *http.Client + store *session.Store + revoker *session.RevocationStore +} + +func NewController(client *http.Client, store *session.Store, revoker *session.RevocationStore) *Controller { + return &Controller{httpClient: client, store: store, revoker: revoker} +} + +// Start handles GET /sso/start requests and redirects users to the central SSO authorize endpoint. +func (h *Controller) Start(c *fiber.Ctx) error { + requestedAlias := normalizeClientParam(c.Query("client")) + if requestedAlias == "" { + requestedAlias = normalizeClientParam(c.Query("client_id")) + } + if requestedAlias == "" { + return fiber.NewError(fiber.StatusBadRequest, "missing client") + } + + alias, cfg, ok := findSSOClientConfig(requestedAlias) + if !ok || cfg.PublicID == "" { + return fiber.NewError(fiber.StatusBadRequest, "unknown client") + } + + authorizeEndpoint := strings.TrimSpace(config.SSOAuthorizeURL) + if authorizeEndpoint == "" { + return fiber.NewError(fiber.StatusInternalServerError, "authorize endpoint not configured") + } + + state, err := secure.RandomString(48) + if err != nil { + utils.Log.Errorf("generate state failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare authorization") + } + + nonce, err := secure.RandomString(32) + if err != nil { + utils.Log.Errorf("generate nonce failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare authorization") + } + + codeVerifier, err := secure.PKCECodeVerifier(96) + if err != nil { + utils.Log.Errorf("generate code verifier failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare authorization") + } + + digest := sha256.Sum256([]byte(codeVerifier)) + challenge := secure.Base64URLEncode(digest[:]) + + authorizeURL, err := url.Parse(authorizeEndpoint) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "invalid authorize endpoint") + } + + scope := cfg.Scope + if scope == "" { + scope = "openid profile" + } + if !strings.Contains(" "+scope+" ", " openid ") { + scope = scope + " openid" + } + + rawReturn := strings.TrimSpace(c.Query("return_to")) + if rawReturn == "" { + rawReturn = cfg.DefaultReturnURI + } + + returnTo, err := normalizeReturnTarget(rawReturn, cfg) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + query := authorizeURL.Query() + query.Set("response_type", "code") + query.Set("client_id", cfg.PublicID) + query.Set("redirect_uri", cfg.RedirectURI) + query.Set("scope", strings.TrimSpace(scope)) + query.Set("state", state) + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + query.Set("nonce", nonce) + // if prompt := strings.TrimSpace(cfg.Prompt); prompt != "" { + // query.Set("prompt", prompt) + // } + if extraPrompt := strings.TrimSpace(c.Query("prompt")); extraPrompt != "" { + query.Set("prompt", extraPrompt) + } + authorizeURL.RawQuery = query.Encode() + + payload := &session.PKCESession{ + CodeVerifier: codeVerifier, + Nonce: nonce, + ClientAlias: alias, + ClientID: cfg.PublicID, + RedirectURI: cfg.RedirectURI, + Scope: strings.TrimSpace(scope), + ReturnTo: returnTo, + CreatedAt: time.Now().UTC(), + } + + if err := h.store.Save(c.Context(), state, payload); err != nil { + utils.Log.Errorf("store pkce session failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare authorization") + } + + utils.Log.WithFields(logrus.Fields{ + "client": alias, + "state": state, + "return_to": returnTo, + }).Info("sso start redirect") + + return c.Redirect(authorizeURL.String(), fiber.StatusFound) +} + +// Callback handles the redirect from SSO containing the authorization code. +func (h *Controller) Callback(c *fiber.Ctx) error { + state := strings.TrimSpace(c.Query("state")) + code := strings.TrimSpace(c.Query("code")) + if state == "" || code == "" { + return fiber.NewError(fiber.StatusBadRequest, "missing code or state") + } + + sessionData, err := h.store.Get(c.Context(), state) + if err != nil { + utils.Log.Errorf("load pkce session failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to validate authorization state") + } + if sessionData == nil { + return fiber.NewError(fiber.StatusBadRequest, "authorization state not found or expired") + } + defer func() { + if err := h.store.Delete(context.Background(), state); err != nil { + utils.Log.Warnf("failed to delete pkce session: %v", err) + } + }() + + tokenEndpoint := strings.TrimSpace(config.SSOTokenURL) + if tokenEndpoint == "" { + return fiber.NewError(fiber.StatusInternalServerError, "token endpoint not configured") + } + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("code_verifier", sessionData.CodeVerifier) + form.Set("redirect_uri", sessionData.RedirectURI) + form.Set("client_id", sessionData.ClientID) + + req, err := http.NewRequestWithContext(c.Context(), http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to create token request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.Errorf("token request failed: %v", err) + return fiber.NewError(fiber.StatusBadGateway, "failed to exchange authorization code") + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + utils.Log.Warnf("token response status %d", resp.StatusCode) + return fiber.NewError(fiber.StatusBadGateway, "token exchange rejected") + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` + } + + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fiber.NewError(fiber.StatusBadGateway, "invalid token response") + } + if tokenResp.Error != "" { + return fiber.NewError(fiber.StatusBadGateway, tokenResp.Description) + } + if tokenResp.AccessToken == "" { + return fiber.NewError(fiber.StatusBadGateway, "missing access token") + } + + verification, err := sso.VerifyAccessToken(tokenResp.AccessToken) + if err != nil { + utils.Log.Errorf("access token verification failed: %v", err) + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + // prepare cookies + issueCookies(c, tokenResp, verification) + + redirectTarget := sessionData.ReturnTo + if redirectTarget == "" { + redirectTarget = "/" + } + + utils.Log.WithFields(logrus.Fields{ + "client": sessionData.ClientAlias, + "user_id": verification.UserID, + "return_to": redirectTarget, + }).Info("sso callback successful") + + return c.Redirect(redirectTarget, fiber.StatusFound) +} + +// UserInfo proxies the user profile from the central SSO so the frontend can obtain +// enriched user metadata (roles, permissions, etc.) without exposing tokens to the browser. +func (h *Controller) UserInfo(c *fiber.Ctx) error { + accessName := config.SSOAccessCookieName + if accessName == "" { + accessName = "sso_access" + } + + token := strings.TrimSpace(c.Cookies(accessName)) + tokenFromCookie := token != "" + + if !tokenFromCookie { + authHeader := strings.TrimSpace(c.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + token = strings.TrimSpace(authHeader[7:]) + } + } + + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + if revoker := session.GetRevocationStore(); revoker != nil { + if fingerprint := session.TokenFingerprint(token); fingerprint != "" { + revoked, err := revoker.IsRevoked(c.Context(), fingerprint) + if err != nil { + utils.Log.WithError(err).Warn("failed to check token revocation for userinfo") + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + if revoked { + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + } + } + + if _, err := sso.VerifyAccessToken(token); err != nil { + utils.Log.WithError(err).Warn("access token verification failed for userinfo") + return fiber.NewError(fiber.StatusUnauthorized, "unauthenticated") + } + + endpoint := strings.TrimSpace(config.SSOGetMeURL) + if endpoint == "" { + return fiber.NewError(fiber.StatusInternalServerError, "userinfo endpoint not configured") + } + + req, err := http.NewRequestWithContext(c.Context(), http.MethodGet, endpoint, nil) + if err != nil { + utils.Log.Errorf("failed to build userinfo request: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare userinfo request") + } + req.Header.Set("Accept", "application/json") + + // SSO /auth/get-me expects the access cookie; add Authorization as well for compatibility. + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + if tokenFromCookie { + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", accessName, token)) + } + + resp, err := h.httpClient.Do(req) + if err != nil { + utils.Log.Errorf("userinfo request failed: %v", err) + return fiber.NewError(fiber.StatusBadGateway, "failed to fetch user profile") + } + defer resp.Body.Close() + + utils.Log.WithFields(logrus.Fields{"status": resp.StatusCode}).Info("sso userinfo response") + + body, err := io.ReadAll(resp.Body) + if err != nil { + utils.Log.Errorf("failed to read userinfo response: %v", err) + return fiber.NewError(fiber.StatusBadGateway, "invalid user profile response") + } + + // if sanitized, perms, ok := sanitizeUserInfoPayload(body); ok { + // if caps := capabilities.FromPermissions(perms); len(caps) > 0 { + // injectCapabilities(sanitized, caps) + // } + // return c.Status(resp.StatusCode).JSON(sanitized) + // } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + c.Set("Content-Type", ct) + } else { + c.Type("json") + } + + return c.Status(resp.StatusCode).Send(body) +} + +// Logout clears SSO cookies and removes any leftover PKCE session state. +func (h *Controller) Logout(c *fiber.Ctx) error { + requestedAlias := normalizeClientParam(c.Query("client")) + if requestedAlias == "" { + requestedAlias = normalizeClientParam(c.Query("client_id")) + } + var ( + alias string + cfg config.SSOClientConfig + hasClientInfo bool + ) + if requestedAlias != "" { + alias, cfg, hasClientInfo = findSSOClientConfig(requestedAlias) + } + + accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") + refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") + + var accessToken, refreshToken string + if accessName != "" { + accessToken = strings.TrimSpace(c.Cookies(accessName)) + } + if refreshName != "" { + refreshToken = strings.TrimSpace(c.Cookies(refreshName)) + } + hadAccessCookie := accessToken != "" + hadRefreshCookie := refreshToken != "" + + state := strings.TrimSpace(c.Query("state")) + if state != "" { + if err := h.store.Delete(c.Context(), state); err != nil { + utils.Log.Warnf("failed to delete pkce session during logout: %v", err) + } + } + + if !hadAccessCookie && !hadRefreshCookie && state == "" { + return fiber.NewError(fiber.StatusUnauthorized, "not authenticated") + } + + if hadAccessCookie { + if verification, err := sso.VerifyAccessToken(accessToken); err != nil { + utils.Log.WithError(err).Warn("failed to verify access token during logout") + } else { + if revoker := session.GetRevocationStore(); revoker != nil { + if err := revoker.MarkUserLogout(c.Context(), verification.UserID, time.Now().UTC()); err != nil { + utils.Log.WithError(err).Warn("failed to mark user logout") + } + } + h.revokeToken(c.Context(), accessToken, verification) + } + } + if refreshToken != "" { + h.revokeRefreshToken(c.Context(), refreshToken) + } + + clearSSOCookie(c, accessName) + clearSSOCookie(c, refreshName) + + redirectTarget := "" + rawReturn := strings.TrimSpace(c.Query("return_to")) + if hasClientInfo { + if rawReturn == "" { + rawReturn = cfg.DefaultReturnURI + } + if normalized, err := normalizeReturnTarget(rawReturn, cfg); err == nil { + redirectTarget = normalized + } else if rawReturn != "" { + utils.Log.WithError(err).Warn("invalid return_to during logout") + } + } else if rawReturn != "" { + if strings.HasPrefix(rawReturn, "/") && !strings.HasPrefix(rawReturn, "//") { + redirectTarget = rawReturn + } + } + + utils.Log.WithFields(logrus.Fields{ + "client": alias, + "state": state, + "redirect": redirectTarget, + }).Info("sso logout completed") + + if redirectTarget != "" { + return c.Redirect(redirectTarget, fiber.StatusFound) + } + + return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "signed out"}) +} + +func (h *Controller) revokeToken(ctx context.Context, token string, verification *sso.VerificationResult) { + if h.revoker == nil || verification == nil || verification.Claims == nil { + return + } + fingerprint := session.TokenFingerprint(token) + if fingerprint == "" { + return + } + if verification.Claims.ExpiresAt == nil { + utils.Log.Warn("access token missing expiry claim") + return + } + ttl := time.Until(verification.Claims.ExpiresAt.Time) + if ttl <= 0 { + return + } + if ttl < time.Second { + ttl = time.Second + } + if err := h.revoker.Revoke(ctx, fingerprint, ttl); err != nil { + utils.Log.WithError(err).Warn("failed to revoke access token") + } +} + +func (h *Controller) revokeRefreshToken(ctx context.Context, token string) { + if h.revoker == nil { + return + } + fingerprint := session.TokenFingerprint(token) + if fingerprint == "" { + return + } + const refreshTTL = 30 * 24 * time.Hour + if err := h.revoker.Revoke(ctx, fingerprint, refreshTTL); err != nil { + utils.Log.WithError(err).Warn("failed to revoke refresh token") + } +} + +func issueCookies(c *fiber.Ctx, tokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + IDToken string `json:"id_token"` + Error string `json:"error"` + Description string `json:"error_description"` +}, verification *sso.VerificationResult) { + if revoker := session.GetRevocationStore(); revoker != nil && verification != nil { + if err := revoker.ClearUserLogout(c.Context(), verification.UserID); err != nil { + utils.Log.WithError(err).Warn("failed to clear logout marker") + } + } + + accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") + refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") + maxAge := tokenResp.ExpiresIn + if maxAge <= 0 { + maxAge = int(15 * time.Minute.Seconds()) + } + + sameSite := config.SSOCookieSameSite + if sameSite == "" { + sameSite = "Lax" + } + + cookieDomain := config.SSOCookieDomain + + cookieAccess := &fiber.Cookie{ + Name: accessName, + Value: tokenResp.AccessToken, + Path: "/", + Domain: cookieDomain, + HTTPOnly: true, + Secure: config.SSOCookieSecure, + SameSite: sameSite, + MaxAge: maxAge, + } + c.Cookie(cookieAccess) + if tokenResp.RefreshToken != "" { + cookieRefresh := &fiber.Cookie{ + Name: refreshName, + Value: tokenResp.RefreshToken, + Path: "/", + Domain: cookieDomain, + HTTPOnly: true, + Secure: config.SSOCookieSecure, + SameSite: sameSite, + MaxAge: int((time.Hour * 24 * 30).Seconds()), + } + c.Cookie(cookieRefresh) + } + + // Optional: expose limited info via headers for FE debugging (avoid tokens) + c.Set("X-Auth-User", fmt.Sprintf("%d", verification.UserID)) +} + +func clearSSOCookie(c *fiber.Ctx, name string) { + if name == "" { + return + } + + sameSite := config.SSOCookieSameSite + if sameSite == "" { + sameSite = "Lax" + } + + c.Cookie(&fiber.Cookie{ + Name: name, + Value: "", + Path: "/", + Domain: config.SSOCookieDomain, + HTTPOnly: true, + Secure: config.SSOCookieSecure, + SameSite: sameSite, + Expires: time.Unix(0, 0), + MaxAge: -1, + }) +} + +func resolveSSOCookieName(configuredName, fallback string) string { + name := strings.TrimSpace(configuredName) + if name != "" { + return name + } + return strings.TrimSpace(fallback) +} + +func normalizeClientParam(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + if idx := strings.Index(value, "|"); idx >= 0 { + value = value[:idx] + } + value = strings.TrimSpace(value) + return strings.ToLower(value) +} + +func sanitizeUserInfoPayload(body []byte) (map[string]any, []string, bool) { + if len(body) == 0 { + return map[string]any{}, nil, true + } + + var payload any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, nil, false + } + + perms := collectPermissionNames(payload) + + sensitive := map[string]struct{}{ + "roles": {}, + "permissions": {}, + } + payload = scrubSensitiveKeys(payload, sensitive) + + sanitized, ok := payload.(map[string]any) + if !ok { + sanitized = map[string]any{"data": payload} + } + + return sanitized, perms, true +} + +func scrubSensitiveKeys(value any, sensitive map[string]struct{}) any { + switch v := value.(type) { + case map[string]any: + for key, val := range v { + if _, ok := sensitive[strings.ToLower(key)]; ok { + delete(v, key) + continue + } + v[key] = scrubSensitiveKeys(val, sensitive) + } + return v + case []any: + for i, item := range v { + v[i] = scrubSensitiveKeys(item, sensitive) + } + return v + default: + return value + } +} + +func collectPermissionNames(value any) []string { + names := make(map[string]struct{}) + collectPermissionRec(value, names) + out := make([]string, 0, len(names)) + for name := range names { + out = append(out, name) + } + return out +} + +func collectPermissionRec(value any, acc map[string]struct{}) { + switch v := value.(type) { + case map[string]any: + for key, val := range v { + if strings.EqualFold(key, "permissions") { + if arr, ok := val.([]any); ok { + for _, item := range arr { + if perm, ok := item.(map[string]any); ok { + if name, ok := perm["name"].(string); ok && strings.TrimSpace(name) != "" { + acc[strings.ToLower(strings.TrimSpace(name))] = struct{}{} + } + } + } + } + } else { + collectPermissionRec(val, acc) + } + } + case []any: + for _, item := range v { + collectPermissionRec(item, acc) + } + } +} + +func injectCapabilities(payload map[string]any, caps map[string]bool) { + if len(caps) == 0 { + return + } + if data, ok := payload["data"].(map[string]any); ok { + data["capabilities"] = caps + return + } + payload["capabilities"] = caps +} + +func findSSOClientConfig(requestedAlias string) (string, config.SSOClientConfig, bool) { + if requestedAlias == "" { + return "", config.SSOClientConfig{}, false + } + if cfg, ok := config.SSOClients[requestedAlias]; ok && strings.TrimSpace(cfg.PublicID) != "" { + return requestedAlias, cfg, true + } + for alias, cfg := range config.SSOClients { + if strings.EqualFold(strings.TrimSpace(cfg.PublicID), requestedAlias) && strings.TrimSpace(cfg.PublicID) != "" { + return alias, cfg, true + } + } + return "", config.SSOClientConfig{}, false +} + +func normalizeReturnTarget(returnTo string, cfg config.SSOClientConfig) (string, error) { + returnTo = strings.TrimSpace(returnTo) + if returnTo == "" { + return "", nil + } + if strings.HasPrefix(returnTo, "//") { + return "", fmt.Errorf("invalid return_to") + } + if strings.HasPrefix(returnTo, "/") { + return returnTo, nil + } + + parsed, err := url.Parse(returnTo) + if err != nil { + return "", fmt.Errorf("invalid return_to") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("invalid return_to scheme") + } + + allowedOrigins := make(map[string]struct{}) + if cfg.DefaultReturnURI != "" { + if u, err := url.Parse(cfg.DefaultReturnURI); err == nil && u.Host != "" { + allowedOrigins[u.Scheme+"://"+u.Host] = struct{}{} + } + } + for _, origin := range cfg.AllowedReturnOrigins { + origin = strings.TrimSpace(origin) + if origin == "" { + continue + } + if u, err := url.Parse(origin); err == nil && u.Host != "" && (u.Scheme == "http" || u.Scheme == "https") { + allowedOrigins[u.Scheme+"://"+u.Host] = struct{}{} + } + } + + if len(allowedOrigins) > 0 { + origin := parsed.Scheme + "://" + parsed.Host + if _, ok := allowedOrigins[origin]; !ok { + return "", fmt.Errorf("return_to origin not allowed") + } + } + + return parsed.String(), nil +} diff --git a/internal/modules/sso/controllers/user_sync.controller.go b/internal/modules/sso/controllers/user_sync.controller.go new file mode 100644 index 00000000..9aeb9555 --- /dev/null +++ b/internal/modules/sso/controllers/user_sync.controller.go @@ -0,0 +1,429 @@ +package controllers + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + "gorm.io/gorm" + "strconv" + "strings" + "sync" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/response" + "gitlab.com/mbugroup/lti-api.git/internal/sso" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +const ( + headerClient = "X-Sync-Client" + headerTimestamp = "X-Sync-Timestamp" + headerNonce = "X-Sync-Nonce" + headerSignature = "X-Sync-Signature" + defaultDrift = 2 * time.Minute + defaultNonceTTL = 10 * time.Minute +) + +// UserSyncController handles incoming user management events from the central SSO service. +type UserSyncController struct { + validate *validator.Validate + repo userRepository.UserRepository + redis *redis.Client + clients map[string]config.SSOClientConfig + drift time.Duration + nonceTTL time.Duration + maxBodyBytes int + log *logrus.Logger + localNonces sync.Map +} + +type userSyncRequest struct { + Action string `json:"action" validate:"required,oneof=create update delete logout"` + PublicID string `json:"public_id" validate:"required"` + User userSyncUser `json:"user" validate:"required"` +} + +type userSyncUser struct { + ID int64 `json:"id" validate:"required"` + Email string `json:"email"` + Name string `json:"name"` +} + +func NewUserSyncController(validate *validator.Validate, repo userRepository.UserRepository, redis *redis.Client, clients map[string]config.SSOClientConfig) *UserSyncController { + normalized := make(map[string]config.SSOClientConfig, len(clients)) + for alias, cfg := range clients { + alias = strings.ToLower(strings.TrimSpace(alias)) + normalized[alias] = cfg + } + + drift := config.SSOUserSyncDrift + if drift <= 0 { + drift = defaultDrift + } + + nonceTTL := config.SSOUserSyncNonceTTL + if nonceTTL <= 0 { + nonceTTL = defaultNonceTTL + } + + maxBody := config.SSOUserSyncMaxBodyBytes + if maxBody <= 0 { + maxBody = 32 * 1024 + } + + log := utils.Log + if redis == nil { + log.Warn("SSO user sync nonce store fallback to in-memory cache; enable Redis for replay protection") + } + + return &UserSyncController{ + validate: validate, + repo: repo, + redis: redis, + clients: normalized, + drift: drift, + nonceTTL: nonceTTL, + maxBodyBytes: maxBody, + log: log, + } +} + +func (h *UserSyncController) Sync(c *fiber.Ctx) error { + if ct := strings.TrimSpace(c.Get(fiber.HeaderContentType)); ct != "" && !strings.HasPrefix(strings.ToLower(ct), fiber.MIMEApplicationJSON) { + return fiber.NewError(fiber.StatusUnsupportedMediaType, "content-type must be application/json") + } + + body := c.Body() + if h.maxBodyBytes > 0 && len(body) > h.maxBodyBytes { + return fiber.NewError(fiber.StatusRequestEntityTooLarge, "request body too large") + } + + alias, clientCfg, err := h.authenticate(c, body) + if err != nil { + return err + } + + req := new(userSyncRequest) + if err := json.Unmarshal(body, req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid request body") + } + + req.Action = strings.ToLower(strings.TrimSpace(req.Action)) + req.PublicID = strings.TrimSpace(req.PublicID) + req.User.Email = strings.TrimSpace(req.User.Email) + req.User.Name = strings.TrimSpace(req.User.Name) + + if err := h.validate.Struct(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if clientCfg.PublicID != "" && req.PublicID != clientCfg.PublicID { + return fiber.NewError(fiber.StatusBadRequest, "public_id mismatch with configured client") + } + + if req.Action == "create" || req.Action == "update" { + if req.User.Email == "" || req.User.Name == "" { + return fiber.NewError(fiber.StatusBadRequest, "email and name are required for create/update actions") + } + if err := h.validate.Var(req.User.Email, "email"); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid email format") + } + } + + if req.User.ID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid user id") + } + + switch req.Action { + case "create", "update": + return h.upsertUser(c, alias, req) + case "delete": + return h.removeUser(c, alias, req) + case "logout": + return h.logoutUser(c, alias, req) + default: + return fiber.NewError(fiber.StatusBadRequest, "unsupported action") + } +} + +func (h *UserSyncController) authenticate(c *fiber.Ctx, body []byte) (string, config.SSOClientConfig, error) { + rawAlias := strings.TrimSpace(c.Get(headerClient)) + if rawAlias == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing sync client header") + } + + aliasKey := strings.ToLower(rawAlias) + clientCfg, ok := h.clients[aliasKey] + if !ok { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "unknown sync client") + } + + if err := h.verifyAuthorization(c, aliasKey); err != nil { + return "", config.SSOClientConfig{}, err + } + + secret := strings.TrimSpace(clientCfg.SyncSecret) + if secret == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "sync secret not configured") + } + + timestamp := strings.TrimSpace(c.Get(headerTimestamp)) + nonce := strings.TrimSpace(c.Get(headerNonce)) + signature := strings.TrimSpace(c.Get(headerSignature)) + + if timestamp == "" || nonce == "" || signature == "" { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "missing signature headers") + } + if len(nonce) < 16 { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "nonce too short") + } + + ts, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusBadRequest, "invalid timestamp") + } + + msgTime := time.Unix(ts, 0).UTC() + now := time.Now().UTC() + drift := now.Sub(msgTime) + if drift > h.drift || drift < -h.drift { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "timestamp outside allowed window") + } + + providedSig, err := decodeSignature(signature) + if err != nil { + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature encoding") + } + + expectedSignature := h.calculateSignature(secret, rawAlias, timestamp, nonce, body) + if !hmac.Equal(providedSig, expectedSignature) { + bodyHash := sha256.Sum256(body) + h.log.WithFields(logrus.Fields{ + "alias": rawAlias, + "alias_key": aliasKey, + "timestamp": timestamp, + "nonce": nonce, + "body_len": len(body), + "body_sha256": hex.EncodeToString(bodyHash[:]), + "body_base64": base64.StdEncoding.EncodeToString(body), + "provided_hex_full": hex.EncodeToString(providedSig), + "expected_hex_full": hex.EncodeToString(expectedSignature), + }).Warn("sso sync signature mismatch") + return "", config.SSOClientConfig{}, fiber.NewError(fiber.StatusUnauthorized, "invalid signature") + } + + if err := h.registerNonce(c.Context(), aliasKey, nonce); err != nil { + return "", config.SSOClientConfig{}, err + } + + return aliasKey, clientCfg, nil +} + +func (h *UserSyncController) verifyAuthorization(c *fiber.Ctx, alias string) error { + + authHeader := strings.TrimSpace(c.Get(fiber.HeaderAuthorization)) + if authHeader == "" { + return fiber.NewError(fiber.StatusUnauthorized, "missing authorization header") + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "invalid authorization header") + } + + verification, err := sso.VerifyAccessToken(token) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") + } + + if verification.ServiceAlias == "" || verification.ServiceAlias != alias { + return fiber.NewError(fiber.StatusUnauthorized, "service subject mismatch") + } + if !containsScope(verification.Claims.Scopes(), "sync.users") { + return fiber.NewError(fiber.StatusForbidden, "missing sync scope") + } + + return nil +} + +func (h *UserSyncController) upsertUser(c *fiber.Ctx, alias string, req *userSyncRequest) error { + entity := &entity.User{ + IdUser: req.User.ID, + Email: req.User.Email, + Name: req.User.Name, + } + + //TODO: MIGRATION TO UPSERT BASE REPOSITORY + if err := h.repo.UpsertByIdUser(c.Context(), entity); err != nil { + h.log.Errorf("sso user upsert failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to upsert user") + } + + user, err := h.repo.GetByIdUser(c.Context(), req.User.ID, nil) + if err != nil { + h.log.Errorf("sso user fetch after upsert failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to load user") + } + + h.log.WithFields(logrus.Fields{ + "action": req.Action, + "public_id": req.PublicID, + "alias": alias, + "user_id": req.User.ID, + }).Info("sso user synced") + + msg := fmt.Sprintf("User %s successfully", req.Action) + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: msg, + Data: dto.ToUserListDTO(*user), + }) +} + +func (h *UserSyncController) logoutUser(c *fiber.Ctx, alias string, req *userSyncRequest) error { + revoker := session.GetRevocationStore() + if revoker != nil { + if err := revoker.MarkUserLogout(c.Context(), uint(req.User.ID), time.Now().UTC()); err != nil { + h.log.WithError(err).Error("sso user logout revoke failed") + return fiber.NewError(fiber.StatusInternalServerError, "failed to revoke user session") + } + } else { + h.log.Warn("sso user logout received but revocation store not configured") + } + + h.log.WithFields(logrus.Fields{ + "action": req.Action, + "public_id": req.PublicID, + "alias": alias, + "user_id": req.User.ID, + }).Info("sso user logout enforced") + + return c.Status(fiber.StatusOK).JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "User sessions revoked successfully", + }) +} + +func (h *UserSyncController) removeUser(c *fiber.Ctx, alias string, req *userSyncRequest) error { + if err := h.repo.SoftDeleteByIdUser(c.Context(), req.User.ID); err != nil { + if err == gorm.ErrRecordNotFound { + return fiber.NewError(fiber.StatusNotFound, "user not found") + } + h.log.Errorf("sso user delete failed: %v", err) + return fiber.NewError(fiber.StatusInternalServerError, "failed to delete user") + } + + h.log.WithFields(logrus.Fields{ + "action": req.Action, + "public_id": req.PublicID, + "alias": alias, + "user_id": req.User.ID, + }).Info("sso user deleted") + + return c.Status(fiber.StatusOK).JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "User deleted successfully", + }) +} + +func (h *UserSyncController) registerNonce(ctx context.Context, alias, nonce string) error { + ttl := h.nonceTTL + if ttl <= 0 { + ttl = defaultNonceTTL + } + + key := fmt.Sprintf("sso:sync:%s:%s", alias, nonce) + if h.redis != nil { + stored, err := h.redis.SetNX(ctx, key, "1", ttl).Result() + if err == nil { + if !stored { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + return nil + } + h.log.Errorf("store sync nonce failed: %v", err) + } + + now := time.Now().UTC() + if expRaw, ok := h.localNonces.Load(key); ok { + if expTime, ok := expRaw.(time.Time); ok && expTime.After(now) { + return fiber.NewError(fiber.StatusUnauthorized, "nonce already used") + } + } + h.localNonces.Store(key, now.Add(ttl)) + h.pruneLocalNonces(now) + return nil +} + +func (h *UserSyncController) calculateSignature(secret, alias, timestamp, nonce string, body []byte) []byte { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(alias)) + mac.Write([]byte("\n")) + mac.Write([]byte(timestamp)) + mac.Write([]byte("\n")) + mac.Write([]byte(nonce)) + mac.Write([]byte("\n")) + mac.Write(body) + return mac.Sum(nil) +} + +func containsScope(scopes []string, target string) bool { + target = strings.ToLower(strings.TrimSpace(target)) + if target == "" { + return false + } + for _, scope := range scopes { + if strings.ToLower(strings.TrimSpace(scope)) == target { + return true + } + } + return false +} + +func decodeSignature(sig string) ([]byte, error) { + sig = strings.TrimSpace(sig) + if sig == "" { + return nil, errors.New("empty signature") + } + if decoded, err := hex.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.StdEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + if decoded, err := base64.URLEncoding.DecodeString(sig); err == nil { + return decoded, nil + } + return nil, errors.New("unrecognized signature encoding") +} + +func (h *UserSyncController) pruneLocalNonces(now time.Time) { + h.localNonces.Range(func(key, value any) bool { + exp, ok := value.(time.Time) + if !ok || exp.Before(now) { + h.localNonces.Delete(key) + } + return true + }) +} diff --git a/internal/modules/sso/module.go b/internal/modules/sso/module.go new file mode 100644 index 00000000..4924f071 --- /dev/null +++ b/internal/modules/sso/module.go @@ -0,0 +1,13 @@ +package sso + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type Module struct{} + +func (Module) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + Routes(router, db, validate) +} diff --git a/internal/modules/sso/route.go b/internal/modules/sso/route.go new file mode 100644 index 00000000..a7288ef9 --- /dev/null +++ b/internal/modules/sso/route.go @@ -0,0 +1,36 @@ +package sso + +import ( + "net/http" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/cache" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/middleware" + ssoController "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/controllers" + "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" + userRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" +) + +func Routes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + ttl := config.SSOPKCETTL + if ttl <= 0 { + ttl = 5 * time.Minute + } + + store := session.NewStore(cache.MustRedis(), ttl) + ctrl := ssoController.NewController(&http.Client{Timeout: 10 * time.Second}, store, session.GetRevocationStore()) + userRepo := userRepository.NewUserRepository(db) + syncCtrl := ssoController.NewUserSyncController(validate, userRepo, cache.Redis(), config.SSOClients) + + group := router.Group("/sso") + group.Get("/start", middleware.NewLimiter(30, time.Minute), ctrl.Start) + group.Get("/callback", ctrl.Callback) + group.Get("/userinfo", middleware.NewLimiter(60, time.Minute), ctrl.UserInfo) + group.Post("/logout", middleware.NewLimiter(60, time.Minute), ctrl.Logout) + group.Post("/users/sync", middleware.NewLimiter(30, time.Minute), syncCtrl.Sync) +} diff --git a/internal/modules/sso/session/revocation.go b/internal/modules/sso/session/revocation.go new file mode 100644 index 00000000..9c237c3a --- /dev/null +++ b/internal/modules/sso/session/revocation.go @@ -0,0 +1,163 @@ +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "strconv" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" +) + +// RevocationStore handles token blacklist / revocation entries in Redis. +type RevocationStore struct { + redis *redis.Client + prefix string +} + +var ( + globalRevokerMu sync.RWMutex + globalRevoker *RevocationStore +) + +// NewRevocationStore creates a revocation store with the given redis client and key prefix. +func NewRevocationStore(client *redis.Client, prefix string) *RevocationStore { + return &RevocationStore{ + redis: client, + prefix: strings.TrimSpace(prefix), + } +} + +// SetRevocationStore registers the provided revocation store for global access. +func SetRevocationStore(store *RevocationStore) { + globalRevokerMu.Lock() + globalRevoker = store + globalRevokerMu.Unlock() +} + +// GetRevocationStore returns the globally registered revocation store, or nil if unset. +func GetRevocationStore() *RevocationStore { + globalRevokerMu.RLock() + defer globalRevokerMu.RUnlock() + return globalRevoker +} + +// MustRevocationStore returns the registered revocation store or panics if none is configured. +func MustRevocationStore() *RevocationStore { + store := GetRevocationStore() + if store == nil { + panic("revocation store not initialised") + } + return store +} + +// Revoke stores the fingerprint with the provided TTL. +func (s *RevocationStore) Revoke(ctx context.Context, fingerprint string, ttl time.Duration) error { + if s == nil || s.redis == nil { + return errors.New("revocation store redis client not initialised") + } + fingerprint = strings.TrimSpace(fingerprint) + if fingerprint == "" { + return nil + } + if ttl <= 0 { + ttl = time.Minute + } + key := s.keyFor(fingerprint) + return s.redis.Set(ctx, key, "1", ttl).Err() +} + +// IsRevoked returns true when the fingerprint appears in the blacklist. +func (s *RevocationStore) IsRevoked(ctx context.Context, fingerprint string) (bool, error) { + if s == nil || s.redis == nil { + return false, errors.New("revocation store redis client not initialised") + } + fingerprint = strings.TrimSpace(fingerprint) + if fingerprint == "" { + return false, nil + } + key := s.keyFor(fingerprint) + exists, err := s.redis.Exists(ctx, key).Result() + if err != nil { + return false, err + } + return exists > 0, nil +} + +// MarkUserLogout stores the timestamp of the last forced logout for the given user. +func (s *RevocationStore) MarkUserLogout(ctx context.Context, userID uint, at time.Time) error { + if s == nil || s.redis == nil { + return errors.New("revocation store redis client not initialised") + } + if userID == 0 { + return errors.New("invalid user id") + } + key := s.userLogoutKey(userID) + return s.redis.Set(ctx, key, at.UTC().Format(time.RFC3339Nano), 0).Err() +} + +// ClearUserLogout removes any stored forced logout marker for the given user. +func (s *RevocationStore) ClearUserLogout(ctx context.Context, userID uint) error { + if s == nil || s.redis == nil { + return errors.New("revocation store redis client not initialised") + } + if userID == 0 { + return errors.New("invalid user id") + } + key := s.userLogoutKey(userID) + return s.redis.Del(ctx, key).Err() +} + +// UserLogoutTime returns the timestamp of the last forced logout for the given user. +func (s *RevocationStore) UserLogoutTime(ctx context.Context, userID uint) (time.Time, error) { + var zero time.Time + if s == nil || s.redis == nil { + return zero, errors.New("revocation store redis client not initialised") + } + if userID == 0 { + return zero, errors.New("invalid user id") + } + key := s.userLogoutKey(userID) + value, err := s.redis.Get(ctx, key).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return zero, nil + } + return zero, err + } + ts, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return zero, err + } + return ts, nil +} + +func (s *RevocationStore) keyFor(fingerprint string) string { + prefix := s.prefix + if prefix == "" { + prefix = "sso:blacklist" + } + return prefix + ":" + fingerprint +} + +func (s *RevocationStore) userLogoutKey(userID uint) string { + prefix := s.prefix + if prefix == "" { + prefix = "sso:blacklist" + } + return prefix + ":user-logout:" + strconv.FormatUint(uint64(userID), 10) +} + +// TokenFingerprint hashes token material before persisting it to the blacklist. +func TokenFingerprint(token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/modules/sso/session/store.go b/internal/modules/sso/session/store.go new file mode 100644 index 00000000..f906ae22 --- /dev/null +++ b/internal/modules/sso/session/store.go @@ -0,0 +1,70 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const keyTemplate = "sso:pkce:%s" + +// PKCESession holds data required to complete the OAuth2 PKCE exchange. +type PKCESession struct { + CodeVerifier string `json:"code_verifier"` + Nonce string `json:"nonce"` + ClientAlias string `json:"client_alias"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` + Scope string `json:"scope"` + ReturnTo string `json:"return_to,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Store persists pkce sessions inside Redis using a configurable TTL. +type Store struct { + redis *redis.Client + ttl time.Duration +} + +func NewStore(client *redis.Client, ttl time.Duration) *Store { + return &Store{redis: client, ttl: ttl} +} + +func (s *Store) Save(ctx context.Context, state string, payload *PKCESession) error { + if s.redis == nil { + return fmt.Errorf("redis client is not initialised") + } + bytes, err := json.Marshal(payload) + if err != nil { + return err + } + return s.redis.Set(ctx, fmt.Sprintf(keyTemplate, state), bytes, s.ttl).Err() +} + +func (s *Store) Get(ctx context.Context, state string) (*PKCESession, error) { + if s.redis == nil { + return nil, fmt.Errorf("redis client is not initialised") + } + raw, err := s.redis.Get(ctx, fmt.Sprintf(keyTemplate, state)).Result() + if err != nil { + if err == redis.Nil { + return nil, nil + } + return nil, err + } + var payload PKCESession + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil, err + } + return &payload, nil +} + +func (s *Store) Delete(ctx context.Context, state string) error { + if s.redis == nil { + return fmt.Errorf("redis client is not initialised") + } + return s.redis.Del(ctx, fmt.Sprintf(keyTemplate, state)).Err() +} diff --git a/internal/modules/users/controllers/user.controller.go b/internal/modules/users/controllers/user.controller.go index 88361557..f51dfb10 100644 --- a/internal/modules/users/controllers/user.controller.go +++ b/internal/modules/users/controllers/user.controller.go @@ -71,70 +71,70 @@ func (u *UserController) GetOne(c *fiber.Ctx) error { }) } -func (u *UserController) CreateOne(c *fiber.Ctx) error { - req := new(validation.Create) +// func (u *UserController) CreateOne(c *fiber.Ctx) error { +// req := new(validation.Create) - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } +// if err := c.BodyParser(req); err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") +// } - result, err := u.UserService.CreateOne(c, req) - if err != nil { - return err - } +// result, err := u.UserService.CreateOne(c, req) +// if err != nil { +// return err +// } - return c.Status(fiber.StatusCreated). - JSON(response.Success{ - Code: fiber.StatusCreated, - Status: "success", - Message: "Create user successfully", - Data: dto.ToUserListDTO(*result), - }) -} +// return c.Status(fiber.StatusCreated). +// JSON(response.Success{ +// Code: fiber.StatusCreated, +// Status: "success", +// Message: "Create user successfully", +// Data: dto.ToUserListDTO(*result), +// }) +// } -func (u *UserController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) - param := c.Params("id") +// func (u *UserController) UpdateOne(c *fiber.Ctx) error { +// req := new(validation.Update) +// param := c.Params("id") - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } +// id, err := strconv.Atoi(param) +// if err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") +// } - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } +// if err := c.BodyParser(req); err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") +// } - result, err := u.UserService.UpdateOne(c, req, uint(id)) - if err != nil { - return err - } +// result, err := u.UserService.UpdateOne(c, req, uint(id)) +// if err != nil { +// return err +// } - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Update user successfully", - Data: dto.ToUserListDTO(*result), - }) -} +// return c.Status(fiber.StatusOK). +// JSON(response.Success{ +// Code: fiber.StatusOK, +// Status: "success", +// Message: "Update user successfully", +// Data: dto.ToUserListDTO(*result), +// }) +// } -func (u *UserController) DeleteOne(c *fiber.Ctx) error { - param := c.Params("id") +// func (u *UserController) DeleteOne(c *fiber.Ctx) error { +// param := c.Params("id") - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } +// id, err := strconv.Atoi(param) +// if err != nil { +// return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") +// } - if err := u.UserService.DeleteOne(c, uint(id)); err != nil { - return err - } +// if err := u.UserService.DeleteOne(c, uint(id)); err != nil { +// return err +// } - return c.Status(fiber.StatusOK). - JSON(response.Common{ - Code: fiber.StatusOK, - Status: "success", - Message: "Delete user successfully", - }) -} +// return c.Status(fiber.StatusOK). +// JSON(response.Common{ +// Code: fiber.StatusOK, +// Status: "success", +// Message: "Delete user successfully", +// }) +// } diff --git a/internal/modules/users/repositories/user.repository.go b/internal/modules/users/repositories/user.repository.go index 28c06a74..f9bee9ed 100644 --- a/internal/modules/users/repositories/user.repository.go +++ b/internal/modules/users/repositories/user.repository.go @@ -2,29 +2,118 @@ package repository import ( "context" + "errors" + "time" - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + "github.com/jackc/pgconn" + commonrepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type UserRepository interface { - repository.BaseRepository[entity.User] + commonrepo.BaseRepository[entity.User] IdExists(ctx context.Context, id uint) (bool, error) + GetByIdUser(ctx context.Context, idUser int64, modifier func(*gorm.DB) *gorm.DB) (*entity.User, error) + UpsertByIdUser(ctx context.Context, user *entity.User) error + SoftDeleteByIdUser(ctx context.Context, idUser int64) error } type UserRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.User] + *commonrepo.BaseRepositoryImpl[entity.User] db *gorm.DB } func NewUserRepository(db *gorm.DB) UserRepository { return &UserRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.User](db), + BaseRepositoryImpl: commonrepo.NewBaseRepository[entity.User](db), db: db, } } func (r *UserRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.User](ctx, r.db, id) + return commonrepo.Exists[entity.User](ctx, r.db, id) +} + +func (r *UserRepositoryImpl) GetByIdUser( + ctx context.Context, + idUser int64, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.User, error) { + return r.BaseRepositoryImpl.First(ctx, func(db *gorm.DB) *gorm.DB { + return db.Where("id_user = ?", idUser) + }) +} + +func (r *UserRepositoryImpl) UpsertByIdUser(ctx context.Context, user *entity.User) error { + if user == nil { + return gorm.ErrInvalidData + } + + return r.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + user.DeletedAt = gorm.DeletedAt{} + user.UpdatedAt = now + + err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id_user"}}, + UpdateAll: true, + }).Omit("id", "created_at").Create(user).Error + if err == nil { + return nil + } + + if !isUniqueViolation(err, "users_email_unique") { + return err + } + + var existing entity.User + lockQuery := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("email = ?", user.Email) + if err := lockQuery.First(&existing).Error; err != nil { + return err + } + + user.Id = existing.Id + + updates := map[string]any{ + "id_user": user.IdUser, + "email": user.Email, + "name": user.Name, + "updated_at": now, + "deleted_at": gorm.DeletedAt{}, + } + + if err := tx.Model(&entity.User{}).Where("id = ?", existing.Id).Updates(updates).Error; err != nil { + return err + } + + return nil + }) +} + +func (r *UserRepositoryImpl) SoftDeleteByIdUser(ctx context.Context, idUser int64) error { + query := r.DB().WithContext(ctx).Where("id_user = ?", idUser) + result := query.Delete(&entity.User{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func isUniqueViolation(err error, constraint string) bool { + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + return false + } + if pgErr.Code != "23505" { + return false + } + if constraint == "" { + return true + } + return pgErr.ConstraintName == constraint } diff --git a/internal/modules/users/route.go b/internal/modules/users/route.go index 2c428f3a..9ba6bfb3 100644 --- a/internal/modules/users/route.go +++ b/internal/modules/users/route.go @@ -1,20 +1,22 @@ package users import ( + "github.com/gofiber/fiber/v2" + + "gitlab.com/mbugroup/lti-api.git/internal/middleware" controller "gitlab.com/mbugroup/lti-api.git/internal/modules/users/controllers" user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - - "github.com/gofiber/fiber/v2" ) func UserRoutes(v1 fiber.Router, s user.UserService) { ctrl := controller.NewUserController(s) route := v1.Group("/users") + route.Use(middleware.Auth(s)) route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) + // route.Post("/", ctrl.CreateOne) route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + // route.Patch("/:id", ctrl.UpdateOne) + // route.Delete("/:id", ctrl.DeleteOne) } diff --git a/internal/modules/users/services/user.service.go b/internal/modules/users/services/user.service.go index f8e053e4..3b28197e 100644 --- a/internal/modules/users/services/user.service.go +++ b/internal/modules/users/services/user.service.go @@ -20,6 +20,7 @@ type UserService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.User, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.User, error) DeleteOne(ctx *fiber.Ctx, id uint) error + GetBySSOUserID(ctx *fiber.Ctx, ssoUserID uint) (*entity.User, error) } type userService struct { @@ -68,6 +69,18 @@ func (s userService) GetOne(c *fiber.Ctx, id uint) (*entity.User, error) { return user, nil } +func (s userService) GetBySSOUserID(c *fiber.Ctx, ssoUserID uint) (*entity.User, error) { + user, err := s.Repository.GetByIdUser(c.Context(), int64(ssoUserID), nil) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "User not found") + } + if err != nil { + s.Log.Errorf("Failed get user by SSO id: %+v", err) + return nil, err + } + return user, nil +} + func (s *userService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.User, error) { if err := s.Validate.Struct(req); err != nil { return nil, err diff --git a/internal/route/route.go b/internal/route/route.go index d66828c6..04adf6e2 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -11,11 +11,12 @@ import ( approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" + marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" + ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" - marketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing" // MODULE IMPORTS ) @@ -33,7 +34,8 @@ func Routes(app *fiber.App, db *gorm.DB) { production.ProductionModule{}, approvals.ApprovalModule{}, purchases.PurchaseModule{}, - marketing.MarketingModule{}, + marketing.MarketingModule{}, + ssoModule.Module{}, // MODULE REGISTRY } diff --git a/internal/sso/profile.go b/internal/sso/profile.go new file mode 100644 index 00000000..a211fc74 --- /dev/null +++ b/internal/sso/profile.go @@ -0,0 +1,307 @@ +package sso + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/redis/go-redis/v9" + + "gitlab.com/mbugroup/lti-api.git/internal/cache" + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +const ( + profileCachePrefix = "sso:profile:user:" + profileCacheTTL = time.Minute +) + +var ( + profileClient = &http.Client{Timeout: 5 * time.Second} + + profileLocalCache sync.Map // map[string]cachedProfile +) + +type cachedProfile struct { + Profile *UserProfile + ExpiresAt time.Time +} + +// UserProfile represents the enriched user information returned by the central SSO. +type UserProfile struct { + UserID uint + Roles []Role + Permissions []Permission +} + +// Role describes a role assignment from the SSO profile response. +type Role struct { + ID uint + Key string + Name string + ClientID uint + ClientAlias string + ClientName string + Permissions []Permission + RawReference json.RawMessage `json:"-"` +} + +// Permission describes a granular permission entry from the SSO profile. +type Permission struct { + ID uint + Name string + Action string + ClientID uint + ClientAlias string + ClientName string +} + +// PermissionNames returns a de-duplicated slice of permission identifiers in canonical form. +func (p *UserProfile) PermissionNames() []string { + if p == nil || len(p.Permissions) == 0 { + return nil + } + set := make(map[string]struct{}, len(p.Permissions)) + for _, perm := range p.Permissions { + name := canonicalPermissionName(perm.Name) + if name != "" { + set[name] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for name := range set { + out = append(out, name) + } + return out +} + +// FetchProfile retrieves the SSO profile for the authenticated user, using Redis/in-memory +// caching to reduce load on the SSO service. Only end-user tokens (subject user:ID) are supported. +func FetchProfile(ctx context.Context, token string, verification *VerificationResult) (*UserProfile, error) { + if verification == nil || verification.UserID == 0 { + return nil, errors.New("profile only available for user tokens") + } + key := profileCacheKey(verification.UserID) + + if profile := loadProfileFromLocalCache(key); profile != nil { + return profile, nil + } + + if profile := loadProfileFromRedis(ctx, key); profile != nil { + storeProfileInLocalCache(key, profile) + return profile, nil + } + + profile, err := fetchProfileFromSSO(ctx, token) + if err != nil { + return nil, err + } + + storeProfileInLocalCache(key, profile) + storeProfileInRedis(ctx, key, profile) + return profile, nil +} + +func fetchProfileFromSSO(ctx context.Context, token string) (*UserProfile, error) { + endpoint := strings.TrimSpace(config.SSOGetMeURL) + if endpoint == "" { + return nil, errors.New("sso get-me endpoint not configured") + } + + if ctx == nil { + ctx = context.Background() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("build profile request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + if cookieName := strings.TrimSpace(config.SSOAccessCookieName); cookieName != "" { + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", cookieName, token)) + } + + resp, err := profileClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch profile: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("fetch profile: status %d", resp.StatusCode) + } + + var envelope userInfoEnvelope + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, fmt.Errorf("decode profile: %w", err) + } + + roles := envelope.getRoles() + profile := &UserProfile{} + + // Attempt to infer user id if provided. + if envelope.User != nil && envelope.User.ID > 0 { + profile.UserID = uint(envelope.User.ID) + } + + perms := make([]Permission, 0) + convertedRoles := make([]Role, 0, len(roles)) + for _, r := range roles { + role := Role{ + ID: uint(r.ID), + Key: strings.TrimSpace(r.Key), + Name: strings.TrimSpace(r.Name), + ClientAlias: strings.TrimSpace(r.Client.Alias), + ClientName: strings.TrimSpace(r.Client.Name), + ClientID: uint(r.Client.ID), + } + rolePerms := make([]Permission, 0, len(r.Permissions)) + for _, p := range r.Permissions { + perm := Permission{ + ID: uint(p.ID), + Name: strings.TrimSpace(p.Name), + Action: strings.TrimSpace(p.Action), + ClientAlias: strings.TrimSpace(p.Client.Alias), + ClientName: strings.TrimSpace(p.Client.Name), + ClientID: uint(p.Client.ID), + } + if perm.Name != "" { + rolePerms = append(rolePerms, perm) + perms = append(perms, perm) + } + } + role.Permissions = rolePerms + convertedRoles = append(convertedRoles, role) + } + profile.Roles = convertedRoles + profile.Permissions = perms + + return profile, nil +} + +func loadProfileFromLocalCache(key string) *UserProfile { + if value, ok := profileLocalCache.Load(key); ok { + if cached, ok := value.(cachedProfile); ok { + if time.Now().Before(cached.ExpiresAt) && cached.Profile != nil { + return cached.Profile + } + profileLocalCache.Delete(key) + } + } + return nil +} + +func loadProfileFromRedis(ctx context.Context, key string) *UserProfile { + client := cache.Redis() + if client == nil { + return nil + } + + data, err := client.Get(ctx, key).Bytes() + if err != nil { + if !errors.Is(err, redis.Nil) { + utils.Log.WithError(err).Warn("sso profile redis lookup failed") + } + return nil + } + + var profile UserProfile + if err := json.Unmarshal(data, &profile); err != nil { + utils.Log.WithError(err).Warn("sso profile redis decode failed") + return nil + } + + return &profile +} + +func storeProfileInLocalCache(key string, profile *UserProfile) { + if profile == nil { + return + } + profileLocalCache.Store(key, cachedProfile{ + Profile: profile, + ExpiresAt: time.Now().Add(profileCacheTTL), + }) +} + +func storeProfileInRedis(ctx context.Context, key string, profile *UserProfile) { + client := cache.Redis() + if client == nil || profile == nil { + return + } + + data, err := json.Marshal(profile) + if err != nil { + utils.Log.WithError(err).Warn("sso profile redis encode failed") + return + } + + if err := client.Set(ctx, key, data, profileCacheTTL).Err(); err != nil { + utils.Log.WithError(err).Warn("sso profile redis store failed") + } +} + +func profileCacheKey(userID uint) string { + return profileCachePrefix + strconv.FormatUint(uint64(userID), 10) +} + +func canonicalPermissionName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + +// userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint. +type userInfoEnvelope struct { + Roles []userInfoRole `json:"roles"` + Data *struct { + ID int64 `json:"id"` + Roles []userInfoRole `json:"roles"` + } `json:"data"` + User *struct { + ID int64 `json:"id"` + } `json:"user"` +} + +func (e *userInfoEnvelope) getRoles() []userInfoRole { + if len(e.Roles) > 0 { + return e.Roles + } + if e.Data != nil && len(e.Data.Roles) > 0 { + if e.User == nil && e.Data.ID > 0 { + e.User = &struct { + ID int64 `json:"id"` + }{ID: e.Data.ID} + } + return e.Data.Roles + } + return nil +} + +type userInfoRole struct { + ID int64 `json:"id"` + Key string `json:"key"` + Name string `json:"name"` + Client userInfoClient `json:"client"` + Permissions []userInfoPermRaw `json:"permissions"` +} + +type userInfoClient struct { + ID int64 `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` +} + +type userInfoPermRaw struct { + ID int64 `json:"id"` + Name string `json:"name"` + Action string `json:"action"` + Client userInfoClient `json:"client"` + Details any `json:"details"` +} diff --git a/internal/sso/verifier.go b/internal/sso/verifier.go new file mode 100644 index 00000000..0c8d97e8 --- /dev/null +++ b/internal/sso/verifier.go @@ -0,0 +1,160 @@ +package sso + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/MicahParks/keyfunc/v2" + "github.com/golang-jwt/jwt/v5" + + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type verifier struct { + jwks *keyfunc.JWKS + issuer string + audiences map[string]struct{} +} + +type AccessTokenClaims struct { + Scope string `json:"scope"` + jwt.RegisteredClaims +} + +func (c AccessTokenClaims) Scopes() []string { + if c.Scope == "" { + return nil + } + return strings.Fields(c.Scope) +} + +type VerificationResult struct { + UserID uint + ServiceAlias string + Subject string + Claims *AccessTokenClaims +} + +var ( + globalMu sync.RWMutex + globalV *verifier +) + +func Init(ctx context.Context, jwksURL, issuer string, audiences []string) error { + jwksURL = strings.TrimSpace(jwksURL) + issuer = strings.TrimSpace(issuer) + if jwksURL == "" || issuer == "" { + return errors.New("missing SSO JWKS or issuer configuration") + } + + client := &http.Client{Timeout: 5 * time.Second} + options := keyfunc.Options{ + Ctx: ctx, + Client: client, + RefreshTimeout: 10 * time.Second, + RefreshInterval: time.Hour, + RefreshUnknownKID: true, + RefreshErrorHandler: func(err error) { + utils.Log.Errorf("sso jwks refresh failed: %v", err) + }, + } + + jwks, err := keyfunc.Get(jwksURL, options) + if err != nil { + return fmt.Errorf("load jwks: %w", err) + } + + audienceMap := make(map[string]struct{}, len(audiences)) + for _, aud := range audiences { + aud = strings.TrimSpace(aud) + if aud == "" { + continue + } + audienceMap[aud] = struct{}{} + } + + globalMu.Lock() + globalV = &verifier{jwks: jwks, issuer: issuer, audiences: audienceMap} + globalMu.Unlock() + + utils.Log.Infof("sso verifier initialized for issuer %s (%d keys)", issuer, len(jwks.KIDs())) + return nil +} + +func VerifyAccessToken(token string) (*VerificationResult, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, errors.New("empty token") + } + + globalMu.RLock() + v := globalV + globalMu.RUnlock() + if v == nil { + return nil, errors.New("sso verifier not initialized") + } + + claims := &AccessTokenClaims{} + parser := jwt.NewParser( + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + jwt.WithIssuedAt(), + jwt.WithExpirationRequired(), + ) + + tok, err := parser.ParseWithClaims(token, claims, v.jwks.Keyfunc) + if err != nil { + return nil, fmt.Errorf("parse token: %w", err) + } + if !tok.Valid { + return nil, errors.New("invalid token") + } + + if claims.Issuer != v.issuer { + return nil, errors.New("unexpected token issuer") + } + + if len(v.audiences) > 0 { + validAud := false + for _, aud := range claims.Audience { + if _, ok := v.audiences[aud]; ok { + validAud = true + break + } + } + if !validAud { + return nil, errors.New("unexpected token audience") + } + } + + sub := strings.TrimSpace(claims.Subject) + if sub == "" { + return nil, errors.New("missing subject") + } + + result := &VerificationResult{Claims: claims, Subject: sub} + switch { + case strings.HasPrefix(sub, "user:"): + idStr := strings.TrimPrefix(sub, "user:") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid subject: %w", err) + } + result.UserID = uint(id) + case strings.HasPrefix(sub, "service:"): + alias := strings.TrimSpace(strings.TrimPrefix(sub, "service:")) + if alias == "" { + return nil, errors.New("invalid service subject") + } + result.ServiceAlias = strings.ToLower(alias) + default: + return nil, errors.New("unsupported subject type") + } + + return result, nil +} diff --git a/internal/utils/error.go b/internal/utils/error.go index e63e81a2..e409e50c 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -10,8 +10,8 @@ import ( ) func ErrorHandler(c *fiber.Ctx, err error) error { - if errorsMap := validation.CustomErrorMessages(err); len(errorsMap) > 0 { - return response.Error(c, fiber.StatusBadRequest, "Bad Request", errorsMap) + if message, errorsMap := validation.CustomErrorMessages(err); len(errorsMap) > 0 { + return response.Error(c, fiber.StatusBadRequest, message, nil) } var fiberErr *fiber.Error diff --git a/internal/utils/secure/random.go b/internal/utils/secure/random.go new file mode 100644 index 00000000..152fd2f9 --- /dev/null +++ b/internal/utils/secure/random.go @@ -0,0 +1,84 @@ +package secure + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "strings" +) + +const pkceCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + +// RandomBytes returns securely generated random bytes of given length. +func RandomBytes(length int) ([]byte, error) { + if length <= 0 { + return nil, fmt.Errorf("length must be positive") + } + b := make([]byte, length) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return b, nil +} + +// RandomString returns a base64url encoded random string of approximately the requested length. +func RandomString(length int) (string, error) { + if length <= 0 { + return "", fmt.Errorf("length must be positive") + } + // Generate ceil(length * 6/8) bytes to have enough entropy for base64 url encoding. + byteLen := (length*6 + 7) / 8 + bytes, err := RandomBytes(byteLen) + if err != nil { + return "", err + } + s := base64.RawURLEncoding.EncodeToString(bytes) + if len(s) > length { + return s[:length], nil + } + // If encoded string shorter, pad using charset + if len(s) < length { + sb := strings.Builder{} + sb.WriteString(s) + extraNeeded := length - len(s) + more, err := randomFromCharset(extraNeeded) + if err != nil { + return "", err + } + sb.WriteString(more) + return sb.String(), nil + } + return s, nil +} + +// PKCECodeVerifier generates a random string compliant with RFC 7636. +func PKCECodeVerifier(length int) (string, error) { + if length < 43 { + length = 43 + } + if length > 128 { + length = 128 + } + return randomFromCharset(length) +} + +// randomFromCharset returns a random string of given length using pkceCharset. +func randomFromCharset(length int) (string, error) { + if length <= 0 { + return "", fmt.Errorf("length must be positive") + } + bytes, err := RandomBytes(length) + if err != nil { + return "", err + } + out := make([]byte, length) + for i, b := range bytes { + out[i] = pkceCharset[int(b)%len(pkceCharset)] + } + return string(out), nil +} + +// Base64URLEncode encodes the input bytes using base64 URL encoding without padding. +func Base64URLEncode(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} diff --git a/internal/utils/verify.go b/internal/utils/verify.go deleted file mode 100644 index e8b3a850..00000000 --- a/internal/utils/verify.go +++ /dev/null @@ -1,45 +0,0 @@ -package utils - -import ( - "errors" - "strconv" - - "github.com/golang-jwt/jwt/v5" -) - -func VerifyToken(tokenStr, secret, tokenType string) (uint, error) { - token, err := jwt.Parse(tokenStr, func(_ *jwt.Token) (interface{}, error) { - return []byte(secret), nil - }) - if err != nil || !token.Valid { - return 0, err - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return 0, errors.New("invalid token claims") - } - - jwtType, ok := claims["type"].(string) - if !ok || jwtType != tokenType { - return 0, errors.New("invalid token type") - } - - sub, ok := claims["sub"] - if !ok { - return 0, errors.New("invalid token sub") - } - - switch v := sub.(type) { - case float64: - return uint(v), nil - case string: - id, err := strconv.Atoi(v) - if err != nil { - return 0, errors.New("invalid sub format") - } - return uint(id), nil - default: - return 0, errors.New("unsupported sub type") - } -} diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 00000000..2882dcf7 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +echo "🔍 Waiting for PostgreSQL at $DB_HOST:$DB_PORT..." +until nc -z "$DB_HOST" "$DB_PORT"; do + echo "âŗ PostgreSQL is not ready yet..." + sleep 2 +done +echo "✅ PostgreSQL is ready!" + +echo "🏁 Starting LTI API (with Air hot reload)..." +exec air -c .air.toml \ No newline at end of file diff --git a/tools/templates/dto.tmpl b/tools/templates/dto.tmpl index a03d7018..39b92884 100644 --- a/tools/templates/dto.tmpl +++ b/tools/templates/dto.tmpl @@ -10,15 +10,16 @@ import ( // === DTO Structs === type {{Pascal .Entity}}BaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` + Id uint `json:"id"` + Name string `json:"name"` } type {{Pascal .Entity}}ListDTO struct { - {{Pascal .Entity}}BaseDTO + Id uint `json:"id"` + Name string `json:"name"` CreatedUser *userDTO.UserBaseDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type {{Pascal .Entity}}DetailDTO struct { @@ -42,7 +43,8 @@ func To{{Pascal .Entity}}ListDTO(e entity.{{Pascal .Entity}}) {{Pascal .Entity}} } return {{Pascal .Entity}}ListDTO{ - {{Pascal .Entity}}BaseDTO: To{{Pascal .Entity}}BaseDTO(e), + Id: e.Id, + Name: e.Name, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser,