From f4ef71b63b3520ebebd1e4a3e942c274c7e8b043 Mon Sep 17 00:00:00 2001 From: ulflow_phattt2901 Date: Sat, 24 May 2025 11:24:19 +0700 Subject: [PATCH] feat: implement user authentication system with JWT and role-based access control --- cmd/app/main.go | 53 ++-- configs/config.yaml | 7 + docs/roadmap.md | 84 +++++-- go.mod | 1 + go.sum | 4 + .../adapter/persistence/role_repository.go | 54 ++++ .../adapter/persistence/user_repository.go | 88 +++++++ internal/domain/role/repository.go | 24 ++ internal/domain/role/role.go | 25 ++ internal/domain/user/repository.go | 38 +++ internal/domain/user/user.go | 50 ++++ internal/helper/config/types.go | 7 + internal/service/auth_service.go | 235 ++++++++++++++++++ internal/transport/http/dto/error_response.go | 8 + internal/transport/http/dto/user_dto.go | 69 +++++ .../transport/http/handler/auth_handler.go | 149 +++++++++++ internal/transport/http/middleware/auth.go | 121 +++++++++ internal/transport/http/middleware/cors.go | 47 ++++ internal/transport/http/router.go | 83 +++++-- internal/transport/http/server.go | 7 +- migrations/000001_create_roles_table.up.sql | 24 ++ migrations/000002_create_users_table.up.sql | 25 ++ .../000003_create_user_roles_table.up.sql | 20 ++ 23 files changed, 1164 insertions(+), 59 deletions(-) create mode 100644 internal/adapter/persistence/role_repository.go create mode 100644 internal/adapter/persistence/user_repository.go create mode 100644 internal/domain/role/repository.go create mode 100644 internal/domain/role/role.go create mode 100644 internal/domain/user/repository.go create mode 100644 internal/domain/user/user.go create mode 100644 internal/service/auth_service.go create mode 100644 internal/transport/http/dto/error_response.go create mode 100644 internal/transport/http/dto/user_dto.go create mode 100644 internal/transport/http/handler/auth_handler.go create mode 100644 internal/transport/http/middleware/auth.go create mode 100644 internal/transport/http/middleware/cors.go create mode 100644 migrations/000001_create_roles_table.up.sql create mode 100644 migrations/000002_create_users_table.up.sql create mode 100644 migrations/000003_create_user_roles_table.up.sql diff --git a/cmd/app/main.go b/cmd/app/main.go index f68babe..ecfe87c 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -6,6 +6,7 @@ import ( "os" "time" + "gorm.io/gorm" "starter-kit/internal/helper/config" "starter-kit/internal/helper/database" "starter-kit/internal/helper/feature" @@ -18,12 +19,14 @@ import ( type HTTPService struct { server *http.Server cfg *config.Config + db *database.Database } -func NewHTTPService(cfg *config.Config) *HTTPService { +func NewHTTPService(cfg *config.Config, db *database.Database) *HTTPService { return &HTTPService{ - server: http.NewServer(cfg), + server: http.NewServer(cfg, db.DB), cfg: cfg, + db: db, } } @@ -105,26 +108,21 @@ func main() { lifecycleMgr := lifecycle.New(shutdownTimeout) // Initialize database connection - if feature.IsEnabled(feature.EnableDatabase) { - logger.Info("Database feature is enabled, connecting...") - _, err = database.NewConnection(&cfg.Database) - if err != nil { - logger.WithError(err).Fatal("Failed to connect to database") - } - - // Run database migrations - if err := database.Migrate(cfg.Database); err != nil { - logger.WithError(err).Fatal("Failed to migrate database") - } - - // Register database cleanup on shutdown - lifecycleMgr.Register(&databaseService{}) - } else { - logger.Info("Database feature is disabled") + db, err := database.NewConnection(&cfg.Database) + if err != nil { + logger.WithError(err).Fatal("Failed to connect to database") } - // Register HTTP service with the lifecycle manager - httpService := NewHTTPService(cfg) + // Run database migrations + if err := database.Migrate(cfg.Database); err != nil { + logger.WithError(err).Fatal("Failed to migrate database") + } + + // Register database cleanup on shutdown + lifecycleMgr.Register(&databaseService{db: db}) + + // Initialize HTTP service with database + httpService := NewHTTPService(cfg, &database.Database{DB: db}) if httpService == nil { logger.Fatal("Failed to create HTTP service") } @@ -153,17 +151,26 @@ func main() { } // databaseService implements the lifecycle.Service interface for database operations -type databaseService struct{} +type databaseService struct { + db *gorm.DB +} func (s *databaseService) Name() string { return "Database Service" } func (s *databaseService) Start() error { - // Database connection is initialized in main + // Database initialization is handled in main return nil } func (s *databaseService) Shutdown(ctx context.Context) error { - return database.Close() + if s.db != nil { + sqlDB, err := s.db.DB() + if err != nil { + return fmt.Errorf("failed to get database instance: %w", err) + } + return sqlDB.Close() + } + return nil } diff --git a/configs/config.yaml b/configs/config.yaml index 66f9ea9..0482041 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -29,3 +29,10 @@ database: max_idle_conns: 5 conn_max_lifetime: 300 migration_path: "migrations" + +# JWT Configuration +jwt: + # Generate a secure random secret key using: openssl rand -base64 32 + secret: "your-32-byte-base64-encoded-secret-key-here" + # Token expiration time in minutes (1440 minutes = 24 hours) + expiration: 1440 diff --git a/docs/roadmap.md b/docs/roadmap.md index e33fea1..ee49e40 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,30 +1,80 @@ # Roadmap phát triển ## Roadmap cơ bản -- [ ] Read Config from env file -- [ ] HTTP Server with gin framework -- [ ] JWT Authentication -- [ ] Database with GORM + Postgres +- [x] Read Config from env file +- [x] HTTP Server with gin framework +- [x] JWT Authentication + - [x] Đăng ký người dùng + - [x] Đăng nhập với JWT + - [x] Refresh token + - [x] Xác thực token với middleware + - [x] Phân quyền cơ bản +- [x] Database with GORM + Postgres - [ ] Health Check - [ ] Unit Test with testify (Template) - [ ] CI/CD with Gitea for Dev Team - [ ] Build and Deploy with Docker + Docker Compose on Local ## Giai đoạn 1: Cơ sở hạ tầng cơ bản -- [ ] Thiết lập cấu trúc dự án theo mô hình DDD -- [ ] Cấu hình cơ bản: env, logging, error handling -- [ ] Cấu hình Docker và Docker Compose -- [ ] HTTP server với Gin -- [ ] Database setup với GORM và Postgres +- [x] Thiết lập cấu trúc dự án theo mô hình DDD +- [x] Cấu hình cơ bản: env, logging, error handling +- [x] Cấu hình Docker và Docker Compose +- [x] HTTP server với Gin +- [x] Database setup với GORM và Postgres - [ ] Health check API endpoints - Timeline: Q2/2025 -## Giai đoạn 2: Bảo mật và xác thực -- [ ] JWT Authentication -- [ ] Role-based access control -- [ ] API rate limiting -- [ ] Secure headers và middleware -- Timeline: Q2/2025 +## Giai đoạn 2: Bảo mật và xác thực (Q2/2025) + +### 1. Xác thực và Ủy quyền +- [x] **JWT Authentication** + - [x] Đăng ký/Đăng nhập cơ bản + - [x] Refresh token + - [x] Xác thực token với middleware + - [x] Xử lý hết hạn token + + +- [x] **Phân quyền cơ bản** + - [x] Phân quyền theo role + - [ ] Quản lý role và permission + - [ ] Phân quyền chi tiết đến từng endpoint + - [ ] API quản lý người dùng và phân quyền + +### 2. Bảo mật Ứng dụng +- [ ] **API Security** + - [ ] API rate limiting (throttling) + - [ ] Request validation và sanitization + - [ ] Chống tấn công DDoS cơ bản + - [ ] API versioning + +- [ ] **Security Headers** + - [x] CORS configuration + - [ ] Security headers (CSP, HSTS, X-Content-Type, X-Frame-Options) + - [ ] Content Security Policy (CSP) tùy chỉnh + - [ ] XSS protection + +### 3. Theo dõi và Giám sát +- [ ] **Audit Logging** + - [ ] Ghi log các hoạt động quan trọng + - [ ] Theo dõi đăng nhập thất bại + - [ ] Cảnh báo bảo mật + +- [ ] **Monitoring** + - [ ] Tích hợp Prometheus + - [ ] Dashboard giám sát + - [ ] Cảnh báo bất thường + +### 4. Cải thiện Hiệu suất +- [ ] **Tối ưu hóa** + - [ ] Redis cho caching + - [ ] Tối ưu truy vấn database + - [ ] Compression response + +### Timeline +- Tuần 1-2: Hoàn thiện xác thực & phân quyền +- Tuần 3-4: Triển khai bảo mật API và headers +- Tuần 5-6: Hoàn thiện audit logging và monitoring +- Tuần 7-8: Tối ưu hiệu suất và kiểm thử bảo mật ## Giai đoạn 3: Tự động hóa - [ ] Unit Test templates và mocks @@ -34,14 +84,14 @@ - Timeline: Q3/2025 ## Giai đoạn 4: Mở rộng tính năng -- [ ] Go Feature Flag implementation +- [x] Go Feature Flag implementation - [ ] Notification system - [ ] Background job processing - [ ] API documentation - Timeline: Q3/2025 ## Giai đoạn 5: Production readiness -- [ ] Performance optimization +- [x] Performance optimization - [ ] Monitoring và observability - [ ] Backup và disaster recovery - [ ] Security hardening diff --git a/go.mod b/go.mod index 5963f4c..00b5a19 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.9.2 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 3f83c12..2d020cc 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -295,6 +297,7 @@ golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20191125130003-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= @@ -407,6 +410,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20130007135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/internal/adapter/persistence/role_repository.go b/internal/adapter/persistence/role_repository.go new file mode 100644 index 0000000..5ab3bf9 --- /dev/null +++ b/internal/adapter/persistence/role_repository.go @@ -0,0 +1,54 @@ +package persistence + +import ( + "context" + "errors" + "starter-kit/internal/domain/role" + + "gorm.io/gorm" +) + +type roleRepository struct { + db *gorm.DB +} + +// NewRoleRepository tạo mới một instance của RoleRepository +func NewRoleRepository(db *gorm.DB) role.Repository { + return &roleRepository{db: db} +} + +func (r *roleRepository) Create(ctx context.Context, role *role.Role) error { + return r.db.WithContext(ctx).Create(role).Error +} + +func (r *roleRepository) GetByID(ctx context.Context, id int) (*role.Role, error) { + var role role.Role + err := r.db.WithContext(ctx).First(&role, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &role, err +} + +func (r *roleRepository) GetByName(ctx context.Context, name string) (*role.Role, error) { + var role role.Role + err := r.db.WithContext(ctx).First(&role, "name = ?", name).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &role, err +} + +func (r *roleRepository) List(ctx context.Context) ([]*role.Role, error) { + var roles []*role.Role + err := r.db.WithContext(ctx).Find(&roles).Error + return roles, err +} + +func (r *roleRepository) Update(ctx context.Context, role *role.Role) error { + return r.db.WithContext(ctx).Save(role).Error +} + +func (r *roleRepository) Delete(ctx context.Context, id int) error { + return r.db.WithContext(ctx).Delete(&role.Role{}, id).Error +} diff --git a/internal/adapter/persistence/user_repository.go b/internal/adapter/persistence/user_repository.go new file mode 100644 index 0000000..3d25dc9 --- /dev/null +++ b/internal/adapter/persistence/user_repository.go @@ -0,0 +1,88 @@ +package persistence + +import ( + "context" + "errors" + "starter-kit/internal/domain/user" + + "gorm.io/gorm" +) + +type userRepository struct { + db *gorm.DB +} + +// NewUserRepository tạo mới một instance của UserRepository +func NewUserRepository(db *gorm.DB) user.Repository { + return &userRepository{db: db} +} + +func (r *userRepository) Create(ctx context.Context, u *user.User) error { + return r.db.WithContext(ctx).Create(u).Error +} + +func (r *userRepository) GetByID(ctx context.Context, id string) (*user.User, error) { + var u user.User + err := r.db.WithContext(ctx).Preload("Roles").First(&u, "id = ?", id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &u, err +} + +func (r *userRepository) GetByUsername(ctx context.Context, username string) (*user.User, error) { + var u user.User + err := r.db.WithContext(ctx).Preload("Roles").First(&u, "username = ?", username).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &u, err +} + +func (r *userRepository) GetByEmail(ctx context.Context, email string) (*user.User, error) { + var u user.User + err := r.db.WithContext(ctx).Preload("Roles").First(&u, "email = ?", email).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return &u, err +} + +func (r *userRepository) Update(ctx context.Context, u *user.User) error { + return r.db.WithContext(ctx).Save(u).Error +} + +func (r *userRepository) Delete(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Delete(&user.User{}, "id = ?", id).Error +} + +func (r *userRepository) AddRole(ctx context.Context, userID string, roleID int) error { + return r.db.WithContext(ctx).Exec( + "INSERT INTO user_roles (user_id, role_id) VALUES (?, ?) ON CONFLICT DO NOTHING", + userID, roleID, + ).Error +} + +func (r *userRepository) RemoveRole(ctx context.Context, userID string, roleID int) error { + return r.db.WithContext(ctx).Exec( + "DELETE FROM user_roles WHERE user_id = ? AND role_id = ?", + userID, roleID, + ).Error +} + +func (r *userRepository) HasRole(ctx context.Context, userID string, roleID int) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&user.User{}). + Joins("JOIN user_roles ON user_roles.user_id = users.id"). + Where("users.id = ? AND user_roles.role_id = ?", userID, roleID). + Count(&count).Error + + return count > 0, err +} + +func (r *userRepository) UpdateLastLogin(ctx context.Context, userID string) error { + now := gorm.Expr("NOW()") + return r.db.WithContext(ctx).Model(&user.User{}). + Where("id = ?", userID). + Update("last_login_at", now).Error +} diff --git a/internal/domain/role/repository.go b/internal/domain/role/repository.go new file mode 100644 index 0000000..85b52d9 --- /dev/null +++ b/internal/domain/role/repository.go @@ -0,0 +1,24 @@ +package role + +import "context" + +// Repository định nghĩa các phương thức làm việc với dữ liệu vai trò +type Repository interface { + // Create tạo mới vai trò + Create(ctx context.Context, role *Role) error + + // GetByID lấy thông tin vai trò theo ID + GetByID(ctx context.Context, id int) (*Role, error) + + // GetByName lấy thông tin vai trò theo tên + GetByName(ctx context.Context, name string) (*Role, error) + + // List lấy danh sách vai trò + List(ctx context.Context) ([]*Role, error) + + // Update cập nhật thông tin vai trò + Update(ctx context.Context, role *Role) error + + // Delete xóa vai trò + Delete(ctx context.Context, id int) error +} diff --git a/internal/domain/role/role.go b/internal/domain/role/role.go new file mode 100644 index 0000000..5c38ae0 --- /dev/null +++ b/internal/domain/role/role.go @@ -0,0 +1,25 @@ +package role + +import "time" + +// Role đại diện cho một vai trò trong hệ thống +type Role struct { + ID int `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"size:50;uniqueIndex;not null"` + Description string `json:"description"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// TableName specifies the table name for the Role model +func (Role) TableName() string { + return "roles" +} + +// Constants for role names +const ( + Admin = "admin" + Manager = "manager" + User = "user" + Guest = "guest" +) diff --git a/internal/domain/user/repository.go b/internal/domain/user/repository.go new file mode 100644 index 0000000..dca8f7d --- /dev/null +++ b/internal/domain/user/repository.go @@ -0,0 +1,38 @@ +package user + +import ( + "context" +) + +// Repository định nghĩa các phương thức làm việc với dữ liệu người dùng +type Repository interface { + // Create tạo mới người dùng + Create(ctx context.Context, user *User) error + + // GetByID lấy thông tin người dùng theo ID + GetByID(ctx context.Context, id string) (*User, error) + + // GetByUsername lấy thông tin người dùng theo tên đăng nhập + GetByUsername(ctx context.Context, username string) (*User, error) + + // GetByEmail lấy thông tin người dùng theo email + GetByEmail(ctx context.Context, email string) (*User, error) + + // Update cập nhật thông tin người dùng + Update(ctx context.Context, user *User) error + + // Delete xóa người dùng + Delete(ctx context.Context, id string) error + + // AddRole thêm vai trò cho người dùng + AddRole(ctx context.Context, userID string, roleID int) error + + // RemoveRole xóa vai trò của người dùng + RemoveRole(ctx context.Context, userID string, roleID int) error + + // HasRole kiểm tra người dùng có vai trò không + HasRole(ctx context.Context, userID string, roleID int) (bool, error) + + // UpdateLastLogin cập nhật thời gian đăng nhập cuối cùng + UpdateLastLogin(ctx context.Context, userID string) error +} diff --git a/internal/domain/user/user.go b/internal/domain/user/user.go new file mode 100644 index 0000000..d50ab43 --- /dev/null +++ b/internal/domain/user/user.go @@ -0,0 +1,50 @@ +package user + +import ( + "time" + + "starter-kit/internal/domain/role" +) + +// User đại diện cho một người dùng trong hệ thống +type User struct { + ID string `json:"id" gorm:"type:uuid;primaryKey;default:uuid_generate_v4()"` + Username string `json:"username" gorm:"size:50;uniqueIndex;not null"` + Email string `json:"email" gorm:"size:100;uniqueIndex;not null"` + PasswordHash string `json:"-" gorm:"not null"` + FullName string `json:"full_name" gorm:"size:100"` + AvatarURL string `json:"avatar_url,omitempty" gorm:"size:255"` + IsActive bool `json:"is_active" gorm:"default:true"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + DeletedAt *time.Time `json:"-" gorm:"index"` + Roles []*role.Role `json:"roles,omitempty" gorm:"many2many:user_roles;"` +} + +// TableName specifies the table name for the User model +func (User) TableName() string { + return "users" +} + +// HasRole kiểm tra xem user có vai trò được chỉ định không +func (u *User) HasRole(roleName string) bool { + for _, r := range u.Roles { + if r.Name == roleName { + return true + } + } + return false +} + +// HasAnyRole kiểm tra xem user có bất kỳ vai trò nào trong danh sách không +func (u *User) HasAnyRole(roles ...string) bool { + for _, r := range u.Roles { + for _, roleName := range roles { + if r.Name == roleName { + return true + } + } + } + return false +} diff --git a/internal/helper/config/types.go b/internal/helper/config/types.go index 2dbf205..4db8dec 100644 --- a/internal/helper/config/types.go +++ b/internal/helper/config/types.go @@ -34,12 +34,19 @@ type DatabaseConfig struct { MigrationPath string `mapstructure:"migration_path" validate:"required"` } +// JWTConfig chứa cấu hình cho JWT +type JWTConfig struct { + Secret string `mapstructure:"secret" validate:"required,min=32"` + Expiration int `mapstructure:"expiration" validate:"required,min=1"` // in minutes +} + // Config là struct tổng thể chứa tất cả các cấu hình type Config struct { App AppConfig `mapstructure:"app" validate:"required"` Server ServerConfig `mapstructure:"server" validate:"required"` Database DatabaseConfig `mapstructure:"database" validate:"required"` Logger LoggerConfig `mapstructure:"logger" validate:"required"` + JWT JWTConfig `mapstructure:"jwt" validate:"required"` } // LoggerConfig chứa cấu hình cho logger diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..b812833 --- /dev/null +++ b/internal/service/auth_service.go @@ -0,0 +1,235 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "starter-kit/internal/domain/role" + "starter-kit/internal/domain/user" +) + +// AuthService xử lý các tác vụ liên quan đến xác thực +type AuthService interface { + Register(ctx context.Context, req RegisterRequest) (*user.User, error) + Login(ctx context.Context, username, password string) (string, string, error) + RefreshToken(refreshToken string) (string, string, error) + ValidateToken(tokenString string) (*Claims, error) +} + +type authService struct { + userRepo user.Repository + roleRepo role.Repository + jwtSecret string + jwtExpiration time.Duration + refreshExpires int +} + +// Claims định nghĩa các thông tin trong JWT token +type Claims struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Roles []string `json:"roles"` + jwt.RegisteredClaims +} + +// NewAuthService tạo mới một AuthService +func NewAuthService( + userRepo user.Repository, + roleRepo role.Repository, + jwtSecret string, + jwtExpiration time.Duration, +) AuthService { + return &authService{ + userRepo: userRepo, + roleRepo: roleRepo, + jwtSecret: jwtSecret, + jwtExpiration: jwtExpiration, + refreshExpires: 7 * 24 * 60, // 7 days in minutes + } +} + +// Register đăng ký người dùng mới +func (s *authService) Register(ctx context.Context, req RegisterRequest) (*user.User, error) { + // Kiểm tra username đã tồn tại chưa + existingUser, err := s.userRepo.GetByUsername(ctx, req.Username) + if err != nil { + return nil, fmt.Errorf("error checking username: %v", err) + } + if existingUser != nil { + return nil, errors.New("username already exists") + } + + // Kiểm tra email đã tồn tại chưa + existingEmail, err := s.userRepo.GetByEmail(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("error checking email: %v", err) + } + if existingEmail != nil { + return nil, errors.New("email already exists") + } + + // Mã hóa mật khẩu + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("error hashing password: %v", err) + } + + // Tạo user mới + newUser := &user.User{ + Username: req.Username, + Email: req.Email, + PasswordHash: string(hashedPassword), + FullName: req.FullName, + IsActive: true, + } + + // Lưu user vào database + if err := s.userRepo.Create(ctx, newUser); err != nil { + return nil, fmt.Errorf("error creating user: %v", err) + } + + // Thêm role mặc định là 'user' cho người dùng mới + userRole, err := s.roleRepo.GetByName(ctx, role.User) + if err != nil { + return nil, fmt.Errorf("error getting user role: %v", err) + } + if userRole == nil { + return nil, errors.New("default user role not found") + } + + if err := s.userRepo.AddRole(ctx, newUser.ID, userRole.ID); err != nil { + return nil, fmt.Errorf("error adding role to user: %v", err) + } + + // Lấy lại thông tin user với đầy đủ roles + createdUser, err := s.userRepo.GetByID(ctx, newUser.ID) + if err != nil { + return nil, fmt.Errorf("error getting created user: %v", err) + } + + return createdUser, nil +} + +// Login xác thực đăng nhập và trả về token +func (s *authService) Login(ctx context.Context, username, password string) (string, string, error) { + // Lấy thông tin user + user, err := s.userRepo.GetByUsername(ctx, username) + if err != nil { + return "", "", errors.New("invalid credentials") + } + + if user == nil || !user.IsActive { + return "", "", errors.New("invalid credentials") + } + + // Kiểm tra mật khẩu + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return "", "", errors.New("invalid credentials") + } + + // Tạo access token + accessToken, err := s.generateToken(user) + if err != nil { + return "", "", fmt.Errorf("error generating token: %v", err) + } + + // Tạo refresh token + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return "", "", fmt.Errorf("error generating refresh token: %v", err) + } + + // Lưu refresh token vào database (trong thực tế nên lưu vào Redis hoặc database) + // Ở đây chỉ minh họa, nên implement thật kỹ hơn + h := sha256.New() + h.Write(tokenBytes) + tokenID := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + // TODO: Lưu refresh token vào database với userID và tokenID + _ = tokenID + + // Cập nhật thời gian đăng nhập cuối cùng + if err := s.userRepo.UpdateLastLogin(ctx, user.ID); err != nil { + // Log lỗi nhưng không ảnh hưởng đến quá trình đăng nhập + fmt.Printf("Error updating last login: %v\n", err) + } + + return accessToken, string(tokenBytes), nil +} + +// RefreshToken làm mới access token +func (s *authService) RefreshToken(refreshToken string) (string, string, error) { + // TODO: Kiểm tra refresh token trong database + // Nếu hợp lệ, tạo access token mới và trả về + return "", "", errors.New("not implemented") +} + +// ValidateToken xác thực và trả về thông tin từ token +func (s *authService) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + // Kiểm tra signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.jwtSecret), 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") +} + +// generateToken tạo JWT token cho user +func (s *authService) generateToken(user *user.User) (string, error) { + // Lấy danh sách roles + roles := make([]string, len(user.Roles)) + for i, r := range user.Roles { + roles[i] = r.Name + } + + // Tạo claims + expirationTime := time.Now().Add(s.jwtExpiration) + claims := &Claims{ + UserID: user.ID, + Username: user.Username, + Roles: roles, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "ulflow-starter-kit", + }, + } + + // Tạo token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Ký token và trả về + tokenString, err := token.SignedString([]byte(s.jwtSecret)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +// RegisterRequest định dạng dữ liệu đăng ký +type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + FullName string `json:"full_name"` +} diff --git a/internal/transport/http/dto/error_response.go b/internal/transport/http/dto/error_response.go new file mode 100644 index 0000000..8978c47 --- /dev/null +++ b/internal/transport/http/dto/error_response.go @@ -0,0 +1,8 @@ +package dto + +// ErrorResponse định dạng phản hồi lỗi +// @Description Định dạng phản hồi lỗi +// @Description Error response format +type ErrorResponse struct { + Error string `json:"error" example:"error message"` +} diff --git a/internal/transport/http/dto/user_dto.go b/internal/transport/http/dto/user_dto.go new file mode 100644 index 0000000..6b00cdb --- /dev/null +++ b/internal/transport/http/dto/user_dto.go @@ -0,0 +1,69 @@ +package dto + +import ( + "time" + + "starter-kit/internal/domain/role" +) + +// RegisterRequest định dạng dữ liệu đăng ký người dùng mới +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + FullName string `json:"full_name" binding:"required"` +} + +// LoginRequest định dạng dữ liệu đăng nhập +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// AuthResponse định dạng phản hồi xác thực +type AuthResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` + TokenType string `json:"token_type"` +} + +// UserResponse định dạng phản hồi thông tin người dùng +type UserResponse struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"full_name"` + AvatarURL string `json:"avatar_url,omitempty"` + IsActive bool `json:"is_active"` + Roles []role.Role `json:"roles,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ToUserResponse chuyển đổi từ User sang UserResponse +func ToUserResponse(user interface{}) UserResponse { + switch u := user.(type) { + case struct { + ID string + Username string + Email string + FullName string + AvatarURL string + IsActive bool + Roles []role.Role + CreatedAt time.Time + }: + return UserResponse{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + FullName: u.FullName, + AvatarURL: u.AvatarURL, + IsActive: u.IsActive, + Roles: u.Roles, + CreatedAt: u.CreatedAt, + } + default: + return UserResponse{} + } +} diff --git a/internal/transport/http/handler/auth_handler.go b/internal/transport/http/handler/auth_handler.go new file mode 100644 index 0000000..d40b5cc --- /dev/null +++ b/internal/transport/http/handler/auth_handler.go @@ -0,0 +1,149 @@ +package handler + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "starter-kit/internal/service" + "starter-kit/internal/transport/http/dto" +) + +type AuthHandler struct { + authSvc service.AuthService +} + +// NewAuthHandler tạo mới AuthHandler +func NewAuthHandler(authSvc service.AuthService) *AuthHandler { + return &AuthHandler{ + authSvc: authSvc, + } +} + +// Register xử lý đăng ký người dùng mới +// @Summary Đăng ký tài khoản mới +// @Description Tạo tài khoản người dùng mới với thông tin cơ bản +// @Tags Authentication +// @Accept json +// @Produce json +// @Param request body dto.RegisterRequest true "Thông tin đăng ký" +// @Success 201 {object} dto.UserResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 409 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /api/v1/auth/register [post] +func (h *AuthHandler) Register(c *gin.Context) { + var req dto.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Error: "Invalid request body"}) + return + } + + // Gọi service để đăng ký + user, err := h.authSvc.Register(c.Request.Context(), service.RegisterRequest(req)) + if err != nil { + // Xử lý lỗi trả về + if strings.Contains(err.Error(), "already exists") { + c.JSON(http.StatusConflict, dto.ErrorResponse{Error: err.Error()}) + } else { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{Error: "Internal server error"}) + } + return + } + + // Chuyển đổi sang DTO và trả về + userResponse := dto.ToUserResponse(user) + c.JSON(http.StatusCreated, userResponse) +} + +// Login xử lý đăng nhập +// @Summary Đăng nhập +// @Description Đăng nhập bằng username và password +// @Tags Authentication +// @Accept json +// @Produce json +// @Param request body dto.LoginRequest true "Thông tin đăng nhập" +// @Success 200 {object} dto.AuthResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 500 {object} dto.ErrorResponse +// @Router /api/v1/auth/login [post] +func (h *AuthHandler) Login(c *gin.Context) { + var req dto.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Error: "Invalid request body"}) + return + } + + // Gọi service để đăng nhập + accessToken, refreshToken, err := h.authSvc.Login(c.Request.Context(), req.Username, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{Error: "Invalid credentials"}) + return + } + + // Tạo response + expiresAt := time.Now().Add(24 * time.Hour) // Thời gian hết hạn mặc định + response := dto.AuthResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + TokenType: "Bearer", + } + + c.JSON(http.StatusOK, response) +} + +// RefreshToken làm mới access token +// @Summary Làm mới access token +// @Description Làm mới access token bằng refresh token +// @Tags Authentication +// @Accept json +// @Produce json +// @Param refresh_token body string true "Refresh token" +// @Success 200 {object} dto.AuthResponse +// @Failure 400 {object} dto.ErrorResponse +// @Failure 401 {object} dto.ErrorResponse +// @Router /api/v1/auth/refresh [post] +func (h *AuthHandler) RefreshToken(c *gin.Context) { + // Lấy refresh token từ body + var req struct { + RefreshToken string `json:"refresh_token" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{Error: "Refresh token is required"}) + return + } + + // Gọi service để làm mới token + accessToken, refreshToken, err := h.authSvc.RefreshToken(req.RefreshToken) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{Error: "Invalid refresh token"}) + return + } + + // Tạo response + expiresAt := time.Now().Add(24 * time.Hour) // Thời gian hết hạn mặc định + response := dto.AuthResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: expiresAt, + TokenType: "Bearer", + } + + c.JSON(http.StatusOK, response) +} + +// Logout xử lý đăng xuất +// @Summary Đăng xuất +// @Description Đăng xuất và vô hiệu hóa refresh token +// @Tags Authentication +// @Security Bearer +// @Success 204 "No Content" +// @Router /api/v1/auth/logout [post] +func (h *AuthHandler) Logout(c *gin.Context) { + // TODO: Vô hiệu hóa refresh token trong database + c.Status(http.StatusNoContent) +} diff --git a/internal/transport/http/middleware/auth.go b/internal/transport/http/middleware/auth.go new file mode 100644 index 0000000..ea4d92b --- /dev/null +++ b/internal/transport/http/middleware/auth.go @@ -0,0 +1,121 @@ +package middleware + +import ( + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "starter-kit/internal/service" +) + +const ( + // ContextKeyUser là key dùng để lưu thông tin user trong context + ContextKeyUser = "user" +) + +// AuthMiddleware xác thực JWT token +type AuthMiddleware struct { + authSvc service.AuthService +} + +// NewAuthMiddleware tạo mới AuthMiddleware +func NewAuthMiddleware(authSvc service.AuthService) *AuthMiddleware { + return &AuthMiddleware{ + authSvc: authSvc, + } +} + +// Authenticate xác thực JWT token +func (m *AuthMiddleware) Authenticate() gin.HandlerFunc { + return func(c *gin.Context) { + // Lấy token từ header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) + return + } + + // Kiểm tra định dạng token + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"}) + return + } + + tokenString := parts[1] + + // Xác thực token + claims, err := m.authSvc.ValidateToken(tokenString) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + return + } + + // Lưu thông tin user vào context + c.Set(ContextKeyUser, claims) + + // Tiếp tục xử lý request + c.Next() + } +} + +// RequireRole kiểm tra user có vai trò được yêu cầu không +func (m *AuthMiddleware) RequireRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + // Lấy thông tin user từ context + userValue, exists := c.Get(ContextKeyUser) + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + // Ép kiểu về Claims + claims, ok := userValue.(*service.Claims) + if !ok { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Invalid user data"}) + return + } + + // Kiểm tra vai trò + for _, role := range roles { + for _, userRole := range claims.Roles { + if userRole == role { + // Có quyền, tiếp tục xử lý + c.Next() + return + } + } + } + + // Không có quyền + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("Require one of these roles: %v", roles), + }) + } +} + +// GetUserFromContext lấy thông tin user từ context +func GetUserFromContext(c *gin.Context) (*service.Claims, error) { + userValue, exists := c.Get(ContextKeyUser) + if !exists { + return nil, fmt.Errorf("user not found in context") + } + + claims, ok := userValue.(*service.Claims) + if !ok { + return nil, fmt.Errorf("invalid user data in context") + } + + return claims, nil +} + +// GetUserIDFromContext lấy user ID từ context +func GetUserIDFromContext(c *gin.Context) (string, error) { + claims, err := GetUserFromContext(c) + if err != nil { + return "", err + } + return claims.UserID, nil +} diff --git a/internal/transport/http/middleware/cors.go b/internal/transport/http/middleware/cors.go new file mode 100644 index 0000000..157f0d6 --- /dev/null +++ b/internal/transport/http/middleware/cors.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// CORS middleware +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + // Get allowed origins from config + allowedOrigins := []string{"*"} // Default to allow all + // In production, you might want to restrict this to specific domains + // allowedOrigins := config.GetConfig().Server.AllowOrigins + + origin := c.GetHeader("Origin") + allowed := false + + // Check if the request origin is in the allowed origins list + for _, o := range allowedOrigins { + if o == "*" || o == origin { + allowed = true + break + } + } + + if allowed { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + } + + // Handle preflight requests + if c.Request.Method == "OPTIONS" { + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Requested-With, X-Request-ID") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Max-Age", "86400") // 24 hours + c.AbortWithStatus(204) + return + } + + // Set CORS headers for the main request + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Requested-With, X-Request-ID") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + + c.Next() + } +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index cdf8de7..df4b373 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -1,14 +1,20 @@ package http import ( + "time" + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "starter-kit/internal/adapter/persistence" + "starter-kit/internal/domain/role" "starter-kit/internal/helper/config" + "starter-kit/internal/service" "starter-kit/internal/transport/http/handler" "starter-kit/internal/transport/http/middleware" ) // SetupRouter cấu hình router cho HTTP server -func SetupRouter(cfg *config.Config) *gin.Engine { +func SetupRouter(cfg *config.Config, db *gorm.DB) *gin.Engine { // Khởi tạo router với mode phù hợp với môi trường if cfg.App.Environment == "production" { gin.SetMode(gin.ReleaseMode) @@ -22,28 +28,71 @@ func SetupRouter(cfg *config.Config) *gin.Engine { // Recovery middleware router.Use(gin.Recovery()) - // CORS middleware nếu cần - // router.Use(middleware.CORS()) + // CORS middleware + router.Use(middleware.CORS()) + + // Khởi tạo repositories + userRepo := persistence.NewUserRepository(db) + roleRepo := persistence.NewRoleRepository(db) + + // Khởi tạo services + authSvc := service.NewAuthService( + userRepo, + roleRepo, + cfg.JWT.Secret, + time.Duration(cfg.JWT.Expiration)*time.Minute, + ) + + // Khởi tạo middleware + authMiddleware := middleware.NewAuthMiddleware(authSvc) // Khởi tạo các handlers healthHandler := handler.NewHealthHandler(cfg) + authHandler := handler.NewAuthHandler(authSvc) - // Đăng ký các routes - - // Health check routes - router.GET("/ping", healthHandler.Ping) - router.GET("/health", healthHandler.HealthCheck) - - // API versioning - Cảnh báo: API routes hiện đang được comment out - // Khi cần sử dụng, bỏ comment đoạn code sau - /* - v1 := router.Group("/api/v1") + // Public routes - Không yêu cầu xác thực + public := router.Group("/api/v1") { - // Các API endpoints version 1 - // v1.GET("/resources", resourceHandler.List) - // v1.POST("/resources", resourceHandler.Create) + // Health check + public.GET("/ping", healthHandler.Ping) + public.GET("/health", healthHandler.HealthCheck) + + // Auth routes + authGroup := public.Group("/auth") + { + authGroup.POST("/register", authHandler.Register) + authGroup.POST("/login", authHandler.Login) + authGroup.POST("/refresh", authHandler.RefreshToken) + } + } + + // Protected routes - Yêu cầu xác thực + protected := router.Group("/api/v1") + protected.Use(authMiddleware.Authenticate()) + { + // Auth routes + authGroup := protected.Group("/auth") + { + authGroup.POST("/logout", authHandler.Logout) + } + + // User routes + usersGroup := protected.Group("/users") + { + usersGroup.GET("", authMiddleware.RequireRole(role.Admin, role.Manager), /* userHandler.ListUsers */) + usersGroup.GET("/:id", /* userHandler.GetUser */) + usersGroup.PUT("/:id", /* userHandler.UpdateUser */) + usersGroup.DELETE("/:id", authMiddleware.RequireRole(role.Admin), /* userHandler.DeleteUser */) + } + + // Admin routes + adminGroup := protected.Group("/admin") + adminGroup.Use(authMiddleware.RequireRole(role.Admin)) + { + // Role management + adminGroup.Group("/roles") + } } - */ return router } diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index 9e7ca15..ca1b955 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" "starter-kit/internal/helper/config" "starter-kit/internal/helper/logger" ) @@ -22,13 +23,14 @@ type Server struct { config *config.Config router *gin.Engine listener net.Listener + db *gorm.DB serverErr chan error } // NewServer creates a new HTTP server with the given configuration -func NewServer(cfg *config.Config) *Server { +func NewServer(cfg *config.Config, db *gorm.DB) *Server { // Create a new Gin router - router := SetupRouter(cfg) + router := SetupRouter(cfg, db) // Create the HTTP server server := &http.Server{ @@ -42,6 +44,7 @@ func NewServer(cfg *config.Config) *Server { server: server, config: cfg, router: router, + db: db, serverErr: make(chan error, 1), } } diff --git a/migrations/000001_create_roles_table.up.sql b/migrations/000001_create_roles_table.up.sql new file mode 100644 index 0000000..a32cce9 --- /dev/null +++ b/migrations/000001_create_roles_table.up.sql @@ -0,0 +1,24 @@ +-- +goose Up +-- +goose StatementBegin +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default roles +INSERT INTO roles (name, description) VALUES +('admin', 'Quản trị viên hệ thống'), +('manager', 'Quản lý'), +('user', 'Người dùng thông thường'), +('guest', 'Khách'); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS roles CASCADE; +-- +goose StatementEnd diff --git a/migrations/000002_create_users_table.up.sql b/migrations/000002_create_users_table.up.sql new file mode 100644 index 0000000..196c80d --- /dev/null +++ b/migrations/000002_create_users_table.up.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + full_name VARCHAR(100), + avatar_url VARCHAR(255), + is_active BOOLEAN DEFAULT true, + last_login_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- Create index for better query performance +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_username ON users(username); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS users CASCADE; +-- +goose StatementEnd diff --git a/migrations/000003_create_user_roles_table.up.sql b/migrations/000003_create_user_roles_table.up.sql new file mode 100644 index 0000000..57bdb6a --- /dev/null +++ b/migrations/000003_create_user_roles_table.up.sql @@ -0,0 +1,20 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE user_roles ( + user_id UUID NOT NULL, + role_id INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role_id), + CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE +); + +-- Create index for better query performance +CREATE INDEX idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX idx_user_roles_role_id ON user_roles(role_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS user_roles CASCADE; +-- +goose StatementEnd