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

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

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-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=

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
SELECT * FROM deliveries WHERE date = $1;

View File

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

View File

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