Skip to content

Commit a8b23f4

Browse files
committed
[mobile] add registration by one-time code
1 parent 8127d9d commit a8b23f4

File tree

13 files changed

+280
-10
lines changed

13 files changed

+280
-10
lines changed

cmd/sms-gateway/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import (
77
// @securitydefinitions.basic ApiAuth
88
// @description User authentication
99

10+
// @securitydefinitions.apikey UserCode
11+
// @in header
12+
// @name Authorization
13+
// @description User one-time code authentication
14+
1015
// @securitydefinitions.apikey MobileToken
1116
// @in header
1217
// @name Authorization

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ toolchain go1.23.2
66

77
require (
88
firebase.google.com/go/v4 v4.12.1
9-
github.com/android-sms-gateway/client-go v1.5.4
9+
github.com/android-sms-gateway/client-go v1.5.6
1010
github.com/ansrivas/fiberprometheus/v2 v2.6.1
11-
github.com/capcom6/go-helpers v0.1.1
11+
github.com/capcom6/go-helpers v0.2.0
1212
github.com/capcom6/go-infra-fx v0.2.1
1313
github.com/go-playground/assert/v2 v2.2.0
1414
github.com/go-playground/validator/v10 v10.16.0

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV
2828
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
2929
github.com/android-sms-gateway/client-go v1.5.4 h1:sFMqu+Lc+YtkasesmerckVV8KKL8Qcx/VPEUWvcfbyA=
3030
github.com/android-sms-gateway/client-go v1.5.4/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
31+
github.com/android-sms-gateway/client-go v1.5.6-0.20250326025946-2625b20dcccd h1:VuSsDc7HeRllPmVrFCmMi0ksFDWEDoUEHFuua4ccJ0s=
32+
github.com/android-sms-gateway/client-go v1.5.6-0.20250326025946-2625b20dcccd/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
33+
github.com/android-sms-gateway/client-go v1.5.6 h1:sCiBT1Fn7QBaTlX7Z3eBGDcG+u+3sADmp+rxb0HWuaA=
34+
github.com/android-sms-gateway/client-go v1.5.6/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
3135
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
3236
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
3337
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=
3943
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
4044
github.com/capcom6/go-helpers v0.1.1 h1:kcpK1+VUwo94MZlZX+0Gab4gf78egHTPzW9sOQXLfFE=
4145
github.com/capcom6/go-helpers v0.1.1/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw=
46+
github.com/capcom6/go-helpers v0.1.2-0.20250326030929-5d1f34b4936b h1:grbupORuCS6EJV/IMdEijRwmW7M91bpgqqex/TRKg38=
47+
github.com/capcom6/go-helpers v0.1.2-0.20250326030929-5d1f34b4936b/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw=
48+
github.com/capcom6/go-helpers v0.2.0 h1:OUcUnVbjBiwaTzvyaxkxqRKtrOXv1ifYalQ1NXzFBNM=
49+
github.com/capcom6/go-helpers v0.2.0/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw=
4250
github.com/capcom6/go-infra-fx v0.2.1 h1:8rqr2ZV+YC2R07amHMdlE1XKLUhMe5yO+ffCJ/xXlNY=
4351
github.com/capcom6/go-infra-fx v0.2.1/go.mod h1:klScvB8QAKgJ19FfJOnUKK5tI0o9b79Aj2RmCJHfbN0=
4452
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=

internal/sms-gateway/handlers/3rdparty.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (h *thirdPartyHandler) Register(router fiber.Router) {
4747
h.healthHandler.Register(router)
4848

4949
router.Use(
50-
userauth.New(h.authSvc),
50+
userauth.NewBasic(h.authSvc),
5151
userauth.UserRequired(),
5252
)
5353

internal/sms-gateway/handlers/middlewares/userauth/userauth.go

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import (
1212

1313
const localsUser = "user"
1414

15-
// New returns a middleware that checks for a valid "Authorization" header
16-
// in the form of "Basic <base64-encoded credentials>" and authorizes the user.
17-
func New(authSvc *auth.Service) fiber.Handler {
15+
// NewBasic returns a middleware that will check if the request contains a valid
16+
// "Authorization" header in the form of "Basic <base64 encoded username:password>".
17+
// If the header is valid, the middleware will authorize the user and store the
18+
// user in the request's Locals under the key LocalsUser. If the header is invalid,
19+
// the middleware will call c.Next() and continue with the request.
20+
func NewBasic(authSvc *auth.Service) fiber.Handler {
1821
return func(c *fiber.Ctx) error {
19-
// Get authorization header
2022
auth := c.Get(fiber.HeaderAuthorization)
2123

22-
// Check if the header contains content besides "basic".
2324
if len(auth) <= 6 || !strings.EqualFold(auth[:6], "basic ") {
2425
return c.Next()
2526
}
@@ -55,6 +56,33 @@ func New(authSvc *auth.Service) fiber.Handler {
5556
}
5657
}
5758

59+
// NewCode returns a middleware that will check if the request contains a valid
60+
// "Authorization" header in the form of "Code <one-time user authorization code>".
61+
// If the header is valid, the middleware will authorize the user and store the
62+
// user in the request's Locals under the key LocalsUser. If the header is invalid,
63+
// the middleware will call c.Next() and continue with the request.
64+
func NewCode(authSvc *auth.Service) fiber.Handler {
65+
return func(c *fiber.Ctx) error {
66+
auth := c.Get(fiber.HeaderAuthorization)
67+
68+
if len(auth) <= 5 || !strings.EqualFold(auth[:5], "code ") {
69+
return c.Next()
70+
}
71+
72+
// Get the code
73+
code := auth[5:]
74+
75+
user, err := authSvc.AuthorizeUserByCode(code)
76+
if err != nil {
77+
return fiber.ErrUnauthorized
78+
}
79+
80+
c.Locals(localsUser, user)
81+
82+
return c.Next()
83+
}
84+
}
85+
5886
// HasUser checks if a user is present in the Locals of the given context.
5987
// It returns true if the Locals contain a user under the key LocalsUser,
6088
// otherwise returns false.

internal/sms-gateway/handlers/mobile.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func (h *mobileHandler) getDevice(device models.Device, c *fiber.Ctx) error {
6060
// @Summary Register device
6161
// @Description Registers new device for new or existing user. Returns user credentials only for new users
6262
// @Security ApiAuth
63+
// @Security UserCode
6364
// @Security ServerKey
6465
// @Tags Device
6566
// @Accept json
@@ -198,6 +199,29 @@ func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error {
198199
return c.SendStatus(fiber.StatusNoContent)
199200
}
200201

202+
// @Summary Get one-time code for device registration
203+
// @Description Returns one-time code for device registration
204+
// @Security ApiAuth
205+
// @Tags Device
206+
// @Accept json
207+
// @Produce json
208+
// @Success 200 {object} smsgateway.MobileUserCodeResponse "User code"
209+
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
210+
// @Router /mobile/v1/user/code [get]
211+
//
212+
// Get user code
213+
func (h *mobileHandler) getUserCode(user models.User, c *fiber.Ctx) error {
214+
code, err := h.authSvc.GenerateUserCode(user.ID)
215+
if err != nil {
216+
return err
217+
}
218+
219+
return c.JSON(smsgateway.MobileUserCodeResponse{
220+
Code: code.Code,
221+
ValidUntil: code.ValidUntil,
222+
})
223+
}
224+
201225
// @Summary Change password
202226
// @Description Changes the user's password
203227
// @Security MobileToken
@@ -231,7 +255,8 @@ func (h *mobileHandler) Register(router fiber.Router) {
231255
router = router.Group("/mobile/v1")
232256

233257
router.Post("/device",
234-
userauth.New(h.authSvc),
258+
userauth.NewBasic(h.authSvc),
259+
userauth.NewCode(h.authSvc),
235260
keyauth.New(keyauth.Config{
236261
Next: func(c *fiber.Ctx) bool {
237262
// Skip server key authorization in the following cases:
@@ -247,6 +272,12 @@ func (h *mobileHandler) Register(router fiber.Router) {
247272
h.postDevice,
248273
)
249274

275+
router.Get("/user/code",
276+
userauth.NewBasic(h.authSvc),
277+
userauth.UserRequired(),
278+
userauth.WithUser(h.getUserCode),
279+
)
280+
250281
router.Use(
251282
deviceauth.New(h.authSvc),
252283
)
@@ -260,6 +291,7 @@ func (h *mobileHandler) Register(router fiber.Router) {
260291
router.Get("/message", deviceauth.WithDevice(h.getMessage))
261292
router.Patch("/message", deviceauth.WithDevice(h.patchMessage))
262293

294+
// Should be under `userauth.NewBasic` protection instead of `deviceauth`
263295
router.Patch("/user/password", deviceauth.WithDevice(h.changePassword))
264296

265297
h.webhooksCtrl.Register(router.Group("/webhooks"))

internal/sms-gateway/modules/auth/module.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package auth
22

33
import (
4+
"context"
5+
46
"go.uber.org/fx"
57
"go.uber.org/zap"
68
)
@@ -12,4 +14,17 @@ var Module = fx.Module(
1214
}),
1315
fx.Provide(New),
1416
fx.Provide(newRepository, fx.Private),
17+
fx.Invoke(func(lc fx.Lifecycle, svc *Service) {
18+
ctx, cancel := context.WithCancel(context.Background())
19+
lc.Append(fx.Hook{
20+
OnStart: func(_ context.Context) error {
21+
go svc.Run(ctx)
22+
return nil
23+
},
24+
OnStop: func(_ context.Context) error {
25+
cancel()
26+
return nil
27+
},
28+
})
29+
}),
1530
)

internal/sms-gateway/modules/auth/repository.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ func newRepository(db *gorm.DB) *repository {
1515
}
1616
}
1717

18+
// GetByID returns a user by their ID.
19+
func (r *repository) GetByID(id string) (models.User, error) {
20+
user := models.User{}
21+
22+
return user, r.db.Where("id = ?", id).Take(&user).Error
23+
}
24+
1825
func (r *repository) GetByLogin(login string) (models.User, error) {
1926
user := models.User{}
2027

internal/sms-gateway/modules/auth/service.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package auth
22

33
import (
4+
"context"
5+
"crypto/rand"
46
"crypto/sha256"
57
"crypto/subtle"
68
"encoding/hex"
@@ -36,6 +38,7 @@ type Service struct {
3638
config Config
3739

3840
users *repository
41+
codesCache *cache.Cache[string]
3942
usersCache *cache.Cache[models.User]
4043

4144
devicesSvc *devices.Service
@@ -55,10 +58,39 @@ func New(params Params) *Service {
5558
logger: params.Logger.Named("Service"),
5659
idgen: idgen,
5760

61+
codesCache: cache.New[string](cache.Config{}),
5862
usersCache: cache.New[models.User](cache.Config{TTL: 1 * time.Hour}),
5963
}
6064
}
6165

66+
// GenerateUserCode generates a unique one-time user authorization code
67+
func (s *Service) GenerateUserCode(userID string) (AuthCode, error) {
68+
var code string
69+
var err error
70+
71+
b := make([]byte, 3)
72+
validUntil := time.Now().Add(codeTTL)
73+
for range 3 {
74+
if _, err = rand.Read(b); err != nil {
75+
continue
76+
}
77+
num := (int(b[0]) << 16) | (int(b[1]) << 8) | int(b[2])
78+
code = fmt.Sprintf("%06d", num%1000000)
79+
80+
if err = s.codesCache.SetOrFail(code, userID, cache.WithValidUntil(validUntil)); err != nil {
81+
continue
82+
}
83+
84+
break
85+
}
86+
87+
if err != nil {
88+
return AuthCode{}, fmt.Errorf("can't generate code: %w", err)
89+
}
90+
91+
return AuthCode{Code: code, ValidUntil: validUntil}, nil
92+
}
93+
6294
func (s *Service) RegisterUser(login, password string) (models.User, error) {
6395
user := models.User{
6496
ID: login,
@@ -143,6 +175,21 @@ func (s *Service) AuthorizeUser(username, password string) (models.User, error)
143175
return user, nil
144176
}
145177

178+
// AuthorizeUserByCode authorizes a user by one-time code.
179+
func (s *Service) AuthorizeUserByCode(code string) (models.User, error) {
180+
userID, err := s.codesCache.GetAndDelete(code)
181+
if err != nil {
182+
return models.User{}, err
183+
}
184+
185+
user, err := s.users.GetByID(userID)
186+
if err != nil {
187+
return models.User{}, err
188+
}
189+
190+
return user, nil
191+
}
192+
146193
func (s *Service) ChangePassword(userID string, currentPassword string, newPassword string) error {
147194
user, err := s.users.GetByLogin(userID)
148195
if err != nil {
@@ -171,3 +218,24 @@ func (s *Service) ChangePassword(userID string, currentPassword string, newPassw
171218

172219
return nil
173220
}
221+
222+
// Run starts a ticker that triggers the clean function every hour.
223+
// It runs indefinitely until the provided context is canceled.
224+
func (s *Service) Run(ctx context.Context) {
225+
ticker := time.NewTicker(1 * time.Hour)
226+
defer ticker.Stop()
227+
228+
for {
229+
select {
230+
case <-ctx.Done():
231+
return
232+
case <-ticker.C:
233+
s.clean(ctx)
234+
}
235+
}
236+
}
237+
238+
func (s *Service) clean(_ context.Context) {
239+
s.codesCache.Cleanup()
240+
s.usersCache.Cleanup()
241+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
package auth
22

3+
import "time"
4+
5+
const codeTTL = 5 * time.Minute
6+
37
type Mode string
48

59
const (
610
ModePublic Mode = "public"
711
ModePrivate Mode = "private"
812
)
13+
14+
// AuthCode is a one-time user authorization code
15+
type AuthCode struct {
16+
Code string
17+
ValidUntil time.Time
18+
}

0 commit comments

Comments
 (0)