Rails API + JWT auth + VueJS SPA: Part 3, Passwords and Tokens

Rails API + JWT auth + VueJS SPA: Part 3, Passwords and Tokens
In two previous articles ([Part 1](https://blog.usejournal.com/rails-api-jwt-auth-vuejs-spa-eb4cf740a3ae), [Part 2](https://medium.com/@yuliaoletskaya/rails-api-jwt-auth-vuejs-spa-part-2-roles-601e4372a7e7)) we’ve built a secure todos application with an ability to manage todos, a basic admin panel and a support of 3 different types of user roles.

In this part we‘ll add a forgot my password feature and an ability to edit user roles via the admin panel.

Acceptance criteria:
 — User should be able to restore their password via theforgot my password feature. Once User submits their email, they should receive a secure link via mail with reset password instructions.
 — On the password reset User should be logged out from all devices. User will have to enter their new password to log back in.
 — Admin should have an ability to edit all user roles except for their own (poka-yoke).
 — As User role is stored in the access token’s payload, once user’s role is edited — access token must be flushed, so the user will have to make a new refresh request in order to receive the access token with an updated payload.

The Backend

As we’re building an API-first application let’s start with the backend as usual. I’d propose to use a classic approach for password resets with unique password reset tokens which are used for generating reset password links.
1. Generate a rails migration.

$  rails g migration add_reset_password_fields

The migration itself:

add_reset_password_fields.rb

class AddResetPasswordFields < ActiveRecord::Migration[5.2]
  def change
    add_column :users, :reset_password_token, :string, default: nil
    add_column :users, :reset_password_token_expires_at, :datetime, default: nil
    add_index  :users, :reset_password_token
  end
end

2. Now we can add reset password token generation methods to the User model. After the password is reset we should clean up the tokens, so the same token couldn’t be used twice.

user.rb

class User < ApplicationRecord
  include ActiveModel::Serializers::JSON
  has_secure_password
  has_many :todos

  enum role: %i[user manager admin].freeze

  validates :email,
            format: { with: URI::MailTo::EMAIL_REGEXP },
            presence: true,
            uniqueness: { case_sensitive: false }

  def attributes
    { id: id, email: email, role: role }
  end

  def generate_password_token!
    begin
      self.reset_password_token = SecureRandom.urlsafe_base64
    end while User.exists?(reset_password_token: self.reset_password_token)
    self.reset_password_token_expires_at = 1.day.from_now
    save!
  end

  def clear_password_token!
    self.reset_password_token = nil
    self.reset_password_token_expires_at = nil
    save!
  end
end

Now we’re ready to build the password resets controller. The desired flow is as follows: User submits their email (first endpoint), then gets a secure link which leads them to enter-new-password page (second endpoint), and then User enters the data and hits submit (third endpoint). So, we’d need to implement 3 actions within the controller.

3. Implement POST /password_resets endpoint. This endpoint sends mails so we’ll need to build a mailer class as well.
user_mailer.rb

class UserMailer < ApplicationMailer
  def reset_password(user)
    @user = user
    mail(to: @user.email, subject: 'Reset your password')
  end
end

A password reset link goes within the mailer template.
reset_password.text.erb

Hi <%= @user.email %>,

You have requested to reset your password.
Please follow this link:
<%= "http://localhost:8080/#/password_resets/#{@user.reset_password_token}" %>
Reset password URL is valid within 24 hours.

Have a nice day!

The action itself.

post_passwords_resets.rb

class PasswordResetsController < ApplicationController
  def create
    user = User.find_by(email: params[:email])
    if user
      user.generate_password_token!
      UserMailer.reset_password(user).deliver_now
    end

    render json: :ok
  end
end

4. Implement GET /reset_passwords/:token/edit . The action itself verifies if a specific reset password token is valid.

get_passwords_resets.rb

class PasswordResetsController < ApplicationController
  before_action :set_user, only: [:edit]
  # ...

  def edit
    render json: :ok
  end

  private

  def set_user
    @user = User.find_by(reset_password_token: params[:token])
    raise ResetPasswordError unless @user&.reset_password_token_expires_at && @user.reset_password_token_expires_at > Time.now
  end
end

Custom exception is added as well. Let’s add a handler for this type of exceptions to the application controller.

reset_password_error.rb

# app/errors/reset_password_error.rb

class ResetPasswordError < StandardError
end

# app/controllers/application_controller.rb

class ApplicationController < ActionController::API
  include JWTSessions::RailsAuthorization
  rescue_from ActionController::ParameterMissing, with: :bad_request
  rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from JWTSessions::Errors::Unauthorized, with: :not_authorized
  rescue_from JWTSessions::Errors::ClaimsVerification, with: :forbidden
  rescue_from ResetPasswordError, with: :not_authorized

  private

  def current_user
    @current_user ||= User.find(payload['user_id'])
  end

  def bad_request
    render json: { error: 'Bad request' }, status: :bad_request
  end

  def forbidden
    render json: { error: 'Forbidden' }, status: :forbidden
  end

  def not_authorized
    render json: { error: 'Not authorized' }, status: :unauthorized
  end

  def not_found
    render json: { error: 'Not found' }, status: :not_found
  end

  def unprocessable_entity(exception)
    render json: { error: exception.record.errors.full_messages.join(' ') }, status: :unprocessable_entity
  end
end

5. Implement PATCH /reset_passwords/:token

patch_passwords_reset.rb

class PasswordResetsController < ApplicationController
  before_action :set_user, only: [:edit, :update]
  KEYS = [:password, :password_confirmation].freeze
  
  # ...

  def update
    @user.update!(password_params)
    @user.clear_password_token!
    render json: :ok
  end

  private

  def password_params
    params.tap { |p| p.require(KEYS) }.permit(*KEYS)
  end
  
  # ...
end

Right after the password reset we clean up the tokens in the update action.
The routes.

routes.rb

Rails.application.routes.draw do
  # ...
  resources :password_resets, only: [:create] do
    collection do
      get ':token', action: :edit, as: :edit
      patch ':token', action: :update
    end
  end
  # ...
end

And specs to ensure it works.

passwords_resets_controller_spec.rb

RSpec.describe PasswordResetsController, type: :controller do
  let(:user) { create(:user) }

  describe "POST #create" do
    it do
      expect(UserMailer).to receive(:reset_password).once.and_return(double(deliver_now: true))
      post :create, params: { email: user.email }
      expect(response).to be_successful
    end

    it do
      expect(UserMailer).to_not receive(:reset_password)
      post :create, params: { email: '[email protected]' }
      expect(response).to be_successful
    end
  end

  describe "GET #edit" do
    it do
      user.generate_password_token!
      get :edit, params: { token: user.reset_password_token }
      expect(response).to be_successful
    end

    it 'returns unauthorized for expired tokens' do
      user.generate_password_token!
      user.update({ reset_password_token_expires_at: 2.days.ago })
      get :edit, params: { token: user.reset_password_token }
      expect(response).to have_http_status(401)
    end

    it 'returns unauthorized for invalid expirations' do
      user.generate_password_token!
      user.update({ reset_password_token_expires_at: nil })
      get :edit, params: { token: user.reset_password_token }
      expect(response).to have_http_status(401)
    end

    it 'returns unauthorized for invalid params' do
      user.generate_password_token!
      get :edit, params: { token: 1 }
      expect(response).to have_http_status(401)
    end
  end

  describe "PATCH #update" do
    let(:new_password) { 'new_password' }
    it do
      user.generate_password_token!
      patch :update, params: { token: user.reset_password_token, password: new_password, password_confirmation: new_password }
      expect(response).to be_successful
    end

    it 'returns 422 if passwords do not match' do
      user.generate_password_token!
      patch :update, params: { token: user.reset_password_token, password: new_password, password_confirmation: 1 }
      expect(response).to have_http_status(422)
    end

    it 'returns 400 if param is missing' do
      user.generate_password_token!
      patch :update, params: { token: user.reset_password_token, password: new_password }
      expect(response).to have_http_status(400)
    end
  end
end

All of it implements the first AC on the backend. Now we can implement the second one.
6. An attentive reader might remember that the session in the application is represented with 2 tokens — access and refresh. They both are stored in redis (partly, for access token only its UID is persisted), so obviously, to flush the session we must delete the tokens from the redis. But first, we should link a user to their sessions to know exactly which sessions to flush. To be able to do that we can use namespaces which can group the sessions by their user (or any other common attributes).
Firstly, let’s add a namespace with user ID to all places in the app where we are operating over sessions, specifically signin , signup and refresh controllers.

# app/controllers/signup_controller.rb

class SignupController < ApplicationController
  KEYS = [:email, :password, :password_confirmation].freeze

  def create
    user = User.new(user_params)
    if user.save
      payload  = { user_id: user.id, aud: [user.role] }
      session = JWTSessions::Session.new(payload: payload,
                                         refresh_by_access_allowed: true,
                                         namespace: "user_#{user.id}")
      tokens = session.login

      response.set_cookie(JWTSessions.access_cookie,
                          value: tokens[:access],
                          httponly: true,
                          secure: Rails.env.production?)
      render json: { csrf: tokens[:csrf] }
    else
      render json: { error: user.errors.full_messages.join(' ') },
             status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.tap { |p| p.require(KEYS) }.permit(*KEYS)
  end
end

# app/controllers/signin_controller.rb

class SigninController < ApplicationController
  # ...

  def create
    # ...
    session = JWTSessions::Session.new(payload: payload,
                                       refresh_by_access_allowed: true,
                                       namespace: "user_#{user.id}")
    # ...
  end

  # ...
end

# app/controllers/refresh_controller.rb

class RefreshController < ApplicationController
  # ...

  def create
    session = JWTSessions::Session.new(payload: claimless_payload,
                                       refresh_by_access_allowed: true,
                                       namespace: "user_#{claimless_payload['user_id']}")
    # ...
  end
end

namespace: "user_#{user.id}" attribute is added to the session declaration.

7. With this being done we can flush all sessions which share a common namespace.

patch_password_reset_2.rb

class PasswordResetsController < ApplicationController  
  # ...

  def update
    @user.update!(password_params)
    @user.clear_password_token!
    JWTSessions::Session.new(namespace: "user_#{@user.id}").flush_namespaced
    render json: :ok
  end
  # ...
end

8. Let’s move on to the next ACs and allow Admins to edit user roles. We should keep in mind that while both Admins and Managers are allowed to view users, only Admins should have a permission to edit them (that’s controlled by the allowed_aud method). Also there’s a check in update action validating that Admins shouldn’t be able to modify their own role.
To fulfill the fourth AC we’re using flush_namespaced_access_tokens, it will keep the refresh token and remove the access only, which makes user’s locally stored access tokens invalid and user will have to perform a new refresh request.

admin_users_controller.rb

class Admin::UsersController < ApplicationController
  before_action :authorize_access_request!
  before_action :set_user, only: [:show, :update]
  VIEW_ROLES = %w[admin manager].freeze
  EDIT_ROLES = %w[admin].freeze

  def index
    @users = User.all

    render json: @users
  end

  def show
    render json: @user
  end

  def update
    if current_user.id != @user.id
      @user.update!(user_params)
      JWTSessions::Session.new(namespace: "user_#{@user.id}").flush_namespaced_access_tokens
      render json: @user
    else
      render json: { error: 'Admin cannot modify their own role' }, status: :bad_request
    end
  end

  def token_claims
    {
      aud: allowed_aud,
      verify_aud: true
    }
  end

  private

  def allowed_aud
    action_name == 'update' ? EDIT_ROLES : VIEW_ROLES
  end

  def set_user
    @user = User.find(params[:id])
  end

  def user_params
    params.require(:user).permit(:role)
  end
end

Here are the specs.

RSpec.describe Admin::UsersController, type: :controller do
  let!(:user) { create(:user) }
  let!(:manager) { create(:user, role: :manager) }
  let!(:admin) { create(:user, role: :admin) }

  describe 'GET #index' do
    it 'allows admin to receive users list' do
      sign_in_as(admin)
      get :index
      expect(response).to be_successful
      expect(response_json.size).to eq 3
    end

    it 'allows manager to receive users list' do
      sign_in_as(manager)
      get :index
      expect(response).to be_successful
      expect(response_json.size).to eq 3
    end

    it 'does not allow regular user to receive users list' do
      sign_in_as(user)
      get :index
      expect(response).to have_http_status(403)
    end
  end

  describe 'GET #show' do
    it 'allows admin to get a user' do
      sign_in_as(admin)
      get :show, params: { id: user.id }
      expect(response).to be_successful
    end

    it 'allows manager to get a user' do
      sign_in_as(manager)
      get :show, params: { id: user.id }
      expect(response).to be_successful
    end

    it 'does not allow regular user to get a user' do
      sign_in_as(user)
      get :show, params: { id: user.id }
      expect(response).to have_http_status(403)
    end
  end

  describe 'PATCH #update' do
    it 'allows admin to update a user' do
      sign_in_as(admin)
      patch :update, params: { id: user.id, user: { role: :manager } }
      expect(response).to be_successful
      expect(user.reload.role).to eq 'manager'
    end

    it 'does not allow manager to update a user' do
      sign_in_as(manager)
      patch :update, params: { id: user.id, user: { role: :manager } }
      expect(response).to have_http_status(403)
    end

    it 'does not allow user to update a user' do
      sign_in_as(user)
      patch :update, params: { id: user.id, user: { role: :manager } }
      expect(response).to have_http_status(403)
    end

    it 'does not allow admin to update their own role' do
      sign_in_as(admin)
      patch :update, params: { id: admin.id, user: { role: :manager } }
      expect(response).to have_http_status(400)
    end
  end
end

admin_user_controller_spec.rb

API is ready and we can start to work on the frontend.

The Frontend

Foreword: JS code in this article is simple and straightforward, there‘re endless possibilities of refactoring (removing code-duplicates, using store instead of making API requests on each page load, etc), but all those improvements will lead me to a material for a separate article and I’m too lazy for this, so here goes the most naive JS implementation possible.

1. First, let’s build ForgotPassword component and add navigation links.

// todos-vue/src/components/ForgotPassword.vue

<template>
  <form class="form-app form-forgot-password" @submit.prevent="submit">
    <div class="alert alert-info" v-if="notice">{{ notice }}</div>
    <div class="alert alert-danger" v-if="error">{{ error }}</div>
    <div class="form-group">
      <label for="email">Email address</label>
      <input v-model="email" type="email" class="form-control" id="email" placeholder="[email protected]">
    </div>
    <button type="submit" class="btn btn-primary mb-3">Reset Password</button>
    <div>
      <router-link to="/">Sign in</router-link>
      <br />
      <router-link to="/signup">Sign up</router-link>
    </div>
  </form>
</template>

<script>
export default {
  name: 'ForgotPassword',
  data () {
    return {
      email: '',
      error: '',
      notice: ''
    }
  },
  methods: {
    submit () {
      this.$http.plain.post('/password_resets', { email: this.email })
        .then(() => this.submitSuccessful())
        .catch(error => this.submitFailed(error))
    },
    submitSuccessful () {
      this.notice = 'Email with password reset instructions had been sent.'
      this.error = ''
      this.email = ''
    },
    submitFailed (error) {
      this.error = (error.response && error.response.data && error.response.data.error) || ''
    }
  }
}
</script>

ForgotPassword.js

Update Routes

// todos-vue/src/router/index.js

// ...
import ForgotPassword from '@/components/ForgotPassword'
// ...

export default new Router({
  routes: [
   // ...
    {
      path: '/forgot_password',
      name: 'ForgotPassword',
      component: ForgotPassword
    },
    // ...
  ]
})

js_routes_1.js

And add router links to Signin/Signup components.

// todos-vue/src/components/Signin.vue

<template>
  // ...
    <div>
      <router-link to="/signup">Sign up</router-link>
      <br />
      <router-link to="/forgot_password">Forgot Password</router-link>
    </div>
  // ...
</template>

signin_router_links.js
Here’re the views

2. ResetPassword.vue component — the one to which leads the link from the reset password email. The component on load sends a GET request to the reset passwords endpoint to verify that the token from URL is correct.

// todos-vue/src/components/ResetPassword.vue

<template>
  <form class="form-app form-reset-password" @submit.prevent="reset">
    <div class="alert alert-info" v-if="notice">{{ notice }}</div>
    <div class="alert alert-danger" v-if="error">{{ error }}</div>
    <div class="form-group">
      <label for="password">New Password</label>
      <input v-model="password" type="password" class="form-control" id="password" placeholder="Password">
    </div>
    <div class="form-group">
      <label for="password">Password Confirmation</label>
      <input v-model="password_confirmation" type="password" class="form-control" id="password_confirmation" placeholder="Password Confirmation">
    </div>
    <button type="submit" class="btn btn-primary mb-3">Reset password</button>
    <div>
      <router-link to="/">Sign in</router-link>
    </div>
  </form>
</template>

<script>
export default {
  name: 'ResetPassword',
  data () {
    return {
      password: '',
      password_confirmation: '',
      error: '',
      notice: ''
    }
  },
  created () {
    this.checkPasswordToken()
  },
  methods: {
    reset () {
      this.$http.plain.patch(`/password_resets/${this.$route.params.token}`, { password: this.password, password_confirmation: this.password_confirmation })
        .then(response => this.resetSuccessful(response))
        .catch(error => this.resetFailed(error))
    },
    resetSuccessful (response) {
      this.notice = 'Your password has been reset successfully! Please sign in with your new password.'
      this.error = ''
      this.password = ''
      this.password_confirmation = ''
    },
    resetFailed (error) {
      this.error = (error.response && error.response.data && error.response.data.error) || 'Something went wrong'
      this.notice = ''
    },
    checkPasswordToken () {
      this.$http.plain.get(`/password_resets/${this.$route.params.token}`)
        .catch(error => {
          this.resetFailed(error)
          this.$router.replace('/')
        })
    }
  }
}
</script>

ResetPassword.js
Routes

// todos-vue/src/router/index.js

// ...
import ResetPassword from '@/components/ResetPassword'
// ...

export default new Router({
  routes: [
    // ...
    {
      path: '/password_resets/:token',
      name: 'ResetPassword',
      component: ResetPassword
    }
    // ...
  ]
})

reset_password_routes.js

Visualisation (all views are pretty similar, but still)

3. Now, when JS client users are able to reset their passwords, let’s add the ability to edit roles. We’ll create a separate Edit components for users in admin space. It also prohibits to non-admins to access edit users page and to admins to modify their own roles.
To implement this check let’s add currentUserId as a getter to the Vue store.

// todos-vue/src/store.js

import Vue from 'vue'
import Vuex from 'vuex'
import createPersistedState from 'vuex-persistedstate'
Vue.use(Vuex)

export const store = new Vuex.Store({
  // ...
  getters: {
    // ...
    currentUserId (state) {
      return state.currentUser && state.currentUser.id
    }
  },
  // ...
  },
  plugins: [createPersistedState()]
})

current_user.js

The edit component contains a label with the selected user’s email and a select box with available roles list.

// todos-vue/src/components/Edit.vue

<template>
  <div class="users">
    <AppHeader></AppHeader>
    <form class="form-app form-edit" @submit.prevent="update">
      <div class="alert alert-info" v-if="notice">{{ notice }}</div>
      <div class="alert alert-danger" v-if="error">{{ error }}</div>
      <div class="form-group">
        <label>Email address - {{ user.email }}</label>
      </div>
      <div class="form-group">
        <select v-model="user.role" class="form-control" id="role">
          <option value='admin'>Admin</option>
          <option value='manager'>Manager</option>
          <option value='user'>User</option>
        </select>
      </div>
      <button type="submit" class="btn btn-primary mb-3">Update</button>
      <div>
        <router-link to="/admin/users">Users</router-link>
      </div>
    </form>
  </div>
</template>

<script>
import AppHeader from '@/components/AppHeader'

export default {
  name: 'UserEdit',
  data () {
    return {
      error: '',
      notice: '',
      user: {}
    }
  },
  created () {
    this.checkSignedIn()
  },
  methods: {
    update () {
      this.$http.secured.patch(`/admin/users/${this.$route.params.id}`, { user: { role: this.user.role } })
        .then(response => this.updateSuccessful(response))
        .catch(error => this.updateFailed(error))
    },
    updateSuccessful (response) {
      this.notice = 'User updated'
      this.error = ''
    },
    updateFailed (error) {
      this.error = (error.response && error.response.data && error.response.data.error) || ''
      this.notice = ''
    },
    setError (error, text) {
      this.error = (error.response && error.response.data && error.response.data.error) || text
      this.notice = ''
    },
    checkSignedIn () {
      if (this.$store.state.signedIn && this.$store.getters.isAdmin) {
        this.$http.secured.get(`/admin/users/${this.$route.params.id}`)
          .then(response => {
            if (this.$store.getters.currentUserId === response.data.id) {
              this.$router.replace('/')
              return
            }
            this.user = response.data
          })
          .catch(error => { this.setError(error, 'Something went wrong') })
      } else {
        this.$router.replace('/')
      }
    }
  },
  components: { AppHeader }
}
</script>

Edit.js

Routes

// todos-vue/src/router/index.js

// ...
import UserEdit from '@/components/admin/users/Edit'
// ...

export default new Router({
  routes: [
    // ...
    {
      path: '/admin/users/:id',
      name: 'UserEdit',
      component: UserEdit
    }
    // ...
  ]
})

edit_routes.js
The view

4. We have the component, but there’s no way to navigate to it through the app yet. Let’s add links to the users edit view visible only for Admins.

// todos-vue/src/components/admin/users/List.vue

<template>
 // ...
      <tbody>
        <tr v-for="user in users" :key="user.id" :user="user">
          <th>{{ user.id }}</th>
          <td td v-if="showUsersLink(user)">
            <router-link :to="`/admin/users/${user.id}`">
              {{ user.email }}
            </router-link>
          </td>
          <td td v-else>
            {{ user.email }}
          </td>
  // ...
</template>

<script>

export default {
  // ...
  methods: {
    // ...
    showUsersLink (user) {
      return this.$store.getters.isAdmin && this.$store.getters.currentUserId !== user.id
    }
    // ...
  }
}
</script>

List.js

The view (all links are available for Admin, except for the link to their own profile)

5. And the last, but not least. In this JS client we’re storing current user info in the local storage. So even in case access token is reseted after refresh on the server and the app automatically requests a new access token — it still doesn’t update user’s info within the store. Let’s fix that and request user info after refresh to surely be up to date with new user roles.

// todos-vue/src/backend/axious/index.js
// ...

securedAxiosInstance.interceptors.response.use(null, error => {
  if (error.response && error.response.config && error.response.status === 401) {
    // In case 401 is caused by expired access cookie - we'll do refresh request
    return plainAxiosInstance.post('/refresh', {}, { headers: { 'X-CSRF-TOKEN': store.state.csrf } })
      .then(response => {
        // request new users info
        plainAxiosInstance.get('/me')
          .then(meResponse => store.commit('setCurrentUser', { currentUser: meResponse.data, csrf: response.data.csrf }))
        // And after successful refresh - repeat the original request
        let retryConfig = error.response.config
        retryConfig.headers['X-CSRF-TOKEN'] = response.data.csrf
        return plainAxiosInstance.request(retryConfig)
      }).catch(error => {
        store.commit('unsetCurrentUser')
        // redirect to signin in case refresh request fails
        location.replace('/')
        return Promise.reject(error)
      })
  } else {
    return Promise.reject(error)
  }
})

export { securedAxiosInstance, plainAxiosInstance }

routers.js

The application code can be found on GitHub.
Thanks for reading! It was fun making this series of articles. 
If you have any questions or feedback please feel free to reach me on twitter. Cheers!

Recommended Courses:

The Ultimate Vue JS 2 Developers Course
http://bit.ly/2OtvI7t

Building a TodoMVC Application in Vue, React and Angular
http://bit.ly/2LuFZSV

Vue.js Fast Crash Course
http://bit.ly/2AsHEn0

Vue JS Essentials with Vuex and Vue Router
http://bit.ly/2vjVMJ8

Suggest:

Ruby on Rails Tutorial for Beginners

Ruby on Rails 5 Tutorial: Build Web Application

Web Development Tutorial - JavaScript, HTML, CSS

Javascript Project Tutorial: Budget App

E-Commerce JavaScript Tutorial - Shopping Cart from Scratch

JavaScript Programming Tutorial Full Course for Beginners