diff --git a/Gemfile b/Gemfile index 72d2ea1..282fa0b 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,7 @@ gem 'devise' # Flexible authentication solution for Rails with Warden # Authentications & Authorizations gem 'pundit' # Minimal authorization through OO design and pure Ruby classes +gem 'doorkeeper' # OAuth 2 provider for your Rails / Grape app # Assets gem 'sassc' diff --git a/Gemfile.lock b/Gemfile.lock index ce57ccf..d26739f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -167,6 +167,8 @@ GEM docile (1.4.0) dockerfile-rails (1.3.0) rails + doorkeeper (5.6.6) + railties (>= 5) erubi (1.12.0) fabrication (2.30.0) faraday (2.7.5) @@ -464,6 +466,7 @@ DEPENDENCIES devise discard dockerfile-rails (>= 1.2) + doorkeeper fabrication ffaker figaro diff --git a/app/controllers/api/v1/application_controller.rb b/app/controllers/api/v1/application_controller.rb new file mode 100644 index 0000000..d4c8f48 --- /dev/null +++ b/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 diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 0000000..c303593 --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Api + module V1 + class UsersController < ApplicationController + skip_before_action :doorkeeper_authorize!, only: %i[create] + + def create + user = build_user + client_app = find_client_application + + return render(json: { error: 'Invalid client ID' }, status: :forbidden) unless client_app + + if user.save + access_token = create_access_token(user, client_app) + render_success_response(user, access_token) + else + render_failure_response(user) + end + end + + private + + def user_params + params.permit(:email, :password) + end + + def build_user + User.new(email: user_params[:email], password: user_params[:password]) + end + + def find_client_application + Doorkeeper::Application.find_by(uid: params[:client_id]) + end + + def generate_refresh_token + loop do + token = SecureRandom.hex(32) + break token unless Doorkeeper::AccessToken.exists?(refresh_token: token) + end + end + + def create_access_token(user, client_app) + Doorkeeper::AccessToken.create( + resource_owner_id: user.id, + application_id: client_app.id, + refresh_token: generate_refresh_token, + expires_in: Doorkeeper.configuration.access_token_expires_in.to_i, + scopes: '' + ) + end + + def render_success_response(user, access_token) + user_data = build_user_data(user, access_token) + render(json: { user: user_data }) + end + + def build_user_data(user, access_token) + { + id: user.id, + email: user.email, + access_token: access_token.token, + token_type: 'bearer', + expires_in: access_token.expires_in, + refresh_token: access_token.refresh_token, + created_at: access_token.created_at.to_time.to_i + } + end + + def render_failure_response(user) + render(json: { error: user.errors.full_messages }, status: :unprocessable_entity) + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c1805be..999dc4e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,4 +3,6 @@ class ApplicationController < ActionController::Base include Localization include Pagy::Backend + + before_action :authenticate_user!, unless: -> { is_a?(HealthCheckController) } end diff --git a/app/models/concerns/authenticable.rb b/app/models/concerns/authenticable.rb index b556429..c3bc140 100644 --- a/app/models/concerns/authenticable.rb +++ b/app/models/concerns/authenticable.rb @@ -7,5 +7,12 @@ module Authenticable # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable + + # the authenticate method from devise documentation + # todo extract it inside authenticable + def self.authenticate(email, password) + user = User.find_for_authentication(email: email) + user&.valid_password?(password) ? user : nil + end end end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 0000000..53741db --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +Doorkeeper.configure do + # Change the ORM that doorkeeper will use (requires ORM extensions installed). + # Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms + orm :active_record + + # This block will be called to check whether the resource owner is authenticated or not. + resource_owner_authenticator do + # Put your resource owner authentication logic here. + # Example implementation: + # User.find_by(id: session[:user_id]) || redirect_to(new_user_session_url) + end + + resource_owner_from_credentials do |_routes| + User.authenticate(params[:email], params[:password]) + end + + use_refresh_token + allow_blank_redirect_uri true + grant_flows %w[password] + skip_authorization do |resource_owner, client| + true + end + +end diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml new file mode 100644 index 0000000..99fa3d4 --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,151 @@ +en: + activerecord: + attributes: + doorkeeper/application: + name: 'Name' + redirect_uri: 'Redirect URI' + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: 'cannot contain a fragment.' + invalid_uri: 'must be a valid URI.' + unspecified_scheme: 'must specify a scheme.' + relative_uri: 'must be an absolute URI.' + secured_uri: 'must be an HTTPS/SSL URI.' + forbidden_uri: 'is forbidden by the server.' + scopes: + not_match_configured: "doesn't match configured on the server." + + doorkeeper: + applications: + confirmations: + destroy: 'Are you sure?' + buttons: + edit: 'Edit' + destroy: 'Destroy' + submit: 'Submit' + cancel: 'Cancel' + authorize: 'Authorize' + form: + error: 'Whoops! Check your form for possible errors' + help: + confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.' + redirect_uri: 'Use one line per URI' + blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI." + scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.' + edit: + title: 'Edit application' + index: + title: 'Your applications' + new: 'New Application' + name: 'Name' + callback_url: 'Callback URL' + confidential: 'Confidential?' + actions: 'Actions' + confidentiality: + 'yes': 'Yes' + 'no': 'No' + new: + title: 'New Application' + show: + title: 'Application: %{name}' + application_id: 'UID' + secret: 'Secret' + secret_hashed: 'Secret hashed' + scopes: 'Scopes' + confidential: 'Confidential' + callback_urls: 'Callback urls' + actions: 'Actions' + not_defined: 'Not defined' + + authorizations: + buttons: + authorize: 'Authorize' + deny: 'Deny' + error: + title: 'An error has occurred' + new: + title: 'Authorization required' + prompt: 'Authorize %{client_name} to use your account?' + able_to: 'This application will be able to' + show: + title: 'Authorization code' + form_post: + title: 'Submit this form' + + authorized_applications: + confirmations: + revoke: 'Are you sure?' + buttons: + revoke: 'Revoke' + index: + title: 'Your authorized applications' + application: 'Application' + created_at: 'Created At' + date_format: '%Y-%m-%d %H:%M:%S' + + pre_authorization: + status: 'Pre-authorization' + + errors: + messages: + # Common error messages + invalid_request: + unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.' + missing_param: 'Missing required parameter: %{value}.' + request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.' + invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI." + unauthorized_client: 'The client is not authorized to perform this request using this method.' + access_denied: 'The resource owner or authorization server denied the request.' + invalid_scope: 'The requested scope is invalid, unknown, or malformed.' + invalid_code_challenge_method: 'The code challenge method must be plain or S256.' + server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.' + temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.' + + # Configuration error messages + credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.' + resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.' + admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.' + + # Access grant errors + unsupported_response_type: 'The authorization server does not support this response type.' + unsupported_response_mode: 'The authorization server does not support this response mode.' + + # Access token errors + invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' + invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.' + unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.' + + invalid_token: + revoked: "The access token was revoked" + expired: "The access token expired" + unknown: "The access token is invalid" + revoke: + unauthorized: "You are not authorized to revoke this token" + + forbidden_token: + missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".' + + flash: + applications: + create: + notice: 'Application created.' + destroy: + notice: 'Application deleted.' + update: + notice: 'Application updated.' + authorized_applications: + destroy: + notice: 'Application revoked.' + + layouts: + admin: + title: 'Doorkeeper' + nav: + oauth2_provider: 'OAuth2 Provider' + applications: 'Applications' + home: 'Home' + application: + title: 'OAuth authorization required' diff --git a/config/routes.rb b/config/routes.rb index 032b63f..1aee3e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,8 @@ Rails.application.routes.draw do + use_doorkeeper do + skip_controllers :authorizations, :applications, :authorized_applications + end # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html devise_for :users @@ -9,4 +12,11 @@ get "/health_check", to: 'health_check#health_check', as: :rails_health_check resources :search_stats, only: [:index, :show] + + namespace :api do + namespace :v1 do + # User sign_up + resources :users, only: :create + end + end end diff --git a/db/migrate/20230615070454_create_doorkeeper_tables.rb b/db/migrate/20230615070454_create_doorkeeper_tables.rb new file mode 100644 index 0000000..e2cfac5 --- /dev/null +++ b/db/migrate/20230615070454_create_doorkeeper_tables.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class CreateDoorkeeperTables < ActiveRecord::Migration[7.0] + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + t.string :secret, null: false + + # Remove `null: false` if you are planning to use grant flows + # that doesn't require redirect URI to be used during authorization + # like Client Credentials flow or Resource Owner Password. + t.text :redirect_uri + t.string :scopes, null: false, default: '' + t.boolean :confidential, null: false, default: true + t.timestamps null: false + end + + add_index :oauth_applications, :uid, unique: true + + create_table :oauth_access_tokens do |t| + t.references :resource_owner, index: true + + # Remove `null: false` if you are planning to use Password + # Credentials Grant flow that doesn't require an application. + t.references :application, null: false + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + # t.text :token, null: false + t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.string :scopes + t.datetime :created_at, null: false + t.datetime :revoked_at + + # The authorization server MAY issue a new refresh token, in which case + # *the client MUST discard the old refresh token* and replace it with the + # new refresh token. The authorization server MAY revoke the old + # refresh token after issuing a new refresh token to the client. + # @see https://datatracker.ietf.org/doc/html/rfc6749#section-6 + # + # Doorkeeper implementation: if there is a `previous_refresh_token` column, + # refresh tokens will be revoked after a related access token is used. + # If there is no `previous_refresh_token` column, previous tokens are + # revoked as soon as a new access token is created. + # + # Comment out this line if you want refresh tokens to be instantly + # revoked after use. + t.string :previous_refresh_token, null: false, default: "" + end + + add_index :oauth_access_tokens, :token, unique: true + + # See https://github.com/doorkeeper-gem/doorkeeper/issues/1592 + if ActiveRecord::Base.connection.adapter_name == "SQLServer" + execute <<~SQL.squish + CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token) + WHERE refresh_token IS NOT NULL + SQL + else + add_index :oauth_access_tokens, :refresh_token, unique: true + end + + add_foreign_key( + :oauth_access_tokens, + :oauth_applications, + column: :application_id + ) + end +end diff --git a/db/schema.rb b/db/schema.rb index 5f377a0..fd91cf5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,40 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_06_12_121926) do +ActiveRecord::Schema.define(version: 2023_06_15_070454) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "plpgsql" + create_table "oauth_access_tokens", force: :cascade do |t| + t.bigint "resource_owner_id" + t.bigint "application_id", null: false + t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.string "scopes" + t.datetime "created_at", precision: 6, null: false + t.datetime "revoked_at", precision: 6 + t.string "previous_refresh_token", default: "", null: false + t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id" + t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true + t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" + t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true + end + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri" + t.string "scopes", default: "", null: false + t.boolean "confidential", default: true, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true + end + create_table "result_links", force: :cascade do |t| t.bigint "search_stat_id", null: false t.string "link_type", null: false @@ -54,6 +82,7 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id" add_foreign_key "result_links", "search_stats" add_foreign_key "search_stats", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 5ebbdc8..ebf87fe 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -8,6 +8,12 @@ require 'fabrication' +# if there is no OAuth application created, create them +if Doorkeeper::Application.count.zero? + Doorkeeper::Application.create(name: "iOS client", redirect_uri: "", scopes: "") + Doorkeeper::Application.create(name: "Android client", redirect_uri: "", scopes: "") +end + # Generate dummy data for SearchStat user = User.where(email: 'user@demo.com').first_or_create(Fabricate.attributes_for(:user, email: 'user@demo.com')) diff --git a/spec/fabricators/application_fabricator.rb b/spec/fabricators/application_fabricator.rb new file mode 100644 index 0000000..257c085 --- /dev/null +++ b/spec/fabricators/application_fabricator.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Fabricator(:application, from: Doorkeeper::Application) do + name 'Android client' +end diff --git a/spec/fabricators/search_stat_fabricator.rb b/spec/fabricators/search_stat_fabricator.rb index d39524c..da3e360 100644 --- a/spec/fabricators/search_stat_fabricator.rb +++ b/spec/fabricators/search_stat_fabricator.rb @@ -11,5 +11,5 @@ top_ad_count { rand(1..5) } status { rand(1..3) } raw_response { FFaker::HTMLIpsum.body } - user_id { demo_user.id } + user { demo_user } end diff --git a/spec/requests/api/v1/users_controller_spec.rb b/spec/requests/api/v1/users_controller_spec.rb new file mode 100644 index 0000000..9ad819c --- /dev/null +++ b/spec/requests/api/v1/users_controller_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::UsersController, type: :request do + describe 'POST /api/v1/users' do + context 'when a user registers' do + let(:create_user_params) do + application ||= Fabricate(:application) + { + email: 'dummy@dummy.com', + password: 'aaaaaaA1', + client_id: application.uid + } + end + + context 'given valid params' do + it 'returns the user' do + post '/api/v1/users', params: create_user_params + expect(JSON.parse(response.body)['user']['email']).to eq('dummy@dummy.com') + expect(response).to have_http_status(:success) + end + end + + context 'given an existing email' do + it 'receives an error' do + User.create(email: 'user@demo.com', password: 'Secret@11') + post '/api/v1/users', params: create_user_params.merge(email: 'user@demo.com') + expect(JSON.parse(response.body).keys).to contain_exactly('error') + end + end + + context 'given an invalid password' do + it 'receives an error' do + post '/api/v1/users', params: create_user_params.merge(password: '123') + expect(JSON.parse(response.body).keys).to contain_exactly('error') + end + end + + context 'given an invalid client_id' do + it 'receives an error' do + post '/api/v1/users', params: create_user_params.merge(client_id: 'not valid') + expect(JSON.parse(response.body).keys).to contain_exactly('error') + end + end + end + end +end