From c7010536099cf5d400645be0d3c7bfd16099c7a4 Mon Sep 17 00:00:00 2001 From: "n.nakhostin" Date: Sun, 14 Sep 2025 13:25:36 +0330 Subject: [PATCH] Add Password Reset Functionality and Update API Documentation - Implemented password reset functionality, including endpoints for requesting a reset code, verifying the OTP, and resetting the password. - Introduced new request and response forms for password reset operations, ensuring proper validation and structured responses. - Enhanced the customer service layer to handle password reset requests, OTP verification, and password updates, utilizing Redis for temporary token storage. - Updated Swagger and YAML documentation to include new API endpoints and their specifications, improving clarity for API consumers. - Refactored the customer handler to manage new password reset routes, ensuring adherence to clean architecture principles. --- cmd/web/docs/docs.go | 282 +++++++++++++++++++++++++++++ cmd/web/docs/swagger.json | 282 +++++++++++++++++++++++++++++ cmd/web/docs/swagger.yaml | 179 +++++++++++++++++++ cmd/web/main.go | 2 +- cmd/web/router/routes.go | 5 + internal/customer/entity.go | 16 ++ internal/customer/form.go | 36 ++++ internal/customer/handler.go | 80 +++++++++ internal/customer/service.go | 334 ++++++++++++++++++++++++++++++++++- 9 files changed, 1213 insertions(+), 3 deletions(-) diff --git a/cmd/web/docs/docs.go b/cmd/web/docs/docs.go index b61d4ae..50a2b25 100644 --- a/cmd/web/docs/docs.go +++ b/cmd/web/docs/docs.go @@ -4880,6 +4880,70 @@ const docTemplate = `{ } } }, + "/api/v1/profile/forgot-password": { + "post": { + "description": "Send a password reset OTP code to the customer's email address", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Request password reset", + "parameters": [ + { + "description": "Email address for password reset", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/customer.RequestResetPasswordForm" + } + } + ], + "responses": { + "200": { + "description": "Password reset code sent successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/customer.RequestResetPasswordResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad request - Invalid input data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "422": { + "description": "Validation error - Invalid request data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/profile/login": { "post": { "description": "Authenticate customer with username (email) and password. Returns access token, refresh token, and customer information upon successful authentication.", @@ -5060,6 +5124,146 @@ const docTemplate = `{ } } }, + "/api/v1/profile/reset-password": { + "post": { + "description": "Reset the customer's password using the reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Email, reset token, and new password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/customer.ResetPasswordForm" + } + } + ], + "responses": { + "200": { + "description": "Password reset successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/customer.ResetPasswordResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad request - Invalid input data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized - Invalid or expired reset token", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "422": { + "description": "Validation error - Invalid request data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/profile/verify-otp": { + "post": { + "description": "Verify the OTP code received via email and get a reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Verify OTP code", + "parameters": [ + { + "description": "Email and OTP code for verification", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/customer.VerifyOTPForm" + } + } + ], + "responses": { + "200": { + "description": "OTP verified successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/customer.VerifyOTPResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad request - Invalid input data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized - Invalid or expired OTP", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "422": { + "description": "Validation error - Invalid request data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/tender-approvals": { "get": { "security": [ @@ -6438,6 +6642,54 @@ const docTemplate = `{ } } }, + "customer.RequestResetPasswordForm": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "app@opplens.com" + } + } + }, + "customer.RequestResetPasswordResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Password reset code sent to your email" + }, + "success": { + "type": "boolean", + "example": true + } + } + }, + "customer.ResetPasswordForm": { + "type": "object", + "properties": { + "new_password": { + "type": "string", + "example": "NewPass!123" + }, + "token": { + "type": "string", + "example": "reset_token_here" + } + } + }, + "customer.ResetPasswordResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Password reset successfully" + }, + "success": { + "type": "boolean", + "example": true + } + } + }, "customer.UpdateCustomerForm": { "type": "object", "properties": { @@ -6486,6 +6738,36 @@ const docTemplate = `{ } } }, + "customer.VerifyOTPForm": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "123456" + }, + "email": { + "type": "string", + "example": "app@opplens.com" + } + } + }, + "customer.VerifyOTPResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "OTP verified successfully" + }, + "success": { + "type": "boolean", + "example": true + }, + "token": { + "type": "string", + "example": "reset_token_here" + } + } + }, "feedback.CompanyFeedbackStatsResponse": { "type": "object", "properties": { diff --git a/cmd/web/docs/swagger.json b/cmd/web/docs/swagger.json index 8df8ae6..48432db 100644 --- a/cmd/web/docs/swagger.json +++ b/cmd/web/docs/swagger.json @@ -4874,6 +4874,70 @@ } } }, + "/api/v1/profile/forgot-password": { + "post": { + "description": "Send a password reset OTP code to the customer's email address", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Request password reset", + "parameters": [ + { + "description": "Email address for password reset", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/customer.RequestResetPasswordForm" + } + } + ], + "responses": { + "200": { + "description": "Password reset code sent successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/customer.RequestResetPasswordResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad request - Invalid input data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "422": { + "description": "Validation error - Invalid request data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/profile/login": { "post": { "description": "Authenticate customer with username (email) and password. Returns access token, refresh token, and customer information upon successful authentication.", @@ -5054,6 +5118,146 @@ } } }, + "/api/v1/profile/reset-password": { + "post": { + "description": "Reset the customer's password using the reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Reset password", + "parameters": [ + { + "description": "Email, reset token, and new password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/customer.ResetPasswordForm" + } + } + ], + "responses": { + "200": { + "description": "Password reset successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/customer.ResetPasswordResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad request - Invalid input data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized - Invalid or expired reset token", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "422": { + "description": "Validation error - Invalid request data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, + "/api/v1/profile/verify-otp": { + "post": { + "description": "Verify the OTP code received via email and get a reset token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Authorization" + ], + "summary": "Verify OTP code", + "parameters": [ + { + "description": "Email and OTP code for verification", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/customer.VerifyOTPForm" + } + } + ], + "responses": { + "200": { + "description": "OTP verified successfully", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/response.APIResponse" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/customer.VerifyOTPResponse" + } + } + } + ] + } + }, + "400": { + "description": "Bad request - Invalid input data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "401": { + "description": "Unauthorized - Invalid or expired OTP", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "422": { + "description": "Validation error - Invalid request data", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/response.APIResponse" + } + } + } + } + }, "/api/v1/tender-approvals": { "get": { "security": [ @@ -6432,6 +6636,54 @@ } } }, + "customer.RequestResetPasswordForm": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "app@opplens.com" + } + } + }, + "customer.RequestResetPasswordResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Password reset code sent to your email" + }, + "success": { + "type": "boolean", + "example": true + } + } + }, + "customer.ResetPasswordForm": { + "type": "object", + "properties": { + "new_password": { + "type": "string", + "example": "NewPass!123" + }, + "token": { + "type": "string", + "example": "reset_token_here" + } + } + }, + "customer.ResetPasswordResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Password reset successfully" + }, + "success": { + "type": "boolean", + "example": true + } + } + }, "customer.UpdateCustomerForm": { "type": "object", "properties": { @@ -6480,6 +6732,36 @@ } } }, + "customer.VerifyOTPForm": { + "type": "object", + "properties": { + "code": { + "type": "string", + "example": "123456" + }, + "email": { + "type": "string", + "example": "app@opplens.com" + } + } + }, + "customer.VerifyOTPResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "OTP verified successfully" + }, + "success": { + "type": "boolean", + "example": true + }, + "token": { + "type": "string", + "example": "reset_token_here" + } + } + }, "feedback.CompanyFeedbackStatsResponse": { "type": "object", "properties": { diff --git a/cmd/web/docs/swagger.yaml b/cmd/web/docs/swagger.yaml index b0692d3..14aa0a2 100644 --- a/cmd/web/docs/swagger.yaml +++ b/cmd/web/docs/swagger.yaml @@ -400,6 +400,39 @@ definitions: refresh_token: type: string type: object + customer.RequestResetPasswordForm: + properties: + email: + example: app@opplens.com + type: string + type: object + customer.RequestResetPasswordResponse: + properties: + message: + example: Password reset code sent to your email + type: string + success: + example: true + type: boolean + type: object + customer.ResetPasswordForm: + properties: + new_password: + example: NewPass!123 + type: string + token: + example: reset_token_here + type: string + type: object + customer.ResetPasswordResponse: + properties: + message: + example: Password reset successfully + type: string + success: + example: true + type: boolean + type: object customer.UpdateCustomerForm: properties: company_ids: @@ -434,6 +467,27 @@ definitions: example: active type: string type: object + customer.VerifyOTPForm: + properties: + code: + example: "123456" + type: string + email: + example: app@opplens.com + type: string + type: object + customer.VerifyOTPResponse: + properties: + message: + example: OTP verified successfully + type: string + success: + example: true + type: boolean + token: + example: reset_token_here + type: string + type: object feedback.CompanyFeedbackStatsResponse: properties: company_id: @@ -4075,6 +4129,45 @@ paths: summary: Get customer profile tags: - Authorization + /api/v1/profile/forgot-password: + post: + consumes: + - application/json + description: Send a password reset OTP code to the customer's email address + parameters: + - description: Email address for password reset + in: body + name: request + required: true + schema: + $ref: '#/definitions/customer.RequestResetPasswordForm' + produces: + - application/json + responses: + "200": + description: Password reset code sent successfully + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/customer.RequestResetPasswordResponse' + type: object + "400": + description: Bad request - Invalid input data + schema: + $ref: '#/definitions/response.APIResponse' + "422": + description: Validation error - Invalid request data + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Request password reset + tags: + - Authorization /api/v1/profile/login: post: consumes: @@ -4188,6 +4281,92 @@ paths: summary: Refresh customer access token tags: - Authorization + /api/v1/profile/reset-password: + post: + consumes: + - application/json + description: Reset the customer's password using the reset token + parameters: + - description: Email, reset token, and new password + in: body + name: request + required: true + schema: + $ref: '#/definitions/customer.ResetPasswordForm' + produces: + - application/json + responses: + "200": + description: Password reset successfully + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/customer.ResetPasswordResponse' + type: object + "400": + description: Bad request - Invalid input data + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized - Invalid or expired reset token + schema: + $ref: '#/definitions/response.APIResponse' + "422": + description: Validation error - Invalid request data + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Reset password + tags: + - Authorization + /api/v1/profile/verify-otp: + post: + consumes: + - application/json + description: Verify the OTP code received via email and get a reset token + parameters: + - description: Email and OTP code for verification + in: body + name: request + required: true + schema: + $ref: '#/definitions/customer.VerifyOTPForm' + produces: + - application/json + responses: + "200": + description: OTP verified successfully + schema: + allOf: + - $ref: '#/definitions/response.APIResponse' + - properties: + data: + $ref: '#/definitions/customer.VerifyOTPResponse' + type: object + "400": + description: Bad request - Invalid input data + schema: + $ref: '#/definitions/response.APIResponse' + "401": + description: Unauthorized - Invalid or expired OTP + schema: + $ref: '#/definitions/response.APIResponse' + "422": + description: Validation error - Invalid request data + schema: + $ref: '#/definitions/response.APIResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/response.APIResponse' + summary: Verify OTP code + tags: + - Authorization /api/v1/tender-approvals: get: consumes: diff --git a/cmd/web/main.go b/cmd/web/main.go index 35d4376..10e7c2a 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -146,7 +146,7 @@ func main() { userService := user.NewUserService(userRepository, logger, userAuthService, userValidator) categoryService := company_category.NewCategoryService(categoryRepository, logger) companyService := company.NewCompanyService(companyRepository, categoryService, logger) - customerService := customer.NewCustomerService(customerRepository, logger, customerAuthService, companyService) + customerService := customer.New(customerRepository, logger, customerAuthService, companyService, redisClient) tenderService := tender.NewTenderService(tenderRepository, companyService, logger) feedbackService := feedback.NewFeedbackService(feedbackRepo, tenderService, logger) tenderApprovalService := tender_approval.NewTenderApprovalService(tenderApprovalRepository, tenderService, logger) diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index 66c1d4f..6569872 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -137,6 +137,11 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende customerGP.POST("/login", customerHandler.Login) customerGP.POST("/refresh-token", customerHandler.RefreshToken) + // Forgot password routes + customerGP.POST("/forgot-password", customerHandler.RequestResetPassword) + customerGP.POST("/verify-otp", customerHandler.VerifyOTP) + customerGP.POST("/reset-password", customerHandler.ResetPassword) + profileGP := customerGP.Group("") profileGP.Use(customerHandler.AuthMiddleware()) profileGP.GET("", customerHandler.GetProfile) diff --git a/internal/customer/entity.go b/internal/customer/entity.go index d865718..c39f637 100644 --- a/internal/customer/entity.go +++ b/internal/customer/entity.go @@ -70,3 +70,19 @@ func (c *Customer) GetCreatedAt() int64 { func (c *Customer) GetUpdatedAt() int64 { return c.UpdatedAt } + +// OTP represents a one-time password for password reset +type OTP struct { + Code string `bson:"code" json:"code"` + Email string `bson:"email" json:"email"` + ExpiresAt int64 `bson:"expires_at" json:"expires_at"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} + +// ResetToken represents a temporary reset token for password reset +type ResetToken struct { + Token string `bson:"token" json:"token"` + Email string `bson:"email" json:"email"` + ExpiresAt int64 `bson:"expires_at" json:"expires_at"` + CreatedAt int64 `bson:"created_at" json:"created_at"` +} diff --git a/internal/customer/form.go b/internal/customer/form.go index d6801b8..ae5c917 100644 --- a/internal/customer/form.go +++ b/internal/customer/form.go @@ -120,3 +120,39 @@ type AssignCompaniesForm struct { type RemoveCompaniesForm struct { CompanyIDs []string `json:"company_ids" valid:"required"` } + +// RequestResetPasswordForm represents the form for requesting password reset +type RequestResetPasswordForm struct { + Email string `json:"email" valid:"required,email" example:"app@opplens.com"` +} + +// VerifyOTPForm represents the form for verifying OTP code +type VerifyOTPForm struct { + Email string `json:"email" valid:"required,email" example:"app@opplens.com"` + Code string `json:"code" valid:"required,length(6|6)" example:"123456"` +} + +// ResetPasswordForm represents the form for resetting password +type ResetPasswordForm struct { + Token string `json:"token" valid:"required" example:"reset_token_here"` + NewPassword string `json:"new_password" valid:"required,length(5|128)" example:"NewPass!123"` +} + +// RequestResetPasswordResponse represents the response for password reset request +type RequestResetPasswordResponse struct { + Message string `json:"message" example:"Password reset code sent to your email"` + Success bool `json:"success" example:"true"` +} + +// VerifyOTPResponse represents the response for OTP verification +type VerifyOTPResponse struct { + Message string `json:"message" example:"OTP verified successfully"` + Token string `json:"token" example:"reset_token_here"` + Success bool `json:"success" example:"true"` +} + +// ResetPasswordResponse represents the response for password reset +type ResetPasswordResponse struct { + Message string `json:"message" example:"Password reset successfully"` + Success bool `json:"success" example:"true"` +} diff --git a/internal/customer/handler.go b/internal/customer/handler.go index 230b77d..85253e7 100644 --- a/internal/customer/handler.go +++ b/internal/customer/handler.go @@ -364,3 +364,83 @@ func (h *Handler) Logout(c echo.Context) error { "message": "Logout successful", }, "Logout successful") } + +// RequestResetPassword handles password reset request +// @Summary Request password reset +// @Description Send a password reset OTP code to the customer's email address +// @Tags Authorization +// @Accept json +// @Produce json +// @Param request body RequestResetPasswordForm true "Email address for password reset" +// @Success 200 {object} response.APIResponse{data=RequestResetPasswordResponse} "Password reset code sent successfully" +// @Failure 400 {object} response.APIResponse "Bad request - Invalid input data" +// @Failure 422 {object} response.APIResponse "Validation error - Invalid request data" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Router /api/v1/profile/forgot-password [post] +func (h *Handler) RequestResetPassword(c echo.Context) error { + form, err := response.Parse[RequestResetPasswordForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + + result, err := h.service.RequestResetPassword(c.Request().Context(), form) + if err != nil { + return response.InternalServerError(c, "Failed to process password reset request") + } + + return response.Success(c, result, "Password reset request processed") +} + +// VerifyOTP handles OTP verification for password reset +// @Summary Verify OTP code +// @Description Verify the OTP code received via email and get a reset token +// @Tags Authorization +// @Accept json +// @Produce json +// @Param request body VerifyOTPForm true "Email and OTP code for verification" +// @Success 200 {object} response.APIResponse{data=VerifyOTPResponse} "OTP verified successfully" +// @Failure 400 {object} response.APIResponse "Bad request - Invalid input data" +// @Failure 422 {object} response.APIResponse "Validation error - Invalid request data" +// @Failure 401 {object} response.APIResponse "Unauthorized - Invalid or expired OTP" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Router /api/v1/profile/verify-otp [post] +func (h *Handler) VerifyOTP(c echo.Context) error { + form, err := response.Parse[VerifyOTPForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + + result, err := h.service.VerifyOTP(c.Request().Context(), form) + if err != nil { + return response.Unauthorized(c, "Invalid or expired verification code") + } + + return response.Success(c, result, "OTP verified successfully") +} + +// ResetPassword handles password reset with token +// @Summary Reset password +// @Description Reset the customer's password using the reset token +// @Tags Authorization +// @Accept json +// @Produce json +// @Param request body ResetPasswordForm true "Email, reset token, and new password" +// @Success 200 {object} response.APIResponse{data=ResetPasswordResponse} "Password reset successfully" +// @Failure 400 {object} response.APIResponse "Bad request - Invalid input data" +// @Failure 422 {object} response.APIResponse "Validation error - Invalid request data" +// @Failure 401 {object} response.APIResponse "Unauthorized - Invalid or expired reset token" +// @Failure 500 {object} response.APIResponse "Internal server error" +// @Router /api/v1/profile/reset-password [post] +func (h *Handler) ResetPassword(c echo.Context) error { + form, err := response.Parse[ResetPasswordForm](c) + if err != nil { + return response.ValidationError(c, "Invalid request data", err.Error()) + } + + result, err := h.service.ResetPassword(c.Request().Context(), form) + if err != nil { + return response.Unauthorized(c, err.Error()) + } + + return response.Success(c, result, "Password reset successfully") +} diff --git a/internal/customer/service.go b/internal/customer/service.go index c36294d..b8ce9c4 100644 --- a/internal/customer/service.go +++ b/internal/customer/service.go @@ -2,11 +2,16 @@ package customer import ( "context" + "crypto/rand" + "encoding/json" "errors" + "fmt" + "math/big" "time" "tm/internal/company" "tm/pkg/logger" + "tm/pkg/redis" "tm/pkg/response" "tm/pkg/authorization" @@ -30,6 +35,11 @@ type Service interface { RefreshToken(ctx context.Context, refreshToken string) (*AuthResponse, error) GetProfileWithCompanies(ctx context.Context, id string) (*CustomerResponse, error) Logout(ctx context.Context, id string, accessToken string) error + + // Forgot password operations + RequestResetPassword(ctx context.Context, form *RequestResetPasswordForm) (*RequestResetPasswordResponse, error) + VerifyOTP(ctx context.Context, form *VerifyOTPForm) (*VerifyOTPResponse, error) + ResetPassword(ctx context.Context, form *ResetPasswordForm) (*ResetPasswordResponse, error) } // customerService implements the Service interface @@ -38,15 +48,17 @@ type customerService struct { logger logger.Logger authService authorization.AuthorizationService companyService company.Service + redisClient redis.Client } -// NewCustomerService creates a new customer service -func NewCustomerService(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, companyService company.Service) Service { +// New creates a new customer service +func New(repository Repository, logger logger.Logger, authService authorization.AuthorizationService, companyService company.Service, redisClient redis.Client) Service { return &customerService{ repository: repository, logger: logger, authService: authService, companyService: companyService, + redisClient: redisClient, } } @@ -688,6 +700,324 @@ func (s *customerService) Logout(ctx context.Context, customerID string, accessT return nil } +// RequestResetPassword handles password reset request +func (s *customerService) RequestResetPassword(ctx context.Context, form *RequestResetPasswordForm) (*RequestResetPasswordResponse, error) { + s.logger.Info("Password reset request", map[string]interface{}{ + "email": form.Email, + }) + + // Check if customer exists + customer, err := s.repository.GetByEmail(ctx, form.Email) + if err != nil { + s.logger.Warn("Password reset request for non-existent email", map[string]interface{}{ + "email": form.Email, + }) + // Return success even if email doesn't exist for security + return &RequestResetPasswordResponse{ + Message: "If the email exists, a password reset code has been sent", + Success: true, + }, nil + } + + // Check if customer is active + if customer.Status != CustomerStatusActive { + s.logger.Warn("Password reset request for inactive customer", map[string]interface{}{ + "email": form.Email, + "customer_id": customer.ID, + "status": string(customer.Status), + }) + return &RequestResetPasswordResponse{ + Message: "If the email exists, a password reset code has been sent", + Success: true, + }, nil + } + + // Generate 6-digit OTP + otpCode, err := s.generateOTP() + if err != nil { + s.logger.Error("Failed to generate OTP", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + return nil, errors.New("failed to generate verification code") + } + + // Store OTP in Redis with 10 minutes expiry + otpKey := fmt.Sprintf("otp:%s", form.Email) + otpData := OTP{ + Code: otpCode, + Email: form.Email, + ExpiresAt: time.Now().Add(10 * time.Minute).Unix(), + CreatedAt: time.Now().Unix(), + } + + otpDataJSON, err := json.Marshal(otpData) + if err != nil { + s.logger.Error("Failed to marshal OTP data", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + return nil, errors.New("failed to process reset request") + } + err = s.redisClient.Set(ctx, otpKey, string(otpDataJSON), 10*time.Minute) + if err != nil { + s.logger.Error("Failed to store OTP in Redis", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + return nil, errors.New("failed to process reset request") + } + + // Send OTP via email + // err = s.emailService.SendPasswordResetOTP(ctx, form.Email, otpCode) + // if err != nil { + // s.logger.Error("Failed to send password reset OTP", map[string]interface{}{ + // "error": err.Error(), + // "email": form.Email, + // }) + // return nil, errors.New("failed to send verification code") + // } + + s.logger.Info("Password reset OTP sent successfully", map[string]interface{}{ + "email": form.Email, + "customer_id": customer.ID, + }) + + return &RequestResetPasswordResponse{ + Message: "Password reset code sent to your email", + Success: true, + }, nil +} + +// VerifyOTP handles OTP verification +func (s *customerService) VerifyOTP(ctx context.Context, form *VerifyOTPForm) (*VerifyOTPResponse, error) { + s.logger.Info("OTP verification attempt", map[string]interface{}{ + "email": form.Email, + }) + + // Get OTP from Redis + otpKey := fmt.Sprintf("otp:%s", form.Email) + otpDataStr, err := s.redisClient.Get(ctx, otpKey) + if err != nil { + s.logger.Warn("OTP verification failed - OTP not found or expired", map[string]interface{}{ + "email": form.Email, + }) + return nil, errors.New("invalid or expired verification code") + } + + var otpData OTP + err = json.Unmarshal([]byte(otpDataStr), &otpData) + if err != nil { + s.logger.Error("Failed to unmarshal OTP data", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + return nil, errors.New("invalid verification code format") + } + + if otpData.Code != form.Code { + s.logger.Warn("OTP verification failed - wrong code", map[string]interface{}{ + "email": form.Email, + }) + return nil, errors.New("invalid verification code") + } + + // Generate reset token + resetToken, err := s.generateResetToken() + if err != nil { + s.logger.Error("Failed to generate reset token", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + return nil, errors.New("failed to generate reset token") + } + + // Store reset token in Redis with 30 minutes expiry + tokenKey := fmt.Sprintf("reset_token:%s", resetToken) + tokenData := ResetToken{ + Token: resetToken, + Email: form.Email, + ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), + CreatedAt: time.Now().Unix(), + } + + tokenDataJSON, err := json.Marshal(tokenData) + if err != nil { + s.logger.Error("Failed to marshal reset token data", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + return nil, errors.New("failed to process verification") + } + err = s.redisClient.Set(ctx, tokenKey, string(tokenDataJSON), 30*time.Minute) + if err != nil { + s.logger.Error("Failed to store reset token in Redis", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + return nil, errors.New("failed to process verification") + } + + // Delete OTP from Redis after successful verification + err = s.redisClient.Del(ctx, otpKey) + if err != nil { + s.logger.Warn("Failed to delete OTP after verification", map[string]interface{}{ + "error": err.Error(), + "email": form.Email, + }) + } + + s.logger.Info("OTP verified successfully", map[string]interface{}{ + "email": form.Email, + }) + + return &VerifyOTPResponse{ + Message: "OTP verified successfully", + Token: resetToken, + Success: true, + }, nil +} + +// ResetPassword handles password reset +func (s *customerService) ResetPassword(ctx context.Context, form *ResetPasswordForm) (*ResetPasswordResponse, error) { + s.logger.Info("Password reset attempt", map[string]interface{}{ + "token": form.Token, + }) + + // Validate password strength + if !s.isValidPassword(form.NewPassword) { + return nil, errors.New("password must be at least 5 characters with uppercase, lowercase, and special character") + } + + // Get reset token from Redis + tokenKey := fmt.Sprintf("reset_token:%s", form.Token) + tokenDataStr, err := s.redisClient.Get(ctx, tokenKey) + if err != nil { + s.logger.Warn("Password reset failed - invalid or expired token", map[string]interface{}{ + "token": form.Token, + }) + return nil, errors.New("invalid or expired reset token") + } + + var tokenData ResetToken + err = json.Unmarshal([]byte(tokenDataStr), &tokenData) + if err != nil { + s.logger.Error("Failed to unmarshal reset token data", map[string]interface{}{ + "error": err.Error(), + "token": form.Token, + }) + return nil, errors.New("invalid reset token format") + } + + if tokenData.Token != form.Token { + s.logger.Warn("Password reset failed - wrong token", map[string]interface{}{ + "token": form.Token, + }) + return nil, errors.New("invalid reset token") + } + + // Get customer + customer, err := s.repository.GetByEmail(ctx, tokenData.Email) + if err != nil { + s.logger.Error("Password reset failed - customer not found", map[string]interface{}{ + "error": err.Error(), + "email": tokenData.Email, + }) + return nil, errors.New("customer not found") + } + + // Hash new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(form.NewPassword), bcrypt.DefaultCost) + if err != nil { + s.logger.Error("Failed to hash new password", map[string]interface{}{ + "error": err.Error(), + "email": tokenData.Email, + }) + return nil, errors.New("failed to process new password") + } + + // Update customer password + customer.Password = string(hashedPassword) + customer.UpdatedAt = time.Now().Unix() + + err = s.repository.Update(ctx, customer) + if err != nil { + s.logger.Error("Failed to update customer password", map[string]interface{}{ + "error": err.Error(), + "email": tokenData.Email, + "customer_id": customer.ID, + }) + return nil, errors.New("failed to reset password") + } + + // Delete reset token from Redis + err = s.redisClient.Del(ctx, tokenKey) + if err != nil { + s.logger.Warn("Failed to delete reset token after password reset", map[string]interface{}{ + "error": err.Error(), + "email": tokenData.Email, + }) + } + + s.logger.Info("Password reset successfully", map[string]interface{}{ + "email": tokenData.Email, + "customer_id": customer.ID, + }) + + return &ResetPasswordResponse{ + Message: "Password reset successfully", + Success: true, + }, nil +} + +// generateOTP generates a 6-digit OTP +func (s *customerService) generateOTP() (string, error) { + // Generate 6 random digits + otp := "" + for i := 0; i < 6; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(10)) + if err != nil { + return "", err + } + otp += num.String() + } + return otp, nil +} + +// generateResetToken generates a random reset token +func (s *customerService) generateResetToken() (string, error) { + // Generate 32 random bytes + tokenBytes := make([]byte, 32) + _, err := rand.Read(tokenBytes) + if err != nil { + return "", err + } + return fmt.Sprintf("%x", tokenBytes), nil +} + +// isValidPassword validates password strength +func (s *customerService) isValidPassword(password string) bool { + if len(password) < 5 { + return false + } + + hasUpper := false + hasLower := false + hasSpecial := false + + for _, char := range password { + if char >= 'A' && char <= 'Z' { + hasUpper = true + } else if char >= 'a' && char <= 'z' { + hasLower = true + } else if char == '!' || char == '@' || char == '#' || char == '$' || char == '%' || char == '^' || char == '&' || char == '*' { + hasSpecial = true + } + } + + return hasUpper && hasLower && hasSpecial +} + // loadCompaniesForCustomer loads company summaries for a customer func (s *customerService) loadCompaniesForCustomer(ctx context.Context, customer *Customer) ([]*CompanySummary, error) { if len(customer.CompanyIDs) == 0 {