From f7a7b192751c1832fbe8c866a7b1419be77264d2 Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Mon, 15 Dec 2025 22:28:01 +0300 Subject: [PATCH] add auth --- Containerfile.auth | 48 ++++ Makefile | 35 ++- cmd/auth/main.go | 32 +++ .../000002_create_users_table.down.sql | 1 + .../000002_create_users_table.up.sql | 10 + compose.yaml | 36 +++ go.mod | 7 +- go.sum | 11 + infrastructure/auth/.env.template | 13 + infrastructure/core/.env.template | 1 + internal/auth/config/config.go | 76 ++++++ internal/auth/domain/errors.go | 11 + internal/auth/domain/id.go | 13 + internal/auth/domain/user.go | 19 ++ internal/auth/handler/grpc/auth.go | 86 +++++++ internal/auth/handler/grpc/errors.go | 13 + internal/auth/handler/http/auth.go | 185 ++++++++++++++ internal/auth/handler/http/user.go | 186 ++++++++++++++ internal/auth/interceptor/logger.go | 24 ++ internal/auth/repository/postgres/user.go | 106 ++++++++ internal/auth/server/server.go | 229 ++++++++++++++++++ internal/auth/service/auth.go | 164 +++++++++++++ internal/lms/config/config.go | 2 + internal/lms/gateway/gateway.go | 58 +++++ internal/lms/server/server.go | 20 ++ 25 files changed, 1383 insertions(+), 3 deletions(-) create mode 100644 Containerfile.auth create mode 100644 cmd/auth/main.go create mode 100644 cmd/migrate/migrations/000002_create_users_table.down.sql create mode 100644 cmd/migrate/migrations/000002_create_users_table.up.sql create mode 100644 infrastructure/auth/.env.template create mode 100644 internal/auth/config/config.go create mode 100644 internal/auth/domain/errors.go create mode 100644 internal/auth/domain/id.go create mode 100644 internal/auth/domain/user.go create mode 100644 internal/auth/handler/grpc/auth.go create mode 100644 internal/auth/handler/grpc/errors.go create mode 100644 internal/auth/handler/http/auth.go create mode 100644 internal/auth/handler/http/user.go create mode 100644 internal/auth/interceptor/logger.go create mode 100644 internal/auth/repository/postgres/user.go create mode 100644 internal/auth/server/server.go create mode 100644 internal/auth/service/auth.go create mode 100644 internal/lms/gateway/gateway.go diff --git a/Containerfile.auth b/Containerfile.auth new file mode 100644 index 0000000..8b2d5d0 --- /dev/null +++ b/Containerfile.auth @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 +ARG GOARCH=amd64 +ARG GOOS=linux +ARG CGO_ENABLED=0 +ARG BUILD_TIME=unknown +ARG VERSION=unknown + +# Stage 1: Build +FROM docker.io/golang:1.24-alpine AS build + +ARG GOOS +ARG GOARCH +ARG CGO_ENABLED +ARG BUILD_TIME +ARG VERSION + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY . . + +RUN --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=cache,target=/go/pkg/mod \ + CGO_ENABLED=${CGO_ENABLED} \ + GOOS=${GOOS} \ + GOARCH=${GOARCH} \ + go build -trimpath \ + -ldflags "-s -w -X main.BuildTime=${BUILD_TIME} -X main.Version=${VERSION}" \ + -o /out/auth ./cmd/auth && \ + chmod +x /out/auth + +# Stage 2: Runtime +FROM gcr.io/distroless/static-debian13:latest AS runtime + +ARG BUILD_TIME +ARG VERSION + +WORKDIR /app + +COPY --from=build --chown=1000:1000 /out/auth /app/bin/auth + +EXPOSE 8081 50052 + +ENTRYPOINT ["/app/bin/auth"] diff --git a/Makefile b/Makefile index 0d03399..e693e4c 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,10 @@ BINARY_DIR=bin # Protobuf parameters PROTOC=protoc PROTO_DIR=api/proto -PROTO_FILE=$(PROTO_DIR)/auth.proto $(PROTO_DIR)/competition.proto $(PROTO_DIR)/task.proto +PROTO_FILE=$(PROTO_DIR)/auth.proto $(PROTO_DIR)/competition.proto $(PROTO_DIR)/results.proto $(PROTO_DIR)/review.proto $(PROTO_DIR)/submission.proto $(PROTO_DIR)/task.proto $(PROTO_DIR)/user.proto PROTO_OUT=. -.PHONY: install i generate gen generate-gw test build run migrate lint fmt format clean help +.PHONY: install i generate gen generate-gw test build run migrate lint fmt format clean help codegen examples install: $(GODOWNLOAD) @@ -65,6 +65,33 @@ format: fmt clean: rm -rf bin/* +codegen: + @if [ -z "$(SERVICE)" ]; then \ + echo "Usage: make codegen SERVICE="; \ + echo "Example: make codegen SERVICE=auth"; \ + exit 1; \ + fi + $(GOBUILD) -o ./$(BINARY_DIR)/codegen ./cmd/codegen + ./$(BINARY_DIR)/codegen $(SERVICE) ./ + +examples: + @echo "📖 Datarush-Go Integration Examples" + @echo "" + @echo "Files:" + @echo " - SQL_BUILDER_ADVANCED.md - Детальные примеры SQL Builder" + @echo " - SQL_BUILDER_CODEGEN.md - Документация Codegen" + @echo " - SQL_EXAMPLES.sql - SQL схемы и примеры" + @echo " - INTEGRATION_EXAMPLES.sh - Интеграционные примеры" + @echo "" + @echo "Быстрый старт:" + @echo " 1. make codegen SERVICE=payment" + @echo " 2. Edit internal/payment/repository/postgres/repo.go" + @echo " 3. Edit internal/payment/service/service.go" + @echo " 4. go test ./internal/payment/..." + @echo "" + @cat INTEGRATION_EXAMPLES.sh + + help: @echo "Available commands:" @echo " install - Install all deps using go mod download" @@ -72,6 +99,9 @@ help: @echo " generate - Generate gRPC code" @echo " gen" @echo " protoc" + @echo " codegen - Generate service template" + @echo " Usage: make codegen SERVICE=" + @echo " examples - Show integration examples" @echo " test - Run tests" @echo " build - Build the binary" @echo " run - Run the application" @@ -80,6 +110,7 @@ help: @echo " fmt" @echo " clean - Clean build artifacts" + .DEFAULT_GOAL := help %: diff --git a/cmd/auth/main.go b/cmd/auth/main.go new file mode 100644 index 0000000..955bda6 --- /dev/null +++ b/cmd/auth/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "datarush/internal/auth/config" + "datarush/internal/auth/server" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + srv := server.New(cfg) + + if err := srv.Start(); err != nil { + log.Fatalf("failed to start server: %v", err) + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("shutting down auth server...") + srv.Stop() + log.Println("auth server stopped") +} diff --git a/cmd/migrate/migrations/000002_create_users_table.down.sql b/cmd/migrate/migrations/000002_create_users_table.down.sql new file mode 100644 index 0000000..c99ddcd --- /dev/null +++ b/cmd/migrate/migrations/000002_create_users_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; diff --git a/cmd/migrate/migrations/000002_create_users_table.up.sql b/cmd/migrate/migrations/000002_create_users_table.up.sql new file mode 100644 index 0000000..e485881 --- /dev/null +++ b/cmd/migrate/migrations/000002_create_users_table.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); diff --git a/compose.yaml b/compose.yaml index 1a6990b..eb00544 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,38 @@ name: order services: + auth: + build: + context: . + dockerfile: Containerfile.auth + depends_on: + postgres: + restart: false + condition: service_healthy + required: true + env_file: + - path: ./infrastructure/auth/.env.template + required: true + - path: ./infrastructure/auth/.env + required: false + ports: + - name: http + target: 8081 + published: 13443 + host_ip: 127.0.0.1 + protocol: tcp + app_protocol: http + - name: grpc + target: 50052 + published: 13444 + host_ip: 127.0.0.1 + protocol: tcp + app_protocol: http + networks: + - default + restart: unless-stopped + shm_size: 4mb + core: build: context: . @@ -18,6 +50,10 @@ services: restart: false condition: service_healthy required: true + auth: + restart: false + condition: service_started + required: true env_file: - path: ./infrastructure/core/.env.template required: true diff --git a/go.mod b/go.mod index 985639a..9d96342 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,10 @@ go 1.24.0 toolchain go1.24.9 require ( + github.com/Masterminds/squirrel v1.5.4 + github.com/gin-gonic/gin v1.10.0 github.com/go-playground/validator/v10 v10.28.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.19.0 github.com/google/uuid v1.6.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 @@ -13,6 +16,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.16.0 + golang.org/x/crypto v0.42.0 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 ) @@ -26,10 +30,11 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.42.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect diff --git a/go.sum b/go.sum index 7d12d80..af41754 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -33,6 +35,7 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -49,6 +52,9 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -72,6 +78,10 @@ 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/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -96,6 +106,7 @@ github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERS github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= diff --git a/infrastructure/auth/.env.template b/infrastructure/auth/.env.template new file mode 100644 index 0000000..1607dd9 --- /dev/null +++ b/infrastructure/auth/.env.template @@ -0,0 +1,13 @@ +AUTH_GRPC_PORT=50052 +AUTH_HTTP_PORT=8081 +AUTH_GRPC_ENABLE_REFLECTION=true + +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_USERNAME=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DATABASE=postgres + +JWT_SECRET=your-secret-key-change-in-production + +LOG_LEVEL=info diff --git a/infrastructure/core/.env.template b/infrastructure/core/.env.template index 916b1cc..e759de3 100644 --- a/infrastructure/core/.env.template +++ b/infrastructure/core/.env.template @@ -8,3 +8,4 @@ POSTGRES_PASSWORD=postgres POSTGRES_DATABASE=postgres REDIS_URI=redis://redis:6379 +AUTH_GRPC_ADDR=auth:50052 \ No newline at end of file diff --git a/internal/auth/config/config.go b/internal/auth/config/config.go new file mode 100644 index 0000000..c29d095 --- /dev/null +++ b/internal/auth/config/config.go @@ -0,0 +1,76 @@ +package config + +import ( + "fmt" + "log" + "net" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + GRPCPort int + GRPCEnableReflection bool + HTTPPort int + LogLevel string + DBHost string + DBPort int + DBUser string + DBPassword string + DBName string + JWTSecret string +} + +func Load() (*Config, error) { + _ = godotenv.Load() + + return &Config{ + GRPCPort: mustGetInt("AUTH_GRPC_PORT", 50052), + GRPCEnableReflection: mustGetBool("AUTH_GRPC_ENABLE_REFLECTION", false), + HTTPPort: mustGetInt("AUTH_HTTP_PORT", 8081), + LogLevel: getEnv("LOG_LEVEL", "info"), + DBHost: getEnv("POSTGRES_HOST", "localhost"), + DBPort: mustGetInt("POSTGRES_PORT", 5432), + DBUser: getEnv("POSTGRES_USERNAME", "postgres"), + DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"), + DBName: getEnv("POSTGRES_DATABASE", "postgres"), + JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-in-production"), + }, nil +} + +func getEnv(key, def string) string { + if val := os.Getenv(key); val != "" { + return val + } + return def +} + +func mustGetInt(key string, def int) int { + val := getEnv(key, strconv.Itoa(def)) + n, err := strconv.Atoi(val) + if err != nil { + log.Fatalf("invalid int for %s: %v", key, err) + } + return n +} + +func mustGetBool(key string, def bool) bool { + val := getEnv(key, strconv.FormatBool(def)) + b, err := strconv.ParseBool(val) + if err != nil { + log.Fatalf("invalid bool for %s: %v", key, err) + } + return b +} + +func (c Config) BuildPostgresConnStr() string { + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName) +} + +func (c Config) BuildPostgresDSN() string { + return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", + c.DBUser, c.DBPassword, net.JoinHostPort(c.DBHost, strconv.Itoa(c.DBPort)), c.DBName) +} diff --git a/internal/auth/domain/errors.go b/internal/auth/domain/errors.go new file mode 100644 index 0000000..48a88bf --- /dev/null +++ b/internal/auth/domain/errors.go @@ -0,0 +1,11 @@ +package domain + +import "errors" + +var ( + ErrUserNotFound = errors.New("user not found") + ErrInvalidEmail = errors.New("invalid email") + ErrInvalidPassword = errors.New("invalid password") + ErrUserAlreadyExists = errors.New("user already exists") + ErrInvalidToken = errors.New("invalid token") +) diff --git a/internal/auth/domain/id.go b/internal/auth/domain/id.go new file mode 100644 index 0000000..bebca73 --- /dev/null +++ b/internal/auth/domain/id.go @@ -0,0 +1,13 @@ +package domain + +import "github.com/google/uuid" + +type ID = uuid.UUID + +func NewID() ID { + return uuid.New() +} + +func ParseID(s string) (ID, error) { + return uuid.Parse(s) +} diff --git a/internal/auth/domain/user.go b/internal/auth/domain/user.go new file mode 100644 index 0000000..3b80a00 --- /dev/null +++ b/internal/auth/domain/user.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +type User struct { + ID ID + Email string + Username string + Password string + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserWithoutPassword struct { + ID ID + Email string + Username string + CreatedAt time.Time +} diff --git a/internal/auth/handler/grpc/auth.go b/internal/auth/handler/grpc/auth.go new file mode 100644 index 0000000..c051852 --- /dev/null +++ b/internal/auth/handler/grpc/auth.go @@ -0,0 +1,86 @@ +package grpc + +import ( + "context" + "log" + "strings" + + "datarush/internal/auth/domain" + "datarush/internal/auth/service" + pb "datarush/pkg/api/auth" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +type AuthHandler struct { + pb.UnimplementedAuthServiceServer + authService *service.AuthService +} + +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +func (h *AuthHandler) SignUp(ctx context.Context, req *pb.SignUpRequest) (*pb.SignUpResponse, error) { + token, err := h.authService.SignUp(ctx, req.Email, req.Username, req.Password) + if err != nil { + log.Printf("signup error: %v", err) + switch err { + case domain.ErrUserAlreadyExists: + return nil, status.Error(codes.AlreadyExists, "user already exists") + default: + return nil, status.Error(codes.Internal, "internal error") + } + } + + return &pb.SignUpResponse{Token: token}, nil +} + +func (h *AuthHandler) SignIn(ctx context.Context, req *pb.SignInRequest) (*pb.SignInResponse, error) { + token, err := h.authService.SignIn(ctx, req.Email, req.Password) + if err != nil { + log.Printf("signin error: %v", err) + switch err { + case domain.ErrInvalidPassword, domain.ErrUserNotFound: + return nil, status.Error(codes.Unauthenticated, "invalid credentials") + default: + return nil, status.Error(codes.Internal, "internal error") + } + } + + return &pb.SignInResponse{Token: token}, nil +} + +func (h *AuthHandler) ValidateToken(ctx context.Context, req *pb.ValidateTokenRequest) (*pb.ValidateTokenResponse, error) { + token := req.Token + + // If token is empty, try to get it from Authorization header (for gRPC-Gateway) + if token == "" { + md, ok := metadata.FromIncomingContext(ctx) + if ok { + authHeaders := md.Get("authorization") + if len(authHeaders) > 0 { + parts := strings.Split(authHeaders[0], " ") + if len(parts) == 2 && parts[0] == "Bearer" { + token = parts[1] + } + } + } + } + + if token == "" { + return nil, status.Error(codes.Unauthenticated, "missing token") + } + + user, err := h.authService.ValidateToken(ctx, token) + if err != nil { + log.Printf("validate token error: %v", err) + return nil, status.Error(codes.Unauthenticated, "invalid token") + } + + return &pb.ValidateTokenResponse{UserId: user.ID.String()}, nil +} diff --git a/internal/auth/handler/grpc/errors.go b/internal/auth/handler/grpc/errors.go new file mode 100644 index 0000000..16873c2 --- /dev/null +++ b/internal/auth/handler/grpc/errors.go @@ -0,0 +1,13 @@ +package grpc + +import ( + "log" +) + +type ErrorHandler struct{} + +func (e *ErrorHandler) Handle(err error) { + if err != nil { + log.Printf("error: %v", err) + } +} diff --git a/internal/auth/handler/http/auth.go b/internal/auth/handler/http/auth.go new file mode 100644 index 0000000..682c4b5 --- /dev/null +++ b/internal/auth/handler/http/auth.go @@ -0,0 +1,185 @@ +package http + +import ( + "encoding/json" + "io" + "log" + "net/http" + "strings" + + "datarush/internal/auth/domain" + "datarush/internal/auth/service" +) + +type SignUpRequest struct { + Email string `json:"email" binding:"required,email"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type SignInRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type TokenResponse struct { + Token string `json:"token"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + CreatedAt string `json:"created_at"` +} + +type ErrorResponse struct { + Detail string `json:"detail"` +} + +type AuthHandler struct { + authService *service.AuthService +} + +func NewAuthHandler(authService *service.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req SignUpRequest + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("read body error: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid request"}) + return + } + defer r.Body.Close() + + if err := json.Unmarshal(body, &req); err != nil { + log.Printf("signup validation error: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid request"}) + return + } + + if req.Email == "" || req.Username == "" || req.Password == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid request"}) + return + } + + token, err := h.authService.SignUp(r.Context(), req.Email, req.Username, req.Password) + if err != nil { + log.Printf("signup error: %v", err) + switch err { + case domain.ErrUserAlreadyExists: + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "user already exists"}) + default: + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "internal error"}) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(TokenResponse{Token: token}) +} + +func (h *AuthHandler) SignIn(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req SignInRequest + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("read body error: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid request"}) + return + } + defer r.Body.Close() + + if err := json.Unmarshal(body, &req); err != nil { + log.Printf("signin validation error: %v", err) + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid request"}) + return + } + + if req.Email == "" || req.Password == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid request"}) + return + } + + token, err := h.authService.SignIn(r.Context(), req.Email, req.Password) + if err != nil { + log.Printf("signin error: %v", err) + switch err { + case domain.ErrInvalidPassword, domain.ErrUserNotFound: + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid credentials"}) + default: + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "internal error"}) + } + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(TokenResponse{Token: token}) +} + +func (h *AuthHandler) GetMe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + token := parts[1] + + user, err := h.authService.ValidateToken(r.Context(), token) + if err != nil { + log.Printf("validate token error: %v", err) + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(UserResponse{ + ID: user.ID.String(), + Email: user.Email, + Username: user.Username, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) +} diff --git a/internal/auth/handler/http/user.go b/internal/auth/handler/http/user.go new file mode 100644 index 0000000..da85dde --- /dev/null +++ b/internal/auth/handler/http/user.go @@ -0,0 +1,186 @@ +package http + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "datarush/internal/auth/service" +) + +type UserAchievementResponse struct { + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + ReceivedAt string `json:"received_at"` +} + +type UserDetailResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + Avatar *string `json:"avatar"` + CreatedAt string `json:"created_at"` + Achievements []UserAchievementResponse `json:"achievements"` +} + +type StatResponse struct { + TotalAttempts int `json:"total_attempts"` + SolvedTasks int `json:"solved_tasks"` +} + +type UserHandler struct { + authService *service.AuthService +} + +func NewUserHandler(authService *service.AuthService) *UserHandler { + return &UserHandler{ + authService: authService, + } +} + +func (h *UserHandler) GetMe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract token from Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + token := parts[1] + + user, err := h.authService.ValidateToken(r.Context(), token) + if err != nil { + log.Printf("validate token error: %v", err) + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(UserDetailResponse{ + ID: user.ID.String(), + Email: user.Email, + Username: user.Username, + Avatar: nil, + CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + Achievements: []UserAchievementResponse{}, + }) +} + +func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + userID := r.Header.Get("X-User-ID") + if userID == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "invalid user_id"}) + return + } + + // Extract token from Authorization header for verification + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + token := parts[1] + + _, err := h.authService.ValidateToken(r.Context(), token) + if err != nil { + log.Printf("validate token error: %v", err) + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + // For now, return a simple user response + // In a real implementation, you would fetch from the database + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(UserDetailResponse{ + ID: userID, + Email: "user@example.com", + Username: "user", + Avatar: nil, + CreatedAt: "2024-12-15T10:30:00Z", + Achievements: []UserAchievementResponse{}, + }) +} + +func (h *UserHandler) GetMyStat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + token := parts[1] + + _, err := h.authService.ValidateToken(r.Context(), token) + if err != nil { + log.Printf("validate token error: %v", err) + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(ErrorResponse{Detail: "unauthorized"}) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(StatResponse{ + TotalAttempts: 0, + SolvedTasks: 0, + }) +} + +func (h *UserHandler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Return empty leaderboard for now + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]UserDetailResponse{}) +} diff --git a/internal/auth/interceptor/logger.go b/internal/auth/interceptor/logger.go new file mode 100644 index 0000000..0ebfbb7 --- /dev/null +++ b/internal/auth/interceptor/logger.go @@ -0,0 +1,24 @@ +package interceptor + +import ( + "context" + "log" + + "google.golang.org/grpc" +) + +type LoggerInterceptor struct{} + +func NewLoggerInterceptor() *LoggerInterceptor { + return &LoggerInterceptor{} +} + +func (li *LoggerInterceptor) UnaryServerInterceptor( + ctx context.Context, + req interface{}, + info *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (interface{}, error) { + log.Printf("gRPC call: %s", info.FullMethod) + return handler(ctx, req) +} diff --git a/internal/auth/repository/postgres/user.go b/internal/auth/repository/postgres/user.go new file mode 100644 index 0000000..9ccff56 --- /dev/null +++ b/internal/auth/repository/postgres/user.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "time" + + "datarush/internal/auth/domain" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" +) + +const ( + usersTable = "users" +) + +var psql = sq.StatementBuilder.PlaceholderFormat(sq.Dollar) + +type UserRepository struct { + db *sqlx.DB +} + +func NewUserRepository(db *sqlx.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(ctx context.Context, user *domain.User) error { + now := time.Now() + user.CreatedAt = now + user.UpdatedAt = now + + query := psql.Insert(usersTable). + Columns("id", "email", "username", "password", "created_at", "updated_at"). + Values(user.ID.String(), user.Email, user.Username, user.Password, user.CreatedAt, user.UpdatedAt) + + _, err := query.RunWith(r.db).ExecContext(ctx) + return err +} + +func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) { + query := psql.Select("id", "email", "username", "password", "created_at", "updated_at"). + From(usersTable). + Where(sq.Eq{"email": email}) + + sqlQuery, args, err := query.ToSql() + if err != nil { + return nil, err + } + + var user domain.User + err = r.db.GetContext(ctx, &user, sqlQuery, args...) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) GetByID(ctx context.Context, id domain.ID) (*domain.User, error) { + query := psql.Select("id", "email", "username", "password", "created_at", "updated_at"). + From(usersTable). + Where(sq.Eq{"id": id.String()}) + + sqlQuery, args, err := query.ToSql() + if err != nil { + return nil, err + } + + var user domain.User + err = r.db.GetContext(ctx, &user, sqlQuery, args...) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrUserNotFound + } + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) Update(ctx context.Context, user *domain.User) error { + user.UpdatedAt = time.Now() + + query := psql.Update(usersTable). + Set("email", user.Email). + Set("username", user.Username). + Set("password", user.Password). + Set("updated_at", user.UpdatedAt). + Where(sq.Eq{"id": user.ID.String()}) + + _, err := query.RunWith(r.db).ExecContext(ctx) + return err +} + +func (r *UserRepository) Delete(ctx context.Context, id domain.ID) error { + query := psql.Delete(usersTable). + Where(sq.Eq{"id": id.String()}) + + _, err := query.RunWith(r.db).ExecContext(ctx) + return err +} diff --git a/internal/auth/server/server.go b/internal/auth/server/server.go new file mode 100644 index 0000000..7411042 --- /dev/null +++ b/internal/auth/server/server.go @@ -0,0 +1,229 @@ +package server + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "strings" + "time" + + "datarush/internal/auth/config" + grpcHandlers "datarush/internal/auth/handler/grpc" + httpHandlers "datarush/internal/auth/handler/http" + authPostgresRepo "datarush/internal/auth/repository/postgres" + "datarush/internal/auth/service" + + pb "datarush/pkg/api/auth" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "google.golang.org/grpc" + "google.golang.org/grpc/reflection" +) + +const ( + httpReadTimeout = 10 * time.Second + httpWriteTimeout = 10 * time.Second + httpIdleTimeout = 60 * time.Second +) + +type Server struct { + grpcServer *grpc.Server + config *config.Config + db *sqlx.DB + httpServer *http.Server +} + +func New(cfg *config.Config) *Server { + return &Server{ + config: cfg, + } +} + +func (s *Server) Start() error { + // Connect to database + db, err := sqlx.Connect("postgres", s.config.BuildPostgresConnStr()) + if err != nil { + return fmt.Errorf("failed to connect to postgres: %w", err) + } + s.db = db + + // Create tables + if err := s.createTables(); err != nil { + return fmt.Errorf("failed to create tables: %w", err) + } + + // Register gRPC services + if err := s.registerGRPCServices(); err != nil { + return fmt.Errorf("failed to register gRPC services: %w", err) + } + + // Start gRPC server in a goroutine + go func() { + lis, err := net.Listen("tcp", fmt.Sprintf(":%d", s.config.GRPCPort)) + if err != nil { + log.Fatalf("failed to listen on grpc port: %v", err) + } + + log.Printf("starting gRPC server on port %d", s.config.GRPCPort) + if err := s.grpcServer.Serve(lis); err != nil { + log.Fatalf("failed to serve gRPC: %v", err) + } + }() + + // Start HTTP server + if err := s.startHTTPServer(); err != nil { + return fmt.Errorf("failed to start HTTP server: %w", err) + } + + return nil +} + +func (s *Server) registerGRPCServices() error { + s.grpcServer = grpc.NewServer() + + // Create repositories + userRepo := authPostgresRepo.NewUserRepository(s.db) + + // Create services + authService := service.NewAuthService(userRepo, s.config.JWTSecret) + + // Create and register handlers + authHandler := grpcHandlers.NewAuthHandler(authService) + pb.RegisterAuthServiceServer(s.grpcServer, authHandler) + + // Enable reflection if configured + if s.config.GRPCEnableReflection { + reflection.Register(s.grpcServer) + } + + return nil +} + +func (s *Server) startHTTPServer() error { + // Create repositories + userRepo := authPostgresRepo.NewUserRepository(s.db) + + // Create services + authService := service.NewAuthService(userRepo, s.config.JWTSecret) + + // Create HTTP handlers + authHandler := httpHandlers.NewAuthHandler(authService) + userHandler := httpHandlers.NewUserHandler(authService) + + // Create router + mux := http.NewServeMux() + + // Register routes with middleware wrapper + mux.HandleFunc("/api/v1/sign-up", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + authHandler.SignUp(w, r) + }) + + mux.HandleFunc("/api/v1/sign-in", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + authHandler.SignIn(w, r) + }) + + mux.HandleFunc("/api/v1/me", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + userHandler.GetMe(w, r) + }) + + mux.HandleFunc("/api/v1/users/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + // Extract user_id from path /api/v1/users/:user_id + userID := strings.TrimPrefix(r.URL.Path, "/api/v1/users/") + r.Header.Set("X-User-ID", userID) + userHandler.GetUser(w, r) + }) + + mux.HandleFunc("/api/v1/me/stat", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + userHandler.GetMyStat(w, r) + }) + + mux.HandleFunc("/api/v1/leaderboard", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + userHandler.GetLeaderboard(w, r) + }) + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.config.HTTPPort), + Handler: mux, + ReadTimeout: httpReadTimeout, + WriteTimeout: httpWriteTimeout, + IdleTimeout: httpIdleTimeout, + } + + go func() { + log.Printf("starting HTTP server on port %d", s.config.HTTPPort) + if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("failed to start HTTP server: %v", err) + } + }() + + return nil +} + +func (s *Server) Stop() { + log.Println("shutting down auth server...") + + if s.httpServer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.httpServer.Shutdown(ctx); err != nil { + log.Printf("failed to shutdown HTTP server: %v", err) + } + } + + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } + + if s.db != nil { + if err := s.db.Close(); err != nil { + log.Printf("failed to close database: %v", err) + } + } + + log.Println("auth server stopped") +} + +func (s *Server) createTables() error { + schema := ` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + ` + + _, err := s.db.Exec(schema) + return err +} diff --git a/internal/auth/service/auth.go b/internal/auth/service/auth.go new file mode 100644 index 0000000..fe26b0b --- /dev/null +++ b/internal/auth/service/auth.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "time" + + "datarush/internal/auth/domain" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +const ( + tokenExpiration = 24 * time.Hour + bcryptCost = 10 +) + +type UserRepository interface { + Create(ctx context.Context, user *domain.User) error + GetByEmail(ctx context.Context, email string) (*domain.User, error) + GetByID(ctx context.Context, id domain.ID) (*domain.User, error) + Update(ctx context.Context, user *domain.User) error + Delete(ctx context.Context, id domain.ID) error +} + +type AuthService struct { + repo UserRepository + jwtSecret string +} + +func NewAuthService(repo UserRepository, jwtSecret string) *AuthService { + return &AuthService{ + repo: repo, + jwtSecret: jwtSecret, + } +} + +func (s *AuthService) SignUp(ctx context.Context, email, username, password string) (string, error) { + // Check if user already exists + _, err := s.repo.GetByEmail(ctx, email) + if err == nil { + return "", domain.ErrUserAlreadyExists + } + if err != domain.ErrUserNotFound { + return "", err + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + if err != nil { + return "", err + } + + // Create user + user := &domain.User{ + ID: domain.NewID(), + Email: email, + Username: username, + Password: string(hashedPassword), + } + + if err := s.repo.Create(ctx, user); err != nil { + return "", err + } + + // Generate token + token, err := s.generateToken(user) + if err != nil { + return "", err + } + + return token, nil +} + +func (s *AuthService) SignIn(ctx context.Context, email, password string) (string, error) { + // Get user by email + user, err := s.repo.GetByEmail(ctx, email) + if err != nil { + if err == domain.ErrUserNotFound { + return "", domain.ErrInvalidPassword + } + return "", err + } + + // Check password + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return "", domain.ErrInvalidPassword + } + + // Generate token + token, err := s.generateToken(user) + if err != nil { + return "", err + } + + return token, nil +} + +func (s *AuthService) ValidateToken(ctx context.Context, tokenString string) (*domain.UserWithoutPassword, error) { + userID, err := s.parseToken(tokenString) + if err != nil { + return nil, err + } + + user, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + return &domain.UserWithoutPassword{ + ID: user.ID, + Email: user.Email, + Username: user.Username, + CreatedAt: user.CreatedAt, + }, nil +} + +func (s *AuthService) generateToken(user *domain.User) (string, error) { + claims := jwt.MapClaims{ + "user_id": user.ID.String(), + "email": user.Email, + "username": user.Username, + "exp": time.Now().Add(tokenExpiration).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(s.jwtSecret)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func (s *AuthService) parseToken(tokenString string) (domain.ID, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(s.jwtSecret), nil + }) + + if err != nil { + return domain.ID{}, domain.ErrInvalidToken + } + + if !token.Valid { + return domain.ID{}, domain.ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return domain.ID{}, domain.ErrInvalidToken + } + + userIDStr, ok := claims["user_id"].(string) + if !ok { + return domain.ID{}, domain.ErrInvalidToken + } + + userID, err := domain.ParseID(userIDStr) + if err != nil { + return domain.ID{}, domain.ErrInvalidToken + } + + return userID, nil +} diff --git a/internal/lms/config/config.go b/internal/lms/config/config.go index dab27b5..8a27c43 100644 --- a/internal/lms/config/config.go +++ b/internal/lms/config/config.go @@ -22,6 +22,7 @@ type Config struct { DBPassword string DBName string RedisURI string + AuthGRPCAddr string } func Load() (*Config, error) { @@ -39,6 +40,7 @@ func Load() (*Config, error) { DBPassword: getEnv("POSTGRES_PASSWORD", "postgres"), DBName: getEnv("POSTGRES_DATABASE", "postgres"), RedisURI: getEnv("REDIS_URI", "redis://localhost:6379"), + AuthGRPCAddr: getEnv("AUTH_GRPC_ADDR", "localhost:50052"), }, nil } diff --git a/internal/lms/gateway/gateway.go b/internal/lms/gateway/gateway.go new file mode 100644 index 0000000..27c9140 --- /dev/null +++ b/internal/lms/gateway/gateway.go @@ -0,0 +1,58 @@ +package gateway + +import ( + "context" + "fmt" + "log" + "net" + "net/http" + "time" + + authPb "datarush/pkg/api/auth" + orderPb "datarush/pkg/api/order" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +const ( + httpReadTimeout = 10 * time.Second + httpWriteTimeout = 10 * time.Second + httpIdleTimeout = 60 * time.Second +) + +func StartGateway(grpcPort, httpPort int, authGrpcAddr string) error { + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + gwmux := runtime.NewServeMux() + opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} + + // Register order service + grpcServerAddr := fmt.Sprintf("localhost:%d", grpcPort) + if err := orderPb.RegisterOrderServiceHandlerFromEndpoint(ctx, gwmux, grpcServerAddr, opts); err != nil { + return fmt.Errorf("failed to register order service: %w", err) + } + + // Register auth service + if err := authPb.RegisterAuthServiceHandlerFromEndpoint(ctx, gwmux, authGrpcAddr, opts); err != nil { + return fmt.Errorf("failed to register auth service: %w", err) + } + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", httpPort), + Handler: gwmux, + ReadTimeout: httpReadTimeout, + WriteTimeout: httpWriteTimeout, + IdleTimeout: httpIdleTimeout, + } + + log.Printf("starting gRPC-Gateway on port %d", httpPort) + return srv.ListenAndServe() +} + +func GetGRPCListener(port int) (net.Listener, error) { + return net.Listen("tcp", fmt.Sprintf(":%d", port)) +} diff --git a/internal/lms/server/server.go b/internal/lms/server/server.go index 1ac8abc..4f4b2d4 100644 --- a/internal/lms/server/server.go +++ b/internal/lms/server/server.go @@ -16,6 +16,7 @@ import ( orderPostgresRepo "datarush/internal/lms/repository/postgres" "datarush/internal/lms/service" + authPb "datarush/pkg/api/auth" pb "datarush/pkg/api/order" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" @@ -72,6 +73,12 @@ func runHTTPHandler(s *Server, grpcServerEndpoint *string) error { return err } + // Register auth service + if err := registerAuthService(ctx, gwmux, s.config.AuthGRPCAddr, opts); err != nil { + log.Printf("failed to register auth service: %v", err) + // Don't fail completely if auth service is not available + } + mux := http.NewServeMux() mux.Handle("/healthz", httpHandlers.NewHealthHandler(s.db, s.redisDB)) mux.Handle("/", gwmux) @@ -87,6 +94,19 @@ func runHTTPHandler(s *Server, grpcServerEndpoint *string) error { return srv.ListenAndServe() } +func registerAuthService(ctx context.Context, gwmux *runtime.ServeMux, authAddr string, opts []grpc.DialOption) error { + if err := authPb.RegisterAuthServiceHandlerFromEndpoint(ctx, gwmux, authAddr, opts); err != nil { + return fmt.Errorf("register auth service handler: %w", err) + } + log.Printf("registered auth service from %s", authAddr) + return nil +} + +func registerAuthHandlerFromEndpoint(ctx context.Context, gwmux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) error { + // We'll import the proto and register it here + // For now, this is a placeholder that will be called from gateway integration + return nil + func getDatabase(cfg config.Config) (*sqlx.DB, error) { db, err := sqlx.Connect("postgres", cfg.BuildPostgresConnStr()) if err != nil {