[mobile] add registration by one-time code

This commit is contained in:
Aleksandr Soloshenko 2025-03-26 10:20:37 +07:00 committed by Aleksandr
parent 8127d9d824
commit a8b23f4dc1
13 changed files with 280 additions and 10 deletions

View File

@ -7,6 +7,11 @@ import (
// @securitydefinitions.basic ApiAuth // @securitydefinitions.basic ApiAuth
// @description User authentication // @description User authentication
// @securitydefinitions.apikey UserCode
// @in header
// @name Authorization
// @description User one-time code authentication
// @securitydefinitions.apikey MobileToken // @securitydefinitions.apikey MobileToken
// @in header // @in header
// @name Authorization // @name Authorization

4
go.mod
View File

@ -6,9 +6,9 @@ toolchain go1.23.2
require ( require (
firebase.google.com/go/v4 v4.12.1 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/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/capcom6/go-infra-fx v0.2.1
github.com/go-playground/assert/v2 v2.2.0 github.com/go-playground/assert/v2 v2.2.0
github.com/go-playground/validator/v10 v10.16.0 github.com/go-playground/validator/v10 v10.16.0

8
go.sum
View File

@ -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/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 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.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 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/ansrivas/fiberprometheus/v2 v2.6.1 h1:wac3pXaE6BYYTF04AC6K0ktk6vCD+MnDOJZ3SK66kXM= 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/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 h1:kcpK1+VUwo94MZlZX+0Gab4gf78egHTPzW9sOQXLfFE=
github.com/capcom6/go-helpers v0.1.1/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw= 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 h1:8rqr2ZV+YC2R07amHMdlE1XKLUhMe5yO+ffCJ/xXlNY=
github.com/capcom6/go-infra-fx v0.2.1/go.mod h1:klScvB8QAKgJ19FfJOnUKK5tI0o9b79Aj2RmCJHfbN0= github.com/capcom6/go-infra-fx v0.2.1/go.mod h1:klScvB8QAKgJ19FfJOnUKK5tI0o9b79Aj2RmCJHfbN0=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=

View File

@ -47,7 +47,7 @@ func (h *thirdPartyHandler) Register(router fiber.Router) {
h.healthHandler.Register(router) h.healthHandler.Register(router)
router.Use( router.Use(
userauth.New(h.authSvc), userauth.NewBasic(h.authSvc),
userauth.UserRequired(), userauth.UserRequired(),
) )

View File

@ -12,14 +12,15 @@ import (
const localsUser = "user" const localsUser = "user"
// New returns a middleware that checks for a valid "Authorization" header // NewBasic returns a middleware that will check if the request contains a valid
// in the form of "Basic <base64-encoded credentials>" and authorizes the user. // "Authorization" header in the form of "Basic <base64 encoded username:password>".
func New(authSvc *auth.Service) fiber.Handler { // 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 { return func(c *fiber.Ctx) error {
// Get authorization header
auth := c.Get(fiber.HeaderAuthorization) auth := c.Get(fiber.HeaderAuthorization)
// Check if the header contains content besides "basic".
if len(auth) <= 6 || !strings.EqualFold(auth[:6], "basic ") { if len(auth) <= 6 || !strings.EqualFold(auth[:6], "basic ") {
return c.Next() 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 <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. // 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, // It returns true if the Locals contain a user under the key LocalsUser,
// otherwise returns false. // otherwise returns false.

View File

@ -60,6 +60,7 @@ func (h *mobileHandler) getDevice(device models.Device, c *fiber.Ctx) error {
// @Summary Register device // @Summary Register device
// @Description Registers new device for new or existing user. Returns user credentials only for new users // @Description Registers new device for new or existing user. Returns user credentials only for new users
// @Security ApiAuth // @Security ApiAuth
// @Security UserCode
// @Security ServerKey // @Security ServerKey
// @Tags Device // @Tags Device
// @Accept json // @Accept json
@ -198,6 +199,29 @@ func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) 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 // @Summary Change password
// @Description Changes the user's password // @Description Changes the user's password
// @Security MobileToken // @Security MobileToken
@ -231,7 +255,8 @@ func (h *mobileHandler) Register(router fiber.Router) {
router = router.Group("/mobile/v1") router = router.Group("/mobile/v1")
router.Post("/device", router.Post("/device",
userauth.New(h.authSvc), userauth.NewBasic(h.authSvc),
userauth.NewCode(h.authSvc),
keyauth.New(keyauth.Config{ keyauth.New(keyauth.Config{
Next: func(c *fiber.Ctx) bool { Next: func(c *fiber.Ctx) bool {
// Skip server key authorization in the following cases: // Skip server key authorization in the following cases:
@ -247,6 +272,12 @@ func (h *mobileHandler) Register(router fiber.Router) {
h.postDevice, h.postDevice,
) )
router.Get("/user/code",
userauth.NewBasic(h.authSvc),
userauth.UserRequired(),
userauth.WithUser(h.getUserCode),
)
router.Use( router.Use(
deviceauth.New(h.authSvc), deviceauth.New(h.authSvc),
) )
@ -260,6 +291,7 @@ func (h *mobileHandler) Register(router fiber.Router) {
router.Get("/message", deviceauth.WithDevice(h.getMessage)) router.Get("/message", deviceauth.WithDevice(h.getMessage))
router.Patch("/message", deviceauth.WithDevice(h.patchMessage)) router.Patch("/message", deviceauth.WithDevice(h.patchMessage))
// Should be under `userauth.NewBasic` protection instead of `deviceauth`
router.Patch("/user/password", deviceauth.WithDevice(h.changePassword)) router.Patch("/user/password", deviceauth.WithDevice(h.changePassword))
h.webhooksCtrl.Register(router.Group("/webhooks")) h.webhooksCtrl.Register(router.Group("/webhooks"))

View File

@ -1,6 +1,8 @@
package auth package auth
import ( import (
"context"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -12,4 +14,17 @@ var Module = fx.Module(
}), }),
fx.Provide(New), fx.Provide(New),
fx.Provide(newRepository, fx.Private), 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
},
})
}),
) )

View File

@ -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) { func (r *repository) GetByLogin(login string) (models.User, error) {
user := models.User{} user := models.User{}

View File

@ -1,6 +1,8 @@
package auth package auth
import ( import (
"context"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
@ -36,6 +38,7 @@ type Service struct {
config Config config Config
users *repository users *repository
codesCache *cache.Cache[string]
usersCache *cache.Cache[models.User] usersCache *cache.Cache[models.User]
devicesSvc *devices.Service devicesSvc *devices.Service
@ -55,10 +58,39 @@ func New(params Params) *Service {
logger: params.Logger.Named("Service"), logger: params.Logger.Named("Service"),
idgen: idgen, idgen: idgen,
codesCache: cache.New[string](cache.Config{}),
usersCache: cache.New[models.User](cache.Config{TTL: 1 * time.Hour}), 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) { func (s *Service) RegisterUser(login, password string) (models.User, error) {
user := models.User{ user := models.User{
ID: login, ID: login,
@ -143,6 +175,21 @@ func (s *Service) AuthorizeUser(username, password string) (models.User, error)
return user, nil 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 { func (s *Service) ChangePassword(userID string, currentPassword string, newPassword string) error {
user, err := s.users.GetByLogin(userID) user, err := s.users.GetByLogin(userID)
if err != nil { if err != nil {
@ -171,3 +218,24 @@ func (s *Service) ChangePassword(userID string, currentPassword string, newPassw
return nil 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()
}

View File

@ -1,8 +1,18 @@
package auth package auth
import "time"
const codeTTL = 5 * time.Minute
type Mode string type Mode string
const ( const (
ModePublic Mode = "public" ModePublic Mode = "public"
ModePrivate Mode = "private" ModePrivate Mode = "private"
) )
// AuthCode is a one-time user authorization code
type AuthCode struct {
Code string
ValidUntil time.Time
}

View File

@ -7,10 +7,15 @@
GET {{baseUrl}}/device HTTP/1.1 GET {{baseUrl}}/device HTTP/1.1
Authorization: Bearer {{mobileToken}} Authorization: Bearer {{mobileToken}}
###
GET {{baseUrl}}/user/code HTTP/1.1
Authorization: Bearer {{mobileToken}}
### ###
POST {{baseUrl}}/device HTTP/1.1 POST {{baseUrl}}/device HTTP/1.1
# Authorization: Bearer 123456789 # Authorization: Bearer 123456789
Authorization: Basic {{credentials}} # Authorization: Basic {{credentials}}
# Authorization: Code 065379
Content-Type: application/json Content-Type: application/json
{ {

View File

@ -567,6 +567,9 @@
{ {
"ApiAuth": [] "ApiAuth": []
}, },
{
"UserCode": []
},
{ {
"ServerKey": [] "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": { "/mobile/v1/user/password": {
"patch": { "patch": {
"security": [ "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": { "smsgateway.ProcessingState": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1483,6 +1533,12 @@
"type": "apiKey", "type": "apiKey",
"name": "Authorization", "name": "Authorization",
"in": "header" "in": "header"
},
"UserCode": {
"description": "User one-time code authentication",
"type": "apiKey",
"name": "Authorization",
"in": "header"
} }
} }
} }

View File

@ -301,6 +301,15 @@ definitions:
maxLength: 256 maxLength: 256
type: string type: string
type: object type: object
smsgateway.MobileUserCodeResponse:
properties:
code:
example: "123456"
type: string
validUntil:
example: "2020-01-01T00:00:00Z"
type: string
type: object
smsgateway.ProcessingState: smsgateway.ProcessingState:
enum: enum:
- Pending - Pending
@ -848,6 +857,7 @@ paths:
$ref: '#/definitions/smsgateway.ErrorResponse' $ref: '#/definitions/smsgateway.ErrorResponse'
security: security:
- ApiAuth: [] - ApiAuth: []
- UserCode: []
- ServerKey: [] - ServerKey: []
summary: Register device summary: Register device
tags: tags:
@ -908,6 +918,27 @@ paths:
tags: tags:
- Device - Device
- Messages - 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: /mobile/v1/user/password:
patch: patch:
consumes: consumes:
@ -1017,4 +1048,9 @@ securityDefinitions:
in: header in: header
name: Authorization name: Authorization
type: apiKey type: apiKey
UserCode:
description: User one-time code authentication
in: header
name: Authorization
type: apiKey
swagger: "2.0" swagger: "2.0"