From 6e0d616036faf73912031e50c13d547c073dee0e Mon Sep 17 00:00:00 2001 From: masoodk Date: Fri, 19 Jan 2024 20:26:11 +0330 Subject: [PATCH] feat(service): add admin login and register --- config.yml | 2 + config/config.go | 16 +-- config/constant.go | 1 + delivery/http_server/admin/admin/handler.go | 25 ++++ delivery/http_server/admin/admin/login.go | 33 ++++++ delivery/http_server/admin/admin/register.go | 33 ++++++ delivery/http_server/admin/admin/route.go | 14 +++ delivery/http_server/server.go | 10 ++ entity/admin.go | 34 +++--- entity/admin_role.go | 4 + entity/admin_status.go | 4 + entity/gender.go | 4 + main.go | 17 ++- param/admin/admin/login.go | 13 ++ param/admin/admin/register.go | 19 +++ param/admin/admin/token.go | 6 + pkg/err_msg/message.go | 24 ++-- repository/mysql/admin/admin.go | 11 ++ repository/mysql/admin/create.go | 28 +++++ repository/mysql/admin/exist_admin.go | 53 +++++++++ repository/mysql/admin/get.go | 84 +++++++++++++ .../migration/1705675489_add_admins_table.sql | 18 +++ .../1705675814_seeder_add_super_admin.sql | 9 ++ service/admin/admin/login.go | 40 +++++++ service/admin/admin/register.go | 53 +++++++++ service/admin/admin/service.go | 44 +++++++ service/auth/admin/claims.go | 16 +++ service/auth/admin/login.go | 1 - service/auth/admin/service.go | 56 ++++++++- .../benefactor/benefactor/login_register.go | 14 +-- validator/admin/admin/login.go | 42 +++++++ validator/admin/admin/register.go | 53 +++++++++ validator/admin/admin/validator.go | 111 ++++++++++++++++++ 33 files changed, 847 insertions(+), 45 deletions(-) create mode 100644 delivery/http_server/admin/admin/handler.go create mode 100644 delivery/http_server/admin/admin/login.go create mode 100644 delivery/http_server/admin/admin/register.go create mode 100644 delivery/http_server/admin/admin/route.go create mode 100644 param/admin/admin/login.go create mode 100644 param/admin/admin/register.go create mode 100644 param/admin/admin/token.go create mode 100644 repository/mysql/admin/admin.go create mode 100644 repository/mysql/admin/create.go create mode 100644 repository/mysql/admin/exist_admin.go create mode 100644 repository/mysql/admin/get.go create mode 100644 repository/mysql/migration/1705675489_add_admins_table.sql create mode 100644 repository/mysql/migration/1705675814_seeder_add_super_admin.sql create mode 100644 service/admin/admin/login.go create mode 100644 service/admin/admin/register.go create mode 100644 service/admin/admin/service.go create mode 100644 service/auth/admin/claims.go delete mode 100644 service/auth/admin/login.go create mode 100644 validator/admin/admin/login.go create mode 100644 validator/admin/admin/register.go create mode 100644 validator/admin/admin/validator.go diff --git a/config.yml b/config.yml index ac73534..3c18c37 100644 --- a/config.yml +++ b/config.yml @@ -32,6 +32,8 @@ kavenegar_sms_provider: otp_template_new_user: ebhomeverify otp_template_registered_user: ebhomeverify +admin_auth: + sign_key: admin-jwt_secret_test_nik diff --git a/config/config.go b/config/config.go index 8d0b548..ee971f4 100644 --- a/config/config.go +++ b/config/config.go @@ -4,7 +4,8 @@ import ( "git.gocasts.ir/ebhomengo/niki/adapter/redis" smsprovider "git.gocasts.ir/ebhomengo/niki/adapter/sms_provider/kavenegar" "git.gocasts.ir/ebhomengo/niki/repository/mysql" - authservice "git.gocasts.ir/ebhomengo/niki/service/auth/benefactor" + adminauthservice "git.gocasts.ir/ebhomengo/niki/service/auth/admin" + benefactorauthservice "git.gocasts.ir/ebhomengo/niki/service/auth/benefactor" benefactorservice "git.gocasts.ir/ebhomengo/niki/service/benefactor/benefactor" ) @@ -13,10 +14,11 @@ type HTTPServer struct { } type Config struct { - HTTPServer HTTPServer `koanf:"http_server"` - Mysql mysql.Config `koanf:"mysql"` - Auth authservice.Config `koanf:"auth"` - Redis redis.Config `koanf:"redis"` - KavenegarSmsProvider smsprovider.Config `koanf:"kavenegar_sms_provider"` - BenefactorSvc benefactorservice.Config `koanf:"benefactor_service"` + HTTPServer HTTPServer `koanf:"http_server"` + Mysql mysql.Config `koanf:"mysql"` + Auth benefactorauthservice.Config `koanf:"auth"` + AdminAuth adminauthservice.Config `koanf:"admin_auth"` + Redis redis.Config `koanf:"redis"` + KavenegarSmsProvider smsprovider.Config `koanf:"kavenegar_sms_provider"` + BenefactorSvc benefactorservice.Config `koanf:"benefactor_service"` } diff --git a/config/constant.go b/config/constant.go index 8631eab..f5c1ab5 100644 --- a/config/constant.go +++ b/config/constant.go @@ -12,4 +12,5 @@ const ( AccessTokenExpireDuration = time.Hour * 24 RefreshTokenExpireDuration = time.Hour * 24 * 7 AuthMiddlewareContextKey = "claims" + BcryptCost = 3 ) diff --git a/delivery/http_server/admin/admin/handler.go b/delivery/http_server/admin/admin/handler.go new file mode 100644 index 0000000..e867ec5 --- /dev/null +++ b/delivery/http_server/admin/admin/handler.go @@ -0,0 +1,25 @@ +package adminhandler + +import ( + adminservice "git.gocasts.ir/ebhomengo/niki/service/admin/admin" + adminauthservice "git.gocasts.ir/ebhomengo/niki/service/auth/admin" + adminvalidator "git.gocasts.ir/ebhomengo/niki/validator/admin/admin" +) + +type Handler struct { + authConfig adminauthservice.Config + authSvc adminauthservice.Service + adminSvc adminservice.Service + adminVld adminvalidator.Validator +} + +func New(authConfig adminauthservice.Config, authSvc adminauthservice.Service, + adminSvc adminservice.Service, adminVld adminvalidator.Validator, +) Handler { + return Handler{ + authConfig: authConfig, + authSvc: authSvc, + adminSvc: adminSvc, + adminVld: adminVld, + } +} diff --git a/delivery/http_server/admin/admin/login.go b/delivery/http_server/admin/admin/login.go new file mode 100644 index 0000000..f68c4ab --- /dev/null +++ b/delivery/http_server/admin/admin/login.go @@ -0,0 +1,33 @@ +package adminhandler + +import ( + adminserviceparam "git.gocasts.ir/ebhomengo/niki/param/admin/admin" + httpmsg "git.gocasts.ir/ebhomengo/niki/pkg/http_msg" + "github.com/labstack/echo/v4" + "net/http" +) + +func (h Handler) LoginByPhoneNumber(c echo.Context) error { + var req adminserviceparam.LoginWithPhoneNumberRequest + + if bErr := c.Bind(&req); bErr != nil { + return echo.NewHTTPError(http.StatusBadRequest) + } + + if fieldErrors, err := h.adminVld.ValidateLoginWithPhoneNumberRequest(req); err != nil { + msg, code := httpmsg.Error(err) + + return c.JSON(code, echo.Map{ + "message": msg, + "errors": fieldErrors, + }) + } + resp, sErr := h.adminSvc.LoginWithPhoneNumber(c.Request().Context(), req) + if sErr != nil { + msg, code := httpmsg.Error(sErr) + + return echo.NewHTTPError(code, msg) + } + + return c.JSON(http.StatusOK, resp) +} diff --git a/delivery/http_server/admin/admin/register.go b/delivery/http_server/admin/admin/register.go new file mode 100644 index 0000000..1e9b330 --- /dev/null +++ b/delivery/http_server/admin/admin/register.go @@ -0,0 +1,33 @@ +package adminhandler + +import ( + adminserviceparam "git.gocasts.ir/ebhomengo/niki/param/admin/admin" + httpmsg "git.gocasts.ir/ebhomengo/niki/pkg/http_msg" + "github.com/labstack/echo/v4" + "net/http" +) + +func (h Handler) Register(c echo.Context) error { + var req adminserviceparam.RegisterRequest + + if bErr := c.Bind(&req); bErr != nil { + return echo.NewHTTPError(http.StatusBadRequest) + } + + if fieldErrors, err := h.adminVld.ValidateRegisterRequest(req); err != nil { + msg, code := httpmsg.Error(err) + + return c.JSON(code, echo.Map{ + "message": msg, + "errors": fieldErrors, + }) + } + resp, sErr := h.adminSvc.Register(c.Request().Context(), req) + if sErr != nil { + msg, code := httpmsg.Error(sErr) + + return echo.NewHTTPError(code, msg) + } + + return c.JSON(http.StatusOK, resp) +} diff --git a/delivery/http_server/admin/admin/route.go b/delivery/http_server/admin/admin/route.go new file mode 100644 index 0000000..90552a5 --- /dev/null +++ b/delivery/http_server/admin/admin/route.go @@ -0,0 +1,14 @@ +package adminhandler + +import "github.com/labstack/echo/v4" + +func (h Handler) SetRoutes(e *echo.Echo) { + r := e.Group("/admins") + + //nolint:gocritic + //r.POST("/", h.Add).Name = "admin-addkindboxreq" + r.POST("/register", h.Register) + r.POST("/login-by-phone", h.LoginByPhoneNumber) + //nolint:gocritic + //r.PATCH("/:id", h.Update).Name = "admin-updatekindboxreq" +} diff --git a/delivery/http_server/server.go b/delivery/http_server/server.go index 0c8dbdf..e47305f 100644 --- a/delivery/http_server/server.go +++ b/delivery/http_server/server.go @@ -2,6 +2,10 @@ package httpserver import ( "fmt" + adminhandler "git.gocasts.ir/ebhomengo/niki/delivery/http_server/admin/admin" + adminservice "git.gocasts.ir/ebhomengo/niki/service/admin/admin" + adminauthservice "git.gocasts.ir/ebhomengo/niki/service/auth/admin" + adminvalidator "git.gocasts.ir/ebhomengo/niki/validator/admin/admin" config "git.gocasts.ir/ebhomengo/niki/config" benefactorbasehandler "git.gocasts.ir/ebhomengo/niki/delivery/http_server/benefactor/base" @@ -23,6 +27,7 @@ type Server struct { benefactorHandler benefactorhandler.Handler benefactorKindBoxReqHandler benefactorkindboxreqhandler.Handler benefactorBaseHandler benefactorbasehandler.Handler + adminHandler adminhandler.Handler } func New( @@ -33,6 +38,9 @@ func New( benefactorKindBoxReqSvc benefactorkindboxreqservice.Service, benefactorKindBoxReqVld benefactorkindboxreqvalidator.Validator, benefactorAddressSvc benefactoraddressservice.Service, + adminSvc adminservice.Service, + adminVld adminvalidator.Validator, + adminAuthSvc adminauthservice.Service, ) Server { return Server{ Router: echo.New(), @@ -40,6 +48,7 @@ func New( benefactorHandler: benefactorhandler.New(cfg.Auth, authSvc, benefactorSvc, benefactorVld, benefactorAddressSvc), benefactorKindBoxReqHandler: benefactorkindboxreqhandler.New(cfg.Auth, authSvc, benefactorKindBoxReqSvc, benefactorKindBoxReqVld), benefactorBaseHandler: benefactorbasehandler.New(benefactorAddressSvc), + adminHandler: adminhandler.New(cfg.AdminAuth, adminAuthSvc, adminSvc, adminVld), } } @@ -53,6 +62,7 @@ func (s Server) Serve() { s.benefactorHandler.SetRoutes(s.Router) s.benefactorKindBoxReqHandler.SetRoutes(s.Router) s.benefactorBaseHandler.SetRoutes(s.Router) + s.adminHandler.SetRoutes(s.Router) // Start server address := fmt.Sprintf(":%d", s.config.HTTPServer.Port) diff --git a/entity/admin.go b/entity/admin.go index 76e83e6..8e78ed9 100644 --- a/entity/admin.go +++ b/entity/admin.go @@ -1,19 +1,23 @@ package entity -import "time" - type Admin struct { - ID uint - FirstName string - LastName string - PhoneNumber string - Role AdminRole - Address string - Description string - Email string - City string - Gender Gender - Status AdminStatus - Birthday time.Time - StatusChangedAt time.Time + ID uint + FirstName string + LastName string + password string + PhoneNumber string + Role AdminRole + Description string + Email string + Gender Gender + Status AdminStatus +} + +func (a *Admin) GetPassword() string { + + return a.password +} + +func (a *Admin) SetPassword(password string) { + a.password = password } diff --git a/entity/admin_role.go b/entity/admin_role.go index 2251e87..dc06895 100644 --- a/entity/admin_role.go +++ b/entity/admin_role.go @@ -16,6 +16,10 @@ func (s AdminRole) String() string { return AdminRoleStrings[s] } +func (s AdminRole) IsValid() bool { + return s > 0 && int(s) <= len(AdminRoleStrings) +} + // AllAdminRole returns a slice containing all string values of AdminRole. func AllAdminRole() []string { roleStrings := make([]string, len(AdminRoleStrings)) diff --git a/entity/admin_status.go b/entity/admin_status.go index 74d7847..9750c0d 100644 --- a/entity/admin_status.go +++ b/entity/admin_status.go @@ -16,6 +16,10 @@ func (s AdminStatus) String() string { return AdminStatusStrings[s] } +func (s AdminStatus) IsValid() bool { + return s > 0 && int(s) <= len(AdminStatusStrings) +} + // AllAdminStatus returns a slice containing all string values of AdminStatus. func AllAdminStatus() []string { statusStrings := make([]string, len(AdminStatusStrings)) diff --git a/entity/gender.go b/entity/gender.go index 797d872..c860352 100644 --- a/entity/gender.go +++ b/entity/gender.go @@ -26,6 +26,10 @@ func AllGender() []string { return statusStrings } +func (s Gender) IsValid() bool { + return s > 0 && int(s) <= len(GenderStrings) +} + // MapToGender converts a string to the corresponding Gender value. func MapToGender(statusStr string) Gender { for status, str := range GenderStrings { diff --git a/main.go b/main.go index 9f8dcb2..7c1e9b2 100644 --- a/main.go +++ b/main.go @@ -9,13 +9,17 @@ import ( "git.gocasts.ir/ebhomengo/niki/repository/migrator" "git.gocasts.ir/ebhomengo/niki/repository/mysql" mysqladdress "git.gocasts.ir/ebhomengo/niki/repository/mysql/address" + mysqladmin "git.gocasts.ir/ebhomengo/niki/repository/mysql/admin" mysqlbenefactor "git.gocasts.ir/ebhomengo/niki/repository/mysql/benefactor" mysqlkindboxreq "git.gocasts.ir/ebhomengo/niki/repository/mysql/kind_box_req" redisotp "git.gocasts.ir/ebhomengo/niki/repository/redis/redis_otp" + adminservice "git.gocasts.ir/ebhomengo/niki/service/admin/admin" + adminauthservice "git.gocasts.ir/ebhomengo/niki/service/auth/admin" authservice "git.gocasts.ir/ebhomengo/niki/service/auth/benefactor" benefactoraddressservice "git.gocasts.ir/ebhomengo/niki/service/benefactor/address" benefactorservice "git.gocasts.ir/ebhomengo/niki/service/benefactor/benefactor" benefactorkindboxreqservice "git.gocasts.ir/ebhomengo/niki/service/benefactor/kind_box_req" + adminvalidator "git.gocasts.ir/ebhomengo/niki/validator/admin/admin" benefactorvalidator "git.gocasts.ir/ebhomengo/niki/validator/benefactor/benefactor" benefactorkindboxreqvalidator "git.gocasts.ir/ebhomengo/niki/validator/benefactor/kind_box_req" _ "github.com/go-sql-driver/mysql" @@ -27,8 +31,10 @@ func main() { mgr := migrator.New(cfg.Mysql) mgr.Up() - authSvc, benefactorSvc, benefactorVld, benefactorKindBoxReqSvc, benefactorKindBoxReqVld, benefactorAddressSvc := setupServices(cfg) - server := httpserver.New(cfg, benefactorSvc, benefactorVld, authSvc, benefactorKindBoxReqSvc, benefactorKindBoxReqVld, benefactorAddressSvc) + authSvc, benefactorSvc, benefactorVld, benefactorKindBoxReqSvc, benefactorKindBoxReqVld, benefactorAddressSvc, + adminSvc, adminVld, adminAuthSvc := setupServices(cfg) + server := httpserver.New(cfg, benefactorSvc, benefactorVld, authSvc, benefactorKindBoxReqSvc, benefactorKindBoxReqVld, + benefactorAddressSvc, adminSvc, adminVld, adminAuthSvc) server.Serve() } @@ -36,7 +42,7 @@ func main() { func setupServices(cfg config.Config) ( authSvc authservice.Service, benefactorSvc benefactorservice.Service, benefactorVld benefactorvalidator.Validator, benefactorKindBoxReqSvc benefactorkindboxreqservice.Service, benefactorKindBoxReqVld benefactorkindboxreqvalidator.Validator, - benefactorAddressSvc benefactoraddressservice.Service, + benefactorAddressSvc benefactoraddressservice.Service, adminSvc adminservice.Service, adminVld adminvalidator.Validator, adminAuthSvc adminauthservice.Service, ) { authSvc = authservice.New(cfg.Auth) @@ -58,5 +64,10 @@ func setupServices(cfg config.Config) ( benefactorKindBoxReqSvc = benefactorkindboxreqservice.New(benefactorKindBoxReqMysql) benefactorKindBoxReqVld = benefactorkindboxreqvalidator.New(benefactorKindBoxReqMysql, benefactorSvc, benefactorAddressSvc) + adminAuthSvc = adminauthservice.New(cfg.AdminAuth) + adminMysql := mysqladmin.New(MysqlRepo) + adminVld = adminvalidator.New(adminMysql) + adminSvc = adminservice.New(adminMysql, adminAuthSvc) + return } diff --git a/param/admin/admin/login.go b/param/admin/admin/login.go new file mode 100644 index 0000000..f3261ba --- /dev/null +++ b/param/admin/admin/login.go @@ -0,0 +1,13 @@ +package adminserviceparam + +import "git.gocasts.ir/ebhomengo/niki/entity" + +type LoginWithPhoneNumberRequest struct { + PhoneNumber string `json:"phone_number"` + Password string `json:"password"` +} + +type LoginWithPhoneNumberResponse struct { + Admin entity.Admin `json:"admin"` + Tokens Tokens `json:"tokens"` +} diff --git a/param/admin/admin/register.go b/param/admin/admin/register.go new file mode 100644 index 0000000..b01ea49 --- /dev/null +++ b/param/admin/admin/register.go @@ -0,0 +1,19 @@ +package adminserviceparam + +import "git.gocasts.ir/ebhomengo/niki/entity" + +type RegisterRequest struct { + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + Password *string `json:"password"` + PhoneNumber *string `json:"phone_number"` + Role *entity.AdminRole `json:"role"` + Description *string `json:"description"` + Email *string `json:"email"` + Gender *entity.Gender `json:"gender"` + Status *entity.AdminStatus `json:"status"` +} + +type RegisterResponse struct { + Admin entity.Admin +} diff --git a/param/admin/admin/token.go b/param/admin/admin/token.go new file mode 100644 index 0000000..4a18e33 --- /dev/null +++ b/param/admin/admin/token.go @@ -0,0 +1,6 @@ +package adminserviceparam + +type Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} diff --git a/pkg/err_msg/message.go b/pkg/err_msg/message.go index 6712767..f716923 100644 --- a/pkg/err_msg/message.go +++ b/pkg/err_msg/message.go @@ -1,15 +1,17 @@ package errmsg const ( - ErrorMsgNotFound = "record not found" - ErrorMsgSomethingWentWrong = "something went wrong" - ErrorMsgInvalidInput = "invalid input" - ErrorMsgInvalidStatus = "invalid status" - ErrorMsgPhoneNumberIsNotUnique = "phone number is not unique" - ErrorMsgPhoneNumberIsNotValid = "phone number is not valid" - ErrorMsgUserNotAllowed = "user not allowed" - ErrorMsgUserNotFound = "benefactor not found" - ErrorMsgOtpCodeExist = "please wait a little bit" - ErrorMsgOtpCodeIsNotValid = "verification code is not valid" - ErrorMsgCantScanQueryResult = "can't scan query result" + ErrorMsgNotFound = "record not found" + ErrorMsgSomethingWentWrong = "something went wrong" + ErrorMsgInvalidInput = "invalid input" + ErrorMsgInvalidStatus = "invalid status" + ErrorMsgPhoneNumberIsNotUnique = "phone number is not unique" + ErrorMsgEmailIsNotUnique = "email is not unique" + ErrorMsgPhoneNumberIsNotValid = "phone number is not valid" + ErrorMsgUserNotAllowed = "user not allowed" + ErrorMsgUserNotFound = "benefactor not found" + ErrorMsgOtpCodeExist = "please wait a little bit" + ErrorMsgOtpCodeIsNotValid = "verification code is not valid" + ErrorMsgCantScanQueryResult = "can't scan query result" + ErrorMsgPhoneNumberOrPassIsIncorrect = "phone number or password is incorrect" ) diff --git a/repository/mysql/admin/admin.go b/repository/mysql/admin/admin.go new file mode 100644 index 0000000..c97ea8b --- /dev/null +++ b/repository/mysql/admin/admin.go @@ -0,0 +1,11 @@ +package mysqladmin + +import "git.gocasts.ir/ebhomengo/niki/repository/mysql" + +type DB struct { + conn *mysql.DB +} + +func New(conn *mysql.DB) *DB { + return &DB{conn: conn} +} diff --git a/repository/mysql/admin/create.go b/repository/mysql/admin/create.go new file mode 100644 index 0000000..a664fe3 --- /dev/null +++ b/repository/mysql/admin/create.go @@ -0,0 +1,28 @@ +package mysqladmin + +import ( + "context" + "git.gocasts.ir/ebhomengo/niki/entity" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" +) + +func (d DB) AddAdmin(ctx context.Context, admin entity.Admin) (entity.Admin, error) { + const op = "mysqladmin.AddAdmin" + + res, err := d.conn.Conn().ExecContext(ctx, `insert into admins(first_name,last_name,password,phone_number, + role,description,email,gender,status) values (?,?,?,?,?,?,?,?,?)`, + admin.FirstName, admin.LastName, admin.GetPassword(), admin.PhoneNumber, admin.Role.String(), admin.Description, admin.Email, + admin.Gender.String(), admin.Status.String()) + if err != nil { + return entity.Admin{}, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgNotFound).WithKind(richerror.KindUnexpected) + } + + //nolint + // err is always nil + id, _ := res.LastInsertId() + admin.ID = uint(id) + + return admin, nil +} diff --git a/repository/mysql/admin/exist_admin.go b/repository/mysql/admin/exist_admin.go new file mode 100644 index 0000000..87d298b --- /dev/null +++ b/repository/mysql/admin/exist_admin.go @@ -0,0 +1,53 @@ +package mysqladmin + +import ( + "context" + "database/sql" + "errors" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" +) + +func (d DB) AdminExistByPhoneNumber(ctx context.Context, phoneNumber string) (bool, error) { + const op = "mysqlbenefactor.IsExistBenefactorByID" + + row := d.conn.Conn().QueryRowContext(ctx, `select * from admins where phone_number = ?`, phoneNumber) + + _, err := scanAdmin(row) + if err != nil { + sErr := sql.ErrNoRows + //TODO-errorsas: second argument to errors.As should not be *error + //nolint + if errors.As(err, &sErr) { + return false, nil + } + + // TODO - log unexpected error for better observability + return false, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgCantScanQueryResult).WithKind(richerror.KindUnexpected) + } + + return true, nil +} + +func (d DB) AdminExistByEmail(ctx context.Context, email string) (bool, error) { + const op = "mysqlbenefactor.IsExistBenefactorByID" + + row := d.conn.Conn().QueryRowContext(ctx, `select * from admins where email = ?`, email) + + _, err := scanAdmin(row) + if err != nil { + sErr := sql.ErrNoRows + //TODO-errorsas: second argument to errors.As should not be *error + //nolint + if errors.As(err, &sErr) { + return false, nil + } + + // TODO - log unexpected error for better observability + return false, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgCantScanQueryResult).WithKind(richerror.KindUnexpected) + } + + return true, nil +} diff --git a/repository/mysql/admin/get.go b/repository/mysql/admin/get.go new file mode 100644 index 0000000..fcb1ad2 --- /dev/null +++ b/repository/mysql/admin/get.go @@ -0,0 +1,84 @@ +package mysqladmin + +import ( + "context" + "database/sql" + "errors" + "git.gocasts.ir/ebhomengo/niki/entity" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "git.gocasts.ir/ebhomengo/niki/repository/mysql" + "time" +) + +func (d DB) GetAdminByPhoneNumber(ctx context.Context, phoneNumber string) (entity.Admin, error) { + const op = "mysqlbenefactor.IsExistBenefactorByID" + + row := d.conn.Conn().QueryRowContext(ctx, `select * from admins where phone_number = ?`, phoneNumber) + + admin, err := scanAdmin(row) + if err != nil { + sErr := sql.ErrNoRows + //TODO-errorsas: second argument to errors.As should not be *error + //nolint + if errors.As(err, &sErr) { + return entity.Admin{}, richerror.New(op).WithErr(sErr). + WithMessage(errmsg.ErrorMsgNotFound).WithKind(richerror.KindNotFound) + } + + // TODO - log unexpected error for better observability + return entity.Admin{}, richerror.New(op).WithErr(err). + WithMessage(errmsg.ErrorMsgCantScanQueryResult).WithKind(richerror.KindUnexpected) + } + + return admin, nil +} + +func scanAdmin(scanner mysql.Scanner) (entity.Admin, error) { + var createdAt time.Time + var admin entity.Admin + var roleStr, statusStr, password string + // TODO - use db model and mapper between entity and db model OR use this approach + + var adminNullableFields nullableFields + + err := scanner.Scan(&admin.ID, &adminNullableFields.firstName, + &adminNullableFields.lastName, &password, &admin.PhoneNumber, + &roleStr, &adminNullableFields.description, + &adminNullableFields.email, &adminNullableFields.genderStr, + &statusStr, &createdAt) + + admin.Role = entity.MapToAdminRole(roleStr) + admin.Status = entity.MapToAdminStatus(statusStr) + admin.SetPassword(password) + mapNotNullToAdmin(adminNullableFields, &admin) + + return admin, err +} + +type nullableFields struct { + firstName sql.NullString + lastName sql.NullString + description sql.NullString + email sql.NullString + genderStr sql.NullString +} + +// TODO - find the other solution. +func mapNotNullToAdmin(data nullableFields, admin *entity.Admin) { + if data.firstName.Valid { + admin.FirstName = data.firstName.String + } + if data.lastName.Valid { + admin.LastName = data.lastName.String + } + if data.description.Valid { + admin.Description = data.description.String + } + if data.email.Valid { + admin.Email = data.email.String + } + if data.genderStr.Valid { + admin.Gender = entity.MapToGender(data.genderStr.String) + } +} diff --git a/repository/mysql/migration/1705675489_add_admins_table.sql b/repository/mysql/migration/1705675489_add_admins_table.sql new file mode 100644 index 0000000..53017b2 --- /dev/null +++ b/repository/mysql/migration/1705675489_add_admins_table.sql @@ -0,0 +1,18 @@ +-- +migrate Up +CREATE TABLE `admins` +( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `first_name` VARCHAR(191), + `last_name` VARCHAR(191), + `password` TEXT NOT NULL, + `phone_number` VARCHAR(191) NOT NULL UNIQUE, + `role` ENUM('super-admin','admin') NOT NULL, + `description` TEXT, + `email` VARCHAR(191) NOT NULL UNIQUE, + `gender` VARCHAR(191), + `status` VARCHAR(191), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- +migrate Down +DROP TABLE `admins`; \ No newline at end of file diff --git a/repository/mysql/migration/1705675814_seeder_add_super_admin.sql b/repository/mysql/migration/1705675814_seeder_add_super_admin.sql new file mode 100644 index 0000000..ff36d9e --- /dev/null +++ b/repository/mysql/migration/1705675814_seeder_add_super_admin.sql @@ -0,0 +1,9 @@ +-- +migrate Up +-- what can we do for password? +INSERT INTO `admins` (`id`, `phone_number`, `email`,`password`,`role`,`status`) +VALUES (1, '09122702856', 'keshvari@gmail.com','Abc123456','super-admin','active'); + +-- +migrate Down +DELETE +FROM `admins` +WHERE id '1' ; diff --git a/service/admin/admin/login.go b/service/admin/admin/login.go new file mode 100644 index 0000000..3efe316 --- /dev/null +++ b/service/admin/admin/login.go @@ -0,0 +1,40 @@ +package adminservice + +import ( + "context" + adminserviceparam "git.gocasts.ir/ebhomengo/niki/param/admin/admin" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" +) + +func (s Service) LoginWithPhoneNumber(ctx context.Context, req adminserviceparam.LoginWithPhoneNumberRequest) (adminserviceparam.LoginWithPhoneNumberResponse, error) { + const op = richerror.Op("adminservice.LoginWithPhoneNumber") + + admin, err := s.repo.GetAdminByPhoneNumber(ctx, req.PhoneNumber) + if err != nil { + return adminserviceparam.LoginWithPhoneNumberResponse{}, richerror.New(op).WithErr(err).WithKind(richerror.KindUnexpected) + } + + if cErr := CompareHash(admin.GetPassword(), req.Password); cErr != nil { + return adminserviceparam.LoginWithPhoneNumberResponse{}, richerror.New(op).WithErr(cErr).WithMessage(errmsg.ErrorMsgPhoneNumberOrPassIsIncorrect).WithKind(richerror.KindForbidden) + } + + accessToken, aErr := s.auth.CreateAccessToken(admin) + if aErr != nil { + return adminserviceparam.LoginWithPhoneNumberResponse{}, richerror.New(op).WithErr(aErr).WithKind(richerror.KindUnexpected) + } + + refreshToken, rErr := s.auth.CreateRefreshToken(admin) + if rErr != nil { + return adminserviceparam.LoginWithPhoneNumberResponse{}, richerror.New(op).WithErr(rErr).WithKind(richerror.KindUnexpected) + } + + return adminserviceparam.LoginWithPhoneNumberResponse{ + Admin: admin, + Tokens: adminserviceparam.Tokens{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, nil + +} diff --git a/service/admin/admin/register.go b/service/admin/admin/register.go new file mode 100644 index 0000000..92b5da6 --- /dev/null +++ b/service/admin/admin/register.go @@ -0,0 +1,53 @@ +package adminservice + +import ( + "context" + "git.gocasts.ir/ebhomengo/niki/entity" + adminserviceparam "git.gocasts.ir/ebhomengo/niki/param/admin/admin" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" +) + +func (s Service) Register(ctx context.Context, req adminserviceparam.RegisterRequest) (adminserviceparam.RegisterResponse, error) { + const op = richerror.Op("adminservice.Register") + + var newAdmin entity.Admin + if req.FirstName != nil { + newAdmin.FirstName = *req.FirstName + } + if req.LastName != nil { + newAdmin.LastName = *req.LastName + } + if req.PhoneNumber != nil { + newAdmin.PhoneNumber = *req.PhoneNumber + } + if req.Role != nil { + newAdmin.Role = *req.Role + } + if req.Description != nil { + newAdmin.LastName = *req.Description + } + if req.Email != nil { + newAdmin.Email = *req.Email + } + if req.Gender != nil { + newAdmin.Gender = *req.Gender + } + if req.Description != nil { + newAdmin.LastName = *req.Description + } + if req.Email != nil { + newAdmin.Status = *req.Status + } + + if bErr := GenerateHash(req.Password); bErr != nil { + return adminserviceparam.RegisterResponse{}, richerror.New(op).WithErr(bErr).WithKind(richerror.KindUnexpected) + } + newAdmin.SetPassword(*req.Password) + + admin, err := s.repo.AddAdmin(ctx, newAdmin) + if err != nil { + return adminserviceparam.RegisterResponse{}, richerror.New(op).WithErr(err).WithKind(richerror.KindUnexpected) + } + + return adminserviceparam.RegisterResponse{Admin: admin}, err +} diff --git a/service/admin/admin/service.go b/service/admin/admin/service.go new file mode 100644 index 0000000..1aaeb99 --- /dev/null +++ b/service/admin/admin/service.go @@ -0,0 +1,44 @@ +package adminservice + +import ( + "context" + "fmt" + "git.gocasts.ir/ebhomengo/niki/config" + "git.gocasts.ir/ebhomengo/niki/entity" + "golang.org/x/crypto/bcrypt" +) + +type AuthGenerator interface { + CreateAccessToken(benefactor entity.Admin) (string, error) + CreateRefreshToken(benefactor entity.Admin) (string, error) +} + +type Repository interface { + AddAdmin(ctx context.Context, admin entity.Admin) (entity.Admin, error) + GetAdminByPhoneNumber(ctx context.Context, phoneNumber string) (entity.Admin, error) +} + +type Service struct { + repo Repository + auth AuthGenerator +} + +func New(repo Repository, auth AuthGenerator) Service { + return Service{ + repo: repo, + auth: auth, + } +} + +func GenerateHash(password *string) error { + hashedPassword, bErr := bcrypt.GenerateFromPassword([]byte(*password), config.BcryptCost) + if bErr != nil { + return fmt.Errorf("bcrypt error: %w", bErr) + } + *password = string(hashedPassword) + + return nil +} +func CompareHash(hashedPassword, password string) error { + return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) +} diff --git a/service/auth/admin/claims.go b/service/auth/admin/claims.go new file mode 100644 index 0000000..25ed388 --- /dev/null +++ b/service/auth/admin/claims.go @@ -0,0 +1,16 @@ +package adminauthservice + +import ( + "git.gocasts.ir/ebhomengo/niki/entity" + "github.com/golang-jwt/jwt/v4" +) + +type Claims struct { + jwt.RegisteredClaims + UserID uint `json:"user_id"` + Role entity.AdminRole `json:"role"` +} + +func (c Claims) Valid() error { + return c.RegisteredClaims.Valid() +} diff --git a/service/auth/admin/login.go b/service/auth/admin/login.go deleted file mode 100644 index d78da5d..0000000 --- a/service/auth/admin/login.go +++ /dev/null @@ -1 +0,0 @@ -package admin diff --git a/service/auth/admin/service.go b/service/auth/admin/service.go index bb115be..bfd6797 100644 --- a/service/auth/admin/service.go +++ b/service/auth/admin/service.go @@ -1,6 +1,9 @@ -package admin +package adminauthservice import ( + "git.gocasts.ir/ebhomengo/niki/entity" + "github.com/golang-jwt/jwt/v4" + "strings" "time" ) @@ -21,3 +24,54 @@ func New(cfg Config) Service { config: cfg, } } + +func (s Service) CreateAccessToken(admin entity.Admin) (string, error) { + return s.createToken(admin.ID, admin.Role, s.config.AccessSubject, s.config.AccessExpirationTime) +} + +func (s Service) CreateRefreshToken(admin entity.Admin) (string, error) { + return s.createToken(admin.ID, admin.Role, s.config.RefreshSubject, s.config.RefreshExpirationTime) +} + +func (s Service) ParseToken(bearerToken string) (*Claims, error) { + // https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-ParseWithClaims-CustomClaimsType + + tokenStr := strings.Replace(bearerToken, "Bearer ", "", 1) + + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(s.config.SignKey), nil + }) + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, err +} + +func (s Service) createToken(userID uint, role entity.AdminRole, subject string, expireDuration time.Duration) (string, error) { + // create a signer for rsa 256 + // TODO - replace with rsa 256 RS256 - https://github.com/golang-jwt/jwt/blob/main/http_example_test.go + + // set our claims + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: subject, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireDuration)), + }, + UserID: userID, + Role: role, + } + + // TODO - add sign method to config + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := accessToken.SignedString([]byte(s.config.SignKey)) + if err != nil { + return "", err + } + + return tokenString, nil +} diff --git a/service/benefactor/benefactor/login_register.go b/service/benefactor/benefactor/login_register.go index 9c3cb98..59b843b 100644 --- a/service/benefactor/benefactor/login_register.go +++ b/service/benefactor/benefactor/login_register.go @@ -22,7 +22,7 @@ func (s Service) LoginOrRegister(ctx context.Context, req benefactoreparam.Login _, dErr := s.redisOtp.DeleteCodeByPhoneNumber(ctx, req.PhoneNumber) if dErr != nil { - return benefactoreparam.LoginOrRegisterResponse{}, richerror.New(op).WithErr(gErr).WithKind(richerror.KindUnexpected) + return benefactoreparam.LoginOrRegisterResponse{}, richerror.New(op).WithErr(dErr).WithKind(richerror.KindUnexpected) } isExist, benefactor, rErr := s.repo.IsExistBenefactorByPhoneNumber(ctx, req.PhoneNumber) @@ -36,19 +36,19 @@ func (s Service) LoginOrRegister(ctx context.Context, req benefactoreparam.Login Role: entity.UserBenefactorRole, }) if err != nil { - return benefactoreparam.LoginOrRegisterResponse{}, richerror.New(op).WithErr(rErr).WithKind(richerror.KindUnexpected) + return benefactoreparam.LoginOrRegisterResponse{}, richerror.New(op).WithErr(err).WithKind(richerror.KindUnexpected) } benefactor = newBenefactor } - accessToken, err := s.auth.CreateAccessToken(benefactor) - if err != nil { - return benefactoreparam.LoginOrRegisterResponse{}, richerror.New(op).WithErr(rErr).WithKind(richerror.KindUnexpected) + accessToken, aErr := s.auth.CreateAccessToken(benefactor) + if aErr != nil { + return benefactoreparam.LoginOrRegisterResponse{}, richerror.New(op).WithErr(aErr).WithKind(richerror.KindUnexpected) } - refreshToken, err := s.auth.CreateRefreshToken(benefactor) - if err != nil { + refreshToken, rErr := s.auth.CreateRefreshToken(benefactor) + if rErr != nil { return benefactoreparam.LoginOrRegisterResponse{}, richerror.New(op).WithErr(rErr).WithKind(richerror.KindUnexpected) } diff --git a/validator/admin/admin/login.go b/validator/admin/admin/login.go new file mode 100644 index 0000000..3140644 --- /dev/null +++ b/validator/admin/admin/login.go @@ -0,0 +1,42 @@ +package adminvalidator + +import ( + "errors" + adminserviceparam "git.gocasts.ir/ebhomengo/niki/param/admin/admin" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + validation "github.com/go-ozzo/ozzo-validation/v4" + "regexp" +) + +func (v Validator) ValidateLoginWithPhoneNumberRequest(req adminserviceparam.LoginWithPhoneNumberRequest) (map[string]string, error) { + const op = "adminvalidator.ValidateRegisterRequest" + + if err := validation.ValidateStruct(&req, + //TODO - add regex + validation.Field(&req.Password, validation.Required, validation.NotNil, + validation.Length(8, 0)), + + validation.Field(&req.PhoneNumber, + validation.Required, + validation.Match(regexp.MustCompile(phoneNumberRegex)).Error(errmsg.ErrorMsgPhoneNumberIsNotValid), + validation.By(v.doesAdminExistByPhoneNumber))); err != nil { + fieldErrors := make(map[string]string) + + vErr := validation.Errors{} + if errors.As(err, &vErr) { + for key, value := range vErr { + if value != nil { + fieldErrors[key] = value.Error() + } + } + } + + return fieldErrors, richerror.New(op).WithMessage(errmsg.ErrorMsgInvalidInput). + WithKind(richerror.KindInvalid). + WithMeta(map[string]interface{}{"req": req}).WithErr(err) + } + + //nolint + return nil, nil +} diff --git a/validator/admin/admin/register.go b/validator/admin/admin/register.go new file mode 100644 index 0000000..4d6b092 --- /dev/null +++ b/validator/admin/admin/register.go @@ -0,0 +1,53 @@ +package adminvalidator + +import ( + "errors" + adminserviceparam "git.gocasts.ir/ebhomengo/niki/param/admin/admin" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" + richerror "git.gocasts.ir/ebhomengo/niki/pkg/rich_error" + "github.com/go-ozzo/ozzo-validation/is" + validation "github.com/go-ozzo/ozzo-validation/v4" + "regexp" +) + +func (v Validator) ValidateRegisterRequest(req adminserviceparam.RegisterRequest) (map[string]string, error) { + const op = "adminvalidator.ValidateRegisterRequest" + if err := validation.ValidateStruct(&req, + // TODO - add length of code config from benefactor config + validation.Field(&req.FirstName, + validation.Length(3, 40)), + validation.Field(&req.LastName, + validation.Length(3, 40)), + + //TODO - add regex + validation.Field(&req.Password, validation.Required, validation.NotNil, + validation.Length(8, 0)), + validation.Field(&req.Gender, validation.By(v.IsGenderValid)), + validation.Field(&req.Role, validation.By(v.IsRoleValid), validation.Required), + validation.Field(&req.Status, validation.By(v.IsStatusValid), validation.Required), + validation.Field(&req.Email, validation.Required, is.Email, + validation.By(v.doesAdminExistByEmail)), + + validation.Field(&req.PhoneNumber, + validation.Required, + validation.Match(regexp.MustCompile(phoneNumberRegex)).Error(errmsg.ErrorMsgPhoneNumberIsNotValid), + validation.By(v.IsPhoneNumberUnique))); err != nil { + fieldErrors := make(map[string]string) + + vErr := validation.Errors{} + if errors.As(err, &vErr) { + for key, value := range vErr { + if value != nil { + fieldErrors[key] = value.Error() + } + } + } + + return fieldErrors, richerror.New(op).WithMessage(errmsg.ErrorMsgInvalidInput). + WithKind(richerror.KindInvalid). + WithMeta(map[string]interface{}{"req": req}).WithErr(err) + } + + //nolint + return nil, nil +} diff --git a/validator/admin/admin/validator.go b/validator/admin/admin/validator.go new file mode 100644 index 0000000..8388332 --- /dev/null +++ b/validator/admin/admin/validator.go @@ -0,0 +1,111 @@ +package adminvalidator + +import ( + "context" + "fmt" + "git.gocasts.ir/ebhomengo/niki/entity" + errmsg "git.gocasts.ir/ebhomengo/niki/pkg/err_msg" +) + +const ( + phoneNumberRegex = "^09\\d{9}$" +) + +type Repository interface { + AdminExistByPhoneNumber(ctx context.Context, phoneNumber string) (bool, error) + AdminExistByEmail(ctx context.Context, email string) (bool, error) +} +type Validator struct { + repo Repository +} + +func New(repo Repository) Validator { + return Validator{repo: repo} +} + +func (v Validator) doesAdminExistByPhoneNumber(value interface{}) error { + phoneNumber, ok := value.(string) + if !ok { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + adminExisted, err := v.repo.AdminExistByPhoneNumber(context.Background(), phoneNumber) + if err != nil { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + if !adminExisted { + return fmt.Errorf(errmsg.ErrorMsgPhoneNumberOrPassIsIncorrect) + } + return nil +} + +func (v Validator) IsPhoneNumberUnique(value interface{}) error { + phoneNumber, ok := value.(*string) + if !ok { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + adminExisted, err := v.repo.AdminExistByPhoneNumber(context.Background(), *phoneNumber) + if err != nil { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + if adminExisted { + return fmt.Errorf(errmsg.ErrorMsgPhoneNumberIsNotUnique) + } + return nil +} + +func (v Validator) doesAdminExistByEmail(value interface{}) error { + email, ok := value.(*string) + if !ok { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + adminExisted, err := v.repo.AdminExistByEmail(context.Background(), *email) + if err != nil { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + if adminExisted { + return fmt.Errorf(errmsg.ErrorMsgPhoneNumberIsNotUnique) + } + return nil +} + +func (v Validator) IsRoleValid(value interface{}) error { + role, ok := value.(*entity.AdminRole) + if !ok { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + + if isValid := role.IsValid(); isValid != true { + return fmt.Errorf(errmsg.ErrorMsgInvalidInput) + } + + return nil +} + +func (v Validator) IsGenderValid(value interface{}) error { + gender, ok := value.(*entity.Gender) + if gender == nil { + return nil + } + if !ok { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + + if isValid := gender.IsValid(); isValid != true { + return fmt.Errorf(errmsg.ErrorMsgInvalidInput) + } + + return nil +} + +func (v Validator) IsStatusValid(value interface{}) error { + status, ok := value.(*entity.AdminStatus) + if !ok { + return fmt.Errorf(errmsg.ErrorMsgSomethingWentWrong) + } + + if isValid := status.IsValid(); isValid != true { + return fmt.Errorf(errmsg.ErrorMsgInvalidInput) + } + + return nil +}