Haom.in!

Hi!

Register and verification workflow

Try to implement a register workflow for my Spring Boot application

1830 words
9 min read
Authentication and Register is a key point of cyber security and a modern application. Although many web company provides authentication services (e.g. Firebase, Auth0), I want to implement a self-managed authentication system, for study (and resume). In this article, I will try to design a register workflow, including email verification.
register-verification-workflow

I have built up serval authentication systems in my past project. Their workflows are very simple, and not very secure:

Simple Workflow

Suppose I have two tables: one is user, which contains columns including, id, email, password(hashed), verified, … and another one is verified_code, store code/link for verification, like code, email, … (I will use code in further sections)

  • Client enter their email and password, and send them to the backend
  • Backend validate those data, and stored in database, with verified = false
    • When user login, their token will have a status isVerified = false, so we can limit their accessibility
  • Backend generate a code, store in database, and send it to client’s email
  • Client enter the code on their device, which will send the code to the backend
  • Backend validate the code, including the value, expire, and if it is matched with email, extracted from token, session, …
  • Update the user table to set verified to true, and re-assign a new token, if using JWT.

Most of my projects use this one, because it is very simple, easy to set up, and we can add cache, for store code pair, attempt times in future for security or efficient. I also used this in my university project work, where I combined it with firebase, so we can use a verify code on React Native, without a web app (firebase authentication only provides link way to verify). Here is a sample code:

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailVerifyServiceImpl implements EmailVerifyService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final AccountService accountService;
    private final MailService mailService;
    private final SecureRandom random = new SecureRandom();

    private void preCheck(@NonNull final JwtUser user) {
        // check is email is already verified
        if (user.emailVerified()) {
            throw new BusinessException("Email is already verified");
        }

        // check if user is locked out
        final String lockoutKey = AuthConstants.LOCKOUT_PREFIX + user.email();
        if (redisTemplate.hasKey(lockoutKey)) {
            Long ttl = redisTemplate.getExpire(lockoutKey, TimeUnit.MINUTES);
            throw new BusinessException("Too many attempts. Try again in " + ttl + " minutes.");
        }
    }

    private void sendVerificationEmail(@NonNull final String email,
                                       @NonNull final String code) throws MessagingException, IOException {
        MailRequest request = new MailRequest(
                email,
                CommonConstants.PROJECT_EMAIL,
                "Verify your expresso account"
        );

        Map<String, Object> contents = PayloadBuilder.create()
                .with("code", code)
                .build();

        mailService.sendThymeleafEmail(
                request,
                "VerificationCode",
                contents
        );
    }

    @Override
    public String sendVerificationCode(@NonNull final JwtUser user) {
        preCheck(user);

        // generate verification code
        String code = generateVerificationCode();

        // store code in redis with expiration
        final String codeKey = AuthConstants.CODE_PREFIX + user.email();
        redisTemplate.opsForValue().set(
                codeKey,
                code,
                AuthConstants.CODE_EXPIRATION_MINUTES,
                TimeUnit.MINUTES
        );

        try {
            sendVerificationEmail(user.email(), code);
        } catch (MessagingException | IOException e) {
            // clean up redis if email sending fails
            log.info("Failed before send verification email before {}: {}", user.email(), e.getMessage());
            redisTemplate.delete(codeKey);
            throw new BusinessException("Failed before send verification email. Please try again later.");
        }

        return code;
    }

    @Override
    public boolean verifyCode(@NonNull JwtUser user, @NonNull String code) {
        preCheck(user);

        final String codeKey = AuthConstants.CODE_PREFIX + user.email();
        final Object storedCode = redisTemplate.opsForValue().get(codeKey);
        final String attemptKey = AuthConstants.ATTEMPTS_PREFIX + user.email();

        if (storedCode == null) {
            throw new BusinessException("Verification code has expired or does not exist");
        }

        // verify code
        if (Objects.equals(storedCode, code)) {
            try {
                accountService.activeEmail(user.uid());
            } catch (FirebaseAuthException e) {
                throw new FirebaseOperationException(e);
            }

            // delete code and attempts
            redisTemplate.delete(codeKey);
            redisTemplate.delete(attemptKey);

            return true;
        }

        // increment attempt counter
        redisTemplate.opsForValue().increment(attemptKey);
        redisTemplate.expire(attemptKey, AuthConstants.CODE_EXPIRATION_MINUTES, TimeUnit.MINUTES);

        // check if max attempts reached
        final Object attemptRaw = redisTemplate.opsForValue().get(attemptKey);
        final int attempts = attemptRaw != null
                ? Integer.parseInt(attemptRaw.toString())
                : 0;
        if (attempts >= AuthConstants.MAX_VERIFICATION_ATTEMPTS - 1) {
            // lock out user
            final String lockoutKey = AuthConstants.LOCKOUT_PREFIX + user.email();
            redisTemplate.opsForValue().set(
                    lockoutKey,
                    "locked",
                    AuthConstants.LOCKOUT_DURATION_MINUTES,
                    TimeUnit.MINUTES
            );
            throw new BusinessException("Too many failed attempts. You are locked out for "
                    + AuthConstants.LOCKOUT_DURATION_MINUTES + " minutes.");
        }

        return false;
    }

    private String generateVerificationCode() {
        StringBuilder code = new StringBuilder();
        for (int i = 0; i < AuthConstants.CODE_LENGTH; i++) {
            code.append(random.nextInt(10));
        }
        return code.toString();
    }

Question?

But I am still feeling uncomfortable on this workflow, since it keep unverified account in the database. Using a captcha may help block non-human attack, it cannot avoid some boring people to sign up for thousands times .

When using firebase authentication, it can support a huge number of account, but when I try to build up a personal project, without relying on web authentication service. It is obviously that I should avoid these “bad user” as much as possible. In fact, I have seen many projects (like personal blog), without properly protection or configuration, been registered over thousands meaningless account. (This blog is very ‘static’ ( • ̀ω•́ ) )

Clean up

If I need to improve this system, the first thing I come up with will be setting up a scheduler, who will clean up unverified accounts from database per XX minutes. But this never solve the question: First, “bad users” still stay in the database, if one teenager feel boring, he/she can still sign up 500 accounts in 30 minutes. Moreover, I do not think it is efficient to do a scan and delete half of our table.

Solution: Validate before register

In order to avoid bad user in database, we need to validate email before the actual register happen. The easiest way I can come up with is:

  1. Client enter their data, including email, password, name, …
  2. Client send the email only to backend, on which the email is validated
  3. Backend generate a code, send it to user’s email, and store a email:code pair
  4. Client send the request data with their code
  5. Backend validate email with code, and register with user data in database

In fact, we only need to ensure data is stored when email is validated. so we can add an email verified step between step 3 and 4, where user only send email and code, for backend to validate, and store a email:verified record. In this way, user only need to enter data like password, after code is verified, so we can avoid hold sensitive info for a long time.

Using this method, we will never let bad user step into our database, and ensure one confirmation code each user. This method is good enough for me, and only need some extra securities, like rate limits, re-tries limits…

Implement

Now, I am going to put the idea into code: I will choose the three requests way, doing a separate email and code check. However, I will not use email as the main reference, since it is a not such insensitive variables, and it is predictable and easier to tamper with. Instead, I will generate a unique, randomly attemptId as the main reference. We may need extra mapping or store, but it is very easy to implement, and provide more scalability and decoupling.

  /**
   * Data class representing a verification attempt.
   *
   * @property attemptId The unique identifier for the verification attempt.
   * @property email The email address associated with the verification attempt.
   * @property codeHash The hashed verification code.
   * @property verified Indicates whether the verification has been completed.
   */
  private data class Attempt(
      val attemptId: String,
      val email: String,
      val codeHash: String,
      val verified: Boolean = false,
  )

The endpoints I need to implement including:

  1. send verification code
  2. verify code
  3. finish register

and we probably also need an endpoints to resend the code.

I will first write up a plain draft code, and add hit rate limited or other functionality in the future.

Sign up

In this step, we need to generate an id and code pair, and store it for future verification, but let’s first prepare the vos used in the service:

/**
 * Command object for sending a verification email.
 *
 * @property email The email address to send the verification to.
 * @property ip The IP address from which the request originated.
 * @property userAgent The user agent string of the client making the request (optional).
 */
data class SendVerificationCommand(
    val email: String,
    val ip: String,
    val userAgent: String?,
)

/**
 * Result object for sending a verification email.
 *
 * @property attemptId The unique identifier for the verification attempt.
 * @property expiredIn The time in seconds until the verification attempt expires.
 */
data class SendVerificationResult(
    val attemptId: String,
    val expiredIn: Int,
)

What we will do is: first, validate the email, checking if it has not been used or is in invalid format. Then we just generate the attempt, and then store it in redis with an expiry.

We also need to store a email:attemptId pair, since we want to maintain one email one attempt. if the email is currently in an attempt, I choose to return the current used code.

override fun sendVerification(cmd: SendVerificationCommand): SendVerificationResult {
    val email = EmailValidator.normalize(cmd.email)

    // resue active attempt for the same email
    redis.get(emailKey(email))?.let {
        val ttl = redis.getExpire(attemptKey(it))
        if (ttl != null) return SendVerificationResult(it, ttl.toInt())
    }
    
    // validate email
    if (!EmailValidator.isValid(email)) throw InvalidParamException("Invalid email address")
    // TODO: check if email is already registered

    // generate code and save attempt
    val attemptId = UUID.randomUUID().toString()
    val code = generateCode()
    val attempt = Attempt(
        attemptId = attemptId,
        email = email,
        code = code,
    )

    redis.setObj(attemptKey(attemptId), attempt, attemptTtl)
    redis.set(emailKey(email), attemptId, attemptTtl)

    // TODO: send email with the code
    redis.set(cooldownKey(attemptId), "1", resendCooldown)

    return SendVerificationResult(attemptId, attemptTtl.toInt())
}

Resend code

Resend code is not a “must have” functionality, but have one is modern and very friendly. What I will do is: when receive a “resend” request, I will generate a new code, update the code of current attempt, and refresh its expiry.

It should use the attemptId as the identifier, and should return the same format as request a code:

/**
 * Command object for resending a verification code.
 *
 * @property attemptId The unique identifier for the verification attempt.
 * @property ip The IP address from which the request originated.
 * @property userAgent The user agent string of the client making the request (optional).
 */
data class ResendVerificationCommand(
    val attemptId: String,
    val ip: String,
    val userAgent: String?
)

Please notice that, my redis.getExpire() will only return a value if redisTemplate.getExpire() return a non-null and > 0 value.

    override fun resendVerification(cmd: ResendVerificationCommand): SendVerificationResult {
        val (attemptId, _, _) = cmd

        // check attempt exists
        val attempt = loadAttempt(attemptId)
        if (attempt.verified) throw InvalidParamException("Email has already been verified")

        // check cooldow
        redis.getExpire(cooldownKey(attemptId))?.let {
            throw InvalidParamException("Please wait before requesting another code", mapOf("after" to it))
        }

        // generate new code and update attempt
        val newCode = generateCode()
        val updated = attempt.copy(code = newCode)

        redis.setObj(attemptKey(attemptId), updated, attemptTtl)
        redis.set(emailKey(updated.email), attemptId, attemptTtl)

        // TODO: send email with the new code
        redis.set(cooldownKey(attemptId), "1", resendCooldown)

        return SendVerificationResult(attemptId, attemptTtl.toInt())
    }

Verify code

Next step is to verify the email with the code received. Client should provide their attemptId with the code. Moreover, a maximum tries limitation should be implemented, to avoid Bruce Force (there are many other methods we can use). I choose to clean up the attempt, and let user start over.

/**
 * Command object for verifying a code.
 *
 * @property attemptId The unique identifier for the verification attempt.
 * @property code The verification code to be verified.
 */
data class VerifyCodeCommand(
    val attemptId: String,
    val code: String,
)

Since the store attempt is frequently used, I will warp them into another function. So do the cleanUpAttempt() function.

private fun saveAttempt(attempt: Attempt, ttl: Long = attemptTtl) {
    redis.setObj(attemptKey(attempt.attemptId), attempt, ttl)
    redis.set(emailKey(attempt.email), attempt.attemptId, ttl)
}

private fun cleanUpAttempt(attempt: Attempt) {
    redis.delete(attemptKey(attempt.attemptId))
    redis.delete(emailKey(attempt.email))
    redis.delete(triesKey(attempt.attemptId))
    redis.delete(cooldownKey(attempt.attemptId))
}
    
override fun verifyCode(cmd: VerifyCodeCommand) {
    val (attemptId, code, _) = cmd
    val attempt = loadAttempt(attemptId)

    // idempotent success if already verified
    if (attempt.verified) return

    if(code == attempt.code) {
        // if code matches, mark as verified
        val ttl = redis.getExpire(attemptKey(attemptId)) ?: attemptTtl
        val verified = attempt.copy(verified = true)
        
        saveAttempt(verified, ttl)
        return
    }
    
    // if code does not match, throw error
    val tries = redis.increment(triesKey(attemptId)) ?: 1L
    if (tries == 1L) redis.expire(triesKey(attemptId), triesDuration)
    if (tries >= maxVerifyTries) {
        cleanUpAttempt(attempt)
        throw InvalidParamException("Too many failed verification attempts. Please start over.")
    }
    
    throw InvalidParamException("Code invalid", mapOf("remaining_tries" to (maxVerifyTries - tries)))
}

Register

The last step is to create the user in database, with following provided data and the attemptId. In this case, I only ask user to provide their password, and I will retrieve email from attempt.

/**
 * Command object for registering a new user.
 *
 * @property attemptId The unique identifier for the verification attempt.
 * @property password The password for the new user.
 */
data class RegisterCommand(
    val attemptId: String,
    val password: String,
)

/**
 * Result object for registering a new user.
 *
 * @property userId The unique identifier of the newly registered user.
 */
data class RegisterResult(
    val userId: String,
)

We should ensure that attempt existed, and is verified. Moreover, the cleanUpAttempt() should be placed after storing data in database, to avoid a fallback.

@Transactional
override fun register(cmd: RegisterCommand): RegisterResult {
    val (attemptId, password) = cmd
    val attempt = loadAttempt(attemptId)
    if (!attempt.verified) throw InvalidParamException("Email not verified")
    
    // TODO:
    // 1) validate password policy
    // 2) create user account
    // 3) save user to database
    
    cleanUpAttempt(attempt)
    
    return RegisterResult(userId = "newly_created_user_id")
}

Test

Now, the basic workflow has been implemented, let’s make a test do ensure it works fine. I will directly use Postman for test. I prepare the APIs as following:

/**
 * Data class representing a request to send a verification email.
 *
 * @property email The email address to which the verification code will be sent.
 */
data class SendVerificationRequest(
    val email: String,
)

/**
 * Data class representing a request to resend a verification code.
 *
 * @property attemptId The unique identifier for the verification attempt.
 */
data class ResendVerificationRequest(
    val attemptId: String,
)

/**
 * Data class representing a request to verify a code.
 *
 * @property attemptId The unique identifier for the verification attempt.
 * @property code The verification code to be verified.
 */
data class VerifyCodeRequest(
    val attemptId: String,
    val code: String,
)

/**
 * Data class representing a request to register a new user.
 *
 * @property attemptId The unique identifier for the verification attempt.
 * @property password The password for the new user.
 */
data class RegisterRequest(
    val attemptId: String,
    val password: String,
)

@RestController
@RequestMapping("/api/auth")
class AuthController(
    private val signupService: SignupService,
) {
    companion object {
        private val logger = org.slf4j.LoggerFactory.getLogger(AuthController::class.java)
    }

    @PostMapping("/send-verification")
    fun sendVerification(
        @RequestBody dto: SendVerificationRequest,
        request: HttpServletRequest,
    ): ApiResponse {
        val result = SendVerificationCommand(
            email = dto.email,
            ip = request.remoteAddr,
        ).let { signupService.sendVerification(it) }

        return ApiResponse.success("Verification code sent")
            .put("attemptId", result.attemptId)
            .put("expiredIn", result.expiredIn)
            .build()
    }

    @PostMapping("/resend-verification")
    fun resendVerification(
        @RequestBody dto: ResendVerificationRequest,
        request: HttpServletRequest,
    ): ApiResponse {
        val result = ResendVerificationCommand(
            attemptId = dto.attemptId,
            ip = request.remoteAddr,
        ).let { signupService.resendVerification(it) }

        return ApiResponse.success("Verification code resent")
            .put("attemptId", result.attemptId)
            .put("expiredIn", result.expiredIn)
            .build()
    }

    @PostMapping("/verify-code")
    fun verifyCode(
        @RequestBody dto: VerifyCodeRequest,
        request: HttpServletRequest,
    ): ApiResponse {
        VerifyCodeCommand(
            attemptId = dto.attemptId,
            code = dto.code,
            ip = request.remoteAddr,
        ).let { signupService.verifyCode(it) }

        return ApiResponse.success("Code verified").build()
    }

    @PostMapping("/register")
    fun register(
        @RequestBody dto: RegisterRequest,
    ): ApiResponse {
        val result = RegisterCommand(
            attemptId = dto.attemptId,
            password = dto.password,
        ).let { signupService.register(it) }

        return ApiResponse.success("User registered")
            .put("userId", result.userId)
            .build()
    }
}

By sending request to /api/auth/send-verification, I received:

{
    "code": 0,
    "message": "Verification code sent",
    "payload": {
        "attemptId": "885ac023-31fc-466c-9e5e-35f83c40d976",
        "expiredIn": 900
    }
}

Since I logged the attempt and code is not hashed, I directly get the code from console (do not do this in production). And this is what I got for verifying:

{
  "code": 0,
  "message": "Code verified"
}

and works well on register:

{
    "code": 0,
    "message": "User registered",
    "payload": {
        "userId": "newly_created_user_id"
    }
}

Adding rate limitation

The code is working now, but still missing something: what if one user keep requesting for resending? or repeat asking for verification code? We need to add rate limitation on the service.

What I will do is:

  1. limit the send verification from one ip address
  2. limit the send verification for one email
  3. add a maximum times limitation on code resending
/**
 * Rate limit helper method.
 *
 * @param key The Redis key to track the rate limit.
 * @param limit The maximum number of allowed requests within the time window.
 * @param windowSec The time window in seconds for the rate limit.
 * @throws AccessDeniedException if the rate limit is exceeded.
 */
private fun rateLimitOrThrow(key: String, limit: Int, windowSec: Long) {
    val count = redis.increment(key) ?: 1L
    if (count == 1L) redis.expire(key, windowSec)
    if (count > limit) {
        throw AccessDeniedException("Too many requests", mapOf("retry_after" to (redis.getExpire(key) ?: windowSec)))
    }
}
    
    
override fun sendVerification(cmd: SendVerificationCommand): SendVerificationResult {
    val email = EmailValidator.normalize(cmd.email)

    rateLimitOrThrow(rlStartIpKey(cmd.ip), limit = 5, windowSec = 180)
    rateLimitOrThrow(rlStartEmailKey(email), limit = 3, windowSec = 120)
    
    // ...
}

// and

override fun resendVerification(cmd: ResendVerificationCommand): SendVerificationResult {
    // ...
    
    // check resend count
    val resendCount = redis.increment(resendCountKey(attemptId)) ?: 1L
    if (resendCount > maxResending) {
        cleanUpAttempt(attempt)
        throw InvalidParamException("Too many resend requests. Please start over.")
    }
    
    // ...
}

More

This is just one of the solutions, and in fact, I do not think this is good enough, because it feels a little bit complex and fatigue. However, it works well for small project, and for me, anyway, (*´▽`*), I will choose to use Firebase or Auth0 if I really want a project for production, instead for study.