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.
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.
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
☞ 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