0

Lets Build Single Page Application - Part II

In Part I we have setup and configured some basic configuration that needed in order to start the project. Now in this part we will focus on implementing authentication system on the server side(API), which is the foundation part that needed for further implementation. You can find the full source code of this post in my github repository.

Note The source code the repository contains full implementation of authentication in both server side and client side, but in this post I will show only the server side code.

User Model

Create a user model with attributes, paste in some validation and authentication logic like following.

Column Type Key
id integer primary
uuid string unique
username string unique
email string unique
password_digest string none

api/app/models/user.rb

class User < ActiveRecord::Base
  include UserCallbacks
  include Authentication

  extend FriendlyId

  friendly_id :username, use: :slugged

  validates :uuid, uniqueness: true
  validates :password, length: { in: 6..25 }
  validates :username, presence: true, uniqueness: true
  validates :email, presence: true, uniqueness: true , email: true
end

api/app/models/concerns/user_callbacks.rb

module UserCallbacks
  extend ActiveSupport::Concern

  included do
    attr_writer :uuid_generator
    before_create :generate_uuid
  end

  private

  def uuid_generator
    @uuid_generator ||= SecureRandom
  end

  def generate_uuid
    while !uuid.present? || self.class.exists?(uuid: uuid)
      self.uuid = uuid_generator.uuid
    end
  end
end

api/app/models/concerns/authentication.rb

module Authentication
  extend ActiveSupport::Concern

  included do
    has_secure_password
  end

  module ClassMethods
    def authenticate(email, password)
      user = find_by(email: email)
      user if user && user.authenticate(password)
    end
  end
 end

What I would like to do is separate out the code into each responsible module as you can see in the code. If you are new to ActiveSupport::Concern module, here is basically what it does:

  • Get code in included block and evaluate it in the context of the class that include that module.
  • Get code in ClassMethods module and evaluate it in the context of the meta class that include that module. It is as if we wrote
class User
  class << self
    # code in module ClassMethods
  end
end

SessionController

Create a controller call session and put in the following code: api/app/controllers/session_controller.rb

class SessionController < ApplicationController
  before_action :authenticate, only: :destroy
  before_action :not_login_check, only: :create

  def create
    if user = User.authenticate(params[:email], params[:password])
      generated_token = AuthenticationToken.instance.generate(user.uuid)
      user_with_token = UserWithToken.new(token: generated_token, user: user)
      render json: user_with_token
    else
      render_invalid_credential
    end
  end

  def destroy
    AuthenticationToken.instance.revoke(token)
    render json: 'Successfully logout.', status: :moved_permanently
  end

  private

  def render_invalid_credential
    render json: 'Bad credential', status: :moved_permanently
  end
end

I don't want to store authentication token in database that's why User model doesn't have token attribute. AuthenticationToken is a singleton class use to generate and manage authentication token that associate with uuid and store that in redis store, which I will show you later. UserWithToken is a decorator class that decorate on User and add in the token attribute. I used ActiveModel::Serializer to help build API endpoint, so the line with render json: user_with_token will look for a serializer class name UserWithTokenSerializer and generate JSON data.

UserSerializer

The serializer class for User api/app/serializers/user_serializer.rb

class UserSerializer < ActiveModel::Serializer
  attributes :id, :uuid, :email, :username, :slug
end

UserWithTokenSerializer

The serializer class for UserWithToken api/app/serializers/user_with_token_serializer.rb

class UserWithTokenSerializer < UserSerializer
  root 'user'
  attributes :token
end

AuthenticationToken

This is what it looks like api/app/singletons/authentication_token.rb

class AuthenticationToken
  include Singleton

  attr_writer :store, :expire, :token_generator

  def generate(uuid)
    begin
      token = token_generator.uuid
      key   = token_key(token)
    end while store.exists(key)

    store.set(key, uuid)
    store.expire(key, expire)

    token
  end

  def uuid(token)
    store.get token_key(token)
  end

  def revoke(token)
    key = token_key(token)
    store.del(key) if store.exists(key)
  end

  private
  .......

  def token_key(token)
    "auth_token:#{token}"
  end
end

The generate method will generate a token and use it as a key to store user's uuid in the redis store for later use when user authenticate into our application. It also set expire to 24 hours for each generated key. revoke will delete user's uuid from the store base on the passed in token. uuid will get the user's uuid store in redis back base on passed in token.

UsersController

api/app/controllers/users_controller.rb

class UsersController < ApplicationController
  before_action :authenticate, except: :create
  before_action :not_login_check, only: :create

  def show
    render json: UserWithToken.new(token: token, user: current_user)
  end

  def create
    @user = User.new(user_params)
    render_user_errors and return unless @user.save
    generated_token = AuthenticationToken.instance.generate(@user.uuid)
    render json: UserWithToken.new(token: generated_token, user: @user)
  end

  private

  def user_params
    params.require(:user).permit(:username, :email, :password)
  end

  def render_user_errors
    render json: @user.errors, status: :unprocessable_entity
  end
end

There is nothing new here just some basic user registration. In create action we create a new user and if client provide valid information we will log user in by generate token and return token along with new user information back to the client as JSON format.

ApplicationHelper

api/app/helpers/application_helper.rb

module ApplicationHelper
  def current_user
    @user ||= authenticate_with_http_token do |token, options|
      uuid = AuthenticationToken.instance.uuid(token)
      User.find_by(uuid: uuid)
    end
  end

  def authenticate
    current_user || render_unauthorized
  end

  def token
    authenticate_with_http_token { |token, options| token }
  end
end

current_user will return currently logged in user if client request with valid authentication token passed into request header. token is here to help us extract token from the header.

Testing

To wrap up this implementation with some testing. I only show some portion of the code because it is too long. You can find the full source code in the github repository that I mention at the beginning of this post.

require 'rails_helper'

RSpec.describe SessionController, type: :controller do
  routes { Api::Engine.routes }

  describe 'POST create' do
    let(:credential) { { email: 'user@example.com', password: 'secret' } }
    before { @user = FactoryGirl.create(:user, credential) }

    context 'with valid credential' do
      let(:token) { '50c08a03-f9b1-4abf-bd41-81938e20222e' }
      let(:user_with_token) { UserWithToken.new(token: token, user: @user) }

      before do
        allow(AuthenticationToken.instance).to receive(:generate).and_return(token)
        post :create, credential, format: :json
      end

      it 'should generate token' do
        expect(AuthenticationToken.instance).to have_received(:generate)
      end

      it 'should render user with token as JSON' do
        expect(response.body).to eq(UserWithTokenSerializer.new(user_with_token).to_json)
      end
    end

    context 'with invalid credential' do
      let(:invalid_credential) { { email: 'wrong@example.com', password: 'secret' } }

      before { post :create, invalid_credential, format: :json }
      it 'should render unauthorized as JSON' do
        expect(response.body).to eq('Bad credential')
        expect(response.status).to eq(301)
      end
    end
  end

  describe 'GET destroy' do
    context 'logged in' do
      let(:user) { FactoryGirl.build(:user) }
      let(:uuid) { '65e4ceb1-6a4d-44ac-a53e-23a8097bcf08' }
      let(:token) { '50c08a03-f9b1-4abf-bd41-81938e20222e' }

      before do
        request.env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Token.encode_credentials(token)
        allow(User).to receive(:find_by).with({uuid: uuid}).and_return(user)
        allow(AuthenticationToken.instance).to receive(:uuid).and_return(uuid)
        allow(AuthenticationToken.instance).to receive(:revoke).with(token)
        get :destroy, nil, format: :json
      end

      it 'should delete token' do
        puts request.headers
        expect(AuthenticationToken.instance).to have_received(:revoke).once.with(token)
      end

      it 'should generate redirect response as JSON' do
        expect(response.body).to eq('Successfully logout.')
        expect(response.status).to eq(301)
      end
    end

    context 'not logged in' do
      before { get :destroy, nil, format: :json }

      it 'should render unauthorized as JSON' do
        expect(response.body).to eq('Access denied')
        expect(response.status).to eq(401)
      end
    end
  end
end

Conclusion

Now that we are done with building API to authenticate the user, in the next part we will focus on implementing the UI using react.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí