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
This commit is contained in:
2025-07-03 04:33:25 +03:00
parent d08db300fc
commit 0a51727af8
12 changed files with 348 additions and 31 deletions

View File

@@ -11,7 +11,7 @@ const docTemplate = `{
"title": "{{.Title}}", "title": "{{.Title}}",
"contact": {}, "contact": {},
"license": { "license": {
"name": "GPL 3.0" "name": "GPL-3.0"
}, },
"version": "{{.Version}}" "version": "{{.Version}}"
}, },
@@ -139,7 +139,11 @@ const docTemplate = `{
} }
} }
], ],
"responses": {} "responses": {
"200": {
"description": "desc"
}
}
} }
}, },
"/auth/registrationComplete": { "/auth/registrationComplete": {
@@ -154,7 +158,25 @@ const docTemplate = `{
"Auth" "Auth"
], ],
"summary": "Confirm with code, finish creating the account", "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": { "/profile": {
@@ -305,13 +327,16 @@ const docTemplate = `{
], ],
"properties": { "properties": {
"password": { "password": {
"type": "string" "type": "string",
"maxLength": 100
}, },
"totp": { "totp": {
"type": "string" "type": "string"
}, },
"username": { "username": {
"type": "string" "type": "string",
"maxLength": 20,
"minLength": 3
} }
} }
}, },
@@ -346,6 +371,46 @@ const docTemplate = `{
"minLength": 3 "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": { "securityDefinitions": {

View File

@@ -8,7 +8,7 @@
"title": "Easywish client API", "title": "Easywish client API",
"contact": {}, "contact": {},
"license": { "license": {
"name": "GPL 3.0" "name": "GPL-3.0"
}, },
"version": "1.0" "version": "1.0"
}, },
@@ -135,7 +135,11 @@
} }
} }
], ],
"responses": {} "responses": {
"200": {
"description": "desc"
}
}
} }
}, },
"/auth/registrationComplete": { "/auth/registrationComplete": {
@@ -150,7 +154,25 @@
"Auth" "Auth"
], ],
"summary": "Confirm with code, finish creating the account", "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": { "/profile": {
@@ -301,13 +323,16 @@
], ],
"properties": { "properties": {
"password": { "password": {
"type": "string" "type": "string",
"maxLength": 100
}, },
"totp": { "totp": {
"type": "string" "type": "string"
}, },
"username": { "username": {
"type": "string" "type": "string",
"maxLength": 20,
"minLength": 3
} }
} }
}, },
@@ -342,6 +367,46 @@
"minLength": 3 "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": { "securityDefinitions": {

View File

@@ -8,10 +8,13 @@ definitions:
models.LoginRequest: models.LoginRequest:
properties: properties:
password: password:
maxLength: 100
type: string type: string
totp: totp:
type: string type: string
username: username:
maxLength: 20
minLength: 3
type: string type: string
required: required:
- password - password
@@ -39,11 +42,39 @@ definitions:
- password - password
- username - username
type: object 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: info:
contact: {} contact: {}
description: Easy and feature-rich wishlist. description: Easy and feature-rich wishlist.
license: license:
name: GPL 3.0 name: GPL-3.0
title: Easywish client API title: Easywish client API
version: "1.0" version: "1.0"
paths: paths:
@@ -124,7 +155,9 @@ paths:
$ref: '#/definitions/models.RegistrationBeginRequest' $ref: '#/definitions/models.RegistrationBeginRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: desc
summary: Register an account summary: Register an account
tags: tags:
- Auth - Auth
@@ -132,9 +165,20 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
parameters:
- description: desc
in: body
name: request
required: true
schema:
$ref: '#/definitions/models.RegistrationCompleteRequest'
produces: produces:
- application/json - application/json
responses: {} responses:
"200":
description: desc
schema:
$ref: '#/definitions/models.RegistrationCompleteResponse'
summary: Confirm with code, finish creating the account summary: Confirm with code, finish creating the account
tags: tags:
- Auth - Auth

View File

@@ -33,6 +33,7 @@ require (
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect

View File

@@ -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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=

View File

@@ -18,11 +18,13 @@
package controllers package controllers
import ( import (
errs "easywish/internal/errors"
"easywish/internal/middleware" "easywish/internal/middleware"
"easywish/internal/models" "easywish/internal/models"
"easywish/internal/services" "easywish/internal/services"
"easywish/internal/utils" "easywish/internal/utils"
"easywish/internal/utils/enums" "easywish/internal/utils/enums"
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -96,6 +98,8 @@ func (a *authControllerImpl) Refresh(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body models.RegistrationBeginRequest true "desc" // @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] // @Router /auth/registrationBegin [post]
func (a *authControllerImpl) RegistrationBegin(c *gin.Context) { func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
@@ -108,11 +112,15 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
_, err := a.authService.RegistrationBegin(request.Body) _, err := a.authService.RegistrationBegin(request.Body)
if err != nil { 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 return
} }
c.Status(http.StatusAccepted) c.Status(http.StatusOK)
return return
} }
@@ -121,16 +129,37 @@ func (a *authControllerImpl) RegistrationBegin(c *gin.Context) {
// @Tags Auth // @Tags Auth
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param request body models.RegistrationCompleteRequest true "desc"
// @Success 200 {object} models.RegistrationCompleteResponse "desc"
// @Router /auth/registrationComplete [post] // @Router /auth/registrationComplete [post]
func (a *authControllerImpl) RegistrationComplete(c *gin.Context) { 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) { func (a *authControllerImpl) RegisterRoutes(group *gin.RouterGroup) {
group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin) group.POST("/registrationBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.RegistrationBegin)
group.POST("/registrationComplete", a.RegistrationComplete) group.POST("/registrationComplete", middleware.RequestMiddleware[models.RegistrationCompleteRequest](enums.GuestRole), a.RegistrationComplete)
group.POST("/login", a.Login) group.POST("/login", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.Login)
group.POST("/refresh", a.Refresh) group.POST("/refresh", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.UserRole), a.Refresh)
group.POST("/passwordResetBegin", a.PasswordResetBegin) group.POST("/passwordResetBegin", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetBegin)
group.POST("/passwordResetComplete", a.PasswordResetComplete) group.POST("/passwordResetComplete", middleware.RequestMiddleware[models.RegistrationBeginRequest](enums.GuestRole), a.PasswordResetComplete)
} }

View File

@@ -531,6 +531,25 @@ func (q *Queries) GetUserBansByUsername(ctx context.Context, username string) ([
return items, nil 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 const getUserByLoginCredentials = `-- name: GetUserByLoginCredentials :one
SELECT SELECT
users.id, users.id,

View File

@@ -24,6 +24,7 @@ import (
var ( var (
ErrUnauthorized = errors.New("User is not authorized") ErrUnauthorized = errors.New("User is not authorized")
ErrUsernameTaken = errors.New("Provided username is already in use") 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") ErrUserNotFound = errors.New("User was not found")
ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code") ErrInvalidCredentials = errors.New("Invalid username, password or TOTP code")
ErrInvalidToken = errors.New("Token is invalid or expired") ErrInvalidToken = errors.New("Token is invalid or expired")

View File

@@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@@ -24,6 +24,7 @@ import (
"easywish/internal/utils" "easywish/internal/utils"
"easywish/internal/utils/enums" "easywish/internal/utils/enums"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -55,11 +56,40 @@ func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequ
var err error var err error
if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil { 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)) a.log.Error("Failed to add user to database", zap.Error(err))
return false, errs.ErrServerError 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{ if _, err = db.TXQueries.CreateLoginInformation(db.CTX, database.CreateLoginInformationParams{
UserID: user.ID, UserID: user.ID,
@@ -109,13 +139,21 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username) user, err = db.TXQueries.GetUserByUsername(db.CTX, request.Username)
if err != nil { if err != nil {
a.log.Error( if errs.IsPgErr(err, pgerrcode.NoData) {
"Failed to find user attempting to complete registration", a.log.Warn(
"Could not find user attempting to complete registration with given username",
zap.String("username", request.Username), zap.String("username", request.Username),
zap.Error(err)) zap.Error(err))
return nil, errs.ErrUserNotFound return nil, errs.ErrUserNotFound
} }
a.log.Error(
"Failed to get user",
zap.String("username", request.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
confirmationCode, err = db.TXQueries.GetConfirmationCodeByCode(db.CTX, database.GetConfirmationCodeByCodeParams{ confirmationCode, err = db.TXQueries.GetConfirmationCodeByCode(db.CTX, database.GetConfirmationCodeByCodeParams{
UserID: user.ID, UserID: user.ID,
CodeType: int32(enums.RegistrationCodeType), CodeType: int32(enums.RegistrationCodeType),
@@ -123,13 +161,22 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
}) })
if err != nil { if err != nil {
if errs.IsPgErr(err, pgerrcode.NoData) {
a.log.Warn( a.log.Warn(
"User supplied wrong confirmation code for completing registration", "User supplied unexistent confirmation code for completing registration",
zap.String("username", user.Username), zap.String("username", user.Username),
zap.String("code", request.VerificationCode),
zap.Error(err)) zap.Error(err))
return nil, errs.ErrForbidden return nil, errs.ErrForbidden
} }
a.log.Error(
"Failed to acquire specified registration code",
zap.String("username", user.Username),
zap.Error(err))
return nil, errs.ErrServerError
}
err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{ err = db.TXQueries.UpdateConfirmationCode(db.CTX, database.UpdateConfirmationCodeParams{
ID: confirmationCode.ID, ID: confirmationCode.ID,
Used: utils.NewPointer(true), Used: utils.NewPointer(true),
@@ -140,8 +187,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
"Failed to update the user's registration code used state", "Failed to update the user's registration code used state",
zap.String("username", user.Username), zap.String("username", user.Username),
zap.Int64("confirmation_code_id", confirmationCode.ID), zap.Int64("confirmation_code_id", confirmationCode.ID),
zap.Error(err), zap.Error(err))
)
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
@@ -179,6 +225,7 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
return nil, errs.ErrServerError return nil, errs.ErrServerError
} }
// TODO: session info
session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{ session, err = db.TXQueries.CreateSession(db.CTX, database.CreateSessionParams{
UserID: user.ID, UserID: user.ID,
Name: utils.NewPointer("First device"), Name: utils.NewPointer("First device"),
@@ -206,6 +253,10 @@ func (a *authServiceImpl) RegistrationComplete(request models.RegistrationComple
helper.Commit() helper.Commit()
a.log.Info(
"User verified registration",
zap.String("username", request.Username))
response := models.RegistrationCompleteResponse{Tokens: models.Tokens{ response := models.RegistrationCompleteResponse{Tokens: models.Tokens{
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,

View File

@@ -19,6 +19,7 @@ services:
POSTGRES_URL: ${POSTGRES_URL} POSTGRES_URL: ${POSTGRES_URL}
REDIS_URL: ${REDIS_URL} REDIS_URL: ${REDIS_URL}
MINIO_URL: ${MINIO_URL} MINIO_URL: ${MINIO_URL}
ENVIRONMENT: ${ENVIRONMENT}
ports: ports:
- "8080:8080" - "8080:8080"
networks: networks:

View File

@@ -34,6 +34,11 @@ WHERE id = $1;
SELECT * FROM users SELECT * FROM users
WHERE username = $1; 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 ;-- name: GetUserByLoginCredentials :one
SELECT SELECT
users.id, users.id,