From 541847221bd89aad15d42f96b0d4138e6d5ea3f7 Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Fri, 11 Jul 2025 17:43:09 +0300 Subject: [PATCH] chore: tidy swagger comments; feat: password reset models; feat: verification code validator --- backend/docs/docs.go | 154 ++++++++++++++++++++++++-- backend/docs/swagger.json | 154 ++++++++++++++++++++++++-- backend/docs/swagger.yaml | 106 ++++++++++++++++-- backend/internal/controllers/auth.go | 29 +++-- backend/internal/models/auth.go | 17 ++- backend/internal/validation/custom.go | 20 ++++ 6 files changed, 443 insertions(+), 37 deletions(-) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index 6850940..d2b75e5 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -52,7 +52,7 @@ const docTemplate = `{ "summary": "Acquire tokens via login credentials (and 2FA code if needed)", "parameters": [ { - "description": "desc", + "description": " ", "name": "request", "in": "body", "required": true, @@ -63,10 +63,13 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "desc", + "description": " ", "schema": { "$ref": "#/definitions/models.LoginResponse" } + }, + "403": { + "description": "Invalid login credentials" } } } @@ -83,7 +86,25 @@ const docTemplate = `{ "Auth" ], "summary": "Request password reset email", - "responses": {} + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetBeginRequest" + } + } + ], + "responses": { + "200": { + "description": "Reset code sent to the email if it is attached to an account" + }, + "429": { + "description": "Too many recent requests for this email" + } + } } }, "/auth/passwordResetComplete": { @@ -97,8 +118,29 @@ const docTemplate = `{ "tags": [ "Auth" ], - "summary": "Complete password reset with email code and provide 2FA code or backup code if needed", - "responses": {} + "summary": "Complete password reset via email code", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteResponse" + } + }, + "403": { + "description": "Wrong verification code or username" + } + } } }, "/auth/refresh": { @@ -113,7 +155,28 @@ const docTemplate = `{ "Auth" ], "summary": "Receive new tokens via refresh token", - "responses": {} + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.RefreshResponse" + } + }, + "401": { + "description": "Invalid refresh token" + } + } } }, "/auth/registrationBegin": { @@ -130,7 +193,7 @@ const docTemplate = `{ "summary": "Register an account", "parameters": [ { - "description": "desc", + "description": " ", "name": "request", "in": "body", "required": true, @@ -145,6 +208,9 @@ const docTemplate = `{ }, "409": { "description": "Username or email is already taken" + }, + "429": { + "description": "Too many recent registration attempts for this email" } } } @@ -163,7 +229,7 @@ const docTemplate = `{ "summary": "Confirm with code, finish creating the account", "parameters": [ { - "description": "desc", + "description": " ", "name": "request", "in": "body", "required": true, @@ -174,10 +240,13 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "desc", + "description": " ", "schema": { "$ref": "#/definitions/models.RegistrationCompleteResponse" } + }, + "403": { + "description": "Invalid email or verification code" } } } @@ -354,9 +423,76 @@ const docTemplate = `{ } } }, + "models.PasswordResetBeginRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteRequest": { + "type": "object", + "required": [ + "email", + "password", + "verification_code" + ], + "properties": { + "email": { + "type": "string" + }, + "log_out_accounts": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "models.RefreshResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, "models.RegistrationBeginRequest": { "type": "object", "required": [ + "email", "password", "username" ], diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 607e8de..e757280 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -48,7 +48,7 @@ "summary": "Acquire tokens via login credentials (and 2FA code if needed)", "parameters": [ { - "description": "desc", + "description": " ", "name": "request", "in": "body", "required": true, @@ -59,10 +59,13 @@ ], "responses": { "200": { - "description": "desc", + "description": " ", "schema": { "$ref": "#/definitions/models.LoginResponse" } + }, + "403": { + "description": "Invalid login credentials" } } } @@ -79,7 +82,25 @@ "Auth" ], "summary": "Request password reset email", - "responses": {} + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetBeginRequest" + } + } + ], + "responses": { + "200": { + "description": "Reset code sent to the email if it is attached to an account" + }, + "429": { + "description": "Too many recent requests for this email" + } + } } }, "/auth/passwordResetComplete": { @@ -93,8 +114,29 @@ "tags": [ "Auth" ], - "summary": "Complete password reset with email code and provide 2FA code or backup code if needed", - "responses": {} + "summary": "Complete password reset via email code", + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.PasswordResetCompleteResponse" + } + }, + "403": { + "description": "Wrong verification code or username" + } + } } }, "/auth/refresh": { @@ -109,7 +151,28 @@ "Auth" ], "summary": "Receive new tokens via refresh token", - "responses": {} + "parameters": [ + { + "description": " ", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": " ", + "schema": { + "$ref": "#/definitions/models.RefreshResponse" + } + }, + "401": { + "description": "Invalid refresh token" + } + } } }, "/auth/registrationBegin": { @@ -126,7 +189,7 @@ "summary": "Register an account", "parameters": [ { - "description": "desc", + "description": " ", "name": "request", "in": "body", "required": true, @@ -141,6 +204,9 @@ }, "409": { "description": "Username or email is already taken" + }, + "429": { + "description": "Too many recent registration attempts for this email" } } } @@ -159,7 +225,7 @@ "summary": "Confirm with code, finish creating the account", "parameters": [ { - "description": "desc", + "description": " ", "name": "request", "in": "body", "required": true, @@ -170,10 +236,13 @@ ], "responses": { "200": { - "description": "desc", + "description": " ", "schema": { "$ref": "#/definitions/models.RegistrationCompleteResponse" } + }, + "403": { + "description": "Invalid email or verification code" } } } @@ -350,9 +419,76 @@ } } }, + "models.PasswordResetBeginRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteRequest": { + "type": "object", + "required": [ + "email", + "password", + "verification_code" + ], + "properties": { + "email": { + "type": "string" + }, + "log_out_accounts": { + "type": "boolean" + }, + "password": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + } + }, + "models.PasswordResetCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, + "models.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "models.RefreshResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } + }, "models.RegistrationBeginRequest": { "type": "object", "required": [ + "email", "password", "username" ], diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 808a772..ee8f9b7 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -27,6 +27,49 @@ definitions: refresh_token: type: string type: object + models.PasswordResetBeginRequest: + properties: + email: + type: string + required: + - email + type: object + models.PasswordResetCompleteRequest: + properties: + email: + type: string + log_out_accounts: + type: boolean + password: + type: string + verification_code: + type: string + required: + - email + - password + - verification_code + type: object + models.PasswordResetCompleteResponse: + properties: + access_token: + type: string + refresh_token: + type: string + type: object + models.RefreshRequest: + properties: + refresh_token: + type: string + required: + - refresh_token + type: object + models.RefreshResponse: + properties: + access_token: + type: string + refresh_token: + type: string + type: object models.RegistrationBeginRequest: properties: email: @@ -36,6 +79,7 @@ definitions: username: type: string required: + - email - password - username type: object @@ -86,7 +130,7 @@ paths: consumes: - application/json parameters: - - description: desc + - description: ' ' in: body name: request required: true @@ -96,9 +140,11 @@ paths: - application/json responses: "200": - description: desc + description: ' ' schema: $ref: '#/definitions/models.LoginResponse' + "403": + description: Invalid login credentials summary: Acquire tokens via login credentials (and 2FA code if needed) tags: - Auth @@ -106,9 +152,20 @@ paths: post: consumes: - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.PasswordResetBeginRequest' produces: - application/json - responses: {} + responses: + "200": + description: Reset code sent to the email if it is attached to an account + "429": + description: Too many recent requests for this email summary: Request password reset email tags: - Auth @@ -116,20 +173,45 @@ paths: post: consumes: - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.PasswordResetCompleteRequest' produces: - application/json - responses: {} - summary: Complete password reset with email code and provide 2FA code or backup - code if needed + responses: + "200": + description: ' ' + schema: + $ref: '#/definitions/models.PasswordResetCompleteResponse' + "403": + description: Wrong verification code or username + summary: Complete password reset via email code tags: - Auth /auth/refresh: post: consumes: - application/json + parameters: + - description: ' ' + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.RefreshRequest' produces: - application/json - responses: {} + responses: + "200": + description: ' ' + schema: + $ref: '#/definitions/models.RefreshResponse' + "401": + description: Invalid refresh token summary: Receive new tokens via refresh token tags: - Auth @@ -138,7 +220,7 @@ paths: consumes: - application/json parameters: - - description: desc + - description: ' ' in: body name: request required: true @@ -151,6 +233,8 @@ paths: description: Account is created and awaiting verification "409": description: Username or email is already taken + "429": + description: Too many recent registration attempts for this email summary: Register an account tags: - Auth @@ -159,7 +243,7 @@ paths: consumes: - application/json parameters: - - description: desc + - description: ' ' in: body name: request required: true @@ -169,9 +253,11 @@ paths: - application/json responses: "200": - description: desc + description: ' ' schema: $ref: '#/definitions/models.RegistrationCompleteResponse' + "403": + description: Invalid email or verification code summary: Confirm with code, finish creating the account tags: - Auth diff --git a/backend/internal/controllers/auth.go b/backend/internal/controllers/auth.go index 0b3e1b1..537201e 100644 --- a/backend/internal/controllers/auth.go +++ b/backend/internal/controllers/auth.go @@ -54,8 +54,9 @@ func NewAuthController(_log *zap.Logger, as services.AuthService) AuthController // @Tags Auth // @Accept json // @Produce json -// @Param request body models.LoginRequest true "desc" -// @Success 200 {object} models.LoginResponse "desc" +// @Param request body models.LoginRequest true " " +// @Success 200 {object} models.LoginResponse " " +// @Failure 403 "Invalid login credentials" // @Router /auth/login [post] func (a *authControllerImpl) Login(c *gin.Context) { request, ok := utils.GetRequest[models.LoginRequest](c) @@ -83,25 +84,35 @@ func (a *authControllerImpl) Login(c *gin.Context) { // @Tags Auth // @Accept json // @Produce json +// @Param request body models.PasswordResetBeginRequest true " " // @Router /auth/passwordResetBegin [post] +// @Success 200 "Reset code sent to the email if it is attached to an account" +// @Failure 429 "Too many recent requests for this email" func (a *authControllerImpl) PasswordResetBegin(c *gin.Context) { c.Status(http.StatusNotImplemented) } -// @Summary Complete password reset with email code and provide 2FA code or backup code if needed +// @Summary Complete password reset via email code // @Tags Auth // @Accept json // @Produce json +// @Param request body models.PasswordResetCompleteRequest true " " // @Router /auth/passwordResetComplete [post] +// @Success 200 {object} models.PasswordResetCompleteResponse " " +// @Success 403 "Wrong verification code or username" func (a *authControllerImpl) PasswordResetComplete(c *gin.Context) { c.Status(http.StatusNotImplemented) } + // @Summary Receive new tokens via refresh token // @Tags Auth // @Accept json // @Produce json +// @Param request body models.RefreshRequest true " " // @Router /auth/refresh [post] +// @Success 200 {object} models.RefreshResponse " " +// @Failure 401 "Invalid refresh token" func (a *authControllerImpl) Refresh(c *gin.Context) { c.Status(http.StatusNotImplemented) } @@ -110,9 +121,10 @@ func (a *authControllerImpl) Refresh(c *gin.Context) { // @Tags Auth // @Accept json // @Produce json -// @Param request body models.RegistrationBeginRequest true "desc" +// @Param request body models.RegistrationBeginRequest true " " // @Success 200 "Account is created and awaiting verification" -// @Success 409 "Username or email is already taken" +// @Failure 409 "Username or email is already taken" +// @Failure 429 "Too many recent registration attempts for this email" // @Router /auth/registrationBegin [post] func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { @@ -141,8 +153,9 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { // @Tags Auth // @Accept json // @Produce json -// @Param request body models.RegistrationCompleteRequest true "desc" -// @Success 200 {object} models.RegistrationCompleteResponse "desc" +// @Param request body models.RegistrationCompleteRequest true " " +// @Success 200 {object} models.RegistrationCompleteResponse " " +// @Failure 403 "Invalid email or verification code" // @Router /auth/registrationComplete [post] func (a *authControllerImpl) RegistrationComplete(c *gin.Context) { request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c) @@ -172,6 +185,6 @@ func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) { group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete) group.POST("/login", middleware.RequestMiddleware[models.LoginRequest](enums.GuestRole), a.Login) group.POST("/refresh", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.UserRole), a.Refresh) - group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetBegin) + group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.PasswordResetBeginRequest](enums.GuestRole), a.PasswordResetBegin) group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete) } diff --git a/backend/internal/models/auth.go b/backend/internal/models/auth.go index b94bec2..503840f 100644 --- a/backend/internal/models/auth.go +++ b/backend/internal/models/auth.go @@ -30,7 +30,7 @@ type RegistrationBeginRequest struct { type RegistrationCompleteRequest struct { Username string `json:"username" binding:"required" validate:"username"` - VerificationCode string `json:"verification_code" binding:"required"` + VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reg"` Name string `json:"name" binding:"required" validate:"name"` Birthday *string `json:"birthday"` } @@ -57,3 +57,18 @@ type RefreshRequest struct { type RefreshResponse struct { Tokens } + +type PasswordResetBeginRequest struct { + Email string `json:"email" binding:"required,email"` +} + +type PasswordResetCompleteRequest struct { + Email string `json:"email" binding:"required,email"` + VerificationCode string `json:"verification_code" binding:"required" validate:"verification_code=reset"` + NewPassword string `json:"password" binding:"required" validate:"password"` + LogOutAccounts bool `json:"log_out_accounts"` +} + +type PasswordResetCompleteResponse struct { + Tokens +} diff --git a/backend/internal/validation/custom.go b/backend/internal/validation/custom.go index 5addea2..98327ae 100644 --- a/backend/internal/validation/custom.go +++ b/backend/internal/validation/custom.go @@ -19,6 +19,7 @@ package validation import ( "easywish/config" + "fmt" "regexp" "github.com/go-playground/validator/v10" @@ -76,6 +77,25 @@ func GetCustomHandlers() []CustomValidatorHandler { return true }}, + + { + FieldName: "verification_code", + Function: func(fl validator.FieldLevel) bool { + codeType := fl.Param() + code := fl.Field().String() + + if codeType == "reg" { + return regexp.MustCompile(`[\d]{6,6}`).MatchString(code) + } + + if codeType == "reset" { + return regexp.MustCompile( + `^[{(]?([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})[})]?$`, + ).MatchString(code) + } + + panic(fmt.Sprintf("'%s' is not a valid verification code type", codeType)) + }}, }