Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/sms-gateway/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions deployments/docker-swarm-terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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`)"
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion internal/sms-gateway/handlers/3rdparty.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)

Expand Down
38 changes: 33 additions & 5 deletions internal/sms-gateway/handlers/middlewares/userauth/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ import (

const localsUser = "user"

// New returns a middleware that checks for a valid "Authorization" header
// in the form of "Basic <base64-encoded credentials>" 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 <base64 encoded username:password>".
// 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()
}
Expand Down Expand Up @@ -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 <one-time user authorization 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.
Expand Down
34 changes: 33 additions & 1 deletion internal/sms-gateway/handlers/mobile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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),
)
Expand All @@ -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"))
Expand Down
15 changes: 15 additions & 0 deletions internal/sms-gateway/modules/auth/module.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package auth

import (
"context"

"go.uber.org/fx"
"go.uber.org/zap"
)
Expand All @@ -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
},
})
}),
)
7 changes: 7 additions & 0 deletions internal/sms-gateway/modules/auth/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
68 changes: 68 additions & 0 deletions internal/sms-gateway/modules/auth/service.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package auth

import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
Expand Down Expand Up @@ -36,6 +38,7 @@ type Service struct {
config Config

users *repository
codesCache *cache.Cache[string]
usersCache *cache.Cache[models.User]

devicesSvc *devices.Service
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
10 changes: 10 additions & 0 deletions internal/sms-gateway/modules/auth/types.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 6 additions & 1 deletion pkg/swagger/docs/mobile.http
Original file line number Diff line number Diff line change
Expand Up @@ -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

{
Expand Down
Loading