From a2dd8993a658493723078f24042057ffa8efb72a Mon Sep 17 00:00:00 2001 From: Nikolai Papin Date: Sun, 13 Jul 2025 01:57:19 +0300 Subject: [PATCH] feat: auth service logic for purging expired unverified accounts upon registration, new sql queries for this purpose --- backend/internal/database/query.sql.go | 134 +++++++++++++++++++++++++ backend/internal/services/auth.go | 54 +++++++--- sqlc/query.sql | 72 +++++++++++++ 3 files changed, 247 insertions(+), 13 deletions(-) diff --git a/backend/internal/database/query.sql.go b/backend/internal/database/query.sql.go index 4768f10..fc7ef43 100644 --- a/backend/internal/database/query.sql.go +++ b/backend/internal/database/query.sql.go @@ -11,6 +11,48 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const checkUserRegistrationAvailability = `-- name: CheckUserRegistrationAvailability :one +SELECT + COUNT(CASE WHEN users.username = $1::text THEN 1 END) > 0 AS username_busy, + COUNT(CASE WHEN linfo.email = $2::text THEN 1 END) > 0 AS email_busy +FROM users +JOIN login_informations AS linfo ON linfo.user_id = users.id +WHERE + ( + users.username = $1::text OR + linfo.email = $2::text + ) + AND + ( + users.verified IS TRUE OR + NOT EXISTS ( + SELECT 1 + FROM confirmation_codes AS codes + WHERE codes.user_id = users.id + AND codes.code_type = 0 + AND codes.deleted IS FALSE + AND codes.expires_at > CURRENT_TIMESTAMP + ) + ) +` + +type CheckUserRegistrationAvailabilityParams struct { + Username string + Email string +} + +type CheckUserRegistrationAvailabilityRow struct { + UsernameBusy bool + EmailBusy bool +} + +func (q *Queries) CheckUserRegistrationAvailability(ctx context.Context, arg CheckUserRegistrationAvailabilityParams) (CheckUserRegistrationAvailabilityRow, error) { + row := q.db.QueryRow(ctx, checkUserRegistrationAvailability, arg.Username, arg.Email) + var i CheckUserRegistrationAvailabilityRow + err := row.Scan(&i.UsernameBusy, &i.EmailBusy) + return i, err +} + const createBannedUser = `-- name: CreateBannedUser :one INSERT INTO banned_users(user_id, expires_at, reason, banned_by) VALUES ( $1, $2, $3, $4) RETURNING id, user_id, date, reason, expires_at, banned_by, pardoned, pardoned_by @@ -209,6 +251,35 @@ func (q *Queries) CreateUser(ctx context.Context, username string) (User, error) return i, err } +const deleteUnverifiedAccountsHavingUsernameOrEmail = `-- name: DeleteUnverifiedAccountsHavingUsernameOrEmail :one +WITH deleted_rows AS ( + DELETE FROM users + WHERE + (username = $1::text OR + EXISTS ( + SELECT 1 + FROM login_informations AS linfo + WHERE linfo.user_id = users.id + AND linfo.email = $2::text + )) + AND verified IS FALSE + RETURNING id, username, verified, registration_date, deleted +) +SELECT COUNT(*) AS deleted_count FROM deleted_rows +` + +type DeleteUnverifiedAccountsHavingUsernameOrEmailParams struct { + Username string + Email string +} + +func (q *Queries) DeleteUnverifiedAccountsHavingUsernameOrEmail(ctx context.Context, arg DeleteUnverifiedAccountsHavingUsernameOrEmailParams) (int64, error) { + row := q.db.QueryRow(ctx, deleteUnverifiedAccountsHavingUsernameOrEmail, arg.Username, arg.Email) + var deleted_count int64 + err := row.Scan(&deleted_count) + return deleted_count, err +} + const deleteUser = `-- name: DeleteUser :exec DELETE FROM users WHERE id = $1 @@ -603,6 +674,69 @@ func (q *Queries) GetValidConfirmationCodeByCode(ctx context.Context, arg GetVal return i, err } +const getValidConfirmationCodesByUsername = `-- name: GetValidConfirmationCodesByUsername :many +SELECT confirmation_codes.id, user_id, code_type, code_hash, expires_at, used, confirmation_codes.deleted, users.id, username, verified, registration_date, users.deleted FROM confirmation_codes +JOIN users on users.id = confirmation_codes.user_id +WHERE + users.username = $1::text AND + code_type = $2::integer AND + expires_at > CURRENT_TIMESTAMP AND + used IS FALSE +` + +type GetValidConfirmationCodesByUsernameParams struct { + Username string + CodeType int32 +} + +type GetValidConfirmationCodesByUsernameRow struct { + ID int64 + UserID int64 + CodeType int32 + CodeHash string + ExpiresAt pgtype.Timestamp + Used *bool + Deleted *bool + ID_2 int64 + Username string + Verified *bool + RegistrationDate pgtype.Timestamp + Deleted_2 *bool +} + +func (q *Queries) GetValidConfirmationCodesByUsername(ctx context.Context, arg GetValidConfirmationCodesByUsernameParams) ([]GetValidConfirmationCodesByUsernameRow, error) { + rows, err := q.db.Query(ctx, getValidConfirmationCodesByUsername, arg.Username, arg.CodeType) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetValidConfirmationCodesByUsernameRow + for rows.Next() { + var i GetValidConfirmationCodesByUsernameRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.CodeType, + &i.CodeHash, + &i.ExpiresAt, + &i.Used, + &i.Deleted, + &i.ID_2, + &i.Username, + &i.Verified, + &i.RegistrationDate, + &i.Deleted_2, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getValidUserByLoginCredentials = `-- name: GetValidUserByLoginCredentials :one SELECT users.id, diff --git a/backend/internal/services/auth.go b/backend/internal/services/auth.go index 076428c..15e0d3b 100644 --- a/backend/internal/services/auth.go +++ b/backend/internal/services/auth.go @@ -58,30 +58,58 @@ func NewAuthService(_log *zap.Logger, _dbctx database.DbContext, _redis *redis.C func (a *authServiceImpl) RegistrationBegin(request models.RegistrationBeginRequest) (bool, error) { + var occupationStatus database.CheckUserRegistrationAvailabilityRow var user database.User var generatedCode string var generatedCodeHash string var passwordHash string + var err error helper, db, _ := database.NewDbHelperTransaction(a.dbctx) defer helper.Rollback() - var err error + if occupationStatus, err = db.TXQueries.CheckUserRegistrationAvailability(db.CTX, database.CheckUserRegistrationAvailabilityParams{ + Email: request.Email, + Username: request.Username, + }); err != nil { + a.log.Error( + "Failed to check credentials availability for registration", + zap.String("username", request.Username), + zap.String("email", request.Email), + zap.Error(err)) + return false, errs.ErrServerError + } - // TODO: get user if it exists. If not verified and no valid code exists, delete - // and recreate + if occupationStatus.UsernameBusy { + a.log.Warn( + "Attempted registration for a taken username", + zap.String("email", request.Email), + zap.String("username", request.Username)) + return false, errs.ErrUsernameTaken + + } else if occupationStatus.EmailBusy { + // Falsely confirm in order to avoid disclosing registered email addresses + a.log.Warn( + "Attempted registration for a taken email", + zap.String("email", request.Email), + zap.String("username", request.Username)) + return true, nil + + } else { + if _, err := db.TXQueries.DeleteUnverifiedAccountsHavingUsernameOrEmail(db.CTX, database.DeleteUnverifiedAccountsHavingUsernameOrEmailParams{ + Username: request.Username, + Email: request.Email, + }); err != nil { + a.log.Error( + "Failed to purge unverified accounts as part of registration", + zap.String("email", request.Email), + zap.String("username", request.Username), + zap.Error(err)) + return false, errs.ErrServerError + } + } if user, err = db.TXQueries.CreateUser(db.CTX, request.Username); err != nil { - - if errs.MatchPgError(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 } diff --git a/sqlc/query.sql b/sqlc/query.sql index e15eb78..b5f0364 100644 --- a/sqlc/query.sql +++ b/sqlc/query.sql @@ -56,6 +56,29 @@ SELECT users.* FROM users JOIN login_informations linfo ON linfo.user_id = users.id WHERE linfo.email = @email::text; +;-- name: CheckUserRegistrationAvailability :one +-- SELECT +-- COUNT(users.username = @username::text) > 0 AS username_busy, +-- COUNT(linfo.email = @email::text) > 0 AS email_busy +-- FROM users +-- JOIN login_informations AS linfo on linfo.user_id = users.id +-- WHERE +-- ( +-- users.username = @username::text OR +-- linfo.email = @email::text +-- ) +-- AND +-- ( +-- users.verified IS TRUE OR +-- COUNT( +-- SELECT confirmation_codes as codes +-- JOIN users on users.id = codes.user_id +-- WHERE codes.code_type = 0 AND +-- codes.deleted IS FALSE AND +-- codes.expires_at < CURRENT_TIMESTAMP +-- ) = 0; +-- ) + ;-- name: GetValidUserByLoginCredentials :one SELECT users.id, @@ -72,6 +95,46 @@ WHERE banned.user_id IS NULL AND -- Not banned linfo.password_hash = crypt(@password::text, linfo.password_hash); -- Password hash matches +;-- name: CheckUserRegistrationAvailability :one +SELECT + COUNT(CASE WHEN users.username = @username::text THEN 1 END) > 0 AS username_busy, + COUNT(CASE WHEN linfo.email = @email::text THEN 1 END) > 0 AS email_busy +FROM users +JOIN login_informations AS linfo ON linfo.user_id = users.id +WHERE + ( + users.username = @username::text OR + linfo.email = @email::text + ) + AND + ( + users.verified IS TRUE OR + NOT EXISTS ( + SELECT 1 + FROM confirmation_codes AS codes + WHERE codes.user_id = users.id + AND codes.code_type = 0 + AND codes.deleted IS FALSE + AND codes.expires_at > CURRENT_TIMESTAMP + ) + ); + +;-- name: DeleteUnverifiedAccountsHavingUsernameOrEmail :one +WITH deleted_rows AS ( + DELETE FROM users + WHERE + (username = @username::text OR + EXISTS ( + SELECT 1 + FROM login_informations AS linfo + WHERE linfo.user_id = users.id + AND linfo.email = @email::text + )) + AND verified IS FALSE + RETURNING * +) +SELECT COUNT(*) AS deleted_count FROM deleted_rows; + --: }}} --: Banned User Object {{{ @@ -146,6 +209,15 @@ WHERE used IS FALSE AND code_hash = crypt(@code::text, code_hash); +;-- name: GetValidConfirmationCodesByUsername :many +SELECT * FROM confirmation_codes +JOIN users on users.id = confirmation_codes.user_id +WHERE + users.username = @username::text AND + code_type = @code_type::integer AND + expires_at > CURRENT_TIMESTAMP AND + used IS FALSE; + ;-- name: UpdateConfirmationCode :exec UPDATE confirmation_codes SET