diff --git a/go.mod b/go.mod index 302ea01..7a354ff 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/bit-issues/backend go 1.25.7 require ( - github.com/go-core-fx/bunfx v0.0.1 + github.com/go-core-fx/bunfx v0.0.2-0.20260409044649-8812f14d88ce github.com/go-core-fx/config v0.1.0 github.com/go-core-fx/fiberfx v0.5.0 github.com/go-core-fx/goosefx v0.0.1 @@ -11,8 +11,11 @@ require ( github.com/go-core-fx/logger v0.0.1 github.com/go-core-fx/sqlfx v0.1.0 github.com/go-core-fx/validatorfx v0.0.2 + github.com/go-playground/validator/v10 v10.28.0 github.com/go-sql-driver/mysql v1.9.3 github.com/gofiber/fiber/v2 v2.52.12 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 github.com/samber/lo v1.52.0 @@ -21,6 +24,7 @@ require ( github.com/uptrace/bun/dialect/mysqldialect v1.2.18 go.uber.org/fx v1.24.0 go.uber.org/zap v1.27.1 + golang.org/x/crypto v0.49.0 ) require ( @@ -43,11 +47,9 @@ require ( github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofiber/contrib/fiberzap/v2 v2.1.6 // indirect github.com/gofiber/swagger v1.1.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -85,13 +87,12 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/mod v0.33.0 // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 2611f70..9c1e021 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/go-core-fx/bunfx v0.0.1 h1:aGUc4klPifBEQjycbnId7VoZ50UdEqTv6s5mX7F0V/s= -github.com/go-core-fx/bunfx v0.0.1/go.mod h1:spydLRsh5vQJekRAFnQLEvjuekUZOC8QTf/jkyw2Bog= +github.com/go-core-fx/bunfx v0.0.2-0.20260409044649-8812f14d88ce h1:+Pz9nIc5BQS/RFRAS4TrQxlsVQaDX4z3+d7UWE9JAMs= +github.com/go-core-fx/bunfx v0.0.2-0.20260409044649-8812f14d88ce/go.mod h1:spydLRsh5vQJekRAFnQLEvjuekUZOC8QTf/jkyw2Bog= github.com/go-core-fx/config v0.1.0 h1:uKmo+mTt5a8Gtusb7Xf4gkrGcLIbm2doTEUMkdd6oGo= github.com/go-core-fx/config v0.1.0/go.mod h1:gvoLaHr5fHfG5DlYYtNSPTqRlbnxWMqiWL4iWy2oezY= github.com/go-core-fx/fiberfx v0.5.0 h1:e42gDP7R5k2T93pt1ibVS6nrnPNaGR/+6efHO90a398= @@ -78,6 +78,8 @@ github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/ github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/gofiber/swagger v1.1.1 h1:FZVhVQQ9s1ZKLHL/O0loLh49bYB5l1HEAgxDlcTtkRA= github.com/gofiber/swagger v1.1.1/go.mod h1:vtvY/sQAMc/lGTUCg0lqmBL7Ht9O7uzChpbvJeJQINw= +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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -207,30 +209,30 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/app.go b/internal/app.go index 0f970ee..c94813e 100644 --- a/internal/app.go +++ b/internal/app.go @@ -5,8 +5,9 @@ import ( "github.com/bit-issues/backend/internal/config" "github.com/bit-issues/backend/internal/db" - "github.com/bit-issues/backend/internal/example" + "github.com/bit-issues/backend/internal/jwt" "github.com/bit-issues/backend/internal/server" + "github.com/bit-issues/backend/internal/users" "github.com/go-core-fx/bunfx" "github.com/go-core-fx/fiberfx" "github.com/go-core-fx/goosefx" @@ -47,7 +48,8 @@ func Run(version healthfx.Version) { // // BUSINESS MODULES fx.Supply(version), - example.Module(), + jwt.Module(), + users.Module(), // fx.Invoke(func(lc fx.Lifecycle, logger *zap.Logger) { lc.Append(fx.Hook{ diff --git a/internal/config/config.go b/internal/config/config.go index 33404eb..97bc7e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -30,19 +30,20 @@ type databaseConfig struct { MaxIdleConns int `koanf:"max_idle_conns"` } -type exampleConfig struct { - Example string `koanf:"example"` +type jwtConfig struct { + Secret string `koanf:"secret"` + AccessTTL time.Duration `koanf:"access_ttl"` + Issuer string `koanf:"issuer"` } type Config struct { HTTP http `koanf:"http"` Database databaseConfig `koanf:"database"` - - Example exampleConfig `koanf:"example"` + JWT jwtConfig `koanf:"jwt"` } func Default() Config { - //nolint:gosec // default values + //nolint:gosec,mnd // default values return Config{ HTTP: http{ Address: "127.0.0.1:3000", @@ -55,15 +56,16 @@ func Default() Config { }, }, Database: databaseConfig{ - URL: "mariadb://bit-issues:bit-issues@127.0.0.1:3306/bit-issues?charset=utf8mb4&parseTime=True&loc=UTC", + URL: "mariadb://bit-issues:bit-issues@127.0.0.1:3306/bit-issues?charset=utf8mb4&parseTime=True&loc=UTC&clientFoundRows=true", ConnMaxIdleTime: 0, ConnMaxLifetime: 0, MaxOpenConns: 0, MaxIdleConns: 0, }, - - Example: exampleConfig{ - Example: "example", + JWT: jwtConfig{ + Secret: "secret", + AccessTTL: time.Minute * 15, + Issuer: "bitissues.dev", }, } } diff --git a/internal/config/module.go b/internal/config/module.go index 14d0ad7..f38c126 100644 --- a/internal/config/module.go +++ b/internal/config/module.go @@ -1,7 +1,7 @@ package config import ( - "github.com/bit-issues/backend/internal/example" + "github.com/bit-issues/backend/internal/jwt" "github.com/go-core-fx/fiberfx" "github.com/go-core-fx/fiberfx/openapi" "github.com/go-core-fx/sqlfx" @@ -37,9 +37,11 @@ func Module() fx.Option { } }, ), - fx.Provide(func(cfg Config) example.Config { - return example.Config{ - Example: cfg.Example.Example, + fx.Provide(func(cfg Config) jwt.Config { + return jwt.Config{ + Secret: cfg.JWT.Secret, + AccessTTL: cfg.JWT.AccessTTL, + Issuer: cfg.JWT.Issuer, } }), ) diff --git a/internal/db/migrations/20260408120000_users.sql b/internal/db/migrations/20260408120000_users.sql new file mode 100644 index 0000000..dc6d978 --- /dev/null +++ b/internal/db/migrations/20260408120000_users.sql @@ -0,0 +1,19 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS `users` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `email` VARCHAR(255) NOT NULL, + `password_hash` VARCHAR(255) NOT NULL, + `role` ENUM('admin', 'user') NOT NULL DEFAULT 'user', + `status` ENUM('pending', 'active', 'blocked') NOT NULL DEFAULT 'pending', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_users_email` (`email`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; +-- +goose StatementEnd +--- +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS `users`; +-- +goose StatementEnd \ No newline at end of file diff --git a/internal/jwt/config.go b/internal/jwt/config.go new file mode 100644 index 0000000..720f7fe --- /dev/null +++ b/internal/jwt/config.go @@ -0,0 +1,32 @@ +package jwt + +import ( + "fmt" + "time" +) + +const ( + minSecretLength = 32 +) + +type Config struct { + Secret string + AccessTTL time.Duration + Issuer string +} + +func (c Config) Validate() error { + if c.Secret == "" { + return fmt.Errorf("%w: secret is required", ErrInvalidConfig) + } + + if len(c.Secret) < minSecretLength { + return fmt.Errorf("%w: secret must be at least %d bytes", ErrInvalidConfig, minSecretLength) + } + + if c.AccessTTL <= 0 { + return fmt.Errorf("%w: access ttl must be positive", ErrInvalidConfig) + } + + return nil +} diff --git a/internal/jwt/doc.go b/internal/jwt/doc.go new file mode 100644 index 0000000..519acc3 --- /dev/null +++ b/internal/jwt/doc.go @@ -0,0 +1,8 @@ +// Package jwt provides JWT token generation and validation for the application. +// +// The module covers: +// - token generation with HS256 algorithm, +// - token validation and claims extraction, +// - 24-hour token expiry, +// - integration with the users domain. +package jwt diff --git a/internal/jwt/domain.go b/internal/jwt/domain.go new file mode 100644 index 0000000..eeded9b --- /dev/null +++ b/internal/jwt/domain.go @@ -0,0 +1,15 @@ +package jwt + +import ( + "github.com/bit-issues/backend/internal/users" + "github.com/golang-jwt/jwt/v5" +) + +// Claims represents the JWT token claims. +type Claims struct { + jwt.RegisteredClaims + + UserID int64 `json:"user_id"` + Role users.Role `json:"role"` + Status users.Status `json:"status"` +} diff --git a/internal/jwt/errors.go b/internal/jwt/errors.go new file mode 100644 index 0000000..f9abce5 --- /dev/null +++ b/internal/jwt/errors.go @@ -0,0 +1,9 @@ +package jwt + +import "errors" + +var ( + ErrInvalidConfig = errors.New("invalid config") + ErrInvalidToken = errors.New("invalid token") + ErrExpiredToken = errors.New("token has expired") +) diff --git a/internal/jwt/module.go b/internal/jwt/module.go new file mode 100644 index 0000000..7eeaae4 --- /dev/null +++ b/internal/jwt/module.go @@ -0,0 +1,18 @@ +package jwt + +import ( + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +// Module creates and returns an FX module for the jwt package. +// +// The module provides: +// - JWTService for token generation and validation +func Module() fx.Option { + return fx.Module( + "jwt", + logger.WithNamedLogger("jwt"), + fx.Provide(NewService), + ) +} diff --git a/internal/jwt/service.go b/internal/jwt/service.go new file mode 100644 index 0000000..3c458af --- /dev/null +++ b/internal/jwt/service.go @@ -0,0 +1,84 @@ +package jwt + +import ( + "errors" + "fmt" + "strconv" + "time" + + "github.com/bit-issues/backend/internal/users" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// Service handles JWT token operations. +type Service struct { + config Config +} + +// NewService creates and initializes a new JWT service. +func NewService(config Config) *Service { + return &Service{ + config: config, + } +} + +// GenerateToken creates a new JWT token for the user. +func (s *Service) GenerateToken(user *users.User) (string, error) { + now := time.Now() + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: s.config.Issuer, + Subject: strconv.FormatInt(user.ID, 10), + ExpiresAt: jwt.NewNumericDate(now.Add(s.config.AccessTTL)), + NotBefore: jwt.NewNumericDate(now), + IssuedAt: jwt.NewNumericDate(now), + ID: uuid.New().String(), + }, + UserID: user.ID, + Role: user.Role, + Status: user.Status, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(s.config.Secret)) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return tokenString, nil +} + +// ValidateToken validates and parses a JWT token. +// Returns the claims if valid, or an error if invalid/expired. +func (s *Service) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims( + tokenString, + new(Claims), + func(_ *jwt.Token) (any, error) { + return []byte(s.config.Secret), nil + }, + jwt.WithExpirationRequired(), + jwt.WithIssuedAt(), + jwt.WithIssuer(s.config.Issuer), + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}), + ) + + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + if !token.Valid { + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(*Claims) + if !ok { + return nil, ErrInvalidToken + } + + return claims, nil +} diff --git a/internal/server/admin/users/dto.go b/internal/server/admin/users/dto.go new file mode 100644 index 0000000..6b33090 --- /dev/null +++ b/internal/server/admin/users/dto.go @@ -0,0 +1,70 @@ +package users + +import ( + "time" + + "github.com/bit-issues/backend/internal/users" +) + +const ( + defaultLimit = 20 +) + +// AdminUserFilter represents query parameters for filtering users. +// +// @Description Query parameters for filtering and paginating users list. +type AdminUserFilter struct { + Status *users.Status `query:"status" validate:"omitempty,oneof=pending active blocked" enums:"pending,active,blocked"` + Role *users.Role `query:"role" validate:"omitempty,oneof=admin user" enums:"admin,user"` + Limit int `query:"limit" validate:"min=1,max=100" default:"20"` + Offset int `query:"offset" validate:"min=0" default:"0"` +} + +// UpdateUserRequest represents admin request to update user status/role. +// +// @Description Admin can update user status (active/blocked/pending) and role (admin/user). +type UpdateUserRequest struct { + Status *users.Status `json:"status,omitempty" validate:"omitempty,oneof=pending active blocked" enums:"pending,active,blocked"` + Role *users.Role `json:"role,omitempty" validate:"omitempty,oneof=admin user" enums:"admin,user"` +} + +// UserResponse represents user data in admin responses (without password). +// +// @Description User data returned in admin API responses (password excluded). +type UserResponse struct { + ID int64 `json:"id" example:"42"` + Email string `json:"email" example:"user@example.com"` + Role users.Role `json:"role" example:"user" enums:"admin,user"` + Status users.Status `json:"status" example:"active" enums:"pending,active,blocked"` + CreatedAt time.Time `json:"created_at" example:"2026-04-06T10:00:00Z"` + UpdatedAt time.Time `json:"updated_at" example:"2026-04-06T10:00:00Z"` +} + +// UserListResponse represents paginated list of users. +// +// @Description Paginated response containing users list and total count. +type UserListResponse struct { + Items []UserResponse `json:"items"` + Total int `json:"total" example:"42"` +} + +func defaultAdminUserFilter() AdminUserFilter { + return AdminUserFilter{ + Status: nil, + Role: nil, + Limit: defaultLimit, + Offset: 0, + } +} + +// toUserResponse converts domain User to admin UserResponse. +func toUserResponse(u *users.User) UserResponse { + return UserResponse{ + ID: u.ID, + Email: u.Email, + Role: u.Role, + Status: u.Status, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} diff --git a/internal/server/admin/users/handler.go b/internal/server/admin/users/handler.go new file mode 100644 index 0000000..de827cb --- /dev/null +++ b/internal/server/admin/users/handler.go @@ -0,0 +1,167 @@ +package users + +import ( + "errors" + "fmt" + "strconv" + + "github.com/bit-issues/backend/internal/jwt" + "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" + "github.com/bit-issues/backend/internal/users" + "github.com/go-core-fx/fiberfx/handler" + "github.com/go-core-fx/fiberfx/validation" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type Handler struct { + handler.Base + + usersSvc *users.Service + + jwtSvc *jwt.Service +} + +func NewHandler(usersSvc *users.Service, jwtSvc *jwt.Service, validate *validator.Validate) handler.Handler { + return &Handler{ + Base: handler.Base{Validator: validate}, + + usersSvc: usersSvc, + + jwtSvc: jwtSvc, + } +} + +func (h *Handler) Register(r fiber.Router) { + admin := r.Group( + "/admin/users", + h.errorsHandler, + jwtauth.New(h.jwtSvc, h.usersSvc), + jwtauth.WithRole(users.RoleAdmin), + ) + + // GET /admin/users - list all users with optional filters + admin.Get("/", h.handleList) + + // PATCH /admin/users/{id} - update user status/role + admin.Patch("/:id", validation.DecorateWithBodyEx(h.Validator, h.handleUpdate)) +} + +// handleList returns a paginated list of users with optional status filter. +// +// @Summary List all users +// @Description Admin can list all users with optional status filter and pagination. +// @Tags Admin +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param status query users.Status false "Filter by status" +// @Param role query users.Role false "Filter by role" +// @Param limit query int false "Page limit" default(20) +// @Param offset query int false "Page offset" default(0) +// @Success 200 {object} UserListResponse "Users list" +// @Failure 401 {object} fiberfx.ErrorResponse "Unauthorized" +// @Failure 403 {object} fiberfx.ErrorResponse "Forbidden" +// @Router /admin/users [get] +func (h *Handler) handleList(c *fiber.Ctx) error { + filter := defaultAdminUserFilter() + + if err := h.QueryParserValidator(c, &filter); err != nil { + return fmt.Errorf("failed to parse query: %w", err) + } + + // Get users from service + usersList, err := h.usersSvc.List(c.Context(), filter.Status, filter.Role, filter.Limit, filter.Offset) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + + // Get total count for pagination + total, err := h.usersSvc.Count(c.Context(), filter.Status, filter.Role) + if err != nil { + return fmt.Errorf("failed to count users: %w", err) + } + + // Convert to response DTOs + items := make([]UserResponse, 0, len(usersList)) + for _, u := range usersList { + items = append(items, toUserResponse(&u)) + } + + return c.JSON(UserListResponse{ + Items: items, + Total: int(total), + }) +} + +// handleUpdate updates user status and/or role by admin. +// +// @Summary Update user +// @Description Admin can update user status (active/blocked/pending) and role (admin/user). +// @Tags Admin +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int64 true "User ID" +// @Param request body UpdateUserRequest true "Update data" +// @Success 200 {object} UserResponse "Updated user" +// @Failure 400 {object} fiberfx.ErrorResponse "Validation error" +// @Failure 401 {object} fiberfx.ErrorResponse "Unauthorized" +// @Failure 403 {object} fiberfx.ErrorResponse "Forbidden" +// @Failure 404 {object} fiberfx.ErrorResponse "User not found" +// @Router /admin/users/{id} [patch] +func (h *Handler) handleUpdate(c *fiber.Ctx, req *UpdateUserRequest) error { + // Parse user ID from path + idStr := c.Params("id") + userID, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid user id") + } + + // Validate at least one field is provided + if req.Status == nil && req.Role == nil { + return fiber.NewError(fiber.StatusBadRequest, "at least one of status or role must be provided") + } + + // Perform update + if updErr := h.usersSvc.Update( + c.Context(), + userID, + users.UserUpdate{Status: req.Status, Role: req.Role}, + ); updErr != nil { + if errors.Is(updErr, users.ErrNotFound) { + return fiber.NewError(fiber.StatusNotFound, updErr.Error()) + } + return fmt.Errorf("failed to update user: %w", updErr) + } + + // Fetch updated user + updatedUser, err := h.usersSvc.GetByID(c.Context(), userID) + if err != nil { + return fmt.Errorf("failed to fetch updated user: %w", err) + } + + return c.JSON(toUserResponse(updatedUser)) +} + +// errorsHandler converts service errors to HTTP errors. +func (h *Handler) errorsHandler(c *fiber.Ctx) error { + err := c.Next() + if err == nil { + return nil + } + + switch { + case errors.Is(err, users.ErrEmailAlreadyUsed): + return fiber.NewError(fiber.StatusConflict, err.Error()) + case errors.Is(err, users.ErrNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + case errors.Is(err, users.ErrInvalidCredential): + return fiber.NewError(fiber.StatusUnauthorized, err.Error()) + case errors.Is(err, users.ErrNotActive): + return fiber.NewError(fiber.StatusForbidden, err.Error()) + + default: + return err //nolint:wrapcheck // err is already wrapped + } +} diff --git a/internal/server/auth/dto.go b/internal/server/auth/dto.go new file mode 100644 index 0000000..c9c9b19 --- /dev/null +++ b/internal/server/auth/dto.go @@ -0,0 +1,63 @@ +package auth + +import ( + "time" + + "github.com/bit-issues/backend/internal/users" +) + +// RegisterRequest represents user registration data. +// +// @Description User registration request with email and password. +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=72"` +} + +// LoginRequest represents user login data. +// +// @Description User login credentials. +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +// LoginResponse represents successful login response. +// +// @Description Successful login response containing JWT token and user info. +type LoginResponse struct { + AccessToken string `json:"access_token"` + User UserResponseDTO `json:"user"` +} + +// UserResponseDTO represents user data in responses (without password). +// +// @Description User data returned in API responses (password excluded). +type UserResponseDTO struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role users.Role `json:"role"` + Status users.Status `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ChangePasswordRequest represents password change request. +// +// @Description Request to change user's password (requires old password for verification). +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=8,max=72"` +} + +// toUserResponseDTO converts domain User to response DTO. +func toUserResponseDTO(u *users.User) UserResponseDTO { + return UserResponseDTO{ + ID: u.ID, + Email: u.Email, + Role: u.Role, + Status: u.Status, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} diff --git a/internal/server/auth/handler.go b/internal/server/auth/handler.go new file mode 100644 index 0000000..0fd0744 --- /dev/null +++ b/internal/server/auth/handler.go @@ -0,0 +1,144 @@ +package auth + +import ( + "errors" + "fmt" + + "github.com/bit-issues/backend/internal/jwt" + "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" + "github.com/bit-issues/backend/internal/users" + "github.com/go-core-fx/fiberfx/handler" + "github.com/go-core-fx/fiberfx/validation" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type Handler struct { + handler.Base + + usersSvc *users.Service + jwtSvc *jwt.Service +} + +func NewHandler(service *users.Service, validate *validator.Validate, jwtSvc *jwt.Service) handler.Handler { + return &Handler{ + Base: handler.Base{Validator: validate}, + usersSvc: service, + jwtSvc: jwtSvc, + } +} + +func (h *Handler) Register(r fiber.Router) { + auth := r.Group("/auth", h.errorsHandler) + + auth.Post("/register", validation.DecorateWithBodyEx(h.Validator, h.handleRegister)) + auth.Post("/login", validation.DecorateWithBodyEx(h.Validator, h.handleLogin)) + auth.Post( + "/change-password", + jwtauth.New(h.jwtSvc, h.usersSvc), + validation.DecorateWithBodyEx(h.Validator, h.handleChangePassword), + ) +} + +// handleRegister handles user registration. +// +// @Summary Register a new user +// @Description Register a new user with email and password. The user will be created with status "pending" and requires admin activation. +// @Tags Authentication +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "Registration request" +// @Success 201 {object} UserResponseDTO "User created successfully" +// @Failure 400 {object} fiberfx.ErrorResponse "Validation error" +// @Failure 409 {object} fiberfx.ErrorResponse "Email already exists" +// @Router /auth/register [post] +func (h *Handler) handleRegister(c *fiber.Ctx, req *RegisterRequest) error { + user, err := h.usersSvc.Register(c.Context(), users.UserInput{ + Email: req.Email, + Password: req.Password, + Role: users.RoleUser, + }) + if err != nil { + return fmt.Errorf("failed to register user: %w", err) + } + + return c.Status(fiber.StatusCreated).JSON(toUserResponseDTO(user)) +} + +// handleLogin handles user login. +// +// @Summary User login +// @Description Authenticate user with email and password, returns JWT token and user info. +// @Tags Authentication +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Login credentials" +// @Success 200 {object} LoginResponse "Login successful" +// @Failure 401 {object} fiberfx.ErrorResponse "Invalid credentials" +// @Failure 403 {object} fiberfx.ErrorResponse "Account not active" +// @Router /auth/login [post] +func (h *Handler) handleLogin(c *fiber.Ctx, req *LoginRequest) error { + user, err := h.usersSvc.Login(c.Context(), req.Email, req.Password) + if err != nil { + return fmt.Errorf("failed to login user: %w", err) + } + + token, err := h.jwtSvc.GenerateToken(user) + if err != nil { + return fmt.Errorf("failed to generate token: %w", err) + } + + return c.JSON(LoginResponse{ + AccessToken: token, + User: toUserResponseDTO(user), + }) +} + +// handleChangePassword handles password change for authenticated user. +// +// @Summary Change password +// @Description Change the authenticated user's password. Requires JWT authentication. +// @Tags Authentication +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body ChangePasswordRequest true "New password data" +// @Success 204 "Password changed successfully" +// @Failure 400 {object} fiberfx.ErrorResponse "Validation error" +// @Failure 401 {object} fiberfx.ErrorResponse "Unauthorized" +// @Failure 403 {object} fiberfx.ErrorResponse "Forbidden" +// @Router /auth/change-password [post] +func (h *Handler) handleChangePassword(c *fiber.Ctx, req *ChangePasswordRequest) error { + user, ok := jwtauth.GetUser(c) + if !ok { + return fiber.NewError(fiber.StatusUnauthorized, "unauthorized") + } + + if err := h.usersSvc.ChangePassword(c.Context(), user.ID, req.OldPassword, req.NewPassword); err != nil { + return fmt.Errorf("failed to change password: %w", err) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// errorsHandler converts service errors to HTTP errors. +func (h *Handler) errorsHandler(c *fiber.Ctx) error { + err := c.Next() + if err == nil { + return nil + } + + switch { + case errors.Is(err, users.ErrEmailAlreadyUsed): + return fiber.NewError(fiber.StatusConflict, err.Error()) + case errors.Is(err, users.ErrNotFound): + return fiber.NewError(fiber.StatusNotFound, err.Error()) + case errors.Is(err, users.ErrInvalidCredential): + return fiber.NewError(fiber.StatusUnauthorized, err.Error()) + case errors.Is(err, users.ErrNotActive): + return fiber.NewError(fiber.StatusForbidden, err.Error()) + + default: + return err //nolint:wrapcheck // err is already wrapped + } +} diff --git a/internal/server/docs/docs.go b/internal/server/docs/docs.go index 6819b00..d149bd2 100644 --- a/internal/server/docs/docs.go +++ b/internal/server/docs/docs.go @@ -22,7 +22,520 @@ const docTemplate = `{ }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", - "paths": {} + "paths": { + "/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin can list all users with optional status filter and pagination.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List all users", + "parameters": [ + { + "enum": [ + "pending", + "active", + "blocked" + ], + "type": "string", + "description": "Filter by status", + "name": "status", + "in": "query" + }, + { + "enum": [ + "admin", + "user" + ], + "type": "string", + "description": "Filter by role", + "name": "role", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Page limit", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "default": 0, + "description": "Page offset", + "name": "offset", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Users list", + "schema": { + "$ref": "#/definitions/users.UserListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/admin/users/{id}": { + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin can update user status (active/blocked/pending) and role (admin/user).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Update user", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/users.UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "Updated user", + "schema": { + "$ref": "#/definitions/users.UserResponse" + } + }, + "400": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/auth/change-password": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Change the authenticated user's password. Requires JWT authentication.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Change password", + "parameters": [ + { + "description": "New password data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.ChangePasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "Password changed successfully" + }, + "400": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/auth/login": { + "post": { + "description": "Authenticate user with email and password, returns JWT token and user info.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "User login", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "Login successful", + "schema": { + "$ref": "#/definitions/auth.LoginResponse" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "403": { + "description": "Account not active", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Register a new user with email and password. The user will be created with status \"pending\" and requires admin activation.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authentication" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "Registration request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "User created successfully", + "schema": { + "$ref": "#/definitions/auth.UserResponseDTO" + } + }, + "400": { + "description": "Validation error", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + }, + "409": { + "description": "Email already exists", + "schema": { + "$ref": "#/definitions/fiberfx.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "auth.ChangePasswordRequest": { + "description": "Request to change user's password (requires old password for verification).", + "type": "object", + "required": [ + "new_password", + "old_password" + ], + "properties": { + "new_password": { + "type": "string", + "maxLength": 72, + "minLength": 8 + }, + "old_password": { + "type": "string" + } + } + }, + "auth.LoginRequest": { + "description": "User login credentials.", + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "auth.LoginResponse": { + "description": "Successful login response containing JWT token and user info.", + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/auth.UserResponseDTO" + } + } + }, + "auth.RegisterRequest": { + "description": "User registration request with email and password.", + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string", + "maxLength": 72, + "minLength": 8 + } + } + }, + "auth.UserResponseDTO": { + "description": "User data returned in API responses (password excluded).", + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "role": { + "$ref": "#/definitions/users.Role" + }, + "status": { + "$ref": "#/definitions/users.Status" + }, + "updated_at": { + "type": "string" + } + } + }, + "fiberfx.ErrorResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer" + }, + "details": {}, + "message": { + "type": "string" + } + } + }, + "users.Role": { + "type": "string", + "enum": [ + "admin", + "user" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleUser" + ] + }, + "users.Status": { + "type": "string", + "enum": [ + "pending", + "active", + "blocked" + ], + "x-enum-varnames": [ + "StatusPending", + "StatusActive", + "StatusBlocked" + ] + }, + "users.UpdateUserRequest": { + "description": "Admin can update user status (active/blocked/pending) and role (admin/user).", + "type": "object", + "properties": { + "role": { + "enum": [ + "admin", + "user" + ], + "allOf": [ + { + "$ref": "#/definitions/users.Role" + } + ] + }, + "status": { + "enum": [ + "pending", + "active", + "blocked" + ], + "allOf": [ + { + "$ref": "#/definitions/users.Status" + } + ] + } + } + }, + "users.UserListResponse": { + "description": "Paginated response containing users list and total count.", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/users.UserResponse" + } + }, + "total": { + "type": "integer", + "example": 42 + } + } + }, + "users.UserResponse": { + "description": "User data returned in admin API responses (password excluded).", + "type": "object", + "properties": { + "created_at": { + "type": "string", + "example": "2026-04-06T10:00:00Z" + }, + "email": { + "type": "string", + "example": "user@example.com" + }, + "id": { + "type": "integer", + "example": 42 + }, + "role": { + "enum": [ + "admin", + "user" + ], + "allOf": [ + { + "$ref": "#/definitions/users.Role" + } + ], + "example": "user" + }, + "status": { + "enum": [ + "pending", + "active", + "blocked" + ], + "allOf": [ + { + "$ref": "#/definitions/users.Status" + } + ], + "example": "active" + }, + "updated_at": { + "type": "string", + "example": "2026-04-06T10:00:00Z" + } + } + } + } }` // SwaggerInfo holds exported Swagger Info so clients can modify it diff --git a/internal/server/middlewares/jwtauth/jwtauth.go b/internal/server/middlewares/jwtauth/jwtauth.go new file mode 100644 index 0000000..9c04f48 --- /dev/null +++ b/internal/server/middlewares/jwtauth/jwtauth.go @@ -0,0 +1,82 @@ +package jwtauth + +import ( + "errors" + "fmt" + + "github.com/bit-issues/backend/internal/jwt" + "github.com/bit-issues/backend/internal/users" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/keyauth" +) + +type localsKey string + +const ( + userKey localsKey = "user" +) + +// New returns a middleware that validates JWT token and sets user info in context. +func New(jwtSvc *jwt.Service, usersSvc *users.Service) fiber.Handler { + return keyauth.New(keyauth.Config{ + Validator: func(c *fiber.Ctx, token string) (bool, error) { + claims, err := jwtSvc.ValidateToken(token) + if err != nil { + return false, fmt.Errorf("failed to validate token: %w", err) + } + + user, err := usersSvc.GetByID(c.Context(), claims.UserID) + if err != nil { + return false, fiber.NewError(fiber.StatusUnauthorized, "user not found") + } + + if user.Status != users.StatusActive { + return false, fiber.NewError(fiber.StatusForbidden, "user is not active") + } + + // Set user info in context + c.Locals(userKey, user) + + return true, nil + }, + }) +} + +func ErrorsHandler() fiber.Handler { + return func(c *fiber.Ctx) error { + err := c.Next() + if err == nil { + return nil + } + + switch { + case errors.Is(err, jwt.ErrInvalidToken): + return fiber.NewError(fiber.StatusUnauthorized, err.Error()) + case errors.Is(err, jwt.ErrExpiredToken): + return fiber.NewError(fiber.StatusUnauthorized, err.Error()) + + default: + return err //nolint:wrapcheck // err is already wrapped + } + } +} + +func WithRole(required users.Role) fiber.Handler { + return func(c *fiber.Ctx) error { + user, ok := GetUser(c) + if !ok { + return fiber.ErrUnauthorized + } + + if user.Role != required { + return fiber.ErrForbidden + } + + return c.Next() + } +} + +func GetUser(c *fiber.Ctx) (*users.User, bool) { + user, ok := c.Locals(userKey).(*users.User) + return user, ok +} diff --git a/internal/server/module.go b/internal/server/module.go index fe0c871..a6021b3 100644 --- a/internal/server/module.go +++ b/internal/server/module.go @@ -1,7 +1,10 @@ package server import ( + "github.com/bit-issues/backend/internal/server/admin/users" + "github.com/bit-issues/backend/internal/server/auth" "github.com/bit-issues/backend/internal/server/docs" + "github.com/bit-issues/backend/internal/server/middlewares/jwtauth" "github.com/go-core-fx/fiberfx" "github.com/go-core-fx/fiberfx/handler" "github.com/go-core-fx/fiberfx/health" @@ -26,6 +29,12 @@ func Module() fx.Option { }), fx.Supply(docs.SwaggerInfo), + fx.Provide( + fx.Annotate(users.NewHandler, fx.ResultTags(`group:"handlers"`)), + fx.Annotate(auth.NewHandler, fx.ResultTags(`group:"handlers"`)), + fx.Private, + ), + fx.Provide( health.NewHandler, openapi.NewHandler, @@ -42,7 +51,10 @@ func Module() fx.Option { v1 := app.Group("/api/v1") openapiHandler.Register(v1.Group("/docs")) - v1.Use(validation.Middleware) + v1.Use( + validation.Middleware, + jwtauth.ErrorsHandler(), + ) for _, h := range handlers { h.Register(v1) diff --git a/internal/users/doc.go b/internal/users/doc.go new file mode 100644 index 0000000..561ee1b --- /dev/null +++ b/internal/users/doc.go @@ -0,0 +1,8 @@ +// Package users contains the MVP user-management module. +// +// The module covers: +// - registration with email/password, +// - login validation with account status checks, +// - admin user listing and status management, +// - password change endpoint. +package users diff --git a/internal/users/domain.go b/internal/users/domain.go new file mode 100644 index 0000000..9b6b8fe --- /dev/null +++ b/internal/users/domain.go @@ -0,0 +1,67 @@ +package users + +import "time" + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleUser Role = "user" +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusActive Status = "active" + StatusBlocked Status = "blocked" +) + +type UserInput struct { + Email string + Password string + Role Role +} + +type UserUpdate struct { + Status *Status + Role *Role +} + +// User is the domain entity used by service and handler layers. +type User struct { + ID int64 + Email string + Role Role + Status Status + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserWithPasswordHash struct { + User + + PasswordHash string +} + +func (u UserUpdate) IsEmpty() bool { + return u.Status == nil && u.Role == nil +} + +// IsValidRole checks if the role is one of the allowed values. +func IsValidRole(r Role) bool { + switch r { + case RoleAdmin, RoleUser: + return true + } + return false +} + +// IsValidStatus checks if the status is one of the allowed values. +func IsValidStatus(s Status) bool { + switch s { + case StatusPending, StatusActive, StatusBlocked: + return true + } + return false +} diff --git a/internal/users/errors.go b/internal/users/errors.go new file mode 100644 index 0000000..8e0068b --- /dev/null +++ b/internal/users/errors.go @@ -0,0 +1,10 @@ +package users + +import "errors" + +var ( + ErrNotFound = errors.New("user not found") + ErrEmailAlreadyUsed = errors.New("email already used") + ErrInvalidCredential = errors.New("invalid credentials") + ErrNotActive = errors.New("user is not active") +) diff --git a/internal/users/models.go b/internal/users/models.go new file mode 100644 index 0000000..31f7499 --- /dev/null +++ b/internal/users/models.go @@ -0,0 +1,57 @@ +package users + +import ( + "time" + + "github.com/go-core-fx/bunfx" + "github.com/uptrace/bun" + "github.com/uptrace/bun/schema" +) + +// userModel is the storage representation bound to Bun and SQL schema. +type userModel struct { + bun.BaseModel `bun:"table:users,alias:u"` + bunfx.TimedModel + + ID int64 `bun:"id,pk,autoincrement"` + Email string `bun:"email,notnull,unique"` + PasswordHash string `bun:"password_hash,notnull"` + Role Role `bun:"role,notnull,default:'user'"` + Status Status `bun:"status,notnull,default:'pending'"` +} + +func newUserModel(u UserInput, passwordHash string) *userModel { + now := time.Now() + + return &userModel{ + BaseModel: schema.BaseModel{}, + TimedModel: bunfx.TimedModel{ + CreatedAt: now, + UpdatedAt: now, + }, + + ID: 0, + Email: u.Email, + PasswordHash: passwordHash, + Role: u.Role, + Status: StatusPending, + } +} + +func (m *userModel) toDomain() *UserWithPasswordHash { + if m == nil { + return nil + } + + return &UserWithPasswordHash{ + User: User{ + ID: m.ID, + Email: m.Email, + Role: m.Role, + Status: m.Status, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + }, + PasswordHash: m.PasswordHash, + } +} diff --git a/internal/users/module.go b/internal/users/module.go new file mode 100644 index 0000000..2d94c22 --- /dev/null +++ b/internal/users/module.go @@ -0,0 +1,15 @@ +package users + +import ( + "github.com/go-core-fx/logger" + "go.uber.org/fx" +) + +func Module() fx.Option { + return fx.Module( + "users", + logger.WithNamedLogger("users"), + fx.Provide(NewRepository, fx.Private), + fx.Provide(NewService), + ) +} diff --git a/internal/users/password.go b/internal/users/password.go new file mode 100644 index 0000000..f268c17 --- /dev/null +++ b/internal/users/password.go @@ -0,0 +1,110 @@ +package users + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "math" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" +) + +// OWASP recommended Argon2id parameters: +// - Memory: 19 MiB (19 * 1024 KiB) +// - Iterations: 2 +// - Parallelism: 1 +// - Salt length: 16 bytes +// - Key length: 32 bytes. +const ( + argon2idMemory = 19 * 1024 // 19 MiB in KiB + argon2idIterations = 2 + argon2idParallelism = 1 + argon2idSaltLength = 16 + argon2idKeyLength = 32 +) + +func hashPasswordArgon2id(password string) (string, error) { + salt := make([]byte, argon2idSaltLength) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("failed to generate salt: %w", err) + } + + hash := argon2.IDKey( + []byte(password), + salt, + argon2idIterations, + argon2idMemory, + argon2idParallelism, + argon2idKeyLength, + ) + + // Encode as: $argon2id$v=19$m=19456,t=2,p=1$$ + encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, + argon2idMemory, + argon2idIterations, + argon2idParallelism, + base64.StdEncoding.EncodeToString(salt), + base64.StdEncoding.EncodeToString(hash), + ) + + return encoded, nil +} + +func verifyPasswordArgon2id(password, encodedHash string) error { + const valsCount = 6 + + // Parse the encoded hash: $argon2id$v=19$m=19456,t=2,p=1$$ + vals := strings.Split(encodedHash, "$") + if len(vals) != valsCount { + return fmt.Errorf("%w: invalid hash", ErrInvalidCredential) + } + if vals[1] != "argon2id" || vals[2] != "v="+strconv.Itoa(argon2.Version) { + return fmt.Errorf("%w: invalid hash", ErrInvalidCredential) + } + + // vals[0] - пустая строка + // vals[1] - "argon2id" + // vals[2] - "v=19" + // vals[3] - "m=19456,t=2,p=1" + + var memory, iterations, parallelism uint32 + _, err := fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &memory, &iterations, ¶llelism) + if err != nil { + return fmt.Errorf("%w: invalid hash", ErrInvalidCredential) + } + + salt, err := base64.StdEncoding.DecodeString(vals[4]) + if err != nil { + return fmt.Errorf("%w: invalid hash", ErrInvalidCredential) + } + + hash, err := base64.StdEncoding.DecodeString(vals[5]) + if err != nil { + return fmt.Errorf("%w: invalid hash", ErrInvalidCredential) + } + + if parallelism == 0 || parallelism > math.MaxUint8 || len(hash) > math.MaxUint16 { + return fmt.Errorf("%w: invalid hash", ErrInvalidCredential) + } + + // Compute hash of the provided password with same parameters + computedHash := argon2.IDKey( + []byte(password), + salt, + iterations, + memory, + uint8(parallelism), + uint32(len(hash)), //nolint:gosec // checked abpve + ) + + // Constant-time comparison + if subtle.ConstantTimeCompare(hash, computedHash) == 0 { + return ErrInvalidCredential + } + + return nil +} diff --git a/internal/users/repository.go b/internal/users/repository.go new file mode 100644 index 0000000..a35f077 --- /dev/null +++ b/internal/users/repository.go @@ -0,0 +1,152 @@ +package users + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/bit-issues/backend/internal/db" + "github.com/uptrace/bun" +) + +type Repository struct { + db *bun.DB +} + +func NewRepository(db *bun.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) Create(ctx context.Context, input UserInput, passwordHash string) (*User, error) { + model := newUserModel(input, passwordHash) + if _, err := r.db.NewInsert().Model(model).Exec(ctx); err != nil { + if db.IsUniqueViolation(err) { + return nil, ErrEmailAlreadyUsed + } + + return nil, fmt.Errorf("failed to create user: %w", err) + } + + return &model.toDomain().User, nil +} + +func (r *Repository) GetByEmail(ctx context.Context, email string) (*UserWithPasswordHash, error) { + var model userModel + if err := r.db.NewSelect().Model(&model).Where("email = ?", email).Limit(1).Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("failed to get user by email: %w", err) + } + + return model.toDomain(), nil +} + +func (r *Repository) GetByID(ctx context.Context, id int64) (*UserWithPasswordHash, error) { + var model userModel + if err := r.db.NewSelect().Model(&model).Where("id = ?", id).Limit(1).Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + + return nil, fmt.Errorf("failed to get user by id: %w", err) + } + + return model.toDomain(), nil +} + +func (r *Repository) List(ctx context.Context, status *Status, role *Role, limit, offset int) ([]User, error) { + models := make([]userModel, 0) + query := r.db.NewSelect().Model(&models).OrderExpr("id DESC") + + if status != nil { + query = query.Where("status = ?", *status) + } + if role != nil { + query = query.Where("role = ?", *role) + } + + query = query.Limit(limit).Offset(offset) + + if err := query.Scan(ctx); err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + + users := make([]User, 0, len(models)) + for _, model := range models { + users = append(users, model.toDomain().User) + } + + return users, nil +} + +func (r *Repository) Count(ctx context.Context, status *Status, role *Role) (int64, error) { + query := r.db.NewSelect().Model((*userModel)(nil)) + + if status != nil { + query = query.Where("status = ?", *status) + } + if role != nil { + query = query.Where("role = ?", *role) + } + + count, err := query.Count(ctx) + if err != nil { + return 0, fmt.Errorf("failed to count users: %w", err) + } + + return int64(count), nil +} + +func (r *Repository) UpdatePasswordHash(ctx context.Context, id int64, passwordHash string) error { + result, err := r.db.NewUpdate(). + Model((*userModel)(nil)). + Set("password_hash = ?", passwordHash). + Where("id = ?", id). + Exec(ctx) + if err != nil { + return fmt.Errorf("failed to update user password hash: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + if rows == 0 { + return ErrNotFound + } + + return nil +} + +func (r *Repository) Update(ctx context.Context, id int64, update UserUpdate) error { + if update.IsEmpty() { + return nil + } + + query := r.db.NewUpdate().Model((*userModel)(nil)).Where("id = ?", id) + + if update.Status != nil { + query = query.Set("status = ?", *update.Status) + } + if update.Role != nil { + query = query.Set("role = ?", *update.Role) + } + + result, err := query.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get affected rows: %w", err) + } + if rows == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/users/service.go b/internal/users/service.go new file mode 100644 index 0000000..c83c01d --- /dev/null +++ b/internal/users/service.go @@ -0,0 +1,94 @@ +package users + +import ( + "context" + "errors" + "fmt" + "strings" +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Register(ctx context.Context, input UserInput) (*User, error) { + input.Email = strings.ToLower(input.Email) + + passwordHash, err := hashPasswordArgon2id(input.Password) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + user, err := s.repo.Create(ctx, input, passwordHash) + if err != nil { + return nil, err + } + + return user, nil +} + +func (s *Service) Login(ctx context.Context, email, password string) (*User, error) { + email = strings.ToLower(email) + + user, err := s.repo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, ErrInvalidCredential + } + + return nil, err + } + + if verifyErr := verifyPasswordArgon2id(password, user.PasswordHash); verifyErr != nil { + return nil, fmt.Errorf("failed to verify password: %w", verifyErr) + } + + if user.Status != StatusActive { + return nil, ErrNotActive + } + + return &user.User, nil +} + +func (s *Service) List(ctx context.Context, status *Status, role *Role, limit, offset int) ([]User, error) { + return s.repo.List(ctx, status, role, limit, offset) +} + +func (s *Service) Count(ctx context.Context, status *Status, role *Role) (int64, error) { + return s.repo.Count(ctx, status, role) +} + +func (s *Service) Update(ctx context.Context, id int64, update UserUpdate) error { + return s.repo.Update(ctx, id, update) +} + +func (s *Service) GetByID(ctx context.Context, id int64) (*User, error) { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + return &user.User, nil +} + +func (s *Service) ChangePassword(ctx context.Context, id int64, oldPassword, newPassword string) error { + user, err := s.repo.GetByID(ctx, id) + if err != nil { + return err + } + + if verifyErr := verifyPasswordArgon2id(oldPassword, user.PasswordHash); verifyErr != nil { + return fmt.Errorf("failed to verify password: %w", verifyErr) + } + + hash, err := hashPasswordArgon2id(newPassword) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + return s.repo.UpdatePasswordHash(ctx, id, hash) +} diff --git a/requests.http b/requests.http new file mode 100644 index 0000000..e5ddb2c --- /dev/null +++ b/requests.http @@ -0,0 +1,107 @@ +@baseURL=http://localhost:3000/api/v1 + +### +# Register a new admin user +POST {{baseURL}}/auth/register +Content-Type: application/json + +{ + "email": "admin@example.com", + "password": "securePass123" +} + +### +# Login with admin credentials +# @name adminLogin +@adminAccessToken={{adminLogin.response.body.$.access_token}} +POST {{baseURL}}/auth/login +Content-Type: application/json + +{ + "email": "admin@example.com", + "password": "securePass123" +} + +### +# Register a new user +# @name register +@email={{register.response.body.$.email}} +POST {{baseURL}}/auth/register +Content-Type: application/json + +{ + "email": "user{{$randomInt 10 100}}@example.com", + "password": "securePass123" +} + +### +# Login with user credentials +# @name login +@accessToken={{login.response.body.$.access_token}} +POST {{baseURL}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "securePass123" +} + +### +# Change password (requires authentication) +POST {{baseURL}}/auth/change-password +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "old_password": "securePass123", + "new_password": "newStrongPass456" +} + +### +# List all users (admin only) +# Requires admin JWT token in Authorization header +# Optional query parameters: status (pending/active/blocked), role (admin/user), limit, offset +GET {{baseURL}}/admin/users?status=pending +Authorization: Bearer {{adminAccessToken}} + +### +# Update user status/role (admin only) +# Requires admin JWT token in Authorization header +PATCH {{baseURL}}/admin/users/1 +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "status": "active", + "role": "user" +} + +### +# Example: Activate a pending user +PATCH {{baseURL}}/admin/users/2 +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "status": "active" +} + +### +# Example: Block a user +PATCH {{baseURL}}/admin/users/3 +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "status": "blocked" +} + +### +# Example: Promote user to admin +PATCH {{baseURL}}/admin/users/4 +Content-Type: application/json +Authorization: Bearer {{adminAccessToken}} + +{ + "role": "admin" +}