add auth module

This commit is contained in:
Egor Pozharov
2026-04-15 19:17:10 +06:00
parent e50f81f7f3
commit be0b13acbf
12 changed files with 410 additions and 8 deletions

View File

@@ -7,6 +7,7 @@ import (
"os" "os"
"time" "time"
"github.com/chedius/delivery-tracker/internal/auth"
db "github.com/chedius/delivery-tracker/internal/db/sqlc" db "github.com/chedius/delivery-tracker/internal/db/sqlc"
"github.com/chedius/delivery-tracker/internal/delivery" "github.com/chedius/delivery-tracker/internal/delivery"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
@@ -15,6 +16,19 @@ import (
"github.com/joho/godotenv" "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() { func main() {
ctx := context.Background() ctx := context.Background()
godotenv.Load() godotenv.Load()
@@ -27,6 +41,7 @@ func main() {
defer pool.Close() defer pool.Close()
queries := db.New(pool) queries := db.New(pool)
_, authHandler := initAuth(queries)
h := delivery.NewHandler(queries) h := delivery.NewHandler(queries)
r := gin.Default() r := gin.Default()
@@ -44,13 +59,20 @@ func main() {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })
r.GET("/api/deliveries", h.GetDeliveries) r.POST("/api/auth/register", authHandler.Register)
r.GET("/api/deliveries/:id", h.GetDeliveryByID) r.POST("/api/auth/login", authHandler.Login)
r.GET("/api/deliveries/count", h.GetDeliveryCount)
r.POST("/api/deliveries", h.CreateDelivery) authorized := r.Group("/api")
r.PATCH("/api/deliveries/:id", h.UpdateDelivery) authorized.Use(auth.AuthMiddleware([]byte(os.Getenv("JWT_SECRET"))))
r.PATCH("/api/deliveries/:id/status", h.UpdateDeliveryStatus) {
r.DELETE("/api/deliveries/:id", h.DeleteDelivery) 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") r.Run(":8080")
} }

View File

@@ -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)
}

View File

@@ -12,6 +12,8 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
) )
require github.com/golang-jwt/jwt/v5 v5.3.1
require ( require (
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
@@ -41,7 +43,7 @@ require (
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.23.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/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect

View File

@@ -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-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 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=

View File

@@ -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")
)

View File

@@ -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,
})
}

View File

@@ -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")
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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 -- name: GetDeliveriesByDate :many
SELECT * FROM deliveries WHERE date = $1; SELECT * FROM deliveries WHERE date = $1;

View File

@@ -12,10 +12,12 @@ import (
type Querier interface { type Querier interface {
CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (Delivery, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteDelivery(ctx context.Context, id pgtype.UUID) error DeleteDelivery(ctx context.Context, id pgtype.UUID) error
GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error) GetDeliveriesByDate(ctx context.Context, date pgtype.Date) ([]Delivery, error)
GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error) GetDeliveryByID(ctx context.Context, id pgtype.UUID) (Delivery, error)
GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow, error)
GetUserByUsername(ctx context.Context, username string) (User, error)
UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error UpdateDelivery(ctx context.Context, arg UpdateDeliveryParams) error
UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error UpdateDeliveryStatus(ctx context.Context, arg UpdateDeliveryStatusParams) error
} }

View File

@@ -57,6 +57,29 @@ func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams)
return i, err 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 const deleteDelivery = `-- name: DeleteDelivery :exec
DELETE FROM deliveries WHERE id = $1 DELETE FROM deliveries WHERE id = $1
` `
@@ -156,6 +179,22 @@ func (q *Queries) GetDeliveryCount(ctx context.Context) ([]GetDeliveryCountRow,
return items, nil 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 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 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
` `