Skip to content

Commit

Permalink
Implement basic authentication mechanism
Browse files Browse the repository at this point in the history
- The format of Authorization header values: 'Bearer BASE64(username:password)'
- Added a translation helper that shortens the lookup key based on the nesting
  • Loading branch information
woarewe committed Apr 1, 2023
1 parent e9f8de6 commit 66a5c73
Show file tree
Hide file tree
Showing 23 changed files with 347 additions and 48 deletions.
7 changes: 7 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,12 @@ AllCops:
Style/StringLiterals:
EnforcedStyle: double_quotes

Style/ClassAndModuleChildren:
Exclude:
- test/**/*_test.rb

Naming/RescuedExceptionsVariableName:
PreferredName: error

Style/Documentation:
Enabled: false
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ gem "pg", "~> 1.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", "~> 5.0"

gem "bcrypt", "~> 3.1.7"

# Build JSON APIs with ease [https://github.com/rails/jbuilder]
# gem "jbuilder"

Expand Down Expand Up @@ -45,6 +47,7 @@ group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[mri mingw x64_mingw]
gem "factory_bot_rails", "~> 6.2.0"
gem "faker", "~> 3.1.1"
gem "rubocop", "~> 1.48", ">= 1.48.1", require: false
gem "rubocop-performance", "~> 1.16", require: false
gem "rubocop-rails", "~> 2.18", require: false
Expand Down
5 changes: 5 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ GEM
minitest (>= 5.1)
tzinfo (~> 2.0)
ast (2.4.2)
bcrypt (3.1.18)
bootsnap (1.16.0)
msgpack (~> 1.2)
builder (3.2.4)
Expand Down Expand Up @@ -100,6 +101,8 @@ GEM
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (3.1.1)
i18n (>= 1.8.11, < 2)
globalid (1.1.0)
activesupport (>= 5.0)
grape (1.7.0)
Expand Down Expand Up @@ -228,10 +231,12 @@ PLATFORMS
x86_64-linux

DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap
debug
dotenv-rails (~> 2.8, >= 2.8.1)
factory_bot_rails (~> 6.2.0)
faker (~> 3.1.1)
grape (~> 1.7)
grape-swagger (~> 1.6)
pg (~> 1.1)
Expand Down
12 changes: 0 additions & 12 deletions app/api/api.rb

This file was deleted.

20 changes: 20 additions & 0 deletions app/gateways/rest/api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module REST
class API < Grape::API
format "json"
prefix "api"

helpers Helpers::Auth

before do
authenticate!
end

get "hello" do
"World"
end

add_swagger_documentation mount_path: "/swagger"
end
end
19 changes: 19 additions & 0 deletions app/gateways/rest/api/helpers/auth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

module REST
class API
module Helpers
module Auth
def authenticate!
@current_user = Services::Authentication.new.call(headers)
rescue Services::Authentication::Error => error
error!(error.message, 401)
end

def current_user
@current_user
end
end
end
end
end
58 changes: 58 additions & 0 deletions app/gateways/rest/services/authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# frozen_string_literal: true

module REST
module Services
class Authentication
include Translations

HEADER = "Authorization"
SCHEME = "Bearer"

Error = Class.new(StandardError)

def call(headers)
payload = header_payload!(headers)
username, password = parse_payload!(payload)
authenticate!(username, password)
end

private

def header_payload!(headers)
raise Error, t!("errors.missing_header", header: HEADER) unless headers.key?(HEADER)

headers.fetch(HEADER)
end

def parse_payload!(payload)
scheme, token = payload.split
invalid_format! if invalid_scheme?(scheme)

Base64
.decode64(token)
.then { |decoded| decoded.split(":") }
.tap { |username, password| invalid_format! if username.nil? || password.nil? }
end

def authenticate!(username, password)
creds = ::Authentication::Credentials.find_by(username:)
invalid_creds! if creds.nil?
invalid_creds! unless creds.authenticate(password)

creds.owner
end

def invalid_format!
raise Error, t!("errors.invalid_format", header: HEADER, scheme: SCHEME)
end

def invalid_creds!
raise Error, t!("errors.invalid_creds")
end

def invalid_scheme?(scheme)
scheme != SCHEME
end
end
end
end
21 changes: 21 additions & 0 deletions app/lib/translations.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module Translations
private

def _translations_base_path
self.class.name.split("::").map(&:underscore)
end

def t(path, **options)
I18n.t(_translations_full_path(path), **options)
end

def t!(path, **options)
I18n.t!(_translations_full_path(path), **options)
end

def _translations_full_path(path)
[*_translations_base_path, path].join(".")
end
end
4 changes: 4 additions & 0 deletions app/models/admin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

class Admin < ApplicationRecord
end
7 changes: 7 additions & 0 deletions app/models/authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

module Authentication
def self.table_name_prefix
"authentication_"
end
end
9 changes: 9 additions & 0 deletions app/models/authentication/credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true

module Authentication
class Credentials < ApplicationRecord
belongs_to :owner, polymorphic: true

has_secure_password
end
end
1 change: 1 addition & 0 deletions config/initializers/inflections.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@

ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "API"
inflect.acronym "REST"
end
40 changes: 9 additions & 31 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
@@ -1,33 +1,11 @@
# Files in the config/locales directory are used for internationalization
# and are automatically loaded by Rails. If you want to use locales other
# than English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
# I18n.t "hello"
#
# In views, this is aliased to just `t`:
#
# <%= t("hello") %>
#
# To use a different locale, set it with `I18n.locale`:
#
# I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# The following keys must be escaped otherwise they will not be retrieved by
# the default I18n backend:
#
# true, false, on, off, yes, no
#
# Instead, surround them with single quotes.
#
# en:
# "true": "foo"
#
# To learn more, please read the Rails Internationalization guide
# available at https://guides.rubyonrails.org/i18n.html.

en:
rest:
services:
authentication:
errors:
missing_header: Header "%{header}" is missing"
invalid_format: >
Invalid payload for the header %{header}
Please, use the following format: "%{scheme} Base64(USERNAME:PASSWORD)
invalid_creds: Invalid username or password
hello: "Hello world"
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@

# Defines the root path route ("/")
# root "articles#index"
mount API => "/"
mount REST::API => "/"
end
16 changes: 16 additions & 0 deletions db/migrate/20230331175515_create_auth_credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class CreateAuthCredentials < ActiveRecord::Migration[7.0]
def change
create_table :authentication_credentials do |t|
t.string :username, null: false, index: { unique: true }
t.text :password_digest, null: false

t.string :owner_type, null: false
t.bigint :owner_id, null: false
t.timestamps
end

add_index(:authentication_credentials, %i[owner_id owner_type], unique: true)
end
end
7 changes: 7 additions & 0 deletions db/migrate/20230331210005_create_admins.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class CreateAdmins < ActiveRecord::Migration[7.0]
def change
create_table :admins, &:timestamps
end
end
18 changes: 17 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions lib/tasks/admins.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

namespace :admin do
task create: :environment do
username = ENV.fetch("ADMIN_USERNAME")
password = ENV.fetch("ADMIN_PASSWORD")

Authentication::Credentials.create!(username:, password:, owner: Admin.create!)
token = Base64.encode64("#{username}:#{password}")
payload = "Bearer #{token}"
puts "Your payload for #{REST::Services::Authentication::HEADER} header: #{payload}"
end
end
8 changes: 8 additions & 0 deletions test/factories/authentication/credentials.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

FactoryBot.define do
factory :authentication_credentials, class: "Authentication::Credentials" do
password { Faker::Internet.password }
username { Faker::Internet.username }
end
end
Loading

0 comments on commit 66a5c73

Please sign in to comment.