Lets Build Single Page Application - Part II
Bài đăng này đã không được cập nhật trong 8 năm
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 |
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