[auth] allow to change password from device

This commit is contained in:
Aleksandr Soloshenko 2024-11-29 17:57:42 +07:00 committed by Aleksandr
parent a1f606734f
commit cf6ceaef98
10 changed files with 318 additions and 18 deletions

2
go.mod
View File

@ -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

10
go.sum
View File

@ -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=

View File

@ -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"))
}

View File

@ -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
}

View File

@ -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
}

View File

@ -44,4 +44,14 @@ Content-Type: application/json
###
GET {{baseUrl}}/webhooks HTTP/1.1
Authorization: Bearer {{mobileToken}}
Authorization: Bearer {{mobileToken}}
###
PATCH {{baseUrl}}/user/password HTTP/1.1
Authorization: Bearer {{mobileToken}}
Content-Type: application/json
{
"currentPassword": "wsmgz1akhoo24o",
"newPassword": "wsmgz1akhoo24o"
}

View File

@ -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": {

View File

@ -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

View File

@ -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()

View File

@ -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)
}