This commit is contained in:
Dmitri 2026-04-21 09:50:51 +02:00
parent 0af44340d5
commit 6bd886664d
Signed by: kanopo
GPG Key ID: 759ADD40E3132AC7
15 changed files with 317 additions and 70 deletions

BIN
api

Binary file not shown.

View File

@ -12,19 +12,23 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent" "go.uber.org/fx/fxevent"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/jackc/pgx/v5/pgxpool"
) )
func main() { func main() {
fx.New( fx.New(
fx.Provide( fx.Provide(
config.Provide, //config config.Provide,
logger.ProvideLogger, //logger logger.ProvideLogger,
db.ProvidePool, // pool provider db.ProvidePool,
usersdb.New, // generated code for sqlc func(pool *pgxpool.Pool) usersdb.Querier {
users.NewService, // service return usersdb.New(pool)
http.NewServer, // http server },
health.NewHandler, // http handler users.NewService,
auth.NewHandler, //http handler http.NewServer,
health.NewHandler,
auth.NewHandler,
), ),
fx.Invoke( fx.Invoke(
http.GlueRoutes, http.GlueRoutes,

4
go.mod
View File

@ -3,9 +3,12 @@ module git.kanopo.dev/rhythm/rhythm-backend
go 1.26.2 go 1.26.2
require ( 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/jackc/pgx/v5 v5.9.2
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pressly/goose/v3 v3.27.0 github.com/pressly/goose/v3 v3.27.0
golang.org/x/crypto v0.48.0
) )
require ( require (
@ -34,7 +37,6 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/dig v1.19.0 // indirect go.uber.org/dig v1.19.0 // indirect
golang.org/x/arch v0.22.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/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect

2
go.sum
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

@ -5,12 +5,16 @@ import (
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
"log" "log"
"os" "os"
"time"
) )
type Config struct { type Config struct {
AppEnv string AppEnv string
DbUrl string DbUrl string
ServerPort string ServerPort string
JWTSecret string
JWTExpiry time.Duration
RefreshExpiry time.Duration
} }
func Load() Config { func Load() Config {
@ -22,29 +26,35 @@ func Load() Config {
port := getEnv("DB_PORT") port := getEnv("DB_PORT")
host := getEnv("DB_HOST") 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) 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 == "" { if appEnv == "" {
appEnv = "development" appEnv = "development"
} }
jwtSecret := getEnv("JWT_SECRET")
if jwtSecret == "" {
log.Fatal("JWT_SECRET env variable is required")
}
cfg := Config{ cfg := Config{
AppEnv: appEnv, AppEnv: appEnv,
DbUrl: dbUrl, DbUrl: dbUrl,
ServerPort: "8080", ServerPort: "8080",
JWTSecret: jwtSecret,
JWTExpiry: time.Minute * 15,
RefreshExpiry: time.Hour * 24 * 7,
} }
return cfg return cfg
} }
func getEnv(key string) string { func getEnv(key string) string {
v := os.Getenv(key) v := os.Getenv(key)
if v == "" { 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 return v
} }

View File

@ -5,13 +5,15 @@
package usersdb package usersdb
import ( import (
"github.com/jackc/pgx/v5/pgtype" "time"
"github.com/google/uuid"
) )
type User struct { type User struct {
ID pgtype.UUID ID uuid.UUID
Email string Email string
Password string Password string
CreatedAt pgtype.Timestamptz CreatedAt time.Time
UpdatedAt pgtype.Timestamptz UpdatedAt time.Time
} }

View File

@ -7,13 +7,14 @@ package usersdb
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype" "github.com/google/uuid"
) )
type Querier interface { type Querier interface {
CreateUser(ctx context.Context, arg CreateUserParams) (User, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id uuid.UUID) error
GetUser(ctx context.Context, id pgtype.UUID) (User, error) GetUser(ctx context.Context, id uuid.UUID) (User, error)
GetUserByEmail(ctx context.Context, email string) (User, error)
} }
var _ Querier = (*Queries)(nil) var _ Querier = (*Queries)(nil)

View File

@ -2,6 +2,10 @@
select * from users select * from users
where id = $1 limit 1; where id = $1 limit 1;
-- name: GetUserByEmail :one
select * from users
where email = $1 limit 1;
-- name: CreateUser :one -- name: CreateUser :one
insert into users ( insert into users (
email, password email, password

View File

@ -8,7 +8,7 @@ package usersdb
import ( import (
"context" "context"
"github.com/jackc/pgx/v5/pgtype" "github.com/google/uuid"
) )
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
@ -43,7 +43,7 @@ DELETE FROM users
WHERE id = $1 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) _, err := q.db.Exec(ctx, deleteUser, id)
return err return err
} }
@ -53,7 +53,7 @@ select id, email, password, created_at, updated_at from users
where id = $1 limit 1 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) row := q.db.QueryRow(ctx, getUser, id)
var i User var i User
err := row.Scan( err := row.Scan(
@ -65,3 +65,21 @@ func (q *Queries) GetUser(ctx context.Context, id pgtype.UUID) (User, error) {
) )
return i, err 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
}

View File

@ -1,48 +1,91 @@
package auth package auth
import ( import (
"errors"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "go.uber.org/zap"
"git.kanopo.dev/rhythm/rhythm-backend/internal/config"
"git.kanopo.dev/rhythm/rhythm-backend/internal/service/users"
) )
type Handler struct { type Handler struct {
svc users.Service
log *zap.SugaredLogger 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{ return &Handler{
svc: svc,
log: log, log: log,
cfg: cfg,
} }
} }
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) { func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.POST("/login", h.Login) rg.POST("/login", h.Login)
rg.POST("/register", h.Register)
} }
func setRefreshTokenCookie(c *gin.Context, token string) { func setRefreshTokenCookie(c *gin.Context, token string, maxAge int) {
maxAge := time.Hour * 24 * 7
c.SetCookie( c.SetCookie(
"refresh_token", // name "refresh_token",
token, // value token,
int(maxAge), // maxAge (seconds, 7 days) maxAge,
"/", // path "/",
"", // domain "",
false, // secure (true in production) false,
true, // httpOnly true,
) )
} }
func (h *Handler) Login(c *gin.Context) { func (h *Handler) Register(c *gin.Context) {
// var req users.LoginReq var req struct {
// if err := c.ShouldBindJSON(&req); err != nil { Email string `json:"email" binding:"required,email"`
// c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) Password string `json:"password" binding:"required,min=8"`
// return }
// }
// if err := c.ShouldBindJSON(&req); err != nil {
// res := h.service.Login(req) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// setRefreshTokenCookie(c, res.RefreshToken) return
c.JSON(http.StatusOK, gin.H{"msg": "ok"}) }
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})
} }

View File

@ -12,5 +12,4 @@ func GlueRoutes(r *gin.Engine, healthHandler *health.Handler, authHandler *auth.
v1 := api.Group("/v1") v1 := api.Group("/v1")
authHandler.RegisterRoutes(v1.Group("/auth")) authHandler.RegisterRoutes(v1.Group("/auth"))
} }

68
internal/jwt/jwt.go Normal file
View File

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

View File

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

View File

@ -1,34 +1,104 @@
package users package users
import ( import (
usersdb "git.kanopo.dev/rhythm/rhythm-backend/internal/db/users" "context"
"errors"
"go.uber.org/zap" "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 repo usersdb.Querier
cfg *config.Config
log *zap.SugaredLogger log *zap.SugaredLogger
} }
func NewService(repo usersdb.Querier, log *zap.SugaredLogger) *Service { func NewService(repo usersdb.Querier, cfg *config.Config, log *zap.SugaredLogger) Service {
return &Service{ return &service{
repo: repo, repo: repo,
cfg: cfg,
log: log, log: log,
} }
} }
// type LoginReq struct { func (s *service) Login(ctx context.Context, email, passwordPlain string) (*AuthResult, error) {
// Email string `json:"email" binding:"required"` user, err := s.repo.GetUserByEmail(ctx, email)
// Password string `json:"password" binding:"required"` if err != nil {
// } s.log.Infof("login failed: user not found with email %v", email)
// type AuthRes struct { return nil, ErrInvalidCredentials
// AccessToken string `json:"accessToken"` }
// RefreshToken string // not parset to json, set with cookies
// } if !password.CheckPassword(passwordPlain, user.Password) {
// s.log.Infof("login failed: invalid password for email %v", email)
// func (s *Service) Login(req LoginReq) AuthRes { return nil, ErrInvalidCredentials
// return AuthRes{ }
// AccessToken: "ciao",
// RefreshToken: "ciao", 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)
}

View File

@ -15,6 +15,15 @@ sql:
sql_package: "pgx/v5" sql_package: "pgx/v5"
emit_json_tags: false emit_json_tags: false
emit_interface: true 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: overrides:
go: null go: null
plugins: [] plugins: []