JWT Token-based custom user authentication for Rails API only (Part 03)

This part emphasis on sessions create/destroy services with email confirmation and reset password services of the user custom JWT auth.

JWT Token-based custom user authentication for Rails API only (Part 03)
JWT Token-based custom user authentication for Rails API only (Part 02)
We will create user’s sign up and delete itself services with manually created JWT and authentication library in rails.
PART 02

The code available at:

sulmanweb/rails-api-user-custom-auth
Rails API user custom authentication using JWT project - sulmanweb/rails-api-user-custom-auth
PART 03 GitHub Code
This part emphasis on sessions create/destroy services with email confirmation and reset password services of the user custom JWT auth.

Sessions Controller:

Now that we have created all the required JWT libraries required for authentication, we will move fast and create three services of sign in, sign out, and validate token.

Create a file app/controllers/auth/sessions_controller.rb with following data:

class Auth::SessionsController < ApplicationController
  include CreateSession

  before_action :authenticate_user, only: [:validate_token, :destroy]
  
  def create
    return error_insufficient_params unless params[:email].present? && params[:password].present?
    @user = User.find_by(email: params[:email])
    if @user
      if @user.authenticate(params[:password])
        @token = jwt_session_create @user.id
        if @token
          @token = "Bearer #{@token}"
          return success_session_created
        else
          return error_token_create
        end
      else
        return error_invalid_credentials
      end
    else
      return error_invalid_credentials
    end
  end

  def validate_token
    @token = request.headers['Authorization']
    @user = current_user
    success_valid_token
  end

  def destroy
    headers = request.headers['Authorization'].split(' ').last
    session = Session.find_by(token: JsonWebToken.decode(headers)[:token])
    session.close
    success_session_destroy
  end

  protected

  def success_session_created
    response.headers['Authorization'] = "Bearer #{@token}"
    render status: :created, template: "auth/auth"
  end

  def success_valid_token
    response.headers['Authorization'] = "Bearer #{@token}"
    render status: :ok, template: "auth/auth"
  end

  def success_session_destroy
    render status: :no_content, json: {}
  end

  def error_invalid_credentials
    render status: :unauthorized, json: {errors: [I18n.t('errors.controllers.auth.invalid_credentials')]}
  end

  def error_token_create
    render status: :unprocessable_entity, json: {errors: [I18n.t('errors.controllers.auth.token_not_created')]}
  end

  def error_insufficient_params
    render status: :unprocessable_entity, json: {errors: [I18n.t('errors.controllers.insufficient_params')]}
  end
end

The authenticate method in line 10 is provided by bcrypt gem we installed before. This method converts the provided password to a hash and matches that hash to a hashed password saved in database.

The validate_token method is an extra method can be used by a logged-in user coming after sometime. This method return 401 if token is expired or incorrect otherwise return the user data which may be updated through another active session.

Test these services by adding to config/routes.rb in auth namespace:

post "sign_in", to: "sessions#create"
get "validate_token", to: "sessions#validate_token"
delete "sign_out", to: "sessions#destroy"
Sign In service
Sign In service
Validate Token Service
Validate Token Service
Sign Out Service
Sign Out Service

User Email Confirmation:

Now we create user email confirmation system. In user model app/models/user.rb a method for sending email to confirm its account.

def send_confirm_email
  unless confirmed?
    verification = UserVerification.create(user_id: id, verify_type: :confirm_email)
    url = Rails.application.routes.url_helpers.auth_confirm_email_url(host: "localhost:3000", token: verification.token)
    # ADD Email Job with `url` added in "CONFIRM EMAIL" button
  end
end

Also call above method in user after create callback so that whenever user is created send confirmation email:

after_create :send_confirm_email
Create Email job using API or Action Mailer SMTP system for actually sending the email. Docs are here: https://guides.rubyonrails.org/action_mailer_basics.html

Now create controller for user confirmation services at app/controllers/auth/confirmations_controller.rb with following code:

class Auth::ConfirmationsController < ApplicationController
  include CreateSession

  before_action :authenticate_user, only: :resend_confirm_email

  def confirm_email
    return error_insufficient_params unless params[:token]

    verification = UserVerification.search(:pending, :confirm_email, params[:token])
    return error_invalid_token if verification.nil?
    if (verification.created_at + UserVerification::TOKEN_LIFETIME) > Time.now
      verification.user.confirm
      verification.update(status: :done)
      @token = jwt_session_create verification.user_id
      # Redirect to the page that says the email is confirmed successfully or can be redirected to the app
      redirect_to "#{ENV['REDIRECT_CONFIRM_EMAIL']}?token=#{@token}"
    else
      error_confirm_email_late
    end
  end

  def resend_confirm_email
    current_user.send_confirm_email
    success_resend_confirm_email
  end

  protected

  def success_resend_confirm_email
    render status: :ok, json: {message: I18n.t('messages.resend_confirm_email')}
  end

  def error_insufficient_params
    render status: :unprocessable_entity, json: {errors: [I18n.t('errors.controllers.insufficient_params')]}
  end

  def error_confirm_email_late
    render status: :unauthorized, json: {errors: [I18n.t('errors.controllers.verifications.late')]}
  end

  def error_invalid_token
    render status: :unauthorized, json: {errors: [I18n.t('errors.controllers.verifications.invalid_token')]}
  end
end

The confirm_email method gets the query param of token of user verification received in email, verifies that token, and then redirects to the page that says ‘Your email is verified successfully’.

The resend_confirm_email method can be used to nag a logged in user if not confirmed its email.

Add these two methods in routes auth namespace:

get "confirm_email", to: "confirmations#confirm_email"
put "resend_confirm_email", to: "confirmations#resend_confirm_email"

Forgot and Change User Password:

Similar to confirmation email system we did above we create a forgot password email sending method is user model app/models/user.rb:

def send_reset_email
  if confirmed?
    verification = UserVerification.create(user_id: id, verify_type: :reset_email)
    url = Rails.application.routes.url_helpers.auth_verify_reset_password_email_url(host: "localhost:3000", token: verification.token)
    # ADD Email Job with `url` added in "RESET YOUR EMAIL" button
  end
end

Now create passwords controller app/controllers/auth/passwords_controller.rb:

class Auth::PasswordsController < ApplicationController
  include CreateSession

  before_action :authenticate_user, only: [:reset_password]
  
  def create_reset_email
    return error_insufficient_params unless params[:email].present?
    user = User.find_by(email: params[:email])
    user.send_reset_email unless user.nil?
    success_send_reset_email
  end

  def verify_reset_email_token
    return error_insufficient_params unless params[:token]
    verification = UserVerification.search(:pending, :reset_email, params[:token])
    return error_invalid_token if verification.nil?
    if (verification.created_at + UserVerification::TOKEN_LIFETIME) > Time.now
      verification.update(status: :done)
      verification.user.confirm unless verification.user.confirmed?
      token = jwt_session_create verification.user_id
      # Redirect to the page where a logged in user can change its password
      redirect_to "#{ENV['REDIRECT_RESET_EMAIL']}?token=#{token}"
    else
      error_reset_email_late
    end
  end

  def reset_password
    @user = current_user
    return error_insufficient_params unless params[:password].present? && params[:confirm_password].present?
    return error_password_mismatch if params[:password] != params[:confirm_password]
    if @user.update(password: params[:password])
      return success_password_reset
    else
      return error_user_save
    end
  end

  protected

  def error_insufficient_params
    render status: :unprocessable_entity, json: {errors: [I18n.t('errors.controllers.insufficient_params')]}
  end

  def success_send_reset_email
    render status: :created, json: {message: I18n.t('messages.reset_password_email_sent')}
  end

  def success_password_reset
    render status: :ok, json: {message: I18n.t('messages.email_reset_success')}
  end

  def error_reset_email_late
    render status: :unauthorized, json: {errors: [I18n.t('errors.controllers.verifications.late')]}
  end

  def error_invalid_token
    render status: :unauthorized, json: {errors: [I18n.t('errors.controllers.verifications.invalid_token')]}
  end

  def error_user_save
    render status: :unprocessable_entity, json: {errors: @user.errors.full_messages}
  end

  def error_password_mismatch
    render status: :unprocessable_entity, json: {errors: [I18n.t('errors.controllers.auth.password_mismatch')]}
  end
end

create_reset_email that takes email in body and if a user with the email given exists in the system then sends an email with user verification token.

verify_reset_email_token method is called when the reset password link is clicked in the forgot password email. This method verifies the token sent in email and creates a user session and redirects to a page where logged-in user can change their password.

reset_password method is for logged-in user whether coming through proper session or forgot password verification session. This method takes the new password for the user to change its account password.

Now add these services to config/routes.rb file in auth namespace:

post "forgot_password_email", to: "passwords#create_reset_email"
get "verify_reset_password_email", to: "passwords#verify_reset_email_token"
put "reset_password", to: "passwords#reset_password"

Now our custom user authentication system is complete with the following routes:

                            auth_sign_up POST   /auth/sign_up(.:format)                                                                           auth/registrations#create
                            auth_destroy DELETE /auth/destroy(.:format)                                                                           auth/registrations#destroy
                            auth_sign_in POST   /auth/sign_in(.:format)                                                                           auth/sessions#create
                     auth_validate_token GET    /auth/validate_token(.:format)                                                                    auth/sessions#validate_token
                           auth_sign_out DELETE /auth/sign_out(.:format)                                                                          auth/sessions#destroy
                      auth_confirm_email GET    /auth/confirm_email(.:format)                                                                     auth/confirmations#confirm_email
               auth_resend_confirm_email PUT    /auth/resend_confirm_email(.:format)                                                              auth/confirmations#resend_confirm_email
              auth_forgot_password_email POST   /auth/forgot_password_email(.:format)                                                             auth/passwords#create_reset_email
        auth_verify_reset_password_email GET    /auth/verify_reset_password_email(.:format)                                                       auth/passwords#verify_reset_email_token
                     auth_reset_password PUT    /auth/reset_password(.:format)                                                                    auth/passwords#reset_password

Final code of all three parts is at:

sulmanweb/rails-api-user-custom-auth
Rails API user custom authentication using JWT project - sulmanweb/rails-api-user-custom-auth
GitHub Repo

Conclusion:

Custom user authentication gives us full control over the code so much that if the system requires the authenticating entity to be only mobile number not email then that is also possible. We can also change any scenario required for the project and also extra table and data is not present in database.

Happy Coding!