From 64d7476ff0e8b25181ec013e0d77d9e7018ebe2a Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Wed, 26 Mar 2025 10:20:37 +0700 Subject: [PATCH 1/2] [mobile] add registration by one-time code --- cmd/sms-gateway/main.go | 5 ++ go.mod | 4 +- go.sum | 8 +++ internal/sms-gateway/handlers/3rdparty.go | 2 +- .../handlers/middlewares/userauth/userauth.go | 38 +++++++++-- internal/sms-gateway/handlers/mobile.go | 34 +++++++++- internal/sms-gateway/modules/auth/module.go | 15 ++++ .../sms-gateway/modules/auth/repository.go | 7 ++ internal/sms-gateway/modules/auth/service.go | 68 +++++++++++++++++++ internal/sms-gateway/modules/auth/types.go | 10 +++ pkg/swagger/docs/mobile.http | 7 +- pkg/swagger/docs/swagger.json | 56 +++++++++++++++ pkg/swagger/docs/swagger.yaml | 36 ++++++++++ 13 files changed, 280 insertions(+), 10 deletions(-) diff --git a/cmd/sms-gateway/main.go b/cmd/sms-gateway/main.go index d46cd7d4..0471754a 100644 --- a/cmd/sms-gateway/main.go +++ b/cmd/sms-gateway/main.go @@ -7,6 +7,11 @@ import ( // @securitydefinitions.basic ApiAuth // @description User authentication +// @securitydefinitions.apikey UserCode +// @in header +// @name Authorization +// @description User one-time code authentication + // @securitydefinitions.apikey MobileToken // @in header // @name Authorization diff --git a/go.mod b/go.mod index 33599dde..c4f6e9e4 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,9 @@ toolchain go1.23.2 require ( firebase.google.com/go/v4 v4.12.1 - github.com/android-sms-gateway/client-go v1.5.4 + github.com/android-sms-gateway/client-go v1.5.6 github.com/ansrivas/fiberprometheus/v2 v2.6.1 - github.com/capcom6/go-helpers v0.1.1 + github.com/capcom6/go-helpers v0.2.0 github.com/capcom6/go-infra-fx v0.2.1 github.com/go-playground/assert/v2 v2.2.0 github.com/go-playground/validator/v10 v10.16.0 diff --git a/go.sum b/go.sum index 174abf61..c31f17b3 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,10 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/android-sms-gateway/client-go v1.5.4 h1:sFMqu+Lc+YtkasesmerckVV8KKL8Qcx/VPEUWvcfbyA= github.com/android-sms-gateway/client-go v1.5.4/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4= +github.com/android-sms-gateway/client-go v1.5.6-0.20250326025946-2625b20dcccd h1:VuSsDc7HeRllPmVrFCmMi0ksFDWEDoUEHFuua4ccJ0s= +github.com/android-sms-gateway/client-go v1.5.6-0.20250326025946-2625b20dcccd/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4= +github.com/android-sms-gateway/client-go v1.5.6 h1:sCiBT1Fn7QBaTlX7Z3eBGDcG+u+3sADmp+rxb0HWuaA= +github.com/android-sms-gateway/client-go v1.5.6/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/ansrivas/fiberprometheus/v2 v2.6.1 h1:wac3pXaE6BYYTF04AC6K0ktk6vCD+MnDOJZ3SK66kXM= @@ -39,6 +43,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/capcom6/go-helpers v0.1.1 h1:kcpK1+VUwo94MZlZX+0Gab4gf78egHTPzW9sOQXLfFE= github.com/capcom6/go-helpers v0.1.1/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw= +github.com/capcom6/go-helpers v0.1.2-0.20250326030929-5d1f34b4936b h1:grbupORuCS6EJV/IMdEijRwmW7M91bpgqqex/TRKg38= +github.com/capcom6/go-helpers v0.1.2-0.20250326030929-5d1f34b4936b/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw= +github.com/capcom6/go-helpers v0.2.0 h1:OUcUnVbjBiwaTzvyaxkxqRKtrOXv1ifYalQ1NXzFBNM= +github.com/capcom6/go-helpers v0.2.0/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw= github.com/capcom6/go-infra-fx v0.2.1 h1:8rqr2ZV+YC2R07amHMdlE1XKLUhMe5yO+ffCJ/xXlNY= github.com/capcom6/go-infra-fx v0.2.1/go.mod h1:klScvB8QAKgJ19FfJOnUKK5tI0o9b79Aj2RmCJHfbN0= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= diff --git a/internal/sms-gateway/handlers/3rdparty.go b/internal/sms-gateway/handlers/3rdparty.go index 670ad590..c81152c3 100644 --- a/internal/sms-gateway/handlers/3rdparty.go +++ b/internal/sms-gateway/handlers/3rdparty.go @@ -47,7 +47,7 @@ func (h *thirdPartyHandler) Register(router fiber.Router) { h.healthHandler.Register(router) router.Use( - userauth.New(h.authSvc), + userauth.NewBasic(h.authSvc), userauth.UserRequired(), ) diff --git a/internal/sms-gateway/handlers/middlewares/userauth/userauth.go b/internal/sms-gateway/handlers/middlewares/userauth/userauth.go index 94a17973..6b88fbdb 100644 --- a/internal/sms-gateway/handlers/middlewares/userauth/userauth.go +++ b/internal/sms-gateway/handlers/middlewares/userauth/userauth.go @@ -12,14 +12,15 @@ import ( const localsUser = "user" -// New returns a middleware that checks for a valid "Authorization" header -// in the form of "Basic " and authorizes the user. -func New(authSvc *auth.Service) fiber.Handler { +// NewBasic returns a middleware that will check if the request contains a valid +// "Authorization" header in the form of "Basic ". +// If the header is valid, the middleware will authorize the user and store the +// user in the request's Locals under the key LocalsUser. If the header is invalid, +// the middleware will call c.Next() and continue with the request. +func NewBasic(authSvc *auth.Service) fiber.Handler { return func(c *fiber.Ctx) error { - // Get authorization header auth := c.Get(fiber.HeaderAuthorization) - // Check if the header contains content besides "basic". if len(auth) <= 6 || !strings.EqualFold(auth[:6], "basic ") { return c.Next() } @@ -55,6 +56,33 @@ func New(authSvc *auth.Service) fiber.Handler { } } +// NewCode returns a middleware that will check if the request contains a valid +// "Authorization" header in the form of "Code ". +// If the header is valid, the middleware will authorize the user and store the +// user in the request's Locals under the key LocalsUser. If the header is invalid, +// the middleware will call c.Next() and continue with the request. +func NewCode(authSvc *auth.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + auth := c.Get(fiber.HeaderAuthorization) + + if len(auth) <= 5 || !strings.EqualFold(auth[:5], "code ") { + return c.Next() + } + + // Get the code + code := auth[5:] + + user, err := authSvc.AuthorizeUserByCode(code) + if err != nil { + return fiber.ErrUnauthorized + } + + c.Locals(localsUser, user) + + return c.Next() + } +} + // HasUser checks if a user is present in the Locals of the given context. // It returns true if the Locals contain a user under the key LocalsUser, // otherwise returns false. diff --git a/internal/sms-gateway/handlers/mobile.go b/internal/sms-gateway/handlers/mobile.go index 6cabfc22..c81d58fb 100644 --- a/internal/sms-gateway/handlers/mobile.go +++ b/internal/sms-gateway/handlers/mobile.go @@ -60,6 +60,7 @@ func (h *mobileHandler) getDevice(device models.Device, c *fiber.Ctx) error { // @Summary Register device // @Description Registers new device for new or existing user. Returns user credentials only for new users // @Security ApiAuth +// @Security UserCode // @Security ServerKey // @Tags Device // @Accept json @@ -198,6 +199,29 @@ func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } +// @Summary Get one-time code for device registration +// @Description Returns one-time code for device registration +// @Security ApiAuth +// @Tags Device +// @Accept json +// @Produce json +// @Success 200 {object} smsgateway.MobileUserCodeResponse "User code" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" +// @Router /mobile/v1/user/code [get] +// +// Get user code +func (h *mobileHandler) getUserCode(user models.User, c *fiber.Ctx) error { + code, err := h.authSvc.GenerateUserCode(user.ID) + if err != nil { + return err + } + + return c.JSON(smsgateway.MobileUserCodeResponse{ + Code: code.Code, + ValidUntil: code.ValidUntil, + }) +} + // @Summary Change password // @Description Changes the user's password // @Security MobileToken @@ -231,7 +255,8 @@ func (h *mobileHandler) Register(router fiber.Router) { router = router.Group("/mobile/v1") router.Post("/device", - userauth.New(h.authSvc), + userauth.NewBasic(h.authSvc), + userauth.NewCode(h.authSvc), keyauth.New(keyauth.Config{ Next: func(c *fiber.Ctx) bool { // Skip server key authorization in the following cases: @@ -247,6 +272,12 @@ func (h *mobileHandler) Register(router fiber.Router) { h.postDevice, ) + router.Get("/user/code", + userauth.NewBasic(h.authSvc), + userauth.UserRequired(), + userauth.WithUser(h.getUserCode), + ) + router.Use( deviceauth.New(h.authSvc), ) @@ -260,6 +291,7 @@ func (h *mobileHandler) Register(router fiber.Router) { router.Get("/message", deviceauth.WithDevice(h.getMessage)) router.Patch("/message", deviceauth.WithDevice(h.patchMessage)) + // Should be under `userauth.NewBasic` protection instead of `deviceauth` router.Patch("/user/password", deviceauth.WithDevice(h.changePassword)) h.webhooksCtrl.Register(router.Group("/webhooks")) diff --git a/internal/sms-gateway/modules/auth/module.go b/internal/sms-gateway/modules/auth/module.go index 4f4e1d7f..02dd1018 100644 --- a/internal/sms-gateway/modules/auth/module.go +++ b/internal/sms-gateway/modules/auth/module.go @@ -1,6 +1,8 @@ package auth import ( + "context" + "go.uber.org/fx" "go.uber.org/zap" ) @@ -12,4 +14,17 @@ var Module = fx.Module( }), fx.Provide(New), fx.Provide(newRepository, fx.Private), + fx.Invoke(func(lc fx.Lifecycle, svc *Service) { + ctx, cancel := context.WithCancel(context.Background()) + lc.Append(fx.Hook{ + OnStart: func(_ context.Context) error { + go svc.Run(ctx) + return nil + }, + OnStop: func(_ context.Context) error { + cancel() + return nil + }, + }) + }), ) diff --git a/internal/sms-gateway/modules/auth/repository.go b/internal/sms-gateway/modules/auth/repository.go index 7298c19a..42244ced 100644 --- a/internal/sms-gateway/modules/auth/repository.go +++ b/internal/sms-gateway/modules/auth/repository.go @@ -15,6 +15,13 @@ func newRepository(db *gorm.DB) *repository { } } +// GetByID returns a user by their ID. +func (r *repository) GetByID(id string) (models.User, error) { + user := models.User{} + + return user, r.db.Where("id = ?", id).Take(&user).Error +} + func (r *repository) GetByLogin(login string) (models.User, error) { user := models.User{} diff --git a/internal/sms-gateway/modules/auth/service.go b/internal/sms-gateway/modules/auth/service.go index 16560292..0b4357ef 100644 --- a/internal/sms-gateway/modules/auth/service.go +++ b/internal/sms-gateway/modules/auth/service.go @@ -1,6 +1,8 @@ package auth import ( + "context" + "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" @@ -36,6 +38,7 @@ type Service struct { config Config users *repository + codesCache *cache.Cache[string] usersCache *cache.Cache[models.User] devicesSvc *devices.Service @@ -55,10 +58,39 @@ func New(params Params) *Service { logger: params.Logger.Named("Service"), idgen: idgen, + codesCache: cache.New[string](cache.Config{}), usersCache: cache.New[models.User](cache.Config{TTL: 1 * time.Hour}), } } +// GenerateUserCode generates a unique one-time user authorization code +func (s *Service) GenerateUserCode(userID string) (AuthCode, error) { + var code string + var err error + + b := make([]byte, 3) + validUntil := time.Now().Add(codeTTL) + for range 3 { + if _, err = rand.Read(b); err != nil { + continue + } + num := (int(b[0]) << 16) | (int(b[1]) << 8) | int(b[2]) + code = fmt.Sprintf("%06d", num%1000000) + + if err = s.codesCache.SetOrFail(code, userID, cache.WithValidUntil(validUntil)); err != nil { + continue + } + + break + } + + if err != nil { + return AuthCode{}, fmt.Errorf("can't generate code: %w", err) + } + + return AuthCode{Code: code, ValidUntil: validUntil}, nil +} + func (s *Service) RegisterUser(login, password string) (models.User, error) { user := models.User{ ID: login, @@ -143,6 +175,21 @@ func (s *Service) AuthorizeUser(username, password string) (models.User, error) return user, nil } +// AuthorizeUserByCode authorizes a user by one-time code. +func (s *Service) AuthorizeUserByCode(code string) (models.User, error) { + userID, err := s.codesCache.GetAndDelete(code) + if err != nil { + return models.User{}, err + } + + user, err := s.users.GetByID(userID) + if err != nil { + return models.User{}, err + } + + return user, nil +} + func (s *Service) ChangePassword(userID string, currentPassword string, newPassword string) error { user, err := s.users.GetByLogin(userID) if err != nil { @@ -171,3 +218,24 @@ func (s *Service) ChangePassword(userID string, currentPassword string, newPassw return nil } + +// Run starts a ticker that triggers the clean function every hour. +// It runs indefinitely until the provided context is canceled. +func (s *Service) Run(ctx context.Context) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.clean(ctx) + } + } +} + +func (s *Service) clean(_ context.Context) { + s.codesCache.Cleanup() + s.usersCache.Cleanup() +} diff --git a/internal/sms-gateway/modules/auth/types.go b/internal/sms-gateway/modules/auth/types.go index 88607fc2..e3505fed 100644 --- a/internal/sms-gateway/modules/auth/types.go +++ b/internal/sms-gateway/modules/auth/types.go @@ -1,8 +1,18 @@ package auth +import "time" + +const codeTTL = 5 * time.Minute + type Mode string const ( ModePublic Mode = "public" ModePrivate Mode = "private" ) + +// AuthCode is a one-time user authorization code +type AuthCode struct { + Code string + ValidUntil time.Time +} diff --git a/pkg/swagger/docs/mobile.http b/pkg/swagger/docs/mobile.http index 30f43987..d15e8edf 100644 --- a/pkg/swagger/docs/mobile.http +++ b/pkg/swagger/docs/mobile.http @@ -7,10 +7,15 @@ GET {{baseUrl}}/device HTTP/1.1 Authorization: Bearer {{mobileToken}} +### +GET {{baseUrl}}/user/code HTTP/1.1 +Authorization: Bearer {{mobileToken}} + ### POST {{baseUrl}}/device HTTP/1.1 # Authorization: Bearer 123456789 -Authorization: Basic {{credentials}} +# Authorization: Basic {{credentials}} +# Authorization: Code 065379 Content-Type: application/json { diff --git a/pkg/swagger/docs/swagger.json b/pkg/swagger/docs/swagger.json index c9204293..b4726281 100644 --- a/pkg/swagger/docs/swagger.json +++ b/pkg/swagger/docs/swagger.json @@ -567,6 +567,9 @@ { "ApiAuth": [] }, + { + "UserCode": [] + }, { "ServerKey": [] } @@ -764,6 +767,40 @@ } } }, + "/mobile/v1/user/code": { + "get": { + "security": [ + { + "ApiAuth": [] + } + ], + "description": "Returns one-time code for device registration", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Device" + ], + "summary": "Get one-time code for device registration", + "responses": { + "200": { + "description": "User code", + "schema": { + "$ref": "#/definitions/smsgateway.MobileUserCodeResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + } + } + } + }, "/mobile/v1/user/password": { "patch": { "security": [ @@ -1319,6 +1356,19 @@ } } }, + "smsgateway.MobileUserCodeResponse": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "123456" + }, + "validUntil": { + "type": "string", + "example": "2020-01-01T00:00:00Z" + } + } + }, "smsgateway.ProcessingState": { "type": "string", "enum": [ @@ -1483,6 +1533,12 @@ "type": "apiKey", "name": "Authorization", "in": "header" + }, + "UserCode": { + "description": "User one-time code authentication", + "type": "apiKey", + "name": "Authorization", + "in": "header" } } } \ No newline at end of file diff --git a/pkg/swagger/docs/swagger.yaml b/pkg/swagger/docs/swagger.yaml index 92e51c8a..79bee9e2 100644 --- a/pkg/swagger/docs/swagger.yaml +++ b/pkg/swagger/docs/swagger.yaml @@ -301,6 +301,15 @@ definitions: maxLength: 256 type: string type: object + smsgateway.MobileUserCodeResponse: + properties: + code: + example: "123456" + type: string + validUntil: + example: "2020-01-01T00:00:00Z" + type: string + type: object smsgateway.ProcessingState: enum: - Pending @@ -848,6 +857,7 @@ paths: $ref: '#/definitions/smsgateway.ErrorResponse' security: - ApiAuth: [] + - UserCode: [] - ServerKey: [] summary: Register device tags: @@ -908,6 +918,27 @@ paths: tags: - Device - Messages + /mobile/v1/user/code: + get: + consumes: + - application/json + description: Returns one-time code for device registration + produces: + - application/json + responses: + "200": + description: User code + schema: + $ref: '#/definitions/smsgateway.MobileUserCodeResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + security: + - ApiAuth: [] + summary: Get one-time code for device registration + tags: + - Device /mobile/v1/user/password: patch: consumes: @@ -1017,4 +1048,9 @@ securityDefinitions: in: header name: Authorization type: apiKey + UserCode: + description: User one-time code authentication + in: header + name: Authorization + type: apiKey swagger: "2.0" From aae4717ba8ec8d65d94624a416f1aec8e9dfc11e Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 27 Mar 2025 06:14:16 +0700 Subject: [PATCH 2/2] [deploy] limit user operations from device --- deployments/docker-swarm-terraform/main.tf | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/deployments/docker-swarm-terraform/main.tf b/deployments/docker-swarm-terraform/main.tf index c0a078bf..90f5b8e6 100644 --- a/deployments/docker-swarm-terraform/main.tf +++ b/deployments/docker-swarm-terraform/main.tf @@ -147,6 +147,7 @@ resource "docker_service" "app" { #endregion #region Primary Limited + #region Device Registration labels { label = "traefik.http.routers.${var.app-name}-new_limited.rule" value = "Host(`api.sms-gate.app`) && PathPrefix(`/mobile/v1/device`) && Method(`POST`)" @@ -164,6 +165,25 @@ resource "docker_service" "app" { value = "le" } #endregion + #region User Operations + labels { + label = "traefik.http.routers.${var.app-name}-new_limited-user.rule" + value = "Host(`api.sms-gate.app`) && PathPrefix(`/mobile/v1/user`)" + } + labels { + label = "traefik.http.routers.${var.app-name}-new_limited-user.entrypoints" + value = "https" + } + labels { + label = "traefik.http.routers.${var.app-name}-new_limited-user.middlewares" + value = "rate-limit_5-per-1m,${var.app-name}-new-addprefix" + } + labels { + label = "traefik.http.routers.${var.app-name}-new_limited-user.tls.certresolver" + value = "le" + } + #endregion + #endregion labels { label = "traefik.http.services.${var.app-name}.loadbalancer.server.port"