Skip to content

Commit

Permalink
Merge pull request #35 from malparty/feature/user-login-api
Browse files Browse the repository at this point in the history
[#17] [API] As a User, I can sign up and sign in/out with a username and password
  • Loading branch information
malparty committed Jun 23, 2021
2 parents 0486376 + b0a1dfa commit 3fc9b39
Show file tree
Hide file tree
Showing 38 changed files with 500 additions and 67 deletions.
2 changes: 1 addition & 1 deletion Gemfile
Expand Up @@ -13,6 +13,7 @@ gem 'fabrication' # Fabrication generates objects in Ruby. Fabricators are schem
gem 'sidekiq' # background processing for Ruby
gem 'bootsnap', require: false # Reduces boot times through caching; required in config/boot.rb
gem 'i18n-js', '3.5.1' # A library to provide the I18n translations on the Javascript
gem 'jsonapi-serializer' # A fast JSON:API serializer for Ruby Objects.

# Authentications & Authorizations
gem 'devise' # Authentication solution for Rails with Warden
Expand Down Expand Up @@ -53,7 +54,6 @@ group :development, :test do
gem 'rubocop-rails', require: false # A RuboCop extension focused on enforcing Rails best practices and coding conventions.
gem 'rubocop-rspec', require: false # Code style checking for RSpec files
gem 'rubocop-performance', require: false # An extension of RuboCop focused on code performance checks.
gem 'ffaker' # used to easily generate fake data: names, addresses, phone numbers, etc.

gem 'undercover' # Report missing test coverage in new changes
gem 'danger' # Automated code review.
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Expand Up @@ -198,6 +198,8 @@ GEM
json_matchers (0.11.1)
json_schema
json_schema (0.21.0)
jsonapi-serializer (2.2.0)
activesupport (>= 4.2)
kramdown (2.3.1)
rexml
kramdown-parser-gfm (1.1.0)
Expand Down Expand Up @@ -489,6 +491,7 @@ DEPENDENCIES
foreman
i18n-js (= 3.5.1)
json_matchers
jsonapi-serializer
letter_opener
listen (= 3.1.5)
mini_magick
Expand Down
17 changes: 17 additions & 0 deletions app/controllers/api/v1/application_controller.rb
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module API
module V1
class ApplicationController < ActionController::API
# equivalent of authenticate_user! on devise, but this one will check the oauth token
before_action :doorkeeper_authorize!

private

# helper method to access the current user from the token
def current_user
@current_user ||= User.find_by(id: doorkeeper_token[:resource_owner_id])
end
end
end
end
41 changes: 41 additions & 0 deletions app/controllers/api/v1/tokens_controller.rb
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module API
module V1
class TokensController < Doorkeeper::TokensController
include ErrorHandlerConcern

# Overridden from doorkeeper as the doorkeeper revoke action does not return response according to json-api spec
def revoke
# The authorization server responds with HTTP status code 200 if the client
# submitted an invalid token or the token has been revoked successfully.
if token.blank?
render json: token_revoke_response, status: :ok
# The authorization server validates [...] and whether the token
# was issued to the client making the revocation request. If this
# validation fails, the request is refused and the client is informed
# of the error by the authorization server as described below.
elsif authorized?
revoke_token
render json: token_revoke_response, status: :ok
else
render json: revocation_error_response, status: :forbidden
end
end

private

# Overridden from doorkeeper as it does not return response according to json-api spec
def revocation_error_response
error_description = I18n.t(:unauthorized, scope: %i[doorkeeper errors messages revoke])
{
errors: build_error(detail: error_description, code: :invalid_client)
}
end

def token_revoke_response
{ meta: I18n.t('doorkeeper.token_revoked') }
end
end
end
end
37 changes: 37 additions & 0 deletions app/controllers/api/v1/users_controller.rb
@@ -0,0 +1,37 @@
# frozen_string_literal: true

module API
module V1
class UsersController < ApplicationController
include API::V1::ErrorHandlerConcern

skip_before_action :doorkeeper_authorize!, only: :create

before_action :ensure_valid_client, only: :create

def create
user = User.new(create_params.except(:client_id, :client_secret))

if user.save
render json: UserTokenSerializer.new(user, { params: { client_id: @client_app.id } }),
status: :created
else
render json: ActiveModel::ErrorsSerializer.new(user.errors)
end
end

private

def create_params
params.permit(:email, :password, :last_name, :first_name, :client_id, :client_secret)
end

def ensure_valid_client
@client_app = Doorkeeper::Application.by_uid_and_secret(create_params[:client_id],
create_params[:client_secret])

render_error 'Invalid client credentials', status: :forbidden, source: :client_id if @client_app.blank?
end
end
end
end
12 changes: 1 addition & 11 deletions app/controllers/application_controller.rb
Expand Up @@ -2,20 +2,10 @@

class ApplicationController < ActionController::Base
include Localization
include DeviseParameter

protect_from_forgery with: :exception

before_action :authenticate_user!
before_action :update_allowed_parameters, if: :devise_controller?

protected

def update_allowed_parameters
devise_parameter_sanitizer.permit(:sign_up) do |u|
u.permit(:first_name, :last_name, :email, :password, :password_confirmation)
end
devise_parameter_sanitizer.permit(:account_update) do |u|
u.permit(:first_name, :last_name, :email, :password, :current_password)
end
end
end
Empty file removed app/controllers/concerns/.keep
Empty file.
29 changes: 29 additions & 0 deletions app/controllers/concerns/api/v1/error_handler_concern.rb
@@ -0,0 +1,29 @@
# frozen_string_literal: true

module API
module V1
module ErrorHandlerConcern
extend ActiveSupport::Concern

private

# Render Error Message in json_api format
def render_error(detail, source: nil, status: :unprocessable_entity)
error = build_error(detail: detail, source: source)
render_errors [error], status
end

def render_errors(jsonapi_errors, status = :unprocessable_entity)
render json: { errors: jsonapi_errors }, status: status
end

def build_error(detail:, source: nil, code: nil)
{
source: source,
detail: detail,
code: code
}.compact
end
end
end
end
14 changes: 14 additions & 0 deletions app/controllers/concerns/devise_parameter.rb
@@ -0,0 +1,14 @@
# frozen_string_literal: true

module DeviseParameter
protected

def update_allowed_parameters
devise_parameter_sanitizer.permit(:sign_up) do |u|
u.permit(:first_name, :last_name, :email, :password, :password_confirmation)
end
devise_parameter_sanitizer.permit(:account_update) do |u|
u.permit(:first_name, :last_name, :email, :password, :current_password)
end
end
end
21 changes: 21 additions & 0 deletions app/models/user.rb
Expand Up @@ -11,4 +11,25 @@ def self.authenticate(email, password)
user = User.find_for_authentication(email: email)
user&.valid_password?(password) ? user : nil
end

def create_access_token(client_app_id)
Doorkeeper::AccessToken.create(
resource_owner_id: id,
application_id: client_app_id,
refresh_token: generate_refresh_token,
expires_in: Doorkeeper.configuration.access_token_expires_in.to_i,
scopes: ''
)
end

private

def generate_refresh_token
loop do
# generate a random token string and return it,
# unless there is already another token with the same string
token = SecureRandom.hex(32)
break token unless Doorkeeper::AccessToken.exists?(refresh_token: token)
end
end
end
13 changes: 13 additions & 0 deletions app/serializers/active_model/errors_serializer.rb
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module ActiveModel
class ErrorsSerializer
def initialize(errors)
@errors = errors
end

def as_json(_options = nil)
{ errors: @errors.errors.map { |e| { detail: e.full_message, source: e.attribute } } }
end
end
end
13 changes: 13 additions & 0 deletions app/serializers/doorkeeper/token_serializer.rb
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Doorkeeper
class TokenSerializer
include JSONAPI::Serializer

attributes :token, :token_type, :expires_in, :refresh_token

attribute :created_at do |token|
token.created_at.to_i
end
end
end
7 changes: 7 additions & 0 deletions app/serializers/user_serializer.rb
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class UserSerializer
include JSONAPI::Serializer

attributes :email, :last_name, :first_name
end
9 changes: 9 additions & 0 deletions app/serializers/user_token_serializer.rb
@@ -0,0 +1,9 @@
# frozen_string_literal: true

class UserTokenSerializer < UserSerializer
set_type :user

attribute :access_token do |user, params|
Doorkeeper::TokenSerializer.new(user.create_access_token(params[:client_id])).serializable_hash[:data]
end
end
14 changes: 7 additions & 7 deletions app/views/devise/registrations/edit.html.erb
Expand Up @@ -4,17 +4,17 @@
<%= render "devise/shared/error_messages", resource: resource %>

<div class="field">
<%= f.label :first_name %><br />
<%= f.label :first_name %><br/>
<%= f.text_field :first_name, autofocus: true %>
</div>

<div class="field">
<%= f.label :last_name %><br />
<%= f.label :last_name %><br/>
<%= f.text_field :last_name %>
</div>

<div class="field">
<%= f.label :email %><br />
<%= f.label :email %><br/>
<%= f.email_field :email, autocomplete: "email" %>
</div>

Expand All @@ -23,21 +23,21 @@
<% end %>

<div class="field">
<%= f.label :password %> <i>(leave blank if you don't want to change it)</i><br />
<%= f.label :password %> <i>(leave blank if you don't want to change it)</i><br/>
<%= f.password_field :password, autocomplete: "new-password" %>
<% if @minimum_password_length %>
<br />
<br/>
<em><%= @minimum_password_length %> characters minimum</em>
<% end %>
</div>

<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.label :password_confirmation %><br/>
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>

<div class="field">
<%= f.label :current_password %> <i>(we need your current password to confirm your changes)</i><br />
<%= f.label :current_password %> <i>(we need your current password to confirm your changes)</i><br/>
<%= f.password_field :current_password, autocomplete: "current-password" %>
</div>

Expand Down
12 changes: 6 additions & 6 deletions app/views/devise/registrations/new.html.erb
Expand Up @@ -4,30 +4,30 @@
<%= render "devise/shared/error_messages", resource: resource %>

<div class="field">
<%= f.label :first_name %><br />
<%= f.label :first_name %><br/>
<%= f.text_field :first_name, autofocus: true %>
</div>

<div class="field">
<%= f.label :last_name %><br />
<%= f.label :last_name %><br/>
<%= f.text_field :last_name %>
</div>

<div class="field">
<%= f.label :email %><br />
<%= f.label :email %><br/>
<%= f.email_field :email, autocomplete: "email" %>
</div>

<div class="field">
<%= f.label :password %>
<% if @minimum_password_length %>
<em>(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<em>(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br/>
<%= f.password_field :password, autocomplete: "new-password" %>
</div>

<div class="field">
<%= f.label :password_confirmation %><br />
<%= f.label :password_confirmation %><br/>
<%= f.password_field :password_confirmation, autocomplete: "new-password" %>
</div>

Expand Down

0 comments on commit 3fc9b39

Please sign in to comment.