diff --git a/Gemfile b/Gemfile index 3753823..fda4ea5 100644 --- a/Gemfile +++ b/Gemfile @@ -9,6 +9,9 @@ gem 'rubocop', '>= 1.0', '< 2.0' # Use .env file to store environment variables gem 'dotenv-rails' +# Use Json Web Token (JWT) for token based authentication +gem 'jwt' + # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem 'rails', '~> 7.0.4', '>= 7.0.4.3' @@ -28,7 +31,7 @@ gem 'puma', '~> 5.0' # gem "kredis" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem 'bcrypt', '~> 3.1.7' # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] diff --git a/Gemfile.lock b/Gemfile.lock index 1f99aac..3f9f8b7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -89,6 +90,7 @@ GEM irb (1.6.4) reline (>= 0.3.0) json (2.6.3) + jwt (2.7.0) loofah (2.20.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) @@ -183,9 +185,11 @@ PLATFORMS x86_64-linux DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap debug dotenv-rails + jwt pg (~> 1.1) puma (~> 5.0) rails (~> 7.0.4, >= 7.0.4.3) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4ac8823..97ac86d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,24 @@ class ApplicationController < ActionController::API + before_action :authorize_request, except: %i[login signup] + + def not_found + render json: { message: 'Unable to access token', error: 'Token not found' }, status: :unauthorized + end + + # rubocop:disable Lint/UselessAssignment + + def authorize_request + header = request.headers['Authorization'] + header = header.split.last if header + begin + @decoded = JsonWebToken.decode(header) + @current_user = User.find(@decoded[:user_id]) + rescue ActiveRecord::RecordNotFound => e + render json: { errors: e.message }, status: :unauthorized + rescue JWT::DecodeError => e + not_found + end + end + + # rubocop:enable Lint/UselessAssignment end diff --git a/app/controllers/authentication_controller.rb b/app/controllers/authentication_controller.rb new file mode 100644 index 0000000..9385e20 --- /dev/null +++ b/app/controllers/authentication_controller.rb @@ -0,0 +1,66 @@ +class AuthenticationController < ApplicationController + # rubocop:disable Metrics/MethodLength + + def signup + @user = User.find_by_email(signup_params[:email]) + if @user.present? + render json: { message: 'Failed to create a user', error: 'User already exists' }, status: :conflict + else + @user = User.new(signup_params) + if @user.save + token = JsonWebToken.encode(user_id: @user.id) + time = Time.now + 24.hours.to_i + render json: { + token: token, + exp: time.strftime('%m-%d-%Y %H:%M'), + user: { + id: @user.id, + name: @user.name, + email: @user.email + } + }, status: :ok + elsif @user + render json: { message: 'Failed to create an account', error: 'Password cannot be less than 6 letters' }, + status: :unprocessable_entity + else + render json: { message: 'Failed to create an account', error: 'Validation failed' }, + status: :unprocessable_entity + end + end + end + + # rubocop:enable Metrics/MethodLength + + def login + @user = User.find_by_email(login_params[:email]) + if @user&.authenticate(login_params[:password]) + token = JsonWebToken.encode(user_id: @user.id) + time = Time.now + 24.hours.to_i + render json: { + token: token, + exp: time.strftime('%m-%d-%Y %H:%M'), + user: { + id: @user.id, + name: @user.name, + email: @user.email + } + }, status: :ok + elsif @user + render json: { message: 'You are not authorize to access this account', error: 'Incorrect password' }, + status: :unauthorized + else + render json: { message: 'You are not authorize to access this account', error: 'Incorrect email' }, + status: :unauthorized + end + end + + private + + def signup_params + params.require(:user).permit(:name, :email, :password) + end + + def login_params + params.require(:user).permit(:email, :password) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..76aa162 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,40 @@ +class UsersController < ApplicationController + before_action :authorize_request + before_action :find_user + + def index + @users = User.all + render json: @users, status: :ok + end + + def show + render json: @user, status: :ok + end + + def update + return if @user.update(user_params) + + render json: { errors: @user.errors.full_messages }, + status: :unprocessable_entity + end + + def destroy + if @user.destroy + render json: { success: 'User destroyed successfully' }, status: :ok + else + render :json, { error: 'Unable to destroy a user' }, status: :unprocessable_entity + end + end + + private + + def find_user + @user = User.find_by_id(params[:id]) + rescue ActiveRecord::RecordNotFound + render json: { errors: 'User not found' }, status: :not_found + end + + def user_params + params.permit(:name, :email, :password) + end +end diff --git a/app/lib/json_web_token.rb b/app/lib/json_web_token.rb new file mode 100644 index 0000000..e776736 --- /dev/null +++ b/app/lib/json_web_token.rb @@ -0,0 +1,13 @@ +class JsonWebToken + SECRET_KEY = Rails.application.secrets.secret_key_base.to_s + + def self.encode(payload, exp = 24.hours.from_now) + payload[:exp] = exp.to_i + JWT.encode(payload, SECRET_KEY) + end + + def self.decode(token) + decoded = JWT.decode(token, SECRET_KEY)[0] + HashWithIndifferentAccess.new decoded + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..771a9c5 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,8 @@ +class User < ApplicationRecord + has_secure_password + validates :email, presence: true, uniqueness: true + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :password, + length: { minimum: 6 }, + if: -> { new_record? || !password.nil? } +end diff --git a/config/routes.rb b/config/routes.rb index 262ffd5..6812171 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,10 @@ Rails.application.routes.draw do # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + resources :users + post '/auth/login', to: 'authentication#login' + post '/auth/signup', to: 'authentication#signup' + # Defines the root path route ("/") # root "articles#index" end diff --git a/db/migrate/20230413205555_create_users.rb b/db/migrate/20230413205555_create_users.rb new file mode 100644 index 0000000..33b1925 --- /dev/null +++ b/db/migrate/20230413205555_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[7.0] + def change + create_table :users do |t| + t.string :name + t.string :email + t.string :password_digest + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..9c3405b --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,25 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[7.0].define(version: 2023_04_13_205555) do + # These are extensions that must be enabled in order to support this database + enable_extension "plpgsql" + + create_table "users", force: :cascade do |t| + t.string "name" + t.string "email" + t.string "password_digest" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + +end diff --git a/test/controllers/authentication_controller_test.rb b/test/controllers/authentication_controller_test.rb new file mode 100644 index 0000000..ab5e221 --- /dev/null +++ b/test/controllers/authentication_controller_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class AuthenticationControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb new file mode 100644 index 0000000..6c3da77 --- /dev/null +++ b/test/controllers/users_controller_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class UsersControllerTest < ActionDispatch::IntegrationTest + # test "the truth" do + # assert true + # end +end diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..e2f5253 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + name: MyString + email: MyString + password_digest: MyString + +two: + name: MyString + email: MyString + password_digest: MyString diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..82f61e0 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require 'test_helper' + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end