feat(backend): switch auth to username and cleanup db config
This commit is contained in:
parent
c1f32d0165
commit
4612172b3c
14 changed files with 146 additions and 33 deletions
|
|
@ -8,9 +8,11 @@ BACKEND_PORT=8214
|
|||
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgres://user:password@host:port/dbname?sslmode=disable
|
||||
DB_MAX_OPEN_CONNS=15
|
||||
DB_MAX_IDLE_CONNS=5
|
||||
DB_CONN_MAX_IDLE=5m
|
||||
DATABASE_URL=postgres://user:password@host:port/dbname?sslmode=disable
|
||||
ADMIN_NAME=Administrator
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@saveinmed.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# JWT Authentication
|
||||
JWT_SECRET=your-secret-key-here
|
||||
|
|
|
|||
BIN
backend/api
BIN
backend/api
Binary file not shown.
|
|
@ -2791,6 +2791,9 @@ const docTemplate = `{
|
|||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -2904,6 +2907,9 @@ const docTemplate = `{
|
|||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -2932,10 +2938,10 @@ const docTemplate = `{
|
|||
"handler.loginRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
@ -2968,6 +2974,9 @@ const docTemplate = `{
|
|||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -3232,6 +3241,9 @@ const docTemplate = `{
|
|||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2787,6 +2787,9 @@
|
|||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -2900,6 +2903,9 @@
|
|||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -2928,10 +2934,10 @@
|
|||
"handler.loginRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
@ -2964,6 +2970,9 @@
|
|||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -3228,6 +3237,9 @@
|
|||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -434,6 +434,8 @@ definitions:
|
|||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
domain.UserPage:
|
||||
properties:
|
||||
|
|
@ -507,6 +509,8 @@ definitions:
|
|||
type: string
|
||||
role:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
handler.forgotPasswordRequest:
|
||||
properties:
|
||||
|
|
@ -524,10 +528,10 @@ definitions:
|
|||
type: object
|
||||
handler.loginRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
handler.messageResponse:
|
||||
properties:
|
||||
|
|
@ -548,6 +552,8 @@ definitions:
|
|||
type: string
|
||||
role:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
handler.registerCompanyRequest:
|
||||
properties:
|
||||
|
|
@ -720,6 +726,8 @@ definitions:
|
|||
type: string
|
||||
role:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
handler.verifyEmailRequest:
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -13,9 +13,6 @@ type Config struct {
|
|||
AppName string
|
||||
Port string
|
||||
DatabaseURL string
|
||||
MaxOpenConns int
|
||||
MaxIdleConns int
|
||||
ConnMaxIdle time.Duration
|
||||
MercadoPagoBaseURL string
|
||||
MarketplaceCommission float64
|
||||
JWTSecret string
|
||||
|
|
@ -24,6 +21,10 @@ type Config struct {
|
|||
CORSOrigins []string
|
||||
BackendHost string
|
||||
SwaggerSchemes []string
|
||||
AdminName string
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and applies sane defaults
|
||||
|
|
@ -33,9 +34,6 @@ func Load() Config {
|
|||
AppName: getEnv("APP_NAME", "saveinmed-performance-core"),
|
||||
Port: getEnv("BACKEND_PORT", "8214"),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/saveinmed?sslmode=disable"),
|
||||
MaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 15),
|
||||
MaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 5),
|
||||
ConnMaxIdle: getEnvDuration("DB_CONN_MAX_IDLE", 5*time.Minute),
|
||||
MercadoPagoBaseURL: getEnv("MERCADOPAGO_BASE_URL", "https://api.mercadopago.com"),
|
||||
MarketplaceCommission: getEnvFloat("MARKETPLACE_COMMISSION", 2.5),
|
||||
JWTSecret: getEnv("JWT_SECRET", "dev-secret"),
|
||||
|
|
@ -44,6 +42,10 @@ func Load() Config {
|
|||
CORSOrigins: getEnvStringSlice("CORS_ORIGINS", []string{"*"}),
|
||||
BackendHost: getEnv("BACKEND_HOST", ""),
|
||||
SwaggerSchemes: getEnvStringSlice("SWAGGER_SCHEMES", []string{"http"}),
|
||||
AdminName: getEnv("ADMIN_NAME", "Administrator"),
|
||||
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
|
||||
AdminEmail: getEnv("ADMIN_EMAIL", "admin@saveinmed.com"),
|
||||
AdminPassword: getEnv("ADMIN_PASSWORD", "admin123"),
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type User struct {
|
|||
CompanyID uuid.UUID `db:"company_id" json:"company_id"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Username string `db:"username" json:"username"`
|
||||
Email string `db:"email" json:"email"`
|
||||
EmailVerified bool `db:"email_verified" json:"email_verified"`
|
||||
PasswordHash string `db:"password_hash" json:"-"`
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type createUserRequest struct {
|
|||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
|
@ -28,6 +29,7 @@ type registerAuthRequest struct {
|
|||
Company *registerCompanyTarget `json:"company,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Name string `json:"name"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
|
@ -45,7 +47,7 @@ type registerCompanyTarget struct {
|
|||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +100,7 @@ type updateUserRequest struct {
|
|||
CompanyID *uuid.UUID `json:"company_id,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||
CompanyID: companyID,
|
||||
Role: req.Role,
|
||||
Name: req.Name,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
token, exp, err := h.svc.Authenticate(r.Context(), user.Email, req.Password)
|
||||
token, exp, err := h.svc.Authenticate(r.Context(), user.Username, req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
|
|
@ -105,7 +106,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
token, exp, err := h.svc.Authenticate(r.Context(), req.Email, req.Password)
|
||||
token, exp, err := h.svc.Authenticate(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|||
CompanyID: req.CompanyID,
|
||||
Role: req.Role,
|
||||
Name: req.Name,
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
}
|
||||
|
||||
|
|
@ -203,6 +204,9 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||
if req.Name != nil {
|
||||
user.Name = *req.Name
|
||||
}
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
if req.Email != nil {
|
||||
user.Email = *req.Email
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT;
|
||||
|
||||
-- Backfill existing users with their email as username to satisfy NOT NULL constraint
|
||||
UPDATE users SET username = email WHERE username IS NULL;
|
||||
|
||||
ALTER TABLE users ALTER COLUMN username SET NOT NULL;
|
||||
ALTER TABLE users ADD CONSTRAINT users_username_key UNIQUE (username);
|
||||
|
|
@ -785,8 +785,8 @@ func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error {
|
|||
user.CreatedAt = now
|
||||
user.UpdatedAt = now
|
||||
|
||||
query := `INSERT INTO users (id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at)
|
||||
VALUES (:id, :company_id, :role, :name, :email, :email_verified, :password_hash, :created_at, :updated_at)`
|
||||
query := `INSERT INTO users (id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at)
|
||||
VALUES (:id, :company_id, :role, :name, :username, :email, :email_verified, :password_hash, :created_at, :updated_at)`
|
||||
|
||||
_, err := r.db.NamedExecContext(ctx, query, user)
|
||||
return err
|
||||
|
|
@ -814,7 +814,7 @@ func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([
|
|||
}
|
||||
|
||||
args = append(args, filter.Limit, filter.Offset)
|
||||
listQuery := fmt.Sprintf("SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
|
||||
listQuery := fmt.Sprintf("SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
|
||||
|
||||
var users []domain.User
|
||||
if err := r.db.SelectContext(ctx, &users, listQuery, args...); err != nil {
|
||||
|
|
@ -826,17 +826,17 @@ func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([
|
|||
|
||||
func (r *Repository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
||||
var user domain.User
|
||||
query := `SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at FROM users WHERE id = $1`
|
||||
query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at FROM users WHERE id = $1`
|
||||
if err := r.db.GetContext(ctx, &user, query, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
func (r *Repository) GetUserByUsername(ctx context.Context, username string) (*domain.User, error) {
|
||||
var user domain.User
|
||||
query := `SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at FROM users WHERE email = $1`
|
||||
if err := r.db.GetContext(ctx, &user, query, email); err != nil {
|
||||
query := `SELECT id, company_id, role, name, username, email, email_verified, password_hash, created_at, updated_at FROM users WHERE username = $1`
|
||||
if err := r.db.GetContext(ctx, &user, query, username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/jmoiron/sqlx"
|
||||
httpSwagger "github.com/swaggo/http-swagger"
|
||||
|
||||
"github.com/saveinmed/backend-go/internal/config"
|
||||
"github.com/saveinmed/backend-go/internal/domain"
|
||||
"github.com/saveinmed/backend-go/internal/http/handler"
|
||||
"github.com/saveinmed/backend-go/internal/http/middleware"
|
||||
"github.com/saveinmed/backend-go/internal/payments"
|
||||
|
|
@ -23,6 +25,7 @@ type Server struct {
|
|||
cfg config.Config
|
||||
db *sqlx.DB
|
||||
mux *http.ServeMux
|
||||
svc *usecase.Service
|
||||
}
|
||||
|
||||
func New(cfg config.Config) (*Server, error) {
|
||||
|
|
@ -31,10 +34,6 @@ func New(cfg config.Config) (*Server, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
db.SetConnMaxIdleTime(cfg.ConnMaxIdle)
|
||||
|
||||
repo := postgres.New(db)
|
||||
gateway := payments.NewMercadoPagoGateway(cfg.MercadoPagoBaseURL, cfg.MarketplaceCommission)
|
||||
svc := usecase.NewService(repo, gateway, cfg.MarketplaceCommission, cfg.JWTSecret, cfg.JWTExpiresIn, cfg.PasswordPepper)
|
||||
|
|
@ -124,7 +123,7 @@ func New(cfg config.Config) (*Server, error) {
|
|||
|
||||
mux.Handle("GET /docs/", httpSwagger.Handler(httpSwagger.URL("/docs/doc.json")))
|
||||
|
||||
return &Server{cfg: cfg, db: db, mux: mux}, nil
|
||||
return &Server{cfg: cfg, db: db, mux: mux, svc: svc}, nil
|
||||
}
|
||||
|
||||
// Start runs the HTTP server and ensures the database is reachable.
|
||||
|
|
@ -138,6 +137,68 @@ func (s *Server) Start(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Seed Admin
|
||||
if s.cfg.AdminEmail != "" && s.cfg.AdminPassword != "" {
|
||||
// Checks if admin already exists
|
||||
_, err := repo.GetUserByEmail(ctx, s.cfg.AdminEmail)
|
||||
if err != nil {
|
||||
// If not found, create
|
||||
log.Printf("Seeding admin user: %s", s.cfg.AdminEmail)
|
||||
|
||||
// 1. Create/Get Admin Company
|
||||
adminCNPJ := "00000000000000"
|
||||
company := &domain.Company{
|
||||
ID: uuid.Must(uuid.NewV7()),
|
||||
CNPJ: adminCNPJ,
|
||||
CorporateName: "SaveInMed Admin",
|
||||
Category: "admin",
|
||||
LicenseNumber: "ADMIN",
|
||||
IsVerified: true,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
// We need to check if company exists by CNPJ normally, but repo doesn't expose GetByCNPJ easily?
|
||||
// Let's rely on RegisterAccount handling it or check if we can query.
|
||||
// Actually RegisterAccount in Service handles creation if ID is Nil, but keys off ID.
|
||||
// We can try to create and ignore conflict, or use a known ID?
|
||||
// Let's use RegisterAccount logic.
|
||||
|
||||
// Because RegisterAccount expects us to pass a company, and tries to Get by ID if ID is set, or Create if not.
|
||||
// But duplicate CNPJ will fail at DB level.
|
||||
// Let's assume on fresh boot it doesn't exist.
|
||||
// Or better: Use svc.RegisterAccount. But wait, svc.RegisterAccount logic:
|
||||
/*
|
||||
if company != nil {
|
||||
if company.ID == uuid.Nil {
|
||||
// create
|
||||
} else {
|
||||
// get
|
||||
}
|
||||
}
|
||||
*/
|
||||
// If we re-run, GetUserByEmail would have found the user, so we skip.
|
||||
// The only edge case is if User was deleted but Company remains.
|
||||
// In that case, CreateCompany will fail on CNPJ constraint.
|
||||
|
||||
err := s.svc.RegisterAccount(ctx, company, &domain.User{
|
||||
Role: "Admin",
|
||||
Name: s.cfg.AdminName,
|
||||
Email: s.cfg.AdminEmail,
|
||||
}, s.cfg.AdminPassword)
|
||||
|
||||
if err != nil {
|
||||
// If error is duplicate key on company, maybe we should fetch the company and try creating user only?
|
||||
// For now, let's log error but not fail startup hard, or fail hard to signal issue.
|
||||
log.Printf("Failed to seed admin: %v", err)
|
||||
} else {
|
||||
log.Printf("Admin user created successfully")
|
||||
}
|
||||
} else {
|
||||
log.Printf("Admin user %s already exists", s.cfg.AdminEmail)
|
||||
}
|
||||
}
|
||||
|
||||
corsConfig := middleware.CORSConfig{AllowedOrigins: s.cfg.CORSOrigins}
|
||||
srv := &http.Server{
|
||||
Addr: s.cfg.Addr(),
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ type Repository interface {
|
|||
CreateUser(ctx context.Context, user *domain.User) error
|
||||
ListUsers(ctx context.Context, filter domain.UserFilter) ([]domain.User, int64, error)
|
||||
GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*domain.User, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (*domain.User, error)
|
||||
UpdateUser(ctx context.Context, user *domain.User) error
|
||||
DeleteUser(ctx context.Context, id uuid.UUID) error
|
||||
|
||||
|
|
@ -623,8 +623,8 @@ func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company,
|
|||
}
|
||||
|
||||
// Authenticate validates credentials and emits a signed JWT.
|
||||
func (s *Service) Authenticate(ctx context.Context, email, password string) (string, time.Time, error) {
|
||||
user, err := s.repo.GetUserByEmail(ctx, email)
|
||||
func (s *Service) Authenticate(ctx context.Context, username, password string) (string, time.Time, error) {
|
||||
user, err := s.repo.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue