From 0a51727af85e0d238d59cf61883ace290d1852fa Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Thu, 3 Jul 2025 04:33:25 +0300 Subject: [PATCH] refactor: updated swagger; feat: helper function in errors for checking postgres error types; feat: sql query method for finding users by their email; feat: registration begin/complete with checking existing username/email; refactor: error handling in controller --- backend/docs/docs.go | 75 ++++++++++++++++++++++++-- backend/docs/swagger.json | 75 ++++++++++++++++++++++++-- backend/docs/swagger.yaml | 50 +++++++++++++++-- backend/go.mod | 1 + backend/go.sum | 2 + backend/internal/controllers/auth.go | 45 +++++++++++++--- backend/internal/database/query.sql.go | 19 +++++++ backend/internal/errors/auth.go | 1 + backend/internal/errors/postgres.go | 34 ++++++++++++ backend/internal/services/auth.go | 71 ++++++++++++++++++++---- docker-compose.yml | 1 + sqlc/query.sql | 5 ++ 12 files changed, 348 insertions(+), 31 deletions(-) create mode 100644 backend/internal/errors/postgres.go diff --git a/backend/docs/docs.go b/backend/docs/docs.go index c1ebf38..362d3f9 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -11,7 +11,7 @@ const docTemplate = `{ "title": "{{.Title}}", "contact": {}, "license": { - "name": "GPL 3.0" + "name": "GPL-3.0" }, "version": "{{.Version}}" }, @@ -139,7 +139,11 @@ const docTemplate = `{ } } ], - "responses": {} + "responses": { + "200": { + "description": "desc" + } + } } }, "/auth/registrationComplete": { @@ -154,7 +158,25 @@ const docTemplate = `{ "Auth" ], "summary": "Confirm with code, finish creating the account", - "responses": {} + "parameters": [ + { + "description": "desc", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": "desc", + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteResponse" + } + } + } } }, "/profile": { @@ -305,13 +327,16 @@ const docTemplate = `{ ], "properties": { "password": { - "type": "string" + "type": "string", + "maxLength": 100 }, "totp": { "type": "string" }, "username": { - "type": "string" + "type": "string", + "maxLength": 20, + "minLength": 3 } } }, @@ -346,6 +371,46 @@ const docTemplate = `{ "minLength": 3 } } + }, + "models.RegistrationCompleteRequest": { + "type": "object", + "required": [ + "name", + "username", + "verification_code" + ], + "properties": { + "avatar_url": { + "type": "string", + "maxLength": 255 + }, + "birthday": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 75 + }, + "username": { + "type": "string", + "maxLength": 20, + "minLength": 3 + }, + "verification_code": { + "type": "string" + } + } + }, + "models.RegistrationCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index 8868dde..7e283e8 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -8,7 +8,7 @@ "title": "Easywish client API", "contact": {}, "license": { - "name": "GPL 3.0" + "name": "GPL-3.0" }, "version": "1.0" }, @@ -135,7 +135,11 @@ } } ], - "responses": {} + "responses": { + "200": { + "description": "desc" + } + } } }, "/auth/registrationComplete": { @@ -150,7 +154,25 @@ "Auth" ], "summary": "Confirm with code, finish creating the account", - "responses": {} + "parameters": [ + { + "description": "desc", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteRequest" + } + } + ], + "responses": { + "200": { + "description": "desc", + "schema": { + "$ref": "#/definitions/models.RegistrationCompleteResponse" + } + } + } } }, "/profile": { @@ -301,13 +323,16 @@ ], "properties": { "password": { - "type": "string" + "type": "string", + "maxLength": 100 }, "totp": { "type": "string" }, "username": { - "type": "string" + "type": "string", + "maxLength": 20, + "minLength": 3 } } }, @@ -342,6 +367,46 @@ "minLength": 3 } } + }, + "models.RegistrationCompleteRequest": { + "type": "object", + "required": [ + "name", + "username", + "verification_code" + ], + "properties": { + "avatar_url": { + "type": "string", + "maxLength": 255 + }, + "birthday": { + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 75 + }, + "username": { + "type": "string", + "maxLength": 20, + "minLength": 3 + }, + "verification_code": { + "type": "string" + } + } + }, + "models.RegistrationCompleteResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index eebc2bf..1ca5665 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -8,10 +8,13 @@ definitions: models.LoginRequest: properties: password: + maxLength: 100 type: string totp: type: string username: + maxLength: 20 + minLength: 3 type: string required: - password @@ -39,11 +42,39 @@ definitions: - password - username type: object + models.RegistrationCompleteRequest: + properties: + avatar_url: + maxLength: 255 + type: string + birthday: + type: string + name: + maxLength: 75 + type: string + username: + maxLength: 20 + minLength: 3 + type: string + verification_code: + type: string + required: + - name + - username + - verification_code + type: object + models.RegistrationCompleteResponse: + properties: + access_token: + type: string + refresh_token: + type: string + type: object info: contact: {} description: Easy and feature-rich wishlist. license: - name: GPL 3.0 + name: GPL-3.0 title: Easywish client API version: "1.0" paths: @@ -124,7 +155,9 @@ paths: $ref: '#/definitions/models.RegistrationBeginRequest' produces: - application/json - responses: {} + responses: + "200": + description: desc summary: Register an account tags: - Auth @@ -132,9 +165,20 @@ paths: post: consumes: - application/json + parameters: + - description: desc + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.RegistrationCompleteRequest' produces: - application/json - responses: {} + responses: + "200": + description: desc + schema: + $ref: '#/definitions/models.RegistrationCompleteResponse' summary: Confirm with code, finish creating the account tags: - Auth diff --git a/backend/go.mod b/backend/go.mod index 9cde7ff..d39f64b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -33,6 +33,7 @@ require ( github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index ae394c4..2fc218c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -48,6 +48,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/backend/internal/controllers/auth.go b/backend/internal/controllers/auth.go index 8e79fe6..46661cc 100644 --- a/backend/internal/controllers/auth.go +++ b/backend/internal/controllers/auth.go @@ -18,11 +18,13 @@ package controllers import ( + errs "easywish/internal/errors" "easywish/internal/middleware" "easywish/internal/models" "easywish/internal/services" "easywish/internal/utils" "easywish/internal/utils/enums" + "errors" "net/http" "github.com/gin-gonic/gin" @@ -96,6 +98,8 @@ func (a *authControllerImpl) Refresh(c *gin.Context) { // @Accept json // @Produce json // @Param request body models.RegistrationBeginRequest true "desc" +// @Success 200 "Account is created and awaiting verification" +// @Success 409 "Username or email is already taken" // @Router /auth/registrationBegin [post] func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { @@ -108,11 +112,15 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { _, err := a.authService.RegistrationBegin(request.Body) if err != nil { - c.JSON(http.StatusBadRequest, err.Error()) + if errors.Is(err, errs.ErrUsernameTaken) || errors.Is(err, errs.ErrEmailTaken) { + c.Status(http.StatusConflict) + } else { + c.Status(http.StatusInternalServerError) + } return } - c.Status(http.StatusAccepted) + c.Status(http.StatusOK) return } @@ -121,16 +129,37 @@ 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" // @Router /auth/registrationComplete [post] func (a *authControllerImpl) RegistrationComplete(c *gin.Context) { - c.Status(http.StatusNotImplemented) + request, ok := utils.GetRequest[models.RegistrationCompleteRequest](c) + if !ok { + c.Status(http.StatusBadRequest) + return + } + + response, err := a.authService.RegistrationComplete(request.Body) + + if err != nil { + if errors.Is(err, errs.ErrForbidden) { + c.Status(http.StatusForbidden) + } else if errors.Is(err, errs.ErrUnauthorized) { + c.Status(http.StatusUnauthorized) + } else { + c.Status(http.StatusInternalServerError) + } + return + } + + c.JSON(http.StatusOK, response) } func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) { group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin) - group.POST("/registrationComplete", a.RegistrationComplete) - group.POST("/login", a.Login) - group.POST("/refresh", a.Refresh) - group.POST("/passwordResetBegin", a.PasswordResetBegin) - group.POST("/passwordResetComplete", a.PasswordResetComplete) + group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete) + group.POST("/login", middleware.RequestMiddleware[models.RegistrationBeginRequest](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("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete) } diff --git a/backend/internal/database/query.sql.go b/backend/internal/database/query.sql.go index 9b222a4..873bb84 100644 --- a/backend/internal/database/query.sql.go +++ b/backend/internal/database/query.sql.go @@ -531,6 +531,25 @@ func (q *Queries) GetUserBansByUsername(ctx context.Context, username string) ([ return items, nil } +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT users.id, users.username, users.verified, users.registration_date, users.deleted FROM users +JOIN login_informations linfo ON linfo.user_id = users.id +WHERE linfo.email = $1::text +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Verified, + &i.RegistrationDate, + &i.Deleted, + ) + return i, err +} + const getUserByLoginCredentials = `-- name: GetUserByLoginCredentials :one SELECT users.id, diff --git a/backend/internal/errors/auth.go b/backend/internal/errors/auth.go index 36f89fe..427f259 100644 --- a/backend/internal/errors/auth.go +++ b/backend/internal/errors/auth.go @@ -24,6 +24,7 @@ import ( var ( ErrUnauthorized = errors.New("User is not authorized") ErrUsernameTaken = errors.New("Provided username is already in use") + ErrEmailTaken = errors.New("Provided email is already in use") ErrUserNotFound = errors.New("User was not found") ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code") ErrInvalidToken = errors.New("Token is invalid or expired") diff --git a/backend/internal/errors/postgres.go b/backend/internal/errors/postgres.go new file mode 100644 index 0000000..e18a39d --- /dev/null +++ b/backend/internal/errors/postgres.go @@ -0,0 +1,34 @@ +// Copyright (c) 2025 Nikolai Papin +// +// This file is part of Easywish +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +// the GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package errors + +import ( + "errors" + + "github.com/jackc/pgx/v5/pgconn" +) + +func IsPgErr(err error, code string) bool { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == code { + return true + } + } + return false +} diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go index a89812a..7682a93 100644 --- a/backend/internal/services/auth.go +++ b/backend/internal/services/auth.go @@ -24,6 +24,7 @@ import ( "easywish/internal/utils" "easywish/internal/utils/enums" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "go.uber.org/zap" ) @@ -55,11 +56,40 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ var err error if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil { + + if errs.IsPgErr(err, pgerrcode.UniqueViolation) { + a.log.Warn( + "Attempted registration for a taken username", + zap.String("username", request.Username), + zap.Error(err)) + return false, errs.ErrUsernameTaken + } + a.log.Error("Failed to add user to database", zap.Error(err)) return false, errs.ErrServerError } - a.log.Info("Registraion of a new user", zap.String("username", user.Username), zap.Int64("id", user.ID)) + if _, err := db.TXQueries.GetUserByEmail(db.CTX, *request.Email); err == nil { + a.log.Warn( + "Attempted registration for a taken email", + zap.String("email", *request.Email)) + return false, errs.ErrEmailTaken + + } else if !errs.IsPgErr(err, pgerrcode.NoData) { + a.log.Error( + "Failed to check if email is not taken", + zap.String("email", *request.Email), + zap.Error(err)) + return false, errs.ErrServerError + + } else { + a.log.Debug("Verified that email is not taken", zap.String("email", *request.Email)) + } + + a.log.Info( + "Registraion of a new user", + zap.String("username", user.Username), + zap.Int64("id", user.ID)) if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{ UserID: user.ID, @@ -109,11 +139,19 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username) if err != nil { + if errs.IsPgErr(err, pgerrcode.NoData) { + a.log.Warn( + "Could not find user attempting to complete registration with given username", + zap.String("username", request.Username), + zap.Error(err)) + return nil, errs.ErrUserNotFound + } + a.log.Error( - "Failed to find user attempting to complete registration", - zap.String("username", request.Username), + "Failed to get user", + zap.String("username", request.Username), zap.Error(err)) - return nil, errs.ErrUserNotFound + return nil, errs.ErrServerError } confirmationCode, err = db.TXQueries.GetConfirmationCodeByCode(db.CTX, database.GetConfirmationCodeByCodeParams{ @@ -123,11 +161,20 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple }) if err != nil { - a.log.Warn( - "User supplied wrong confirmation code for completing registration", - zap.String("username", user.Username), + if errs.IsPgErr(err, pgerrcode.NoData) { + a.log.Warn( + "User supplied unexistent confirmation code for completing registration", + zap.String("username", user.Username), + zap.String("code", request.VerificationCode), + zap.Error(err)) + return nil, errs.ErrForbidden + } + + a.log.Error( + "Failed to acquire specified registration code", + zap.String("username", user.Username), zap.Error(err)) - return nil, errs.ErrForbidden + return nil, errs.ErrServerError } err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{ @@ -140,8 +187,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple "Failed to update the user's registration code used state", zap.String("username", user.Username), zap.Int64("confirmation_code_id", confirmationCode.ID), - zap.Error(err), - ) + zap.Error(err)) return nil, errs.ErrServerError } @@ -179,6 +225,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple return nil, errs.ErrServerError } + // TODO: session info session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{ UserID: user.ID, Name: utils.NewPointer("First device"), @@ -206,6 +253,10 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple helper.Commit() + a.log.Info( + "User verified registration", + zap.String("username", request.Username)) + response := models.RegistrationCompleteResponse{Tokens: models.Tokens{ AccessToken: accessToken, RefreshToken: refreshToken, diff --git a/docker-compose.yml b/docker-compose.yml index dd9eec4..0b78ac7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: POSTGRES_URL: ${POSTGRES_URL} REDIS_URL: ${REDIS_URL} MINIO_URL: ${MINIO_URL} + ENVIRONMENT: ${ENVIRONMENT} ports: - "8080:8080" networks: diff --git a/sqlc/query.sql b/sqlc/query.sql index 5a9df02..9ffe1f3 100644 --- a/sqlc/query.sql +++ b/sqlc/query.sql @@ -34,6 +34,11 @@ WHERE id = $1; SELECT * FROM users WHERE username = $1; +;-- name: GetUserByEmail :one +SELECT users.* FROM users +JOIN login_informations linfo ON linfo.user_id = users.id +WHERE linfo.email = @email::text; + ;-- name: GetUserByLoginCredentials :one SELECT users.id,