diff --git a/go.mod b/go.mod index babaa72..5140ef3 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22.0 require ( firebase.google.com/go/v4 v4.12.1 - github.com/android-sms-gateway/client-go v1.1.0 + github.com/android-sms-gateway/client-go v1.2.0 github.com/ansrivas/fiberprometheus/v2 v2.6.1 github.com/capcom6/go-helpers v0.0.0-20240521035631-865ee2879fa3 github.com/capcom6/go-infra-fx v0.2.0 diff --git a/go.sum b/go.sum index 3e4dc3f..8d9dfc9 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,10 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= -github.com/android-sms-gateway/client-go v1.1.0 h1:mAPsueRrY/qOdQAU5yO3uLQAb7Po+3jBxB1tiqyClvg= -github.com/android-sms-gateway/client-go v1.1.0/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4= +github.com/android-sms-gateway/client-go v1.1.1-0.20241130131931-31efddf9578d h1:8LYyHCkZP5y0Wsa+DhRUv5NCpS72IDwtvkF0+Qqy+SQ= +github.com/android-sms-gateway/client-go v1.1.1-0.20241130131931-31efddf9578d/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4= +github.com/android-sms-gateway/client-go v1.2.0 h1:P02e/Nm2XY6gpxVQVZiaxh1ZfInVkwfOLzz8Mp/1dy0= +github.com/android-sms-gateway/client-go v1.2.0/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,10 +41,6 @@ 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.0.0-20240521035631-865ee2879fa3 h1:mq9rmBMCCzqGnZtbQqFSd+Ua3fahqUOYaTf26YFhWJc= github.com/capcom6/go-helpers v0.0.0-20240521035631-865ee2879fa3/go.mod h1:WDqc7HZNqHxUTisArkYIBZtqUfJBVyPWeQI+FMwEzAw= -github.com/capcom6/go-infra-fx v0.0.2 h1:Vj2bHqCokDRa5qfHoPa4zVVKIo1QGzCPF+9EMQ9upQc= -github.com/capcom6/go-infra-fx v0.0.2/go.mod h1:Mc7KClD8Z5wMiUAF9rxifMc39E9mMrSrylpqHzVfPM4= -github.com/capcom6/go-infra-fx v0.1.0 h1:RZ0gxFtR2ehopDzSnXSCVJ8I2C4oBUaCz42sQQp75dM= -github.com/capcom6/go-infra-fx v0.1.0/go.mod h1:T/DnT1EDrF9F+44eZw/lZnmsz5Dry0w/CTk0FB1Nct0= github.com/capcom6/go-infra-fx v0.2.0 h1:FrWtdFiG58unIK7xN7kMJn3LfOFecp20W/ZVgvN3bsM= github.com/capcom6/go-infra-fx v0.2.0/go.mod h1:T/DnT1EDrF9F+44eZw/lZnmsz5Dry0w/CTk0FB1Nct0= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= diff --git a/internal/sms-gateway/handlers/mobile.go b/internal/sms-gateway/handlers/mobile.go index bfb5887..44ff380 100644 --- a/internal/sms-gateway/handlers/mobile.go +++ b/internal/sms-gateway/handlers/mobile.go @@ -178,6 +178,35 @@ func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } +// @Summary Change password +// @Description Changes the user's password +// @Security MobileToken +// @Tags Device +// @Accept json +// @Produce json +// @Param request body smsgateway.MobileChangePasswordRequest true "Password change request" +// @Success 204 {object} nil "Password changed successfully" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" +// @Router /mobile/v1/user/password [patch] +// +// Change password +func (h *mobileHandler) changePassword(device models.Device, c *fiber.Ctx) error { + req := smsgateway.MobileChangePasswordRequest{} + + if err := h.BodyParserValidator(c, &req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if err := h.authSvc.ChangePassword(device.UserID, req.CurrentPassword, req.NewPassword); err != nil { + h.Logger.Error("failed to change password", zap.Error(err)) + return fiber.NewError(fiber.StatusUnauthorized, "Invalid current password") + } + + return c.SendStatus(fiber.StatusNoContent) +} + func (h *mobileHandler) Register(router fiber.Router) { router = router.Group("/mobile/v1") @@ -226,6 +255,8 @@ func (h *mobileHandler) Register(router fiber.Router) { router.Get("/message", auth.WithDevice(h.getMessage)) router.Patch("/message", auth.WithDevice(h.patchMessage)) + router.Patch("/user/password", auth.WithDevice(h.changePassword)) + h.webhooksCtrl.Register(router.Group("/webhooks")) } diff --git a/internal/sms-gateway/modules/auth/repository.go b/internal/sms-gateway/modules/auth/repository.go index 579300f..7298c19 100644 --- a/internal/sms-gateway/modules/auth/repository.go +++ b/internal/sms-gateway/modules/auth/repository.go @@ -24,3 +24,7 @@ func (r *repository) GetByLogin(login string) (models.User, error) { func (r *repository) Insert(user *models.User) error { return r.db.Create(user).Error } + +func (r *repository) UpdatePassword(userID string, passwordHash string) error { + return r.db.Model(&models.User{}).Where("id = ?", userID).Update("password_hash", passwordHash).Error +} diff --git a/internal/sms-gateway/modules/auth/service.go b/internal/sms-gateway/modules/auth/service.go index 1fe8fd6..7be09fb 100644 --- a/internal/sms-gateway/modules/auth/service.go +++ b/internal/sms-gateway/modules/auth/service.go @@ -158,3 +158,32 @@ func (s *Service) AuthorizeUser(username, password string) (models.User, error) return user, nil } + +func (s *Service) ChangePassword(userID string, currentPassword string, newPassword string) error { + user, err := s.users.GetByLogin(userID) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if err := crypto.CompareBCryptHash(user.PasswordHash, currentPassword); err != nil { + return fmt.Errorf("current password is incorrect: %w", err) + } + + newHash, err := crypto.MakeBCryptHash(newPassword) + if err != nil { + return fmt.Errorf("failed to hash new password: %w", err) + } + + if err := s.users.UpdatePassword(userID, newHash); err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + // Invalidate cache + hash := sha256.Sum256([]byte(userID + currentPassword)) + cacheKey := hex.EncodeToString(hash[:]) + if err := s.usersCache.Delete(cacheKey); err != nil { + s.logger.Error("can't invalidate user cache", zap.Error(err)) + } + + return nil +} diff --git a/pkg/swagger/docs/mobile.http b/pkg/swagger/docs/mobile.http index 709a891..a06de80 100644 --- a/pkg/swagger/docs/mobile.http +++ b/pkg/swagger/docs/mobile.http @@ -44,4 +44,14 @@ Content-Type: application/json ### GET {{baseUrl}}/webhooks HTTP/1.1 -Authorization: Bearer {{mobileToken}} \ No newline at end of file +Authorization: Bearer {{mobileToken}} + +### +PATCH {{baseUrl}}/user/password HTTP/1.1 +Authorization: Bearer {{mobileToken}} +Content-Type: application/json + +{ + "currentPassword": "wsmgz1akhoo24o", + "newPassword": "wsmgz1akhoo24o" +} diff --git a/pkg/swagger/docs/swagger.json b/pkg/swagger/docs/swagger.json index 2f75acf..8689651 100644 --- a/pkg/swagger/docs/swagger.json +++ b/pkg/swagger/docs/swagger.json @@ -641,6 +641,60 @@ } } }, + "/mobile/v1/user/password": { + "patch": { + "security": [ + { + "MobileToken": [] + } + ], + "description": "Changes the user's password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Device" + ], + "summary": "Change password", + "parameters": [ + { + "description": "Password change request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/smsgateway.MobileChangePasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "Password changed successfully" + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + } + } + } + }, "/mobile/v1/webhooks": { "get": { "security": [ @@ -1021,6 +1075,26 @@ } } }, + "smsgateway.MobileChangePasswordRequest": { + "type": "object", + "required": [ + "currentPassword", + "newPassword" + ], + "properties": { + "currentPassword": { + "description": "Current password", + "type": "string", + "example": "cp2pydvxd2zwpx" + }, + "newPassword": { + "description": "New password, at least 14 characters", + "type": "string", + "minLength": 14, + "example": "cp2pydvxd2zwpx" + } + } + }, "smsgateway.MobileDeviceResponse": { "type": "object", "properties": { diff --git a/pkg/swagger/docs/swagger.yaml b/pkg/swagger/docs/swagger.yaml index a4597f6..51b29cd 100644 --- a/pkg/swagger/docs/swagger.yaml +++ b/pkg/swagger/docs/swagger.yaml @@ -211,6 +211,21 @@ definitions: - recipients - state type: object + smsgateway.MobileChangePasswordRequest: + properties: + currentPassword: + description: Current password + example: cp2pydvxd2zwpx + type: string + newPassword: + description: New password, at least 14 characters + example: cp2pydvxd2zwpx + minLength: 14 + type: string + required: + - currentPassword + - newPassword + type: object smsgateway.MobileDeviceResponse: properties: device: @@ -790,6 +805,40 @@ paths: tags: - Device - Messages + /mobile/v1/user/password: + patch: + consumes: + - application/json + description: Changes the user's password + parameters: + - description: Password change request + in: body + name: request + required: true + schema: + $ref: '#/definitions/smsgateway.MobileChangePasswordRequest' + produces: + - application/json + responses: + "204": + description: Password changed successfully + "400": + description: Invalid request + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + security: + - MobileToken: [] + summary: Change password + tags: + - Device /mobile/v1/webhooks: get: description: Returns list of registered webhooks for device diff --git a/pkg/types/cache/cache.go b/pkg/types/cache/cache.go index 2c65932..e48f550 100644 --- a/pkg/types/cache/cache.go +++ b/pkg/types/cache/cache.go @@ -61,6 +61,14 @@ func (c *Cache[T]) Get(key string) (T, error) { return item.value, nil } +func (c *Cache[T]) Delete(key string) error { + c.mux.Lock() + delete(c.items, key) + c.mux.Unlock() + + return nil +} + func (c *Cache[T]) Drain() map[string]T { t := time.Now() diff --git a/test/e2e/mobile_test.go b/test/e2e/mobile_test.go index b07823e..bc1e38a 100644 --- a/test/e2e/mobile_test.go +++ b/test/e2e/mobile_test.go @@ -1,16 +1,46 @@ package e2e import ( + "encoding/json" "testing" "time" "github.com/go-resty/resty/v2" ) -func makeClient(baseUrl string) *resty.Client { - return resty.New(). - SetBaseURL(baseUrl). - SetTimeout(300 * time.Millisecond) +var ( + publicClient = resty.New(). + SetBaseURL(PublicURL + "/mobile/v1"). + SetTimeout(300 * time.Millisecond) + privateClient = resty.New(). + SetBaseURL(PrivateURL + "/mobile/v1"). + SetTimeout(300 * time.Millisecond) +) + +type mobileRegisterResponse struct { + Token string `json:"token"` + Password string `json:"password"` +} + +func mobileDeviceRegister(t *testing.T, client *resty.Client) mobileRegisterResponse { + res, err := client.R(). + SetHeader("Content-Type", "application/json"). + SetBody(`{"name": "Public Device Name", "pushToken": "token"}`). + Post("device") + if err != nil { + t.Fatal(err) + } + + if !res.IsSuccess() { + t.Fatal(res.StatusCode(), res.String()) + } + + var resp mobileRegisterResponse + if err := json.Unmarshal(res.Body(), &resp); err != nil { + t.Fatal(err) + } + + return resp } func TestPublicDeviceRegister(t *testing.T) { @@ -40,15 +70,13 @@ func TestPublicDeviceRegister(t *testing.T) { }, } - client := makeClient(PublicURL + "/mobile/v1/device") - for _, c := range cases { t.Run(c.name, func(t *testing.T) { - res, err := client.R(). + res, err := publicClient.R(). SetHeader("Content-Type", "application/json"). SetBody(`{"name": "Public Device Name", "pushToken": "token"}`). SetHeaders(c.headers). - Post("") + Post("device") if err != nil { t.Fatal(err) } @@ -87,7 +115,7 @@ func TestPrivateDeviceRegister(t *testing.T) { }, } - client := makeClient(PrivateURL + "/mobile/v1/device") + client := privateClient for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -95,7 +123,76 @@ func TestPrivateDeviceRegister(t *testing.T) { SetHeader("Content-Type", "application/json"). SetBody(`{"name": "Private Device Name", "pushToken": "token"}`). SetHeaders(c.headers). - Post("") + Post("device") + if err != nil { + t.Fatal(err) + } + + if res.StatusCode() != c.expectedStatusCode { + t.Fatal(res.StatusCode(), res.String()) + } + }) + } +} + +func TestPublicDevicePasswordChange(t *testing.T) { + device := mobileDeviceRegister(t, publicClient) + + cases := []struct { + name string + headers map[string]string + body string + expectedStatusCode int + }{ + { + name: "with invalid token", + headers: map[string]string{ + "Authorization": "Bearer 123456789", + }, + body: `{"currentPassword": "123456789", "newPassword": "123456789"}`, + expectedStatusCode: 401, + }, + { + name: "with invalid password", + headers: map[string]string{ + "Authorization": "Bearer " + device.Token, + }, + body: `{"currentPassword": "123456789", "newPassword": "changemeonemoretime"}`, + expectedStatusCode: 401, + }, + { + name: "short password", + headers: map[string]string{ + "Authorization": "Bearer " + device.Token, + }, + body: `{"currentPassword": "` + device.Password + `", "newPassword": "changeme"}`, + expectedStatusCode: 400, + }, + { + name: "success", + headers: map[string]string{ + "Authorization": "Bearer " + device.Token, + }, + body: `{"currentPassword": "` + device.Password + `", "newPassword": "changemeonemoretime"}`, + expectedStatusCode: 204, + }, + { + name: "success with new password", + headers: map[string]string{ + "Authorization": "Bearer " + device.Token, + }, + body: `{"currentPassword": "changemeonemoretime", "newPassword": "` + device.Password + `"}`, + expectedStatusCode: 204, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res, err := publicClient.R(). + SetHeader("Content-Type", "application/json"). + SetBody(c.body). + SetHeaders(c.headers). + Patch("user/password") if err != nil { t.Fatal(err) }