diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..fb8cd78 --- /dev/null +++ b/.air.toml @@ -0,0 +1,51 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = ";export $(grep -v '^#' .env | xargs); ./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..dcdc8b5 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,13 @@ +FROM mcr.microsoft.com/devcontainers/go:1-1.22-bookworm + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment the next lines to use go get to install anything else you need +# USER vscode +# RUN go get -x +# USER root + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..865d010 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/go-postgres +{ + "name": "RPKM67 Auth Service", + "dockerComposeFile": "docker-compose.yml", + "service": "app", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "customizations": { + "vscode": { + "extensions": [ + "aldijav.golangwithdidi", + "VisualStudioExptTeam.vscodeintellicode", + "VisualStudioExptTeam.vscodeintellicode-completions", + "ZainChen.json", + "ms-kubernetes-tools.vscode-kubernetes-tools", + "ms-vscode.makefile-tools", + "DavidAnson.vscode-markdownlint", + "esbenp.prettier-vscode", + "christian-kohler.path-intellisense", + "zxh404.vscode-proto3", + "redhat.vscode-yaml", + "ms-azuretools.vscode-docker", + "aaron-bond.better-comments" + ] + } + }, + "forwardPorts": [ + 5432, + 6379 + ], + "postStartCommand": "make setup" +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..59346b4 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +volumes: + postgres-data: + +services: + app: + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + + volumes: + - ../..:/workspaces:cached + command: sleep infinity + network_mode: service:db + + db: + image: postgres:latest + restart: unless-stopped + network_mode: service:redis + volumes: + - ./postgres-data:/var/lib/postgresql/data + env_file: + - .env + + redis: + image: redis:latest + restart: unless-stopped + env_file: + - .env + volumes: + - ./redis-data:/data diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..600c3eb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +microservices/ +.devcontainer/*-data \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..6d1f670 --- /dev/null +++ b/.env.template @@ -0,0 +1,19 @@ +APP_PORT=3002 +APP_ENV=development + +DB_URL=postgres://root:1234@localhost:5432/rpkm67_db + +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=5678 + +JWT_SECRET=secret +JWT_ACCESS_TTL=3600 +JWT_REFRESH_TTL=259200 +JWT_ISSUER=issuer + +AUTH_CHECK_CHULA_EMAIL=false + +OAUTH_CLIENT_ID=client_id +OAUTH_CLIENT_SECRET=client_secret +OAUTH_REDIRECT_URI=http://localhost:3000 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f33a02c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c38d93b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## Change made + +- [ ]  New features +- [ ]  Bug fixes +- [ ]  Breaking changes +- [ ] Refactor +## Describe what you have done +- +### New Features +- +### Fix +- +### Others +- diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml new file mode 100644 index 0000000..c14dcc4 --- /dev/null +++ b/.github/workflows/build-deploy.yml @@ -0,0 +1,93 @@ +name: Build + +on: + workflow_dispatch: + pull_request: + types: + - closed + branches: + - main + - dev + +env: + SERVICE_NAME: rpkm67-auth + IMAGE_NAME: ghcr.io/${{ github.repository }} + IMAGE_TAG: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true || github.event_name == 'workflow_dispatch' + outputs: + IMAGE_TAG: ${{ steps.tag_action.outputs.new_tag }} + + permissions: + contents: write + packages: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: '0' + + - name: Bump version and push tag + uses: anothrNick/github-tag-action@1.64.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WITH_V: true + RELEASE_BRANCHES: dev + DEFAULT_BUMP: patch + id: tag_action + + - name: Set IMAGE_TAG + run: echo "IMAGE_TAG=${{ steps.tag_action.outputs.new_tag }}" >> $GITHUB_ENV + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to the Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build and Push Docker Image + uses: docker/build-push-action@v3 + with: + push: true + tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }},${{ env.IMAGE_NAME }}:latest + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max + + cd: + name: Continuous Deployment + needs: build + runs-on: ubuntu-latest + env: + IMAGE_TAG: ${{ needs.build.outputs.IMAGE_TAG }} + + steps: + - name: Checkout DevOps repository + uses: actions/checkout@v4 + with: + repository: isd-sgcu/rpkm67-devops + token: ${{ secrets.RPKM67_DEVOPS_TOKEN }} + + - name: Update image tag in dev + uses: mikefarah/yq@master + with: + cmd: yq -i '.[0].value = "${{ env.IMAGE_NAME }}:" + strenv(IMAGE_TAG)' isd/${{ env.SERVICE_NAME }}/deployment.yaml + + - name: Update image tag in prod + uses: mikefarah/yq@master + if: github.ref == 'refs/heads/main' + with: + cmd: yq -i '.[0].value = "${{ env.IMAGE_NAME }}:" + strenv(IMAGE_TAG)' prod/${{ env.SERVICE_NAME }}/deployment.yaml + + - name: Commit & Push changes + uses: actions-js/push@v1.4 + with: + repository: isd-sgcu/rpkm67-devops + github_token: ${{ secrets.RPKM67_DEVOPS_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ffba9ce --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,36 @@ +name: Lint + +on: + workflow_dispatch: + pull_request: + branches: + - main + - dev + push: + branches: + - main + - dev + tags: + - v* + +permissions: + contents: read + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.22.4' + cache: false + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.55 diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..e0696c9 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,37 @@ +name: Unit Tests + +on: + workflow_dispatch: + pull_request: + branches: + - main + - dev + push: + branches: + - main + - dev + tags: + - v* + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.22 + + - name: Download dependencies + run: go mod download + + - name: Vet + run: | + go vet ./... + + - name: Test + run: | + go test -v -coverpkg ./internal/... -coverprofile coverage.out -covermode count ./internal/... + go tool cover -func="./coverage.out" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..161ec4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +### Go template +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# config file +config.yaml +.env +.env.prod + +# idea +.idea + +# volumes +volumes + +# coverage report +coverage.out +coverage.html + +.DS_store +tmp +staff.json +docker-compose.qa.yml + +.devcontainer/*-data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a39f4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.22.4 AS builder +WORKDIR /app + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +RUN go build -ldflags "-s -w" -o server ./cmd/main.go + + +FROM gcr.io/distroless/base-debian12 AS runner +WORKDIR /app + +COPY --from=builder /app/server ./ + +ENV GO_ENV=production + +EXPOSE 3000 + +CMD ["./server"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8dbb163 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +.PHONY: setup pull-latest-mac pull-latest-windows docker docker-qa server watch mock-gen test proto model + +setup: + go mod download + go install github.com/air-verse/air + go install github.com/golang/mock/mockgen@v1.6.0 + go get github.com/isd-sgcu/rpkm67-go-proto@latest + go get github.com/isd-sgcu/rpkm67-model@latest + +pull-latest-mac: + docker pull --platform linux/x86_64 ghcr.io/isd-sgcu/rpkm67-gateway:latest + docker pull --platform linux/x86_64 ghcr.io/isd-sgcu/rpkm67-auth:latest + docker pull --platform linux/x86_64 ghcr.io/isd-sgcu/rpkm67-backend:latest + docker pull --platform linux/x86_64 ghcr.io/isd-sgcu/rpkm67-checkin:latest + docker pull --platform linux/x86_64 ghcr.io/isd-sgcu/rpkm67-store:latest + +pull-latest-windows: + docker pull ghcr.io/isd-sgcu/rpkm67-gateway:latest + docker pull ghcr.io/isd-sgcu/rpkm67-auth:latest + docker pull ghcr.io/isd-sgcu/rpkm67-backend:latest + docker pull ghcr.io/isd-sgcu/rpkm67-checkin:latest + docker pull ghcr.io/isd-sgcu/rpkm67-store:latest + +docker: + docker rm -v -f $$(docker ps -qa) || echo "No containers found. Skipping removal." + docker-compose up + +server: + go run cmd/main.go + +watch: + air + +mock-gen: + mockgen -source ./internal/user/user.service.go -destination ./mocks/user/user.service.go + mockgen -source ./internal/user/user.repository.go -destination ./mocks/user/user.repository.go + +test: + go vet ./... + go test -v -coverpkg ./internal/... -coverprofile coverage.out -covermode count ./internal/... + go tool cover -func=coverage.out + go tool cover -html=coverage.out -o coverage.html + +proto: + go get github.com/isd-sgcu/rpkm67-go-proto@latest + +model: + go get github.com/isd-sgcu/rpkm67-model@latest \ No newline at end of file diff --git a/README.md b/README.md index c70455e..f7da094 100644 --- a/README.md +++ b/README.md @@ -1 +1,57 @@ # rpkm67-auth + +## Stack + +- golang +- gRPC +- postgresql +- redis +- minio + +## Getting Started + +### Prerequisites + +- 💻 +- golang 1.22 or [later](https://go.dev) +- docker +- makefile +- [Go Air](https://github.com/air-verse/air) + +### Installation + +1. Clone this repo +2. Run `go mod download` to download all the dependencies. + +### Running only this service +1. Copy `.env.template` and paste it in the same directory as `.env`. Fill in the appropriate values. +2. Run `make docker`. +3. Run `make server` or `air` for hot-reload. + +### Running all RPKM67 services (all other services are run as containers) +1. Copy `docker-compose.qa.template.yml` and paste it in the same directory as `docker-compose.qa.yml`. Fill in the appropriate values. +2. In `microservices/auth` folder, copy `staff.template.json` and paste it in the same directory as `staff.json`. It is the staffs' student id list (given `staff` roles instead of `user`). +3. Run `make pull-latest-mac` or `make pull-latest-windows` to pull the latest images of other services. +4. Run `make docker-qa`. +5. Run `make server` or `air` for hot-reload. + +### Unit Testing +1. Run `make test` + +## API +When run locally, the gateway url will be available at `localhost:3001`. +- Swagger UI: `localhost:3001/api/v1/docs/index.html#/` +- Grafana: `localhost:3006` (username: admin, password: 1234) +- Prometheus: `localhost:9090` +- Gateway's metrics endpoint: `localhost:3001/metrics` + +## Other microservices/repositories of RPKM67 +- [gateway](https://github.com/isd-sgcu/rpkm67-gateway): Routing and request handling +- [auth](https://github.com/isd-sgcu/rpkm67-auth): Authentication and user service +- [backend](https://github.com/isd-sgcu/rpkm67-backend): Group, Baan selection and Stamp, Pin business logic +- [checkin](https://github.com/isd-sgcu/rpkm67-checkin): Checkin for events service +- [store](https://github.com/isd-sgcu/rpkm67-store): Object storage service for user profile pictures +- [model](https://github.com/isd-sgcu/rpkm67-model): SQL table schema and models +- [proto](https://github.com/isd-sgcu/rpkm67-proto): Protobuf files generator +- [go-proto](https://github.com/isd-sgcu/rpkm67-go-proto): Generated protobuf files for golang +- [frontend](https://github.com/isd-sgcu/firstdate-rpkm67-frontend): Frontend web application diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..e6439b1 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/isd-sgcu/rpkm67-auth/config" + "github.com/isd-sgcu/rpkm67-auth/database" + "github.com/isd-sgcu/rpkm67-auth/internal/auth" + "github.com/isd-sgcu/rpkm67-auth/internal/cache" + "github.com/isd-sgcu/rpkm67-auth/internal/jwt" + "github.com/isd-sgcu/rpkm67-auth/internal/oauth" + "github.com/isd-sgcu/rpkm67-auth/internal/token" + "github.com/isd-sgcu/rpkm67-auth/internal/user" + "github.com/isd-sgcu/rpkm67-auth/logger" + authProto "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/auth/auth/v1" + userProto "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/auth/user/v1" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +func main() { + conf, err := config.LoadConfig() + if err != nil { + panic(fmt.Sprintf("Failed to load config: %v", err)) + } + + logger := logger.New(conf) + + db, err := database.InitDatabase(&conf.Db, conf.App.IsDevelopment()) + if err != nil { + panic(fmt.Sprintf("Failed to connect to database: %v", err)) + } + + redis, err := database.InitRedis(&conf.Redis) + if err != nil { + panic(fmt.Sprintf("Failed to connect to redis: %v", err)) + } + + cacheRepo := cache.NewRepository(redis) + + userRepo := user.NewRepository(db) + userSvc := user.NewService(userRepo, logger.Named("userSvc")) + + jwtSvc := jwt.NewService(conf.Jwt, jwt.NewJwtStrategy(conf.Jwt.Secret), jwt.NewJwtUtils(), logger.Named("jwtSvc")) + tokenSvc := token.NewService(jwtSvc, cacheRepo, token.NewTokenUtils(), logger.Named("tokenSvc")) + oauthConfig := config.LoadOauthConfig(conf.Oauth) + oauthClient := oauth.NewGoogleOauthClient(oauthConfig, logger.Named("oauthClient")) + authSvc := auth.NewService(&conf.Auth, oauthConfig, oauthClient, userSvc, tokenSvc, auth.NewAuthUtils(), logger.Named("authSvc")) + + listener, err := net.Listen("tcp", fmt.Sprintf(":%v", conf.App.Port)) + if err != nil { + panic(fmt.Sprintf("Failed to listen: %v", err)) + } + + grpcServer := grpc.NewServer() + grpc_health_v1.RegisterHealthServer(grpcServer, health.NewServer()) + userProto.RegisterUserServiceServer(grpcServer, userSvc) + authProto.RegisterAuthServiceServer(grpcServer, authSvc) + + reflection.Register(grpcServer) + go func() { + logger.Sugar().Infof("RPKM67 Auth starting at port %v", conf.App.Port) + + if err := grpcServer.Serve(listener); err != nil { + logger.Fatal("Failed to start RPKM67 Auth service", zap.Error(err)) + } + }() + + wait := gracefulShutdown(context.Background(), 2*time.Second, logger, map[string]operation{ + "server": func(ctx context.Context) error { + grpcServer.GracefulStop() + return nil + }, + "database": func(ctx context.Context) error { + sqlDB, err := db.DB() + if err != nil { + return nil + } + return sqlDB.Close() + }, + // "cache": func(ctx context.Context) error { + // return cacheDb.Close() + // }, + }) + + <-wait + + grpcServer.GracefulStop() + logger.Info("Closing the listener") + listener.Close() + logger.Info("RPKM67 Auth service has been shutdown gracefully") +} + +type operation func(ctx context.Context) error + +func gracefulShutdown(ctx context.Context, timeout time.Duration, log *zap.Logger, ops map[string]operation) <-chan struct{} { + wait := make(chan struct{}) + go func() { + s := make(chan os.Signal, 1) + + signal.Notify(s, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + sig := <-s + + log.Named("graceful shutdown").Sugar(). + Infof("got signal \"%v\" shutting down service", sig) + + timeoutFunc := time.AfterFunc(timeout, func() { + log.Named("graceful shutdown").Sugar(). + Errorf("timeout %v ms has been elapsed, force exit", timeout.Milliseconds()) + os.Exit(0) + }) + + defer timeoutFunc.Stop() + + var wg sync.WaitGroup + + for key, op := range ops { + wg.Add(1) + innerOp := op + innerKey := key + go func() { + defer wg.Done() + + log.Named("graceful shutdown").Sugar(). + Infof("cleaning up: %v", innerKey) + if err := innerOp(ctx); err != nil { + log.Named("graceful shutdown").Sugar(). + Errorf("%v: clean up failed: %v", innerKey, err.Error()) + return + } + + log.Named("graceful shutdown").Sugar(). + Infof("%v was shutdown gracefully", innerKey) + }() + } + + wg.Wait() + close(wait) + }() + + return wait +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0981cf5 --- /dev/null +++ b/config/config.go @@ -0,0 +1,129 @@ +package config + +import ( + "os" + "strconv" + + "github.com/joho/godotenv" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +type AppConfig struct { + Port string + Env string +} + +type DbConfig struct { + Url string +} + +type RedisConfig struct { + Host string + Port int + Password string +} + +type JwtConfig struct { + Secret string + AccessTTL int + RefreshTTL int + Issuer string +} + +type AuthConfig struct { + CheckChulaEmail bool +} + +type OauthConfig struct { + ClientId string + ClientSecret string + RedirectUri string +} + +type Config struct { + App AppConfig + Db DbConfig + Redis RedisConfig + Jwt JwtConfig + Auth AuthConfig + Oauth OauthConfig +} + +func LoadConfig() (*Config, error) { + if os.Getenv("APP_ENV") == "" { + err := godotenv.Load(".env") + if err != nil && !os.IsNotExist(err) { + return nil, err + } + } + + appConfig := AppConfig{ + Port: os.Getenv("APP_PORT"), + Env: os.Getenv("APP_ENV"), + } + + dbConfig := DbConfig{ + Url: os.Getenv("DB_URL"), + } + + redisPort, err := strconv.ParseInt(os.Getenv("REDIS_PORT"), 10, 64) + if err != nil { + return nil, err + } + + redisConfig := RedisConfig{ + Host: os.Getenv("REDIS_HOST"), + Port: int(redisPort), + Password: os.Getenv("REDIS_PASSWORD"), + } + + accessTTL, err := strconv.ParseInt(os.Getenv("JWT_ACCESS_TTL"), 10, 64) + if err != nil { + return nil, err + } + refreshTTL, err := strconv.ParseInt(os.Getenv("JWT_REFRESH_TTL"), 10, 64) + if err != nil { + return nil, err + } + + jwtConfig := JwtConfig{ + Secret: os.Getenv("JWT_SECRET"), + AccessTTL: int(accessTTL), + RefreshTTL: int(refreshTTL), + Issuer: os.Getenv("JWT_ISSUER"), + } + + authConfig := AuthConfig{ + CheckChulaEmail: os.Getenv("AUTH_CHECK_CHULA_EMAIL") == "true", + } + + oauthConfig := OauthConfig{ + ClientId: os.Getenv("OAUTH_CLIENT_ID"), + ClientSecret: os.Getenv("OAUTH_CLIENT_SECRET"), + RedirectUri: os.Getenv("OAUTH_REDIRECT_URI"), + } + + return &Config{ + App: appConfig, + Db: dbConfig, + Redis: redisConfig, + Jwt: jwtConfig, + Auth: authConfig, + Oauth: oauthConfig, + }, nil +} + +func (ac *AppConfig) IsDevelopment() bool { + return ac.Env == "development" +} + +func LoadOauthConfig(oauth OauthConfig) *oauth2.Config { + return &oauth2.Config{ + ClientID: oauth.ClientId, + ClientSecret: oauth.ClientSecret, + RedirectURL: oauth.RedirectUri, + Endpoint: google.Endpoint, + Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}, + } +} diff --git a/config/staffs/staff.template.json b/config/staffs/staff.template.json new file mode 100644 index 0000000..2d355e8 --- /dev/null +++ b/config/staffs/staff.template.json @@ -0,0 +1,6 @@ +{ + "staffs": [ + "6932203021", + "6932203121" + ] +} \ No newline at end of file diff --git a/database/db.connection.go b/database/db.connection.go new file mode 100644 index 0000000..8a1ff0a --- /dev/null +++ b/database/db.connection.go @@ -0,0 +1,29 @@ +package database + +import ( + "github.com/isd-sgcu/rpkm67-auth/config" + "github.com/isd-sgcu/rpkm67-model/model" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormLogger "gorm.io/gorm/logger" +) + +func InitDatabase(conf *config.DbConfig, isDebug bool) (db *gorm.DB, err error) { + gormConf := &gorm.Config{TranslateError: true} + + if !isDebug { + gormConf.Logger = gormLogger.Default.LogMode(gormLogger.Silent) + } + + db, err = gorm.Open(postgres.Open(conf.Url), gormConf) + if err != nil { + return nil, err + } + + err = db.AutoMigrate(&model.Group{}, &model.User{}, &model.Selection{}, &model.Stamp{}, &model.CheckIn{}) + if err != nil { + return nil, err + } + + return +} diff --git a/database/redis.connection.go b/database/redis.connection.go new file mode 100644 index 0000000..7fa9486 --- /dev/null +++ b/database/redis.connection.go @@ -0,0 +1,24 @@ +package database + +import ( + "errors" + "fmt" + + "github.com/isd-sgcu/rpkm67-auth/config" + "github.com/redis/go-redis/v9" +) + +func InitRedis(conf *config.RedisConfig) (*redis.Client, error) { + addr := fmt.Sprintf("%s:%d", conf.Host, conf.Port) + + cache := redis.NewClient(&redis.Options{ + Addr: addr, + Password: conf.Password, + }) + + if cache == nil { + return nil, errors.New("Failed to connect to redis server") + } + + return cache, nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..25dd95d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: "3.9" + +services: + db: + image: postgres:15.1-alpine3.17 + container_name: db + restart: unless-stopped + environment: + POSTGRES_USER: root + POSTGRES_PASSWORD: "1234" + POSTGRES_DB: rpkm67_db + networks: + - rpkm67 + volumes: + - ./volumes/postgres:/var/lib/postgresql/data + ports: + - "5432:5432" + + cache: + image: redis:7.2.3-alpine + container_name: cache + restart: unless-stopped + environment: + REDIS_HOST: localhost + REDIS_PASSWORD: "5678" + networks: + - rpkm67 + ports: + - "6379:6379" + +networks: + rpkm67: + name: rpkm67 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f27ada1 --- /dev/null +++ b/go.mod @@ -0,0 +1,45 @@ +module github.com/isd-sgcu/rpkm67-auth + +go 1.22.4 + +require ( + github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/golang/mock v1.6.0 + github.com/google/uuid v1.6.0 + github.com/isd-sgcu/rpkm67-go-proto v0.4.6 + github.com/isd-sgcu/rpkm67-model v0.0.7 + github.com/joho/godotenv v1.5.1 + github.com/pkg/errors v0.9.1 + github.com/redis/go-redis/v9 v9.5.3 + github.com/stretchr/testify v1.9.0 + go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.24.0 + golang.org/x/oauth2 v0.21.0 + google.golang.org/grpc v1.65.0 + gorm.io/driver/postgres v1.5.9 + gorm.io/gorm v1.25.10 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // 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 + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eb65960 --- /dev/null +++ b/go.sum @@ -0,0 +1,116 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/isd-sgcu/rpkm67-go-proto v0.4.6 h1:yUBzUY3ftBfnI2x/MT+MsynBOlV5pOR143c9OTrVGuU= +github.com/isd-sgcu/rpkm67-go-proto v0.4.6/go.mod h1:w+UCeQnJ3wBuJ7Tyf8LiBiPZVb1KlecjMNCB7kBeL7M= +github.com/isd-sgcu/rpkm67-model v0.0.7 h1:3b8gf1Ocg+Ky4xocKtCqVCB3rFDg90IgEXRwNmHt0OE= +github.com/isd-sgcu/rpkm67-model v0.0.7/go.mod h1:dxgLSkrFpbQOXsrzqgepZoEOyZUIG2LBGtm5gsuBbVc= +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/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/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/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= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= +github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/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= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/internal/auth/auth.service.go b/internal/auth/auth.service.go new file mode 100644 index 0000000..1569f8c --- /dev/null +++ b/internal/auth/auth.service.go @@ -0,0 +1,176 @@ +package auth + +import ( + "context" + "net/url" + "strings" + + "github.com/isd-sgcu/rpkm67-auth/config" + "github.com/isd-sgcu/rpkm67-auth/internal/dto" + "github.com/isd-sgcu/rpkm67-auth/internal/oauth" + "github.com/isd-sgcu/rpkm67-auth/internal/token" + "github.com/isd-sgcu/rpkm67-auth/internal/user" + proto "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/auth/auth/v1" + userProto "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/auth/user/v1" + "github.com/isd-sgcu/rpkm67-model/constant" + "go.uber.org/zap" + "golang.org/x/oauth2" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type Service interface { + proto.AuthServiceServer +} + +type serviceImpl struct { + proto.UnimplementedAuthServiceServer + conf *config.AuthConfig + oauthConfig *oauth2.Config + oauthClient oauth.GoogleOauthClient + userSvc user.Service + tokenSvc token.Service + utils AuthUtils + log *zap.Logger +} + +func NewService(conf *config.AuthConfig, oauthConfig *oauth2.Config, oauthClient oauth.GoogleOauthClient, userSvc user.Service, tokenSvc token.Service, utils AuthUtils, log *zap.Logger) Service { + return &serviceImpl{ + conf: conf, + oauthConfig: oauthConfig, + oauthClient: oauthClient, + userSvc: userSvc, + tokenSvc: tokenSvc, + utils: utils, + log: log, + } +} + +func (s *serviceImpl) Validate(_ context.Context, in *proto.ValidateRequest) (res *proto.ValidateResponse, err error) { + userCredentials, err := s.tokenSvc.ValidateToken(in.AccessToken) + if err != nil { + s.log.Named("Validate").Error("ValidateToken: ", zap.Error(err)) + return nil, status.Error(codes.Unauthenticated, err.Error()) + } + + return &proto.ValidateResponse{ + UserId: userCredentials.UserID, + Role: string(userCredentials.Role), + }, nil +} + +func (s *serviceImpl) RefreshToken(_ context.Context, in *proto.RefreshTokenRequest) (res *proto.RefreshTokenResponse, err error) { + credentials, err := s.tokenSvc.RefreshToken(in.RefreshToken) + if err != nil { + s.log.Named("RefreshToken").Error("RefreshToken: ", zap.Error(err)) + return nil, status.Error(codes.Internal, err.Error()) + } + + return &proto.RefreshTokenResponse{ + Credential: s.dtoToProtoCredential(credentials), + }, nil +} + +func (s *serviceImpl) GetGoogleLoginUrl(_ context.Context, in *proto.GetGoogleLoginUrlRequest) (res *proto.GetGoogleLoginUrlResponse, err error) { + URL, err := url.Parse(s.oauthConfig.Endpoint.AuthURL) + if err != nil { + s.log.Named("GetGoogleLoginUrl").Error("Parse: ", zap.Error(err)) + return nil, status.Error(codes.Internal, "Cannot parse Google OAuth URL") + } + parameters := url.Values{} + parameters.Add("client_id", s.oauthConfig.ClientID) + parameters.Add("scope", strings.Join(s.oauthConfig.Scopes, " ")) + parameters.Add("redirect_uri", s.oauthConfig.RedirectURL) + parameters.Add("response_type", "code") + URL.RawQuery = parameters.Encode() + url := URL.String() + + return &proto.GetGoogleLoginUrlResponse{ + Url: url, + }, nil +} + +func (s *serviceImpl) VerifyGoogleLogin(_ context.Context, in *proto.VerifyGoogleLoginRequest) (res *proto.VerifyGoogleLoginResponse, err error) { + code := in.Code + if code == "" { + return nil, status.Error(codes.InvalidArgument, "No code is provided") + } + + email, err := s.oauthClient.GetUserEmail(code) + if err != nil { + s.log.Named("VerifyGoogleLogin").Error("GetUserEmail: ", zap.Error(err)) + switch err.Error() { + case "Invalid code": + return nil, status.Error(codes.InvalidArgument, "Invalid code") + default: + return nil, status.Error(codes.Internal, err.Error()) + } + } + + if s.conf.CheckChulaEmail && !IsEmailChulaStudent(email) { + return nil, status.Error(codes.Unauthenticated, "Email is not a Chula student") + } + + user, err := s.userSvc.FindByEmail(context.Background(), &userProto.FindByEmailRequest{Email: email}) + if err != nil { + st, ok := status.FromError(err) + if !ok { + s.log.Named("VerifyGoogleLogin").Error("FindByEmail: ", zap.Error(err)) + return nil, status.Error(codes.Internal, err.Error()) + } + switch st.Code() { + case codes.NotFound: + s.log.Named("VerifyGoogleLogin").Info("User not found, creating new user") + role := "user" + if s.utils.IsStudentIdInMap(email) { + role = "staff" + } + + createUser := &userProto.CreateUserRequest{ + Email: email, + Role: role, + } + + createdUser, err := s.userSvc.Create(context.Background(), createUser) + if err != nil { + s.log.Named("VerifyGoogleLogin").Error("Create: ", zap.Error(err)) + return nil, err + } + + credentials, err := s.tokenSvc.GetCredentials(createdUser.User.Id, constant.Role(createdUser.User.Role)) + if err != nil { + s.log.Named("VerifyGoogleLogin").Error("GetCredentials: ", zap.Error(err)) + return nil, status.Error(codes.Internal, err.Error()) + } + + return &proto.VerifyGoogleLoginResponse{ + Credential: s.dtoToProtoCredential(credentials), + UserId: createdUser.User.Id, + }, nil + + default: + s.log.Named("VerifyGoogleLogin").Error("FindByEmail: ", zap.Error(err)) + return nil, err + } + } + + credentials, err := s.tokenSvc.GetCredentials(user.User.Id, constant.Role(user.User.Role)) + if err != nil { + s.log.Named("VerifyGoogleLogin").Error("GetCredentials: ", zap.Error(err)) + return nil, status.Error(codes.Internal, err.Error()) + } + + return &proto.VerifyGoogleLoginResponse{ + Credential: s.dtoToProtoCredential(credentials), + UserId: user.User.Id, + }, nil + +} + +func (s *serviceImpl) dtoToProtoCredential(dto *dto.Credentials) *proto.Credential { + return &proto.Credential{ + AccessToken: dto.AccessToken, + RefreshToken: dto.RefreshToken, + ExpiresIn: int32(dto.ExpiresIn), + } +} diff --git a/internal/auth/auth.utils.go b/internal/auth/auth.utils.go new file mode 100644 index 0000000..898db0d --- /dev/null +++ b/internal/auth/auth.utils.go @@ -0,0 +1,79 @@ +package auth + +import ( + "encoding/json" + "os" + "strconv" + "strings" +) + +type AuthUtils interface { + IsStudentIdInMap(studentId string) bool +} + +type authUtilsImpl struct { + staffStudentIdMap map[string]interface{} +} + +func NewAuthUtils() AuthUtils { + staffStudentIdMap, err := extractMapFromFile("./config/staffs/staff.json") + if err != nil { + panic(err) + } + + return &authUtilsImpl{ + staffStudentIdMap: staffStudentIdMap, + } +} + +func IsEmailChulaStudent(email string) bool { + studentId := extractStudentIdFromEmail(email) + if len(studentId) != 10 { + return false + } + + year, err := strconv.ParseInt(studentId[:2], 10, 64) + if err != nil { + return false + } + + return year <= 67 && strings.HasSuffix(email, "@student.chula.ac.th") +} + +func (u *authUtilsImpl) IsStudentIdInMap(email string) bool { + studentId := extractStudentIdFromEmail(email) + + _, ok := u.staffStudentIdMap[studentId] + return ok +} + +func extractStudentIdFromEmail(email string) string { + // Example: "6932203021@student.chula.ac.th" -> "6932203021" + return email[:10] +} + +type marshalledJson struct { + // Other data fields in your original JSON structure + Staffs []string `json:"staffs"` +} + +func extractMapFromFile(filePath string) (map[string]interface{}, error) { + jsonData, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var data marshalledJson + err = json.Unmarshal(jsonData, &data) + if err != nil { + return nil, err + } + + extractedMap := make(map[string]interface{}) + + for _, element := range data.Staffs { + extractedMap[element] = element + } + + return extractedMap, nil +} diff --git a/internal/auth/bcrypt.utils.go b/internal/auth/bcrypt.utils.go new file mode 100644 index 0000000..b523bf7 --- /dev/null +++ b/internal/auth/bcrypt.utils.go @@ -0,0 +1,23 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +type BcryptUtils interface { + GenerateHashedPassword(password string) (string, error) + CompareHashedPassword(hashedPassword string, plainPassword string) error +} + +type bcryptUtilsImpl struct{} + +func NewBcryptUtils() BcryptUtils { + return &bcryptUtilsImpl{} +} + +func (u *bcryptUtilsImpl) GenerateHashedPassword(password string) (string, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(hashedPassword), err +} + +func (u *bcryptUtilsImpl) CompareHashedPassword(hashedPassword string, plainPassword string) error { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(plainPassword)) +} diff --git a/internal/auth/test/auth.service_test.go b/internal/auth/test/auth.service_test.go new file mode 100644 index 0000000..4a93145 --- /dev/null +++ b/internal/auth/test/auth.service_test.go @@ -0,0 +1,23 @@ +package test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type AuthServiceTest struct { + suite.Suite + // controller *gomock.Controller + // logger *zap.Logger +} + +func TestAuthService(t *testing.T) { + suite.Run(t, new(AuthServiceTest)) +} + +func (t *AuthServiceTest) SetupTest() {} + +func (t *AuthServiceTest) TestSignUpSuccess() { + +} diff --git a/internal/cache/cache.repository.go b/internal/cache/cache.repository.go new file mode 100644 index 0000000..ce78d06 --- /dev/null +++ b/internal/cache/cache.repository.go @@ -0,0 +1,54 @@ +package cache + +import ( + "context" + "encoding/json" + "time" + + "github.com/redis/go-redis/v9" +) + +type Repository interface { + SetValue(key string, value interface{}, ttl int) error + GetValue(key string, value interface{}) error + DeleteValue(key string) error +} + +type repositoryImpl struct { + client *redis.Client +} + +func NewRepository(client *redis.Client) Repository { + return &repositoryImpl{client: client} +} + +func (r *repositoryImpl) SetValue(key string, value interface{}, ttl int) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + v, err := json.Marshal(value) + if err != nil { + return err + } + + return r.client.Set(ctx, key, v, time.Duration(ttl)*time.Second).Err() +} + +func (r *repositoryImpl) GetValue(key string, value interface{}) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + v, err := r.client.Get(ctx, key).Result() + if err != nil { + return err + } + + return json.Unmarshal([]byte(v), value) +} + +func (r *repositoryImpl) DeleteValue(key string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return r.client.Del(ctx, key).Err() +} diff --git a/internal/dto/oauth.dto.go b/internal/dto/oauth.dto.go new file mode 100644 index 0000000..c87b74d --- /dev/null +++ b/internal/dto/oauth.dto.go @@ -0,0 +1,5 @@ +package dto + +type GoogleUserEmailResponse struct { + Email string `json:"email"` +} diff --git a/internal/dto/token.dto.go b/internal/dto/token.dto.go new file mode 100644 index 0000000..619531d --- /dev/null +++ b/internal/dto/token.dto.go @@ -0,0 +1,32 @@ +package dto + +import ( + "github.com/golang-jwt/jwt/v4" + "github.com/isd-sgcu/rpkm67-model/constant" +) + +type Credentials struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +type UserCredentials struct { + UserID string `json:"user_id"` + Role constant.Role `json:"role"` +} + +type AuthPayload struct { + jwt.RegisteredClaims + UserId string `json:"user_id"` + Role constant.Role `json:"role"` +} + +type RefreshTokenCache struct { + UserID string `json:"user_id"` + Role constant.Role `json:"role"` +} + +type ResetPasswordTokenCache struct { + UserID string `json:"user_id"` +} diff --git a/internal/jwt/jwt.service.go b/internal/jwt/jwt.service.go new file mode 100644 index 0000000..989e65b --- /dev/null +++ b/internal/jwt/jwt.service.go @@ -0,0 +1,60 @@ +package jwt + +import ( + "fmt" + "time" + + _jwt "github.com/golang-jwt/jwt/v4" + "github.com/isd-sgcu/rpkm67-auth/config" + "github.com/isd-sgcu/rpkm67-auth/internal/dto" + "github.com/isd-sgcu/rpkm67-model/constant" + "github.com/pkg/errors" + "go.uber.org/zap" +) + +type Service interface { + CreateToken(userId string, role constant.Role) (string, error) + ValidateToken(token string) (*_jwt.Token, error) + GetConfig() *config.JwtConfig +} + +type serviceImpl struct { + config config.JwtConfig + strategy JwtStrategy + jwtUtils JwtUtils + log *zap.Logger +} + +func NewService(config config.JwtConfig, strategy JwtStrategy, jwtUtils JwtUtils, log *zap.Logger) Service { + return &serviceImpl{config: config, strategy: strategy, jwtUtils: jwtUtils, log: log} +} + +func (s *serviceImpl) CreateToken(userId string, role constant.Role) (string, error) { + payloads := dto.AuthPayload{ + RegisteredClaims: _jwt.RegisteredClaims{ + Issuer: s.config.Issuer, + ExpiresAt: s.jwtUtils.GetNumericDate(time.Now().Add(time.Second * time.Duration(s.config.AccessTTL))), + IssuedAt: s.jwtUtils.GetNumericDate(time.Now()), + }, + UserId: userId, + Role: role, + } + + token := s.jwtUtils.GenerateJwtToken(_jwt.SigningMethodHS256, payloads) + + tokenStr, err := s.jwtUtils.SignedTokenString(token, s.config.Secret) + if err != nil { + s.log.Named("CreateToken").Error("SignedTokenString: ", zap.Error(err)) + return "", errors.New(fmt.Sprintf("Error while signing the token due to: %s", err.Error())) + } + + return tokenStr, nil +} + +func (s *serviceImpl) ValidateToken(token string) (*_jwt.Token, error) { + return s.jwtUtils.ParseToken(token, s.strategy.AuthDecode) +} + +func (s *serviceImpl) GetConfig() *config.JwtConfig { + return &s.config +} diff --git a/internal/jwt/jwt.strategy.go b/internal/jwt/jwt.strategy.go new file mode 100644 index 0000000..3afbacc --- /dev/null +++ b/internal/jwt/jwt.strategy.go @@ -0,0 +1,28 @@ +package jwt + +import ( + "fmt" + + "github.com/golang-jwt/jwt/v4" + "github.com/pkg/errors" +) + +type JwtStrategy interface { + AuthDecode(token *jwt.Token) (interface{}, error) +} + +type jwtStrategyImpl struct { + secret string +} + +func NewJwtStrategy(secret string) JwtStrategy { + return &jwtStrategyImpl{secret: secret} +} + +func (s *jwtStrategyImpl) AuthDecode(token *jwt.Token) (interface{}, error) { + if _, isValid := token.Method.(*jwt.SigningMethodHMAC); !isValid { + return nil, errors.New(fmt.Sprintf("invalid token %v\n", token.Header["alg"])) + } + + return []byte(s.secret), nil +} diff --git a/internal/jwt/jwt.utils.go b/internal/jwt/jwt.utils.go new file mode 100644 index 0000000..eafdb7d --- /dev/null +++ b/internal/jwt/jwt.utils.go @@ -0,0 +1,36 @@ +package jwt + +import ( + "time" + + "github.com/golang-jwt/jwt/v4" +) + +type JwtUtils interface { + GenerateJwtToken(method jwt.SigningMethod, payloads jwt.Claims) *jwt.Token + GetNumericDate(time time.Time) *jwt.NumericDate + SignedTokenString(token *jwt.Token, secret string) (string, error) + ParseToken(tokenStr string, keyFunc jwt.Keyfunc) (*jwt.Token, error) +} + +type jwtUtilImpl struct{} + +func NewJwtUtils() JwtUtils { + return &jwtUtilImpl{} +} + +func (u *jwtUtilImpl) GenerateJwtToken(method jwt.SigningMethod, payloads jwt.Claims) *jwt.Token { + return jwt.NewWithClaims(method, payloads) +} + +func (u *jwtUtilImpl) GetNumericDate(time time.Time) *jwt.NumericDate { + return jwt.NewNumericDate(time) +} + +func (u *jwtUtilImpl) SignedTokenString(token *jwt.Token, secret string) (string, error) { + return token.SignedString([]byte(secret)) +} + +func (u *jwtUtilImpl) ParseToken(tokenStr string, keyFunc jwt.Keyfunc) (*jwt.Token, error) { + return jwt.Parse(tokenStr, keyFunc) +} diff --git a/internal/oauth/google-oauth.client.go b/internal/oauth/google-oauth.client.go new file mode 100644 index 0000000..749522c --- /dev/null +++ b/internal/oauth/google-oauth.client.go @@ -0,0 +1,66 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + + "github.com/isd-sgcu/rpkm67-auth/internal/dto" + "go.uber.org/zap" + "golang.org/x/oauth2" +) + +type GoogleOauthClient interface { + GetUserEmail(code string) (string, error) +} + +type googleOauthClientImpl struct { + oauthConfig *oauth2.Config + log *zap.Logger +} + +func NewGoogleOauthClient(oauthConfig *oauth2.Config, log *zap.Logger) GoogleOauthClient { + return &googleOauthClientImpl{ + oauthConfig, + log, + } +} + +var ( + InvalidCode = errors.New("Invalid code") + HttpError = errors.New("Unable to get user info") + IOError = errors.New("Unable to read google response") + InvalidFormat = errors.New("Google sent unexpected format") +) + +func (c *googleOauthClientImpl) GetUserEmail(code string) (string, error) { + token, err := c.oauthConfig.Exchange(context.TODO(), code) + if err != nil { + c.log.Named("GetUserEmail").Error("Exchange: ", zap.Error(err)) + return "", InvalidCode + } + + resp, err := http.Get("https://www.googleapis.com/oauth2/v2/userinfo?access_token=" + url.QueryEscape(token.AccessToken)) + if err != nil { + c.log.Named("GetUserEmail").Error("Get: ", zap.Error(err)) + return "", HttpError + } + defer resp.Body.Close() + + response, err := io.ReadAll(resp.Body) + if err != nil { + c.log.Named("GetUserEmail").Error("ReadAll: ", zap.Error(err)) + return "", IOError + } + + var parsedResponse dto.GoogleUserEmailResponse + if err = json.Unmarshal(response, &parsedResponse); err != nil { + c.log.Named("GetUserEmail").Error("Unmarshal: ", zap.Error(err)) + return "", InvalidFormat + } + + return parsedResponse.Email, nil +} diff --git a/internal/token/token.service.go b/internal/token/token.service.go new file mode 100644 index 0000000..03ad686 --- /dev/null +++ b/internal/token/token.service.go @@ -0,0 +1,212 @@ +package token + +import ( + "errors" + "fmt" + "time" + + _jwt "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/isd-sgcu/rpkm67-auth/config" + "github.com/isd-sgcu/rpkm67-auth/internal/cache" + "github.com/isd-sgcu/rpkm67-auth/internal/dto" + "github.com/isd-sgcu/rpkm67-auth/internal/jwt" + "github.com/isd-sgcu/rpkm67-model/constant" + "go.uber.org/zap" +) + +type Service interface { + GetCredentials(userId string, role constant.Role) (*dto.Credentials, error) + CreateCredentials(userId string, role constant.Role) (*dto.Credentials, error) + RefreshToken(refreshToken string) (*dto.Credentials, error) + ValidateToken(token string) (*dto.UserCredentials, error) + GetConfig() *config.JwtConfig +} + +type serviceImpl struct { + jwtService jwt.Service + cache cache.Repository + tokenUtils TokenUtils + log *zap.Logger +} + +func NewService(jwtService jwt.Service, cache cache.Repository, tokenUtils TokenUtils, log *zap.Logger) Service { + return &serviceImpl{ + jwtService: jwtService, + cache: cache, + tokenUtils: tokenUtils, + log: log, + } +} + +func (s *serviceImpl) GetCredentials(userId string, role constant.Role) (*dto.Credentials, error) { + credentials := &dto.Credentials{} + err := s.cache.GetValue(sessionKey(userId), credentials) + if err != nil { + s.log.Named("tokenSvc").Named("GetCredentials").Info("No session found in cache for user", zap.String("userId", userId)) + credentials, err = s.CreateCredentials(userId, role) + if err != nil { + s.log.Named("GetCredentials").Error("CreateCredentials: ", zap.Error(err)) + return nil, err + } + } + + _, err = s.jwtService.ValidateToken(credentials.AccessToken) + if err != nil { // still have refreshToken but accessToken is expired + err := s.cache.DeleteValue(sessionKey(userId)) + if err != nil { + s.log.Named("GetCredentials").Error("DeleteValue: ", zap.Error(err)) + return nil, err + } + + accessToken, err := s.jwtService.CreateToken(userId, role) + if err != nil { + s.log.Named("GetCredentials").Error("CreateToken: ", zap.Error(err)) + return nil, err + } + + newCredentials := &dto.Credentials{ + AccessToken: accessToken, + RefreshToken: credentials.RefreshToken, + ExpiresIn: s.jwtService.GetConfig().AccessTTL, + } + + err = s.cache.SetValue(sessionKey(userId), newCredentials, s.jwtService.GetConfig().AccessTTL) + if err != nil { + s.log.Named("GetCredentials").Error("SetValue: ", zap.Error(err)) + return nil, err + } + + return newCredentials, nil + } + + return credentials, nil +} + +func (s *serviceImpl) CreateCredentials(userId string, role constant.Role) (*dto.Credentials, error) { + accessToken, err := s.jwtService.CreateToken(userId, role) + if err != nil { + s.log.Named("CreateCredentials").Error("CreateToken: ", zap.Error(err)) + return nil, err + } + + refreshToken := createRefreshToken() + + err = s.cache.SetValue(refreshKey(refreshToken), &dto.RefreshTokenCache{ + UserID: userId, + Role: role, + }, s.jwtService.GetConfig().RefreshTTL) + if err != nil { + s.log.Named("CreateCredentials").Error("SetValue refresh: ", zap.Error(err)) + return nil, err + } + + credentials := &dto.Credentials{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: s.jwtService.GetConfig().AccessTTL, + } + + err = s.cache.SetValue(sessionKey(userId), credentials, s.jwtService.GetConfig().AccessTTL) + if err != nil { + s.log.Named("CreateCredentials").Error("SetValue session: ", zap.Error(err)) + return nil, err + } + + return credentials, nil +} + +func (s *serviceImpl) RefreshToken(refreshToken string) (*dto.Credentials, error) { + refreshCache := &dto.RefreshTokenCache{} + err := s.cache.GetValue(refreshKey(refreshToken), refreshCache) + if err != nil { + s.log.Named("RefreshToken").Error("GetValue: ", zap.Error(err)) + return nil, err + } else if (refreshCache == &dto.RefreshTokenCache{}) { + s.log.Named("RefreshToken").Info("GetValue: refresh token not found") + return nil, fmt.Errorf("refresh token not found") + } + + err = s.cache.DeleteValue(refreshKey(refreshToken)) + if err != nil { + s.log.Named("RefreshToken").Error("DeleteValue refresh: ", zap.Error(err)) + return nil, err + } + + err = s.cache.DeleteValue(sessionKey(refreshCache.UserID)) + if err != nil { + s.log.Named("RefreshToken").Error("DeleteValue session: ", zap.Error(err)) + return nil, err + } + + credentials, err := s.CreateCredentials(refreshCache.UserID, refreshCache.Role) + if err != nil { + s.log.Named("RefreshToken").Error("CreateCredentials: ", zap.Error(err)) + return nil, err + } + + return credentials, nil +} + +func (s *serviceImpl) ValidateToken(token string) (*dto.UserCredentials, error) { + jwtToken, err := s.jwtService.ValidateToken(token) + if err != nil { + s.log.Named("ValidateToken").Error("ValidateToken: ", zap.Error(err)) + return nil, err + } + + payloads := jwtToken.Claims.(_jwt.MapClaims) + if payloads["iss"] != s.jwtService.GetConfig().Issuer { + return nil, errors.New("invalid token") + } + + if time.Unix(int64(payloads["exp"].(float64)), 0).Before(time.Now()) { + return nil, errors.New("expired token") + } + + credentials := &dto.Credentials{} + + err = s.cache.GetValue(sessionKey(payloads["user_id"].(string)), credentials) + if err != nil { + s.log.Named("ValidateToken").Error("GetValue: ", zap.Error(err)) + return nil, err + } + + if token != credentials.AccessToken { + return nil, errors.New("invalid token") + } + + userId, ok := payloads["user_id"].(string) + if !ok { + s.log.Named("ValidateToken").Error("user_id not found in payloads") + return nil, fmt.Errorf("user_id not found in payloads") + } + + role, ok := payloads["role"] + if !ok { + s.log.Named("ValidateToken").Error("role not found in payloads") + return nil, fmt.Errorf("role not found in payloads") + } + + return &dto.UserCredentials{ + UserID: userId, + Role: constant.Role(role.(string)), + }, nil + +} + +func (s *serviceImpl) GetConfig() *config.JwtConfig { + return s.jwtService.GetConfig() +} + +func createRefreshToken() string { + return uuid.New().String() +} + +func refreshKey(refreshToken string) string { + return fmt.Sprintf("refresh:%s", refreshToken) +} + +func sessionKey(userId string) string { + return fmt.Sprintf("session:%s", userId) +} diff --git a/internal/token/token.utils.go b/internal/token/token.utils.go new file mode 100644 index 0000000..96a1dc2 --- /dev/null +++ b/internal/token/token.utils.go @@ -0,0 +1,18 @@ +package token + +import "github.com/google/uuid" + +type TokenUtils interface { + GetNewUUID() *uuid.UUID +} + +type tokenUtilsImpl struct{} + +func NewTokenUtils() TokenUtils { + return &tokenUtilsImpl{} +} + +func (u *tokenUtilsImpl) GetNewUUID() *uuid.UUID { + uuid := uuid.New() + return &uuid +} diff --git a/internal/user/test/user.service_test.go b/internal/user/test/user.service_test.go new file mode 100644 index 0000000..862ab18 --- /dev/null +++ b/internal/user/test/user.service_test.go @@ -0,0 +1,23 @@ +package test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type UserServiceTest struct { + suite.Suite + // controller *gomock.Controller + // logger *zap.Logger +} + +func TestUserService(t *testing.T) { + suite.Run(t, new(UserServiceTest)) +} + +func (t *UserServiceTest) SetupTest() {} + +func (t *UserServiceTest) TestSignUpSuccess() { + +} diff --git a/internal/user/user.repository.go b/internal/user/user.repository.go new file mode 100644 index 0000000..e93fe6b --- /dev/null +++ b/internal/user/user.repository.go @@ -0,0 +1,60 @@ +package user + +import ( + "github.com/google/uuid" + "github.com/isd-sgcu/rpkm67-model/model" + "gorm.io/gorm" +) + +type Repository interface { + FindOne(id string, user *model.User) error + FindByEmail(email string, user *model.User) error + Create(user *model.User, stamp *model.Stamp, group *model.Group) error + Update(id string, user *model.User) error + AssignGroup(id string, groupID *uuid.UUID) error +} + +type repositoryImpl struct { + Db *gorm.DB +} + +func NewRepository(db *gorm.DB) Repository { + return &repositoryImpl{Db: db} +} + +func (r *repositoryImpl) FindOne(id string, user *model.User) error { + return r.Db.Model(user).Preload("Stamp").First(user, "id = ?", id).Error +} + +func (r *repositoryImpl) FindByEmail(email string, user *model.User) error { + return r.Db.Model(user).Preload("Stamp").First(user, "email = ?", email).Error +} + +func (r *repositoryImpl) Create(user *model.User, stamp *model.Stamp, group *model.Group) error { + return r.Db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(user).Error; err != nil { + return err + } + + stamp.UserID = &user.ID + if err := tx.Create(stamp).Error; err != nil { + return err + } + user.Stamp = stamp + + group.LeaderID = &user.ID + if err := tx.Create(group).Error; err != nil { + return err + } + + return nil + }) +} + +func (r *repositoryImpl) Update(id string, user *model.User) error { + return r.Db.Model(user).Where("id = ?", id).Updates(user).Error +} + +func (r *repositoryImpl) AssignGroup(id string, groupID *uuid.UUID) error { + return r.Db.Model(&model.User{}).Where("id = ?", id).Update("group_id", groupID).Error +} diff --git a/internal/user/user.service.go b/internal/user/user.service.go new file mode 100644 index 0000000..d5d0c52 --- /dev/null +++ b/internal/user/user.service.go @@ -0,0 +1,116 @@ +package user + +import ( + "context" + "errors" + + proto "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/auth/user/v1" + "github.com/isd-sgcu/rpkm67-model/constant" + "github.com/isd-sgcu/rpkm67-model/model" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "gorm.io/gorm" +) + +type Service interface { + proto.UserServiceServer +} + +type serviceImpl struct { + proto.UnimplementedUserServiceServer + repo Repository + log *zap.Logger +} + +func NewService(repo Repository, log *zap.Logger) proto.UserServiceServer { + return &serviceImpl{ + repo: repo, + log: log, + } +} + +func (s *serviceImpl) Create(_ context.Context, req *proto.CreateUserRequest) (res *proto.CreateUserResponse, err error) { + createUser := &model.User{ + Email: req.Email, + Role: constant.Role(req.Role), + GroupID: nil, + } + newStamp := NewStampModel(&createUser.ID) + newGroup := NewGroupModel(&createUser.ID) + + err = s.repo.Create(createUser, newStamp, newGroup) + if err != nil { + s.log.Named("Create").Error("Create: ", zap.Error(err)) + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, status.Error(codes.AlreadyExists, "duplicate email") + } + return nil, err + } + + err = s.repo.AssignGroup(createUser.ID.String(), &newGroup.ID) + if err != nil { + s.log.Named("Create").Error("AssignGroup: ", zap.Error(err)) + return nil, err + } + createUser.GroupID = &newGroup.ID + + return &proto.CreateUserResponse{ + User: ModelToProto(createUser), + }, nil +} + +func (s *serviceImpl) FindOne(_ context.Context, req *proto.FindOneUserRequest) (res *proto.FindOneUserResponse, err error) { + user := &model.User{} + + err = s.repo.FindOne(req.Id, user) + if err != nil { + s.log.Named("FindOne").Error("FindOne: ", zap.Error(err)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Error(codes.NotFound, "user not found") + } + return nil, status.Error(codes.Internal, err.Error()) + } + + return &proto.FindOneUserResponse{ + User: ModelToProto(user), + }, nil +} + +func (s *serviceImpl) FindByEmail(_ context.Context, req *proto.FindByEmailRequest) (res *proto.FindByEmailResponse, err error) { + user := &model.User{} + + err = s.repo.FindByEmail(req.Email, user) + if err != nil { + s.log.Named("FindByEmail").Error("FindByEmail: ", zap.Error(err)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Error(codes.NotFound, "user not found") + } + return nil, status.Error(codes.Internal, err.Error()) + } + + return &proto.FindByEmailResponse{ + User: ModelToProto(user), + }, nil +} + +func (s *serviceImpl) Update(_ context.Context, req *proto.UpdateUserRequest) (res *proto.UpdateUserResponse, err error) { + updateUser, err := UpdateRequestToModel(req) + if err != nil { + s.log.Named("Update").Error("UpdateRequestToModel: ", zap.Error(err)) + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + err = s.repo.Update(req.Id, updateUser) + if err != nil { + s.log.Named("Update").Error("Update: ", zap.Error(err)) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Error(codes.NotFound, "user not found") + } + return nil, err + } + + return &proto.UpdateUserResponse{ + Success: true, + }, nil +} diff --git a/internal/user/user.utils.go b/internal/user/user.utils.go new file mode 100644 index 0000000..b9f9b86 --- /dev/null +++ b/internal/user/user.utils.go @@ -0,0 +1,94 @@ +package user + +import ( + "github.com/google/uuid" + proto "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/auth/user/v1" + stampProto "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/backend/stamp/v1" + "github.com/isd-sgcu/rpkm67-model/constant" + "github.com/isd-sgcu/rpkm67-model/model" +) + +func ModelToProto(in *model.User) *proto.User { + protoUser := &proto.User{ + Id: in.ID.String(), + Email: in.Email, + Nickname: in.Nickname, + Title: in.Title, + Firstname: in.Firstname, + Lastname: in.Lastname, + Year: int32(in.Year), + Faculty: in.Faculty, + Tel: in.Tel, + ParentTel: in.ParentTel, + Parent: in.Parent, + FoodAllergy: in.FoodAllergy, + DrugAllergy: in.DrugAllergy, + Illness: in.Illness, + Role: in.Role.String(), + PhotoKey: in.PhotoKey, + PhotoUrl: in.PhotoUrl, + Baan: in.Baan, + GroupId: in.GroupID.String(), + ReceiveGift: int32(in.ReceiveGift), + Stamp: &stampProto.Stamp{ + Id: in.Stamp.ID.String(), + PointA: int32(in.Stamp.PointA), + PointB: int32(in.Stamp.PointB), + PointC: int32(in.Stamp.PointC), + PointD: int32(in.Stamp.PointD), + Stamp: in.Stamp.Stamp, + }, + } + + return protoUser +} + +func UpdateRequestToModel(in *proto.UpdateUserRequest) (*model.User, error) { + user := &model.User{ + Nickname: in.Nickname, + Title: in.Title, + Firstname: in.Firstname, + Lastname: in.Lastname, + Year: int(in.Year), + Faculty: in.Faculty, + Tel: in.Tel, + ParentTel: in.ParentTel, + Parent: in.Parent, + FoodAllergy: in.FoodAllergy, + DrugAllergy: in.DrugAllergy, + Illness: in.Illness, + Role: constant.Role(in.Role), + PhotoKey: in.PhotoKey, + PhotoUrl: in.PhotoUrl, + Baan: in.Baan, + ReceiveGift: int(in.ReceiveGift), + } + + if in.GroupId != "" { + groupId, err := uuid.Parse(in.GroupId) + if err != nil { + return nil, err + } + user.GroupID = &groupId + } + + return user, nil +} + +func NewStampModel(userId *uuid.UUID) *model.Stamp { + return &model.Stamp{ + UserID: userId, + PointA: 0, + PointB: 0, + PointC: 0, + PointD: 0, + Stamp: "00000000000", + } +} + +func NewGroupModel(userId *uuid.UUID) *model.Group { + return &model.Group{ + LeaderID: userId, + IsConfirmed: false, + } +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..3a5ab90 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,18 @@ +package logger + +import ( + "github.com/isd-sgcu/rpkm67-auth/config" + "go.uber.org/zap" +) + +func New(conf *config.Config) *zap.Logger { + var logger *zap.Logger + + if conf.App.IsDevelopment() { + logger = zap.Must(zap.NewDevelopment()) + } else { + logger = zap.Must(zap.NewProduction()) + } + + return logger +} diff --git a/microservices/auth/staff.template.json b/microservices/auth/staff.template.json new file mode 100644 index 0000000..2d355e8 --- /dev/null +++ b/microservices/auth/staff.template.json @@ -0,0 +1,6 @@ +{ + "staffs": [ + "6932203021", + "6932203121" + ] +} \ No newline at end of file diff --git a/microservices/prometheus/prometheus.yml b/microservices/prometheus/prometheus.yml new file mode 100644 index 0000000..3d752c6 --- /dev/null +++ b/microservices/prometheus/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: gateway-api + static_configs: + - targets: ['gateway:3001'] + metrics_path: '/api/v1/metrics' \ No newline at end of file diff --git a/mocks/user/user.repository.go b/mocks/user/user.repository.go new file mode 100644 index 0000000..aefa4d4 --- /dev/null +++ b/mocks/user/user.repository.go @@ -0,0 +1,91 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/user/user.repository.go + +// Package mock_user is a generated GoMock package. +package mock_user + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + model "github.com/isd-sgcu/rpkm67-model/model" +) + +// MockRepository is a mock of Repository interface. +type MockRepository struct { + ctrl *gomock.Controller + recorder *MockRepositoryMockRecorder +} + +// MockRepositoryMockRecorder is the mock recorder for MockRepository. +type MockRepositoryMockRecorder struct { + mock *MockRepository +} + +// NewMockRepository creates a new mock instance. +func NewMockRepository(ctrl *gomock.Controller) *MockRepository { + mock := &MockRepository{ctrl: ctrl} + mock.recorder = &MockRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockRepository) Create(user *model.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", user) + ret0, _ := ret[0].(error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockRepositoryMockRecorder) Create(user interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), user) +} + +// FindByEmail mocks base method. +func (m *MockRepository) FindByEmail(email string, user *model.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByEmail", email, user) + ret0, _ := ret[0].(error) + return ret0 +} + +// FindByEmail indicates an expected call of FindByEmail. +func (mr *MockRepositoryMockRecorder) FindByEmail(email, user interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByEmail", reflect.TypeOf((*MockRepository)(nil).FindByEmail), email, user) +} + +// FindOne mocks base method. +func (m *MockRepository) FindOne(id string, user *model.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOne", id, user) + ret0, _ := ret[0].(error) + return ret0 +} + +// FindOne indicates an expected call of FindOne. +func (mr *MockRepositoryMockRecorder) FindOne(id, user interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockRepository)(nil).FindOne), id, user) +} + +// Update mocks base method. +func (m *MockRepository) Update(id string, user *model.User) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", id, user) + ret0, _ := ret[0].(error) + return ret0 +} + +// Update indicates an expected call of Update. +func (mr *MockRepositoryMockRecorder) Update(id, user interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), id, user) +} diff --git a/mocks/user/user.service.go b/mocks/user/user.service.go new file mode 100644 index 0000000..9b4a8f2 --- /dev/null +++ b/mocks/user/user.service.go @@ -0,0 +1,108 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/user/user.service.go + +// Package mock_user is a generated GoMock package. +package mock_user + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + v1 "github.com/isd-sgcu/rpkm67-go-proto/rpkm67/auth/user/v1" +) + +// MockService is a mock of Service interface. +type MockService struct { + ctrl *gomock.Controller + recorder *MockServiceMockRecorder +} + +// MockServiceMockRecorder is the mock recorder for MockService. +type MockServiceMockRecorder struct { + mock *MockService +} + +// NewMockService creates a new mock instance. +func NewMockService(ctrl *gomock.Controller) *MockService { + mock := &MockService{ctrl: ctrl} + mock.recorder = &MockServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockService) EXPECT() *MockServiceMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockService) Create(arg0 context.Context, arg1 *v1.CreateUserRequest) (*v1.CreateUserResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0, arg1) + ret0, _ := ret[0].(*v1.CreateUserResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockServiceMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockService)(nil).Create), arg0, arg1) +} + +// FindByEmail mocks base method. +func (m *MockService) FindByEmail(arg0 context.Context, arg1 *v1.FindByEmailRequest) (*v1.FindByEmailResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindByEmail", arg0, arg1) + ret0, _ := ret[0].(*v1.FindByEmailResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindByEmail indicates an expected call of FindByEmail. +func (mr *MockServiceMockRecorder) FindByEmail(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindByEmail", reflect.TypeOf((*MockService)(nil).FindByEmail), arg0, arg1) +} + +// FindOne mocks base method. +func (m *MockService) FindOne(arg0 context.Context, arg1 *v1.FindOneUserRequest) (*v1.FindOneUserResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindOne", arg0, arg1) + ret0, _ := ret[0].(*v1.FindOneUserResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindOne indicates an expected call of FindOne. +func (mr *MockServiceMockRecorder) FindOne(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindOne", reflect.TypeOf((*MockService)(nil).FindOne), arg0, arg1) +} + +// Update mocks base method. +func (m *MockService) Update(arg0 context.Context, arg1 *v1.UpdateUserRequest) (*v1.UpdateUserResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", arg0, arg1) + ret0, _ := ret[0].(*v1.UpdateUserResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockServiceMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockService)(nil).Update), arg0, arg1) +} + +// mustEmbedUnimplementedUserServiceServer mocks base method. +func (m *MockService) mustEmbedUnimplementedUserServiceServer() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "mustEmbedUnimplementedUserServiceServer") +} + +// mustEmbedUnimplementedUserServiceServer indicates an expected call of mustEmbedUnimplementedUserServiceServer. +func (mr *MockServiceMockRecorder) mustEmbedUnimplementedUserServiceServer() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "mustEmbedUnimplementedUserServiceServer", reflect.TypeOf((*MockService)(nil).mustEmbedUnimplementedUserServiceServer)) +}