diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 5ba5c25..be9e2a6 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -7,6 +7,7 @@ import ( "os" "time" + "github.com/chedius/delivery-tracker/internal/auth" db "github.com/chedius/delivery-tracker/internal/db/sqlc" "github.com/chedius/delivery-tracker/internal/delivery" "github.com/gin-contrib/cors" @@ -15,6 +16,19 @@ import ( "github.com/joho/godotenv" ) +func initAuth(queries *db.Queries) (*auth.Service, *auth.Handler) { + secret := []byte(os.Getenv("JWT_SECRET")) + expiry := 24 * time.Minute + + if len(secret) == 0 { + log.Fatal("JWT_SECRET not set") + } + + service := auth.New(queries, secret, expiry) + handler := auth.NewHandler(service) + return service, handler +} + func main() { ctx := context.Background() godotenv.Load() @@ -27,6 +41,7 @@ func main() { defer pool.Close() queries := db.New(pool) + _, authHandler := initAuth(queries) h := delivery.NewHandler(queries) r := gin.Default() @@ -44,13 +59,20 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - r.GET("/api/deliveries", h.GetDeliveries) - r.GET("/api/deliveries/:id", h.GetDeliveryByID) - r.GET("/api/deliveries/count", h.GetDeliveryCount) - r.POST("/api/deliveries", h.CreateDelivery) - r.PATCH("/api/deliveries/:id", h.UpdateDelivery) - r.PATCH("/api/deliveries/:id/status", h.UpdateDeliveryStatus) - r.DELETE("/api/deliveries/:id", h.DeleteDelivery) + r.POST("/api/auth/register", authHandler.Register) + r.POST("/api/auth/login", authHandler.Login) + + authorized := r.Group("/api") + authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET")))) + { + authorized.GET("/deliveries", h.GetDeliveries) + authorized.GET("/deliveries/:id", h.GetDeliveryByID) + authorized.GET("/deliveries/count", h.GetDeliveryCount) + authorized.POST("/deliveries", h.CreateDelivery) + authorized.PATCH("/deliveries/:id", h.UpdateDelivery) + authorized.PATCH("/deliveries/:id/status", h.UpdateDeliveryStatus) + authorized.DELETE("/deliveries/:id", h.DeleteDelivery) + } r.Run(":8080") } diff --git a/backend/cmd/seed.go/main.go b/backend/cmd/seed.go/main.go new file mode 100644 index 0000000..f374311 --- /dev/null +++ b/backend/cmd/seed.go/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "log" + "os" + + "github.com/chedius/delivery-tracker/internal/auth" + db "github.com/chedius/delivery-tracker/internal/db/sqlc" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" +) + +func main() { + ctx := context.Background() + godotenv.Load() + + dsn := os.Getenv("DATABASE_URL") + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatalf("db connect: %v", err) + } + defer pool.Close() + + queries := db.New(pool) + + // Проверяем, есть ли уже пользователи + _, err = queries.GetUserByUsername(ctx, "admin") + if err == nil { + log.Println("admin user already exists, skipping seed") + return + } + + // Создаём через auth service (правильное хеширование) + secret := []byte(os.Getenv("JWT_SECRET")) + if len(secret) == 0 { + log.Fatalf("JWT_SECRET not set") + } + authService := auth.New(queries, secret, 0) + + // Пароль из env или дефолтный (только для разработки!) + password := os.Getenv("SEED_ADMIN_PASSWORD") + if password == "" { + password = "admin123" // ⚠️ только для dev! + } + + user, token, err := authService.Register(ctx, "admin", password) + if err != nil { + log.Fatalf("failed to create admin: %v", err) + } + + log.Printf("created admin user: id=%s, username=%s", user.ID, user.Username) + log.Printf("token: %s", token) +} diff --git a/backend/go.mod b/backend/go.mod index c0006cd..b74d630 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,8 @@ require ( github.com/google/uuid v1.6.0 ) +require github.com/golang-jwt/jwt/v5 v5.3.1 + require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect @@ -41,7 +43,7 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.48.0 golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 37f5309..e1af464 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -29,6 +29,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/auth/errors.go b/backend/internal/auth/errors.go new file mode 100644 index 0000000..9d4246e --- /dev/null +++ b/backend/internal/auth/errors.go @@ -0,0 +1,12 @@ +package auth + +import "errors" + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserExists = errors.New("user already exists") + ErrUserNotFound = errors.New("user not found") + ErrPasswordMismatch = errors.New("passwords do not match") + ErrCredentialsEmpty = errors.New("username and password cannot be empty") + ErrPasswordTooShort = errors.New("password must be at least 6 characters long") +) diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go new file mode 100644 index 0000000..8d11ad3 --- /dev/null +++ b/backend/internal/auth/handler.go @@ -0,0 +1,77 @@ +package auth + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + authService *Service +} + +func NewHandler(authService *Service) *Handler { + return &Handler{ + authService: authService, + } +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` + Password string `json:"password" binding:"required,min=6"` +} + +func (h *Handler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "details": err.Error()}) + return + } + + token, err := h.authService.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + switch err { + case ErrUserNotFound, ErrInvalidCredentials: + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"token": token}) +} + +func (h *Handler) Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request", "details": err.Error()}) + return + } + + user, token, err := h.authService.Register(c.Request.Context(), req.Username, req.Password) + if err != nil { + switch err { + case ErrUserExists: + c.JSON(http.StatusConflict, gin.H{"error": "user already exists"}) + case ErrPasswordTooShort: + c.JSON(http.StatusBadRequest, gin.H{"error": "password too short"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"}) + } + return + } + + c.JSON(http.StatusCreated, gin.H{ + "user": gin.H{ + "id": user.ID, + "username": user.Username, + }, + "token": token, + }) +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..5de61b4 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,50 @@ +package auth + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + jwt.RegisteredClaims +} + +func GenerateToken(userID uuid.UUID, secret []byte, expiry time.Duration) (string, error) { + if userID == uuid.Nil { + return "", errors.New("user ID cannot be nil") + } + if secret == nil { + return "", errors.New("JWT secret not set") + } + + claims := Claims{ + UserID: userID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} + +func ParseToken(tokenString string, secret []byte) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return secret, nil + }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()})) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token claims") +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..ba2af3f --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,36 @@ +package auth + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func AuthMiddleware(secret []byte) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + c.Abort() + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header"}) + c.Abort() + return + } + + claims, err := ParseToken(tokenString, secret) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + c.Abort() + return + } + + c.Set("userID", claims.UserID) + c.Next() + } +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..69889b1 --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,98 @@ +package auth + +import ( + "context" + "errors" + "time" + + sqlc "github.com/chedius/delivery-tracker/internal/db/sqlc" + "github.com/google/uuid" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/bcrypt" +) + +type Service struct { + queries *sqlc.Queries + secret []byte + expiry time.Duration +} + +type User struct { + ID string + Username string + // Password string +} + +func New(queries *sqlc.Queries, secret []byte, expiry time.Duration) *Service { + return &Service{queries, secret, expiry} +} + +func (s *Service) Register(ctx context.Context, username, password string) (User, string, error) { + if username == "" || password == "" { + return User{}, "", ErrCredentialsEmpty + } + if _, err := s.queries.GetUserByUsername(ctx, username); err == nil { + return User{}, "", ErrUserExists + } + if len(password) < 6 { + return User{}, "", ErrPasswordTooShort + } + hashedPassword, err := s.HashPassword(password) + if err != nil { + return User{}, "", err + } + + user, err := s.queries.CreateUser(ctx, sqlc.CreateUserParams{ + Username: username, + PasswordHash: hashedPassword, + }) + if err != nil { + return User{}, "", err + } + + token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry) + if err != nil { + return User{}, "", err + } + + return User{ + ID: user.ID.String(), + Username: user.Username, + }, token, nil +} + +func (s *Service) Login(ctx context.Context, username, password string) (string, error) { // returns JWT + user, err := s.queries.GetUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrUserNotFound + } + return "", err + } + + if !s.VerifyPassword(user.PasswordHash, password) { + return "", ErrInvalidCredentials + } + + token, err := GenerateToken(uuid.UUID(user.ID.Bytes), s.secret, s.expiry) + if err != nil { + return "", err + } + + return token, nil +} + +func (s *Service) HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + return string(hash), nil +} +func (s *Service) VerifyPassword(hash, password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + + return err == nil +} diff --git a/backend/internal/db/queries/query.sql b/backend/internal/db/queries/query.sql index eff60bf..459bca1 100644 --- a/backend/internal/db/queries/query.sql +++ b/backend/internal/db/queries/query.sql @@ -1,3 +1,11 @@ +-- name: CreateUser :one +INSERT INTO users (username, password_hash) +VALUES ($1, $2) +RETURNING *; + +-- name: GetUserByUsername :one +SELECT * FROM users WHERE username = $1; + -- name: GetDeliveriesByDate :many SELECT * FROM deliveries WHERE date = $1; diff --git a/backend/internal/db/sqlc/querier.go b/backend/internal/db/sqlc/querier.go index 1db82eb..60fc508 100644 --- a/backend/internal/db/sqlc/querier.go +++ b/backend/internal/db/sqlc/querier.go @@ -12,10 +12,12 @@ import ( type Querier interface { CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error) + CreateUser(ctx context.Context, arg CreateUserParams) (User, error) DeleteDelivery(ctx context.Context, id pgtype.UUID) error GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error) + GetUserByUsername(ctx context.Context, username string) (User, error) UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error } diff --git a/backend/internal/db/sqlc/query.sql.go b/backend/internal/db/sqlc/query.sql.go index da975a5..a19ca12 100644 --- a/backend/internal/db/sqlc/query.sql.go +++ b/backend/internal/db/sqlc/query.sql.go @@ -57,6 +57,29 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) return i, err } +const createUser = `-- name: CreateUser :one +INSERT INTO users (username, password_hash) +VALUES ($1, $2) +RETURNING id, username, password_hash, created_at +` + +type CreateUserParams struct { + Username string `db:"username" json:"username"` + PasswordHash string `db:"password_hash" json:"password_hash"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, arg.Username, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + ) + return i, err +} + const deleteDelivery = `-- name: DeleteDelivery :exec DELETE FROM deliveries WHERE id = $1 ` @@ -156,6 +179,22 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, return items, nil } +const getUserByUsername = `-- name: GetUserByUsername :one +SELECT id, username, password_hash, created_at FROM users WHERE username = $1 +` + +func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { + row := q.db.QueryRow(ctx, getUserByUsername, username) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.CreatedAt, + ) + return i, err +} + const updateDelivery = `-- name: UpdateDelivery :exec UPDATE deliveries SET date = $1, pickup_location = $2, product_name = $3, address = $4, phone = $5, additional_phone = $6, has_elevator = $7, comment = $8, updated_at = NOW() WHERE id = $9 `