add auth module
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
54
backend/cmd/seed.go/main.go
Normal file
54
backend/cmd/seed.go/main.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
12
backend/internal/auth/errors.go
Normal file
12
backend/internal/auth/errors.go
Normal 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")
|
||||
)
|
||||
77
backend/internal/auth/handler.go
Normal file
77
backend/internal/auth/handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
50
backend/internal/auth/jwt.go
Normal file
50
backend/internal/auth/jwt.go
Normal 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")
|
||||
}
|
||||
36
backend/internal/auth/middleware.go
Normal file
36
backend/internal/auth/middleware.go
Normal 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()
|
||||
}
|
||||
}
|
||||
98
backend/internal/auth/service.go
Normal file
98
backend/internal/auth/service.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user