mirror of
https://github.com/makayabou/asg-server.git
synced 2026-05-02 17:43:36 +02:00
[mobile] add registration by one-time code
This commit is contained in:
parent
8127d9d824
commit
a8b23f4dc1
@ -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
|
||||
|
||||
4
go.mod
4
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
|
||||
|
||||
8
go.sum
8
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=
|
||||
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@ -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{}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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,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
|
||||
|
||||
{
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user