From ef59a42aeb9c567a7b2a03a33e8a2613206cc2a9 Mon Sep 17 00:00:00 2001 From: steve-mir Date: Sun, 16 Jun 2024 00:28:09 +0100 Subject: [PATCH] Added account deletion --- .vscode/settings.json | 1 + db/migration/000003_security.down.sql | 20 ++ db/migration/000003_security.up.sql | 57 ++++++ db/query/delete_user.sql | 11 + db/sqlc/delete_user.sql.go | 69 +++++++ db/sqlc/models.go | 42 ++++ db/sqlc/querier.go | 3 + internal/app/auth/api/delete_account.go | 64 ++++++ .../app/auth/api/resend_verification_email.go | 2 - internal/app/auth/api/server.go | 7 +- internal/app/auth/services/models.go | 12 ++ internal/app/auth/services/reset_password.go | 18 ++ internal/app/auth/services/user_service.go | 189 ++++++++++++++++++ utils/helpers.go | 13 ++ 14 files changed, 503 insertions(+), 5 deletions(-) create mode 100644 db/migration/000003_security.down.sql create mode 100644 db/migration/000003_security.up.sql create mode 100644 db/query/delete_user.sql create mode 100644 db/sqlc/delete_user.sql.go create mode 100644 internal/app/auth/api/delete_account.go create mode 100644 internal/app/auth/services/reset_password.go diff --git a/.vscode/settings.json b/.vscode/settings.json index fa77acf..1947565 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "Msgf", "pgtype", "pgxpool", + "Timestamptz", "zerolog" ] } \ No newline at end of file diff --git a/db/migration/000003_security.down.sql b/db/migration/000003_security.down.sql new file mode 100644 index 0000000..7f6557c --- /dev/null +++ b/db/migration/000003_security.down.sql @@ -0,0 +1,20 @@ +ALTER TABLE "password_reset_requests" DROP CONSTRAINT IF EXISTS "password_reset_requests_user_id_fkey"; +ALTER TABLE "two_factor_secrets" DROP CONSTRAINT IF EXISTS "two_factor_secrets_user_id_fkey"; +ALTER TABLE "two_factor_revocation" DROP CONSTRAINT IF EXISTS "two_factor_revocation_user_id_fkey"; +ALTER TABLE "two_factor_backup_codes" DROP CONSTRAINT IF EXISTS "two_factor_backup_codes_user_id_fkey"; + +ALTER TABLE "account_recovery_requests" DROP CONSTRAINT IF EXISTS "account_recovery_requests_user_id_fkey"; + + +-- Drop indexes +DROP INDEX IF EXISTS "password_reset_requests_email_user_id_token_expires_at_idx"; +DROP INDEX IF EXISTS "account_recovery_requests_user_id_idx"; + + +-- Drop tables +DROP TABLE IF EXISTS "two_factor_backup_codes"; +DROP TABLE IF EXISTS "two_factor_revocation"; +DROP TABLE IF EXISTS "two_factor_secrets"; +DROP TABLE IF EXISTS "password_reset_requests"; + +DROP TABLE IF EXISTS "account_recovery_requests"; \ No newline at end of file diff --git a/db/migration/000003_security.up.sql b/db/migration/000003_security.up.sql new file mode 100644 index 0000000..f4f955d --- /dev/null +++ b/db/migration/000003_security.up.sql @@ -0,0 +1,57 @@ +CREATE TABLE "password_reset_requests" ( + "id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "user_id" uuid NOT NULL, + "email" varchar NOT NULL, + "token" varchar NOT NULL UNIQUE, + "used" boolean DEFAULT false, + "created_at" timestamptz DEFAULT (now()), + "expires_at" timestamptz NOT NULL +); + +CREATE TABLE "two_factor_secrets" ( + "id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "user_id" uuid NOT NULL, -- UNIQUE, + "secret_key" varchar NOT NULL, + "is_active" boolean DEFAULT true +); + +CREATE TABLE "two_factor_revocation" ( + "id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "user_id" uuid NOT NULL, + "revoked_at" timestamptz, + "revocation_reason" varchar + -- "revoked_by" UUID REFERENCES users(id) +); + +CREATE TABLE "two_factor_backup_codes" ( + "id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "user_id" uuid NOT NULL, + "code" varchar NOT NULL, + "used" boolean DEFAULT false +); + +CREATE TABLE "account_recovery_requests" ( + "id" INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "user_id" uuid NOT NULL, + "email" varchar NOT NULL, + "used" BOOLEAN DEFAULT false, + "recovery_token" varchar unique not null, + "requested_at" timestamptz DEFAULT (now()), + "expires_at" timestamptz not null, + "completed_at" timestamptz +); + +CREATE INDEX ON "password_reset_requests" ( "user_id","email", "token", "expires_at"); + +CREATE INDEX ON "account_recovery_requests" ( "user_id", "recovery_token"); + + +ALTER TABLE "password_reset_requests" ADD FOREIGN KEY ("user_id") REFERENCES "authentications" ("id"); + +ALTER TABLE "two_factor_secrets" ADD FOREIGN KEY ("user_id") REFERENCES "authentications" ("id"); + +ALTER TABLE "two_factor_revocation" ADD FOREIGN KEY ("user_id") REFERENCES "authentications" ("id"); + +ALTER TABLE "two_factor_backup_codes" ADD FOREIGN KEY ("user_id") REFERENCES "authentications" ("id"); + +ALTER TABLE "account_recovery_requests" ADD FOREIGN KEY ("user_id") REFERENCES "authentications" ("id"); diff --git a/db/query/delete_user.sql b/db/query/delete_user.sql new file mode 100644 index 0000000..555b863 --- /dev/null +++ b/db/query/delete_user.sql @@ -0,0 +1,11 @@ +-- name: CreateUserDeleteRequest :exec +INSERT INTO account_recovery_requests ( + user_id, email, recovery_token, expires_at, completed_at + ) +VALUES ($1, $2, $3, $4, $5); + +-- name: GetUserFromDeleteReqByToken :one +SELECT * FROM account_recovery_requests WHERE recovery_token = $1 LIMIT 1; + +-- name: MarkDeleteAsUsedByToken :exec +UPDATE account_recovery_requests SET used = true, completed_at = now() WHERE recovery_token = $1; \ No newline at end of file diff --git a/db/sqlc/delete_user.sql.go b/db/sqlc/delete_user.sql.go new file mode 100644 index 0000000..46f1365 --- /dev/null +++ b/db/sqlc/delete_user.sql.go @@ -0,0 +1,69 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.22.0 +// source: delete_user.sql + +package sqlc + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +const createUserDeleteRequest = `-- name: CreateUserDeleteRequest :exec +INSERT INTO account_recovery_requests ( + user_id, email, recovery_token, expires_at, completed_at + ) +VALUES ($1, $2, $3, $4, $5) +` + +type CreateUserDeleteRequestParams struct { + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + RecoveryToken string `json:"recovery_token"` + ExpiresAt time.Time `json:"expires_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +func (q *Queries) CreateUserDeleteRequest(ctx context.Context, arg CreateUserDeleteRequestParams) error { + _, err := q.db.Exec(ctx, createUserDeleteRequest, + arg.UserID, + arg.Email, + arg.RecoveryToken, + arg.ExpiresAt, + arg.CompletedAt, + ) + return err +} + +const getUserFromDeleteReqByToken = `-- name: GetUserFromDeleteReqByToken :one +SELECT id, user_id, email, used, recovery_token, requested_at, expires_at, completed_at FROM account_recovery_requests WHERE recovery_token = $1 LIMIT 1 +` + +func (q *Queries) GetUserFromDeleteReqByToken(ctx context.Context, recoveryToken string) (AccountRecoveryRequest, error) { + row := q.db.QueryRow(ctx, getUserFromDeleteReqByToken, recoveryToken) + var i AccountRecoveryRequest + err := row.Scan( + &i.ID, + &i.UserID, + &i.Email, + &i.Used, + &i.RecoveryToken, + &i.RequestedAt, + &i.ExpiresAt, + &i.CompletedAt, + ) + return i, err +} + +const markDeleteAsUsedByToken = `-- name: MarkDeleteAsUsedByToken :exec +UPDATE account_recovery_requests SET used = true, completed_at = now() WHERE recovery_token = $1 +` + +func (q *Queries) MarkDeleteAsUsedByToken(ctx context.Context, recoveryToken string) error { + _, err := q.db.Exec(ctx, markDeleteAsUsedByToken, recoveryToken) + return err +} diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 261805c..8f9bb93 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -12,6 +12,17 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type AccountRecoveryRequest struct { + ID int32 `json:"id"` + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + Used pgtype.Bool `json:"used"` + RecoveryToken string `json:"recovery_token"` + RequestedAt pgtype.Timestamptz `json:"requested_at"` + ExpiresAt time.Time `json:"expires_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + type Authentication struct { ID uuid.UUID `json:"id"` Email string `json:"email"` @@ -44,6 +55,16 @@ type EmailVerificationRequest struct { ExpiresAt time.Time `json:"expires_at"` } +type PasswordResetRequest struct { + ID int32 `json:"id"` + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + Token string `json:"token"` + Used pgtype.Bool `json:"used"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + type Role struct { ID int32 `json:"id"` Name string `json:"name"` @@ -64,6 +85,27 @@ type Session struct { FcmToken pgtype.Text `json:"fcm_token"` } +type TwoFactorBackupCode struct { + ID int32 `json:"id"` + UserID uuid.UUID `json:"user_id"` + Code string `json:"code"` + Used pgtype.Bool `json:"used"` +} + +type TwoFactorRevocation struct { + ID int32 `json:"id"` + UserID uuid.UUID `json:"user_id"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + RevocationReason pgtype.Text `json:"revocation_reason"` +} + +type TwoFactorSecret struct { + ID int32 `json:"id"` + UserID uuid.UUID `json:"user_id"` + SecretKey string `json:"secret_key"` + IsActive pgtype.Bool `json:"is_active"` +} + type User struct { ID int32 `json:"id"` UserID uuid.UUID `json:"user_id"` diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 4002f81..ba40b12 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -20,6 +20,7 @@ type Querier interface { // Create a new session CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) CreateUser(ctx context.Context, arg CreateUserParams) (Authentication, error) + CreateUserDeleteRequest(ctx context.Context, arg CreateUserDeleteRequestParams) error // Create a new user login CreateUserLogin(ctx context.Context, arg CreateUserLoginParams) (UserLogin, error) CreateUserProfile(ctx context.Context, arg CreateUserProfileParams) error @@ -39,11 +40,13 @@ type Querier interface { GetUserByID(ctx context.Context, id uuid.UUID) (Authentication, error) GetUserByIdentifier(ctx context.Context, email string) (Authentication, error) GetUserByUsername(ctx context.Context, username pgtype.Text) (uuid.UUID, error) + GetUserFromDeleteReqByToken(ctx context.Context, recoveryToken string) (AccountRecoveryRequest, error) GetUserIDsFromUsernames(ctx context.Context, username pgtype.Text) ([]uuid.UUID, error) // Get user logins by user ID GetUserLoginsByUserID(ctx context.Context, userID uuid.UUID) ([]UserLogin, error) GetUserProfile(ctx context.Context, username pgtype.Text) (GetUserProfileRow, error) GetUserProfileByUID(ctx context.Context, userID uuid.UUID) (User, error) + MarkDeleteAsUsedByToken(ctx context.Context, recoveryToken string) error RevokeSessionById(ctx context.Context, userID uuid.UUID) error RotateSessionTokens(ctx context.Context, arg RotateSessionTokensParams) error UpdateEmailVerificationRequest(ctx context.Context, arg UpdateEmailVerificationRequestParams) (EmailVerificationRequest, error) diff --git a/internal/app/auth/api/delete_account.go b/internal/app/auth/api/delete_account.go new file mode 100644 index 0000000..da85369 --- /dev/null +++ b/internal/app/auth/api/delete_account.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/steve-mir/bukka_backend/internal/app/auth/middlewares" + "github.com/steve-mir/bukka_backend/internal/app/auth/services" + "github.com/steve-mir/bukka_backend/token" +) + +func (s *Server) deleteAccount(ctx *gin.Context) { + authPayload := ctx.MustGet(middlewares.AuthorizationPayloadKey).(*token.Payload) + + var req services.DeleteAccountReq + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + err := services.DeleteAccountRequest(ctx, req.Password, s.store, authPayload.Subject) + if err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + ctx.JSON(http.StatusOK, services.GenericRes{ + Msg: "Account deleted successfully", + }) + +} + +func (s *Server) requestAccountRecovery(ctx *gin.Context) { + var req services.AccountRecoveryReq + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + err := services.AccRecoveryRequest(ctx, s.store, s.taskDistributor, req.Email) + if err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + ctx.JSON(http.StatusOK, services.GenericRes{ + Msg: "URL has been sent to your email", + }) + +} + +func (s *Server) completeAccountRecovery(ctx *gin.Context) { + token := ctx.Query("token") + err := services.AccountRecovery(ctx, s.connPool, s.store, token) + if err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + ctx.JSON(http.StatusOK, services.GenericRes{ + Msg: "Account recovered, you can now login to access your account.", + }) + +} diff --git a/internal/app/auth/api/resend_verification_email.go b/internal/app/auth/api/resend_verification_email.go index 083d277..e7b3215 100644 --- a/internal/app/auth/api/resend_verification_email.go +++ b/internal/app/auth/api/resend_verification_email.go @@ -1,7 +1,6 @@ package api import ( - "log" "net/http" "github.com/gin-gonic/gin" @@ -12,7 +11,6 @@ import ( func (s *Server) resendVerificationEmail(ctx *gin.Context) { authPayload := ctx.MustGet(middlewares.AuthorizationPayloadKey).(*token.Payload) - log.Println(authPayload) err := services.ReSendVerificationEmail(s.store, ctx, s.taskDistributor, authPayload.Subject, authPayload.Email) if err != nil { ctx.JSON(http.StatusBadRequest, errorResponse(err)) diff --git a/internal/app/auth/api/server.go b/internal/app/auth/api/server.go index 76d4ec9..48faf35 100644 --- a/internal/app/auth/api/server.go +++ b/internal/app/auth/api/server.go @@ -48,6 +48,10 @@ func (server *Server) setupRouter() { authRoutes := router.Group("/").Use(middlewares.AuthMiddlerWare(server.config)) authRoutes.GET(baseUrl+"resend_verification", server.resendVerificationEmail) + authRoutes.DELETE(baseUrl+"delete_account", server.deleteAccount) + router.POST(baseUrl+"request_account_recovery", server.requestAccountRecovery) + router.GET(baseUrl+"recover_account", server.completeAccountRecovery) + // router.POST(baseUrl+"change_password", server.register) // router.POST(baseUrl+"request_reset_password", server.register) // router.POST(baseUrl+"reset_password", server.register) @@ -57,9 +61,6 @@ func (server *Server) setupRouter() { // router.POST(baseUrl+"confirm_change_phone", server.register) // router.POST(baseUrl+"change_username", server.register) // router.PATCH(baseUrl+"update_user", server.register) - // router.POST(baseUrl+"delete_account", server.register) - // router.POST(baseUrl+"request_account_recovery", server.register) - // router.POST(baseUrl+"recover_account", server.register) // router.POST(baseUrl+"register_sso", server.register) // router.POST(baseUrl+"login_sso", server.register) // router.POST(baseUrl+"register_mfa", server.register) diff --git a/internal/app/auth/services/models.go b/internal/app/auth/services/models.go index fcd5c09..4d3d4dd 100644 --- a/internal/app/auth/services/models.go +++ b/internal/app/auth/services/models.go @@ -17,6 +17,14 @@ type VerifyEmailReq struct { Token string `json:"token" binding:"required"` // TODO: Add verification to allow only digits within the appropriate length } +type DeleteAccountReq struct { + Password string `json:"password" binding:"required,passwordValidator"` +} + +type AccountRecoveryReq struct { + Email string `json:"email" binding:"required,emailValidator"` +} + // Responses type AuthTokenResp struct { AccessToken string `json:"access_token"` @@ -43,3 +51,7 @@ type VerifyEmailRes struct { Msg string `json:"msg"` Verified bool `json:"verified"` } + +type GenericRes struct { + Msg string `json:"msg"` +} diff --git a/internal/app/auth/services/reset_password.go b/internal/app/auth/services/reset_password.go new file mode 100644 index 0000000..67a9891 --- /dev/null +++ b/internal/app/auth/services/reset_password.go @@ -0,0 +1,18 @@ +package services + +import ( + "errors" + + "github.com/steve-mir/bukka_backend/db/sqlc" +) + +func checkAccountStatus(usr sqlc.Authentication) error { + if usr.IsSuspended.Bool { + return errors.New("account suspended") + } + + if usr.IsDeleted.Bool { + return errors.New("account deleted") + } + return nil +} diff --git a/internal/app/auth/services/user_service.go b/internal/app/auth/services/user_service.go index 4cbcb9c..7bc4593 100644 --- a/internal/app/auth/services/user_service.go +++ b/internal/app/auth/services/user_service.go @@ -1,10 +1,199 @@ package services import ( + "context" + "database/sql" + "errors" + "fmt" + "log" "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/steve-mir/bukka_backend/db/sqlc" + "github.com/steve-mir/bukka_backend/utils" + "github.com/steve-mir/bukka_backend/worker" + "golang.org/x/sync/errgroup" ) // MaxAccountRecoveryDuration is the duration for which an account can be recovered after deletion. const ( MaxAccountRecoveryDuration = 30 * 24 * time.Hour // for example, 30 days + recoveryTokenLength = 39 ) + +func DeleteAccountRequest(ctx context.Context, password string, store sqlc.Store, uid uuid.UUID) error { + + // Check if the user exists and is not already marked as deleted + user, err := store.GetUserByID(ctx, uid) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("user with ID %s not found", uid.String()) + } + return err + } + + // Check password + err = utils.CheckPassword(password, user.PasswordHash) + if err != nil { + return errors.New("password incorrect") + } + + err = checkAccountStatus(user) + if err != nil { + return err + } + + // Additional validations can be added here + // ... + + // Proceed with soft deletion if all checks pass + _, err = store.UpdateUser(ctx, sqlc.UpdateUserParams{ + ID: uid, + IsDeleted: pgtype.Bool{Bool: true, Valid: true}, + DeletedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }) + if err != nil { + return err + } + + return nil +} + +func AccRecoveryRequest(ctx context.Context, store sqlc.Store, td worker.TaskDistributor, email string) error { + // Check if the email is valid. + if !utils.IsEmailFormat(email) { + return fmt.Errorf("invalid email format: %s", email) + } + + // Retrieve the user associated with the email address. + user, err := store.GetUserByIdentifier(ctx, email) + if err != nil { + if err == sql.ErrNoRows { + return errors.New("an error occurred while processing the recovery request") + } + return err + } + + // Check if the user's account is already marked as deleted. + if !user.IsDeleted.Bool { //!user.DeletedAt.Valid + return errors.New("cannot process account recovery") + } + + // Check if the account is within the recovery period. + if time.Since(user.DeletedAt.Time) > MaxAccountRecoveryDuration { + return fmt.Errorf("the account recovery period has expired for email: %s", email) + } + + // Generate a recovery token and send an email to the user with the recovery instructions. + // The token should be a secure, one-time use token with an expiry. + // recoveryToken, err := generateSecureRecoveryToken() + recoveryToken, err := utils.GenerateUniqueToken(recoveryTokenLength) + if err != nil { + return err + } + + err = store.CreateUserDeleteRequest(ctx, sqlc.CreateUserDeleteRequestParams{ + UserID: user.ID, + Email: user.Email, + RecoveryToken: recoveryToken, + ExpiresAt: time.Now().Add(time.Minute * 15), + }) + if err != nil { + return err + } + + err = SendEmail(td, ctx, email, recoveryToken) + if err != nil { + return err + } + + return nil +} + +// TODO: Fix go routines (Consider using lib/pq) +func AccountRecovery(ctx context.Context, connPool *pgxpool.Pool, store sqlc.Store, recoveryToken string) error { + + // Retrieve the user and recovery token information from the database + usr, err := store.GetUserFromDeleteReqByToken(ctx, recoveryToken) + if err != nil { + if err == pgx.ErrNoRows { + return errors.New("account recovery request is invalid or has expired") + } + return err + } + + if usr.Used.Bool { + return errors.New("request token has been used or has expired") + } + + if usr.ExpiresAt.Before(time.Now()) { + return errors.New("account recovery request is invalid or has expired") + } + + tx, err := connPool.Begin(ctx) + if err != nil { + return err + } + defer func() { + if r := recover(); r != nil { + tx.Rollback(ctx) + } + }() + + qtx := sqlc.New(tx) + // Use a wait group to wait for both updates to complete + var eg errgroup.Group + // var err error + + // Goroutine for updating token status + eg.Go(func() error { + // defer wg.Done() + + // Assuming the recovery token is valid, proceed to unmark the account as deleted + _, err = qtx.UpdateUser(ctx, sqlc.UpdateUserParams{ + ID: usr.UserID, + IsDeleted: pgtype.Bool{Bool: false, Valid: true}, + DeletedAt: pgtype.Timestamptz{Time: time.Time{}, Valid: true}, // TODO: Set the null time + }) + if err != nil { + // tx.Rollback(ctx) + log.Println("Error 1", err.Error()) + return err + } + return nil + }) + + // Goroutine for updating user account + eg.Go(func() error { + // defer wg.Done() + + // Optionally, you may want to invalidate the recovery token after successful account recovery + err = qtx.MarkDeleteAsUsedByToken(ctx, recoveryToken) + if err != nil { + // Rollback the transaction in case of an error + // tx.Rollback(ctx) + log.Println("Error 2", err.Error()) + return err + } + return nil + }) + + // Wait for both goroutines to complete + // wg.Wait() + if err := eg.Wait(); err != nil { + tx.Rollback(ctx) + log.Println("Error 3", err.Error()) + return err + } + + // Commit the transaction if all updates were successful + err = tx.Commit(ctx) + if err != nil { + log.Println("Error 4", err.Error()) + return err + } + return nil +} diff --git a/utils/helpers.go b/utils/helpers.go index c83c2a2..7edcc42 100644 --- a/utils/helpers.go +++ b/utils/helpers.go @@ -2,6 +2,7 @@ package utils import ( "crypto/rand" + "encoding/base64" "math/big" "net/netip" ) @@ -38,3 +39,15 @@ func GenerateSecureRandomNumber(max int64) (int64, error) { } return nBig.Int64(), nil } + +func GenerateUniqueToken(len int) (string, error) { + // Generate a cryptographically secure random value + randomBytes := make([]byte, len) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + token := base64.URLEncoding.EncodeToString(randomBytes) + + return token, nil +}