diff --git a/api b/api deleted file mode 100755 index c11bc9f..0000000 Binary files a/api and /dev/null differ diff --git a/cmd/api/main.go b/cmd/api/main.go index 529cecc..0c66e3b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -12,19 +12,23 @@ import ( "go.uber.org/fx" "go.uber.org/fx/fxevent" "go.uber.org/zap" + + "github.com/jackc/pgx/v5/pgxpool" ) func main() { fx.New( fx.Provide( - config.Provide, //config - logger.ProvideLogger, //logger - db.ProvidePool, // pool provider - usersdb.New, // generated code for sqlc - users.NewService, // service - http.NewServer, // http server - health.NewHandler, // http handler - auth.NewHandler, //http handler + config.Provide, + logger.ProvideLogger, + db.ProvidePool, + func(pool *pgxpool.Pool) usersdb.Querier { + return usersdb.New(pool) + }, + users.NewService, + http.NewServer, + health.NewHandler, + auth.NewHandler, ), fx.Invoke( http.GlueRoutes, diff --git a/go.mod b/go.mod index e091e27..bfaf769 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,12 @@ module git.kanopo.dev/rhythm/rhythm-backend go 1.26.2 require ( + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.9.2 github.com/joho/godotenv v1.5.1 github.com/pressly/goose/v3 v3.27.0 + golang.org/x/crypto v0.48.0 ) require ( @@ -34,7 +37,6 @@ require ( go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/dig v1.19.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index ad0a20d..ac369b4 100644 --- a/go.sum +++ b/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/internal/config/config.go b/internal/config/config.go index 1978ad0..6beca9f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,12 +5,16 @@ import ( _ "github.com/joho/godotenv/autoload" "log" "os" + "time" ) type Config struct { - AppEnv string - DbUrl string - ServerPort string + AppEnv string + DbUrl string + ServerPort string + JWTSecret string + JWTExpiry time.Duration + RefreshExpiry time.Duration } func Load() Config { @@ -22,29 +26,35 @@ func Load() Config { port := getEnv("DB_PORT") host := getEnv("DB_HOST") - // postgres://admin:admin@localhost:5432/admin_db dbUrl = fmt.Sprintf("postgres://%v:%v@%v:%v/%v?sslmode=disable", username, password, host, port, name) } - appEnv := os.Getenv("APP_ENV") // development | production + appEnv := os.Getenv("APP_ENV") if appEnv == "" { appEnv = "development" } + jwtSecret := getEnv("JWT_SECRET") + if jwtSecret == "" { + log.Fatal("JWT_SECRET env variable is required") + } + cfg := Config{ - AppEnv: appEnv, - DbUrl: dbUrl, - ServerPort: "8080", + AppEnv: appEnv, + DbUrl: dbUrl, + ServerPort: "8080", + JWTSecret: jwtSecret, + JWTExpiry: time.Minute * 15, + RefreshExpiry: time.Hour * 24 * 7, } return cfg - } func getEnv(key string) string { v := os.Getenv(key) if v == "" { - log.Fatalf("The env variable %v is not defined and the applciation can not operate without\n", key) + log.Fatalf("The env variable %v is not defined and the application can not operate without\n", key) } return v } diff --git a/internal/db/users/models.go b/internal/db/users/models.go index 4b9fa2c..8d93028 100644 --- a/internal/db/users/models.go +++ b/internal/db/users/models.go @@ -5,13 +5,15 @@ package usersdb import ( - "github.com/jackc/pgx/v5/pgtype" + "time" + + "github.com/google/uuid" ) type User struct { - ID pgtype.UUID + ID uuid.UUID Email string Password string - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + CreatedAt time.Time + UpdatedAt time.Time } diff --git a/internal/db/users/querier.go b/internal/db/users/querier.go index 4a971de..2c673d9 100644 --- a/internal/db/users/querier.go +++ b/internal/db/users/querier.go @@ -7,13 +7,14 @@ package usersdb import ( "context" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" ) type Querier interface { CreateUser(ctx context.Context, arg CreateUserParams) (User, error) - DeleteUser(ctx context.Context, id pgtype.UUID) error - GetUser(ctx context.Context, id pgtype.UUID) (User, error) + DeleteUser(ctx context.Context, id uuid.UUID) error + GetUser(ctx context.Context, id uuid.UUID) (User, error) + GetUserByEmail(ctx context.Context, email string) (User, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/db/users/queries.sql b/internal/db/users/queries.sql index d14c089..4d0f857 100644 --- a/internal/db/users/queries.sql +++ b/internal/db/users/queries.sql @@ -2,6 +2,10 @@ select * from users where id = $1 limit 1; +-- name: GetUserByEmail :one +select * from users +where email = $1 limit 1; + -- name: CreateUser :one insert into users ( email, password diff --git a/internal/db/users/queries.sql.go b/internal/db/users/queries.sql.go index e367366..c1ecb72 100644 --- a/internal/db/users/queries.sql.go +++ b/internal/db/users/queries.sql.go @@ -8,7 +8,7 @@ package usersdb import ( "context" - "github.com/jackc/pgx/v5/pgtype" + "github.com/google/uuid" ) const createUser = `-- name: CreateUser :one @@ -43,7 +43,7 @@ DELETE FROM users WHERE id = $1 ` -func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error { +func (q *Queries) DeleteUser(ctx context.Context, id uuid.UUID) error { _, err := q.db.Exec(ctx, deleteUser, id) return err } @@ -53,7 +53,7 @@ select id, email, password, created_at, updated_at from users where id = $1 limit 1 ` -func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (User, error) { +func (q *Queries) GetUser(ctx context.Context, id uuid.UUID) (User, error) { row := q.db.QueryRow(ctx, getUser, id) var i User err := row.Scan( @@ -65,3 +65,21 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (User, error) { ) return i, err } + +const getUserByEmail = `-- name: GetUserByEmail :one +select id, email, password, created_at, updated_at from users +where email = $1 limit 1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Password, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/http/api/auth/handler.go b/internal/http/api/auth/handler.go index 7c67a7e..361f102 100644 --- a/internal/http/api/auth/handler.go +++ b/internal/http/api/auth/handler.go @@ -1,48 +1,91 @@ package auth import ( + "errors" "net/http" - "time" "github.com/gin-gonic/gin" "go.uber.org/zap" + + "git.kanopo.dev/rhythm/rhythm-backend/internal/config" + "git.kanopo.dev/rhythm/rhythm-backend/internal/service/users" ) type Handler struct { + svc users.Service log *zap.SugaredLogger + cfg *config.Config } -func NewHandler(log *zap.SugaredLogger) *Handler { +func NewHandler(svc users.Service, log *zap.SugaredLogger, cfg *config.Config) *Handler { return &Handler{ + svc: svc, log: log, + cfg: cfg, } } func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) { rg.POST("/login", h.Login) + rg.POST("/register", h.Register) } -func setRefreshTokenCookie(c *gin.Context, token string) { - maxAge := time.Hour * 24 * 7 +func setRefreshTokenCookie(c *gin.Context, token string, maxAge int) { c.SetCookie( - "refresh_token", // name - token, // value - int(maxAge), // maxAge (seconds, 7 days) - "/", // path - "", // domain - false, // secure (true in production) - true, // httpOnly + "refresh_token", + token, + maxAge, + "/", + "", + false, + true, ) } -func (h *Handler) Login(c *gin.Context) { - // var req users.LoginReq - // if err := c.ShouldBindJSON(&req); err != nil { - // c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - // return - // } - // - // res := h.service.Login(req) - // setRefreshTokenCookie(c, res.RefreshToken) - c.JSON(http.StatusOK, gin.H{"msg": "ok"}) +func (h *Handler) Register(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.svc.Register(c.Request.Context(), req.Email, req.Password) + if err != nil { + h.log.Error("registration error", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + setRefreshTokenCookie(c, result.RefreshToken, int(h.cfg.RefreshExpiry)) + c.JSON(http.StatusCreated, gin.H{"accessToken": result.AccessToken}) +} + +func (h *Handler) Login(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + result, err := h.svc.Login(c.Request.Context(), req.Email, req.Password) + if err != nil { + if errors.Is(err, users.ErrInvalidCredentials) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + h.log.Error("login error", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + return + } + + setRefreshTokenCookie(c, result.RefreshToken, int(h.cfg.RefreshExpiry)) + c.JSON(http.StatusOK, gin.H{"accessToken": result.AccessToken}) } diff --git a/internal/http/router.go b/internal/http/router.go index abe8963..f03426c 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -12,5 +12,4 @@ func GlueRoutes(r *gin.Engine, healthHandler *health.Handler, authHandler *auth. v1 := api.Group("/v1") authHandler.RegisterRoutes(v1.Group("/auth")) - } diff --git a/internal/jwt/jwt.go b/internal/jwt/jwt.go new file mode 100644 index 0000000..8a9e7ce --- /dev/null +++ b/internal/jwt/jwt.go @@ -0,0 +1,68 @@ +package jwt + +import ( + "errors" + "time" + + "git.kanopo.dev/rhythm/rhythm-backend/internal/config" + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"userId"` + jwt.RegisteredClaims +} + +type TokenPair struct { + AccessToken string + RefreshToken string +} + +func GenerateTokenPair(userID string, cfg *config.Config) (TokenPair, error) { + accessToken, err := generateToken(userID, cfg.JWTSecret, cfg.JWTExpiry) + if err != nil { + return TokenPair{}, err + } + + refreshToken, err := generateToken(userID, cfg.JWTSecret, cfg.RefreshExpiry) + if err != nil { + return TokenPair{}, err + } + + return TokenPair{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +func generateToken(userID, secret string, expiry time.Duration) (string, error) { + 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([]byte(secret)) +} + +func ValidateToken(tokenString, secret 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(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/internal/password/password.go b/internal/password/password.go new file mode 100644 index 0000000..447588e --- /dev/null +++ b/internal/password/password.go @@ -0,0 +1,15 @@ +package password + +import ( + "golang.org/x/crypto/bcrypt" +) + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/internal/service/users/user_service.go b/internal/service/users/user_service.go index f85920c..929155d 100644 --- a/internal/service/users/user_service.go +++ b/internal/service/users/user_service.go @@ -1,34 +1,104 @@ package users import ( - usersdb "git.kanopo.dev/rhythm/rhythm-backend/internal/db/users" + "context" + "errors" + "go.uber.org/zap" + + "git.kanopo.dev/rhythm/rhythm-backend/internal/config" + usersdb "git.kanopo.dev/rhythm/rhythm-backend/internal/db/users" + "git.kanopo.dev/rhythm/rhythm-backend/internal/jwt" + "git.kanopo.dev/rhythm/rhythm-backend/internal/password" ) -type Service struct { +var ErrInvalidCredentials = errors.New("invalid credentials") + +type AuthResult struct { + AccessToken string + RefreshToken string +} + +type Service interface { + Login(ctx context.Context, email, password string) (*AuthResult, error) + Register(ctx context.Context, email, password string) (*AuthResult, error) + GetUserByEmail(ctx context.Context, email string) (usersdb.User, error) +} + +type service struct { repo usersdb.Querier + cfg *config.Config log *zap.SugaredLogger } -func NewService(repo usersdb.Querier, log *zap.SugaredLogger) *Service { - return &Service{ +func NewService(repo usersdb.Querier, cfg *config.Config, log *zap.SugaredLogger) Service { + return &service{ repo: repo, + cfg: cfg, log: log, } } -// type LoginReq struct { -// Email string `json:"email" binding:"required"` -// Password string `json:"password" binding:"required"` -// } -// type AuthRes struct { -// AccessToken string `json:"accessToken"` -// RefreshToken string // not parset to json, set with cookies -// } -// -// func (s *Service) Login(req LoginReq) AuthRes { -// return AuthRes{ -// AccessToken: "ciao", -// RefreshToken: "ciao", -// } -// } +func (s *service) Login(ctx context.Context, email, passwordPlain string) (*AuthResult, error) { + user, err := s.repo.GetUserByEmail(ctx, email) + if err != nil { + s.log.Infof("login failed: user not found with email %v", email) + return nil, ErrInvalidCredentials + } + + if !password.CheckPassword(passwordPlain, user.Password) { + s.log.Infof("login failed: invalid password for email %v", email) + return nil, ErrInvalidCredentials + } + + tokenPair, err := jwt.GenerateTokenPair(user.ID.String(), s.cfg) + if err != nil { + s.log.Errorf("failed to generate token pair %v", err) + return nil, err + } + + s.log.Infof("user logged in successfully with email %v", email) + + return &AuthResult{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, nil +} + +func (s *service) Register(ctx context.Context, email, passwordPlain string) (*AuthResult, error) { + _, err := s.repo.GetUserByEmail(ctx, email) + if err == nil { + s.log.Infof("registration failed: email already exists %v", email) + return nil, ErrInvalidCredentials + } + + hash, err := password.HashPassword(passwordPlain) + if err != nil { + return nil, err + } + + user, err := s.repo.CreateUser(ctx, usersdb.CreateUserParams{ + Email: email, + Password: hash, + }) + if err != nil { + return nil, err + } + + tokenPair, err := jwt.GenerateTokenPair(user.ID.String(), s.cfg) + if err != nil { + s.log.Error("failed to generate token pair", "error", err) + return nil, err + } + + s.log.Info("user registered successfully", "email", email) + + return &AuthResult{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + }, nil +} + +func (s *service) GetUserByEmail(ctx context.Context, email string) (usersdb.User, error) { + return s.repo.GetUserByEmail(ctx, email) +} diff --git a/sqlc.yaml b/sqlc.yaml index 7d52142..fa13496 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -15,6 +15,15 @@ sql: sql_package: "pgx/v5" emit_json_tags: false emit_interface: true + overrides: + - db_type: "uuid" + go_type: + import: "github.com/google/uuid" + type: "UUID" + - db_type: "timestamptz" + go_type: + import: "time" + type: "Time" overrides: go: null plugins: []