feat: auth service logic for purging expired unverified accounts upon registration, new sql queries for this purpose

This commit is contained in:
2025-07-13 01:57:19 +03:00
parent 8fa57eddb1
commit a2dd8993a6
3 changed files with 247 additions and 13 deletions

View File

@@ -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,

View File

@@ -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
// TODO: get user if it exists. If not verified and no valid code exists, delete
// and recreate
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",
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.ErrUsernameTaken
return false, errs.ErrServerError
}
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 {
a.log.Error("Failed to add user to database", zap.Error(err))
return false, errs.ErrServerError
}

View File

@@ -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