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/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,

4
go.mod
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,5 +12,4 @@ func GlueRoutes(r *gin.Engine, healthHandler *health.Handler, authHandler *auth.
v1 := api.Group("/v1")
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
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)
}

View File

@ -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: []