login
This commit is contained in:
parent
0af44340d5
commit
6bd886664d
@ -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
4
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
@ -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
68
internal/jwt/jwt.go
Normal 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")
|
||||
}
|
||||
15
internal/password/password.go
Normal file
15
internal/password/password.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user