diff --git a/core-service/.cursor/plan.md b/core-service/.cursor/plan.md index 4a73fa6..cdbacc6 100644 --- a/core-service/.cursor/plan.md +++ b/core-service/.cursor/plan.md @@ -185,10 +185,10 @@ type ItemPlacement struct { ## πŸ” Π­Ρ‚Π°ΠΏ 2: АутСнтификация (НСдСля 2) ### Π¨Π°Π³ 2.1: JWT аутСнтификация -- [ ] Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ `internal/auth/jwt.go` -- [ ] Π Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΡŽ ΠΈ Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΡŽ JWT Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ² -- [ ] Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ organization-scope Π² Ρ‚ΠΎΠΊΠ΅Π½Ρ‹ -- [ ] Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ middleware для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ Π°ΡƒΡ‚Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ†ΠΈΠΈ +- [x] Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ `internal/auth/jwt.go` +- [x] Π Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΡŽ ΠΈ Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΡŽ JWT Ρ‚ΠΎΠΊΠ΅Π½ΠΎΠ² +- [x] Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ organization-scope Π² Ρ‚ΠΎΠΊΠ΅Π½Ρ‹ +- [x] Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ middleware для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ Π°ΡƒΡ‚Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ†ΠΈΠΈ **JWT структура:** ```go @@ -202,14 +202,14 @@ type Claims struct { ``` ### Π¨Π°Π³ 2.2: Π₯Π΅ΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΏΠ°Ρ€ΠΎΠ»Π΅ΠΉ -- [ ] Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ `internal/auth/password.go` -- [ ] Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ bcrypt для Ρ…Π΅ΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡ -- [ ] Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ ΠΏΠ°Ρ€ΠΎΠ»Π΅ΠΉ +- [x] Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ `internal/auth/password.go` +- [x] Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ bcrypt для Ρ…Π΅ΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡ +- [x] Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ ΠΏΠ°Ρ€ΠΎΠ»Π΅ΠΉ ### Π¨Π°Π³ 2.3: API endpoints для Π°ΡƒΡ‚Π΅Π½Ρ‚ΠΈΡ„ΠΈΠΊΠ°Ρ†ΠΈΠΈ -- [ ] `POST /api/auth/register` - рСгистрация ΠΎΡ€Π³Π°Π½ΠΈΠ·Π°Ρ†ΠΈΠΈ ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ -- [ ] `POST /api/auth/login` - Π²Ρ…ΠΎΠ΄ Π² систСму -- [ ] `POST /api/auth/refresh` - ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ Ρ‚ΠΎΠΊΠ΅Π½Π° (ΠΎΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎ) +- [x] `POST /api/auth/register` - рСгистрация ΠΎΡ€Π³Π°Π½ΠΈΠ·Π°Ρ†ΠΈΠΈ ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ +- [x] `POST /api/auth/login` - Π²Ρ…ΠΎΠ΄ Π² систСму +- [x] `POST /api/auth/refresh` - ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ Ρ‚ΠΎΠΊΠ΅Π½Π° (ΠΎΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎ) **Π‘Ρ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€Π° запросов:** ```go diff --git a/core-service/go.mod b/core-service/go.mod index 4384f0a..4c38970 100644 --- a/core-service/go.mod +++ b/core-service/go.mod @@ -4,10 +4,13 @@ go 1.21 require ( github.com/gin-gonic/gin v1.10.1 + github.com/go-playground/validator/v10 v10.20.0 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.3.1 github.com/joho/godotenv v1.4.0 github.com/lib/pq v1.10.9 github.com/sirupsen/logrus v1.9.3 + golang.org/x/crypto v0.23.0 ) require ( @@ -19,7 +22,6 @@ require ( github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect @@ -31,7 +33,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.23.0 // indirect golang.org/x/net v0.25.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect diff --git a/core-service/go.sum b/core-service/go.sum index 343da64..e021caa 100644 --- a/core-service/go.sum +++ b/core-service/go.sum @@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +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/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/core-service/internal/api/handlers/auth.go b/core-service/internal/api/handlers/auth.go new file mode 100644 index 0000000..4c6e0a5 --- /dev/null +++ b/core-service/internal/api/handlers/auth.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "net/http" + + "erp-mvp/core-service/internal/models" + "erp-mvp/core-service/internal/service" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type AuthHandler struct { + authService service.AuthService + validate *validator.Validate +} + +func NewAuthHandler(authService service.AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + validate: validator.New(), + } +} + +// Register рСгистрация Π½ΠΎΠ²ΠΎΠΉ ΠΎΡ€Π³Π°Π½ΠΈΠ·Π°Ρ†ΠΈΠΈ ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ +func (h *AuthHandler) Register(c *gin.Context) { + var req models.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Π’Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ запрос + if err := h.validate.Struct(req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()}) + return + } + + // ВыполняСм Ρ€Π΅Π³ΠΈΡΡ‚Ρ€Π°Ρ†ΠΈΡŽ + response, err := h.authService.Register(c.Request.Context(), &req) + if err != nil { + if validationErr, ok := err.(*service.ValidationError); ok { + c.JSON(http.StatusBadRequest, gin.H{"error": validationErr.Message}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"}) + return + } + + c.JSON(http.StatusCreated, response) +} + +// Login Π²Ρ…ΠΎΠ΄ Π² систСму +func (h *AuthHandler) Login(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Π’Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ запрос + if err := h.validate.Struct(req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Validation failed", "details": err.Error()}) + return + } + + // ВыполняСм Π²Ρ…ΠΎΠ΄ + response, err := h.authService.Login(c.Request.Context(), &req) + if err != nil { + if validationErr, ok := err.(*service.ValidationError); ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": validationErr.Message}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) + return + } + + c.JSON(http.StatusOK, response) +} diff --git a/core-service/internal/api/middleware/auth.go b/core-service/internal/api/middleware/auth.go new file mode 100644 index 0000000..046149c --- /dev/null +++ b/core-service/internal/api/middleware/auth.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "net/http" + "strings" + + "erp-mvp/core-service/internal/auth" + "github.com/gin-gonic/gin" +) + +type AuthMiddleware struct { + jwtService *auth.JWTService +} + +func NewAuthMiddleware(jwtService *auth.JWTService) *AuthMiddleware { + return &AuthMiddleware{ + jwtService: jwtService, + } +} + +func (m *AuthMiddleware) AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + // ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Ρ‚ΠΎΠΊΠ΅Π½ ΠΈΠ· Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ° Authorization + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + c.Abort() + return + } + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ "Bearer " + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"}) + c.Abort() + return + } + + tokenString := tokenParts[1] + + // Π’Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ Ρ‚ΠΎΠΊΠ΅Π½ + claims, err := m.jwtService.ValidateToken(tokenString) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + // БохраняСм claims Π² контСкстС + c.Set("user_id", claims.UserID) + c.Set("organization_id", claims.OrganizationID) + c.Set("email", claims.Email) + c.Set("role", claims.Role) + + c.Next() + } +} diff --git a/core-service/internal/api/server.go b/core-service/internal/api/server.go index 00a5fa7..55331bd 100644 --- a/core-service/internal/api/server.go +++ b/core-service/internal/api/server.go @@ -5,8 +5,13 @@ import ( "database/sql" "net/http" + "erp-mvp/core-service/internal/auth" "erp-mvp/core-service/internal/config" "erp-mvp/core-service/internal/logger" + "erp-mvp/core-service/internal/repository" + "erp-mvp/core-service/internal/service" + "erp-mvp/core-service/internal/api/handlers" + "erp-mvp/core-service/internal/api/middleware" "github.com/gin-gonic/gin" ) @@ -16,14 +21,42 @@ type Server struct { db *sql.DB logger logger.Logger router *gin.Engine + + // Services + authService service.AuthService + + // Handlers + authHandler *handlers.AuthHandler + + // Middleware + authMiddleware *middleware.AuthMiddleware } func NewServer(cfg *config.Config, db *sql.DB, log logger.Logger) *Server { + // Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·ΠΈΡ€ΡƒΠ΅ΠΌ JWT сСрвис + jwtService := auth.NewJWTService(cfg.JWT.Secret, cfg.JWT.TTL) + + // Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·ΠΈΡ€ΡƒΠ΅ΠΌ Ρ€Π΅ΠΏΠΎΠ·ΠΈΡ‚ΠΎΡ€ΠΈΠΈ + orgRepo := repository.NewOrganizationRepository(db) + userRepo := repository.NewUserRepository(db) + + // Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·ΠΈΡ€ΡƒΠ΅ΠΌ сСрвисы + authService := service.NewAuthService(orgRepo, userRepo, jwtService) + + // Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·ΠΈΡ€ΡƒΠ΅ΠΌ handlers + authHandler := handlers.NewAuthHandler(authService) + + // Π˜Π½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·ΠΈΡ€ΡƒΠ΅ΠΌ middleware + authMiddleware := middleware.NewAuthMiddleware(jwtService) + server := &Server{ - config: cfg, - db: db, - logger: log, - router: gin.Default(), + config: cfg, + db: db, + logger: log, + router: gin.Default(), + authService: authService, + authHandler: authHandler, + authMiddleware: authMiddleware, } server.setupRoutes() @@ -40,13 +73,13 @@ func (s *Server) setupRoutes() { // Auth routes auth := api.Group("/auth") { - auth.POST("/register", s.register) - auth.POST("/login", s.login) + auth.POST("/register", s.authHandler.Register) + auth.POST("/login", s.authHandler.Login) } // Protected routes protected := api.Group("/") - protected.Use(s.authMiddleware()) + protected.Use(s.authMiddleware.AuthRequired()) { // Organizations protected.GET("/organizations/:id", s.getOrganization) @@ -86,21 +119,6 @@ func (s *Server) healthCheck(c *gin.Context) { } // Placeholder handlers - will be implemented in next stages -func (s *Server) register(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) -} - -func (s *Server) login(c *gin.Context) { - c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) -} - -func (s *Server) authMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Auth not implemented yet"}) - c.Abort() - } -} - func (s *Server) getOrganization(c *gin.Context) { c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) } diff --git a/core-service/internal/auth/jwt.go b/core-service/internal/auth/jwt.go new file mode 100644 index 0000000..b9736d8 --- /dev/null +++ b/core-service/internal/auth/jwt.go @@ -0,0 +1,69 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + OrganizationID uuid.UUID `json:"organization_id"` + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +type JWTService struct { + secret string + ttl time.Duration +} + +func NewJWTService(secret string, ttl time.Duration) *JWTService { + return &JWTService{ + secret: secret, + ttl: ttl, + } +} + +// GenerateToken создаёт JWT Ρ‚ΠΎΠΊΠ΅Π½ для ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ +func (j *JWTService) GenerateToken(userID, organizationID uuid.UUID, email, role string) (string, error) { + claims := Claims{ + UserID: userID, + OrganizationID: organizationID, + Email: email, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "erp-mvp-core", + Subject: userID.String(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(j.secret)) +} + +// ValidateToken Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅Ρ‚ JWT Ρ‚ΠΎΠΊΠ΅Π½ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ claims +func (j *JWTService) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return []byte(j.secret), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} diff --git a/core-service/internal/auth/password.go b/core-service/internal/auth/password.go new file mode 100644 index 0000000..b59a63a --- /dev/null +++ b/core-service/internal/auth/password.go @@ -0,0 +1,17 @@ +package auth + +import ( + "golang.org/x/crypto/bcrypt" +) + +// HashPassword Ρ…Π΅ΡˆΠΈΡ€ΡƒΠ΅Ρ‚ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ с использованиСм bcrypt +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPassword провСряСт ΠΏΠ°Ρ€ΠΎΠ»ΡŒ ΠΏΡ€ΠΎΡ‚ΠΈΠ² Ρ…Π΅ΡˆΠ° +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/core-service/internal/repository/organizations.go b/core-service/internal/repository/organizations.go new file mode 100644 index 0000000..46192f3 --- /dev/null +++ b/core-service/internal/repository/organizations.go @@ -0,0 +1,88 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "erp-mvp/core-service/internal/models" + "github.com/google/uuid" +) + +type OrganizationRepository interface { + Create(ctx context.Context, org *models.Organization) error + GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error) + Update(ctx context.Context, org *models.Organization) error +} + +type organizationRepository struct { + db *sql.DB +} + +func NewOrganizationRepository(db *sql.DB) OrganizationRepository { + return &organizationRepository{db: db} +} + +func (r *organizationRepository) Create(ctx context.Context, org *models.Organization) error { + query := ` + INSERT INTO organizations (id, name, type, settings, created_at) + VALUES ($1, $2, $3, $4, $5) + ` + + _, err := r.db.ExecContext(ctx, query, org.ID, org.Name, org.Type, org.Settings, org.CreatedAt) + if err != nil { + return fmt.Errorf("failed to create organization: %w", err) + } + + return nil +} + +func (r *organizationRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error) { + query := ` + SELECT id, name, type, settings, created_at + FROM organizations + WHERE id = $1 + ` + + org := &models.Organization{} + err := r.db.QueryRowContext(ctx, query, id).Scan( + &org.ID, + &org.Name, + &org.Type, + &org.Settings, + &org.CreatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("organization not found") + } + return nil, fmt.Errorf("failed to get organization: %w", err) + } + + return org, nil +} + +func (r *organizationRepository) Update(ctx context.Context, org *models.Organization) error { + query := ` + UPDATE organizations + SET name = $2, type = $3, settings = $4 + WHERE id = $1 + ` + + result, err := r.db.ExecContext(ctx, query, org.ID, org.Name, org.Type, org.Settings) + if err != nil { + return fmt.Errorf("failed to update organization: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("organization not found") + } + + return nil +} diff --git a/core-service/internal/repository/users.go b/core-service/internal/repository/users.go new file mode 100644 index 0000000..b62428a --- /dev/null +++ b/core-service/internal/repository/users.go @@ -0,0 +1,99 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "erp-mvp/core-service/internal/models" + "github.com/google/uuid" +) + +type UserRepository interface { + Create(ctx context.Context, user *models.User, password string) error + GetByEmail(ctx context.Context, email string) (*models.User, error) + GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) +} + +type userRepository struct { + db *sql.DB +} + +func NewUserRepository(db *sql.DB) UserRepository { + return &userRepository{db: db} +} + +func (r *userRepository) Create(ctx context.Context, user *models.User, password string) error { + query := ` + INSERT INTO users (id, organization_id, email, password_hash, role, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ` + + _, err := r.db.ExecContext(ctx, query, + user.ID, + user.OrganizationID, + user.Email, + password, + user.Role, + user.CreatedAt, + ) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + return nil +} + +func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) { + query := ` + SELECT id, organization_id, email, password_hash, role, created_at + FROM users + WHERE email = $1 + ` + + user := &models.User{} + err := r.db.QueryRowContext(ctx, query, email).Scan( + &user.ID, + &user.OrganizationID, + &user.Email, + &user.PasswordHash, + &user.Role, + &user.CreatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return user, nil +} + +func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.User, error) { + query := ` + SELECT id, organization_id, email, password_hash, role, created_at + FROM users + WHERE id = $1 + ` + + user := &models.User{} + err := r.db.QueryRowContext(ctx, query, id).Scan( + &user.ID, + &user.OrganizationID, + &user.Email, + &user.PasswordHash, + &user.Role, + &user.CreatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found") + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return user, nil +} diff --git a/core-service/internal/service/auth_service.go b/core-service/internal/service/auth_service.go new file mode 100644 index 0000000..a41b968 --- /dev/null +++ b/core-service/internal/service/auth_service.go @@ -0,0 +1,118 @@ +package service + +import ( + "context" + "time" + + "erp-mvp/core-service/internal/auth" + "erp-mvp/core-service/internal/models" + "erp-mvp/core-service/internal/repository" + "github.com/google/uuid" +) + +type AuthService interface { + Register(ctx context.Context, req *models.RegisterRequest) (*models.LoginResponse, error) + Login(ctx context.Context, req *models.LoginRequest) (*models.LoginResponse, error) +} + +type authService struct { + orgRepo repository.OrganizationRepository + userRepo repository.UserRepository + jwtService *auth.JWTService +} + +func NewAuthService(orgRepo repository.OrganizationRepository, userRepo repository.UserRepository, jwtService *auth.JWTService) AuthService { + return &authService{ + orgRepo: orgRepo, + userRepo: userRepo, + jwtService: jwtService, + } +} + +func (s *authService) Register(ctx context.Context, req *models.RegisterRequest) (*models.LoginResponse, error) { + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, Ρ‡Ρ‚ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ с Ρ‚Π°ΠΊΠΈΠΌ email Π½Π΅ сущСствуСт + existingUser, err := s.userRepo.GetByEmail(ctx, req.UserEmail) + if err == nil && existingUser != nil { + return nil, &ValidationError{Message: "User with this email already exists"} + } + + // Π‘ΠΎΠ·Π΄Π°Ρ‘ΠΌ ΠΎΡ€Π³Π°Π½ΠΈΠ·Π°Ρ†ΠΈΡŽ + orgID := uuid.New() + org := &models.Organization{ + ID: orgID, + Name: req.OrganizationName, + Type: req.OrganizationType, + Settings: models.JSON{}, + CreatedAt: time.Now(), + } + + if err := s.orgRepo.Create(ctx, org); err != nil { + return nil, err + } + + // Π₯Π΅ΡˆΠΈΡ€ΡƒΠ΅ΠΌ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ + passwordHash, err := auth.HashPassword(req.UserPassword) + if err != nil { + return nil, err + } + + // Π‘ΠΎΠ·Π΄Π°Ρ‘ΠΌ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ + userID := uuid.New() + user := &models.User{ + ID: userID, + OrganizationID: orgID, + Email: req.UserEmail, + Role: "admin", // ΠŸΠ΅Ρ€Π²Ρ‹ΠΉ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ становится Π°Π΄ΠΌΠΈΠ½ΠΎΠΌ + CreatedAt: time.Now(), + } + + if err := s.userRepo.Create(ctx, user, passwordHash); err != nil { + return nil, err + } + + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ JWT Ρ‚ΠΎΠΊΠ΅Π½ + token, err := s.jwtService.GenerateToken(user.ID, org.ID, user.Email, user.Role) + if err != nil { + return nil, err + } + + return &models.LoginResponse{ + Token: token, + User: *user, + ExpiresAt: time.Now().Add(24 * time.Hour), // TTL ΠΈΠ· ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ + }, nil +} + +func (s *authService) Login(ctx context.Context, req *models.LoginRequest) (*models.LoginResponse, error) { + // ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ ΠΏΠΎ email + user, err := s.userRepo.GetByEmail(ctx, req.Email) + if err != nil { + return nil, &ValidationError{Message: "Invalid email or password"} + } + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ ΠΏΠ°Ρ€ΠΎΠ»ΡŒ + if !auth.CheckPassword(req.Password, user.PasswordHash) { + return nil, &ValidationError{Message: "Invalid email or password"} + } + + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ JWT Ρ‚ΠΎΠΊΠ΅Π½ + token, err := s.jwtService.GenerateToken(user.ID, user.OrganizationID, user.Email, user.Role) + if err != nil { + return nil, err + } + + return &models.LoginResponse{ + Token: token, + User: *user, + ExpiresAt: time.Now().Add(24 * time.Hour), // TTL ΠΈΠ· ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ + }, nil +} + +// ValidationError ошибка Π²Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠΈ +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +}