From 3822f2b4ed8577d7fe80b853c1a4099444622f08 Mon Sep 17 00:00:00 2001 From: Andres Alvidrez Date: Tue, 2 Jul 2024 21:49:17 -0600 Subject: [PATCH] Add base User authentication --- Gemfile | 5 ++- Gemfile.lock | 5 ++- app/controllers/application_controller.rb | 3 ++ app/controllers/concerns/authentication.rb | 35 +++++++++++++++ app/controllers/main_controller.rb | 5 +++ app/controllers/password_resets_controller.rb | 43 +++++++++++++++++++ app/controllers/passwords_controller.rb | 24 +++++++++++ app/controllers/registrations_controller.rb | 24 +++++++++++ app/controllers/sessions_controller.rb | 23 ++++++++++ app/mailers/password_mailer.rb | 5 +++ app/models/current.rb | 3 ++ app/models/user.rb | 11 +++++ app/views/layouts/application.html.erb | 37 ++++++++++------ app/views/main/index.html.erb | 1 + .../password_mailer/password_reset.html.erb | 1 + app/views/password_resets/edit.html.erb | 23 ++++++++++ app/views/password_resets/new.html.erb | 12 ++++++ app/views/passwords/edit.html.erb | 28 ++++++++++++ app/views/registrations/new.html.erb | 28 ++++++++++++ app/views/sessions/new.html.erb | 19 ++++++++ config/environments/development.rb | 4 ++ config/routes.rb | 7 ++- db/migrate/20240628211820_create_users.rb | 1 + .../20240703041238_add_index_to_user_email.rb | 5 +++ db/schema.rb | 4 +- 25 files changed, 338 insertions(+), 18 deletions(-) create mode 100644 app/controllers/concerns/authentication.rb create mode 100644 app/controllers/main_controller.rb create mode 100644 app/controllers/password_resets_controller.rb create mode 100644 app/controllers/passwords_controller.rb create mode 100644 app/controllers/registrations_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/mailers/password_mailer.rb create mode 100644 app/models/current.rb create mode 100644 app/views/main/index.html.erb create mode 100644 app/views/password_mailer/password_reset.html.erb create mode 100644 app/views/password_resets/edit.html.erb create mode 100644 app/views/password_resets/new.html.erb create mode 100644 app/views/passwords/edit.html.erb create mode 100644 app/views/registrations/new.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 db/migrate/20240703041238_add_index_to_user_email.rb diff --git a/Gemfile b/Gemfile index c51f9083..71c6492e 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,9 @@ gem "turbo-rails" # Authorization gem "action_policy", "~> 0.7.0" +# Authentication +gem "bcrypt" , "~> 3.1.20" + # Other gem "bootsnap", require: false gem "puma", ">= 5.0" @@ -39,7 +42,7 @@ group :development, :test do gem "debug", platforms: %i[mri windows] gem "dotenv" gem "erb_lint", require: false - gem "letter_opener" + gem "letter_opener", "~> 1.10" gem "pry-byebug" gem "rspec-rails" gem "rubocop-capybara", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 5a4b4216..60d51abf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,7 @@ GEM ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) base64 (0.2.0) + bcrypt (3.1.20) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -471,12 +472,14 @@ GEM PLATFORMS arm64-darwin-21 + arm64-darwin-22 arm64-darwin-23 x86_64-linux DEPENDENCIES action_policy (~> 0.7.0) activerecord-enhancedsqlite3-adapter (~> 0.8.0) + bcrypt (~> 3.1.20) better_errors binding_of_caller bootsnap @@ -490,7 +493,7 @@ DEPENDENCIES erb_lint fuubar importmap-rails - letter_opener + letter_opener (~> 1.10) mission_control-jobs propshaft pry-byebug diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d12..658bada1 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication + + before_action :authenticate_user! end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 00000000..b256ba11 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,35 @@ +module Authentication + extend ActiveSupport::Concern + + included do + helper_method :current_user, :user_signed_in? + + def authenticate_user! + redirect_to root_path, alert: "You must be logged in to do that." unless user_signed_in? + end + + def current_user + Current.user ||= authenticate_user_from_session + end + + def authenticate_user_from_session + User.find_by(id: session[:user_id]) + end + + def user_signed_in? + current_user.present? + end + + def login(user) + Current.user = user + reset_session + session[:user_id] = user.id + end + + def logout + Current.user = nil + reset_session + end + + end +end diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb new file mode 100644 index 00000000..f6b17f11 --- /dev/null +++ b/app/controllers/main_controller.rb @@ -0,0 +1,5 @@ +class MainController < ApplicationController + skip_before_action :authenticate_user! + def index + end +end diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb new file mode 100644 index 00000000..776bc527 --- /dev/null +++ b/app/controllers/password_resets_controller.rb @@ -0,0 +1,43 @@ +class PasswordResetsController < ApplicationController + skip_before_action :authenticate_user!, only: [:new, :create] + before_action :set_user_by_token, only: [:edit, :update] + + def new + end + + def create + @user = User.find_by(email: params[:email]) + + if @user.present? + PasswordMailer.with( + user: @user, + token: @user.generate_token_for(:password_reset) + ).password_reset.deliver_later + end + + redirect_to root_path, notice: "Check your email to reset your password." + end + + def edit + end + + def update + if @user.update(password_params) + redirect_to new_session_path, notice: "Your password has been reset successfully. Please login." + else + render edit, status: :unprocessable_entity + end + end + + private + + def set_user_by_token + @user = User.find_by_token_for(:password_reset, params[:token]) + + redirect_to new_password_reset_path alert: "Invalid token, please try again" unless @user.present? + end + + def password_params + params.require(:user).permit(:password, :password_confirmation) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 00000000..7c6dc767 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,24 @@ +class PasswordsController < ApplicationController + before_action :authenticate_user! + + def edit + end + + def update + if current_user.update(password_params) + redirect_to edit_password_path, notice: "Your password has been updated successfully." + else + render :edit, status: :unprocessable_entity + end + end + + private + + def password_params + params.require(:user).permit( + :password, + :password_confirmation, + :password_challenge + ).with_defaults(password_challenge: "") + end +end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb new file mode 100644 index 00000000..ac1bec50 --- /dev/null +++ b/app/controllers/registrations_controller.rb @@ -0,0 +1,24 @@ +class RegistrationsController < ApplicationController + skip_before_action :authenticate_user! + + def new + @user = User.new + end + + def create + @user = User.new(registration_params) + + if @user.save + login @user + redirect_to root_path + else + render :new, status: :unprocessable_entity + end + end + + private + + def registration_params + params.require(:user).permit(:email, :password, :password_confirmation) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 00000000..d03cc2d0 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,23 @@ +class SessionsController < ApplicationController + skip_before_action :authenticate_user! + + def new + end + + def create + @user = User.authenticate_by(email: params[:email], password: params[:password]) + + if @user + login @user + redirect_to root_path, notice: "You have signed successfully." + else + flash[:alert] = "Invalid email or password." + render :new, status: :unprocessable_entity + end + end + + def destroy + logout + redirect_to root_path, notice: "You have been logged out." + end +end diff --git a/app/mailers/password_mailer.rb b/app/mailers/password_mailer.rb new file mode 100644 index 00000000..bfb7aa8d --- /dev/null +++ b/app/mailers/password_mailer.rb @@ -0,0 +1,5 @@ +class PasswordMailer < ApplicationMailer + def password_reset + mail to: params[:user].email + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 00000000..73a9744b --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user +end diff --git a/app/models/user.rb b/app/models/user.rb index bfc040e8..eedc902e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,17 @@ class User < ApplicationRecord + PASSWORD_RESET_EXPIRATION = 15.minutes + has_one :profile, as: :profileable, dependent: :destroy has_many :saved_events, dependent: :destroy has_many :events, through: :saved_events + + validates :email, presence: true, uniqueness: true + normalizes :email, with: ->(email) { email.strip.downcase } + + has_secure_password + + generates_token_for :password_reset, expires_in: PASSWORD_RESET_EXPIRATION do + password_salt&.last(10) + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5ddef884..2ee8be2b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,19 +1,28 @@ - - RailsWorld - - <%= csrf_meta_tags %> - <%= csp_meta_tag %> - <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> + + RailsWorld + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + <%#= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> - <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> - <%= javascript_importmap_tags %> - + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + - -
- <%= yield %> -
- + +
+
<%= notice %>
+
<%= alert %>
+ <% if user_signed_in? %> + <%= link_to "Edit Password", edit_password_path %> + <%= button_to "Log out", session_path, method: :delete %> + <% else %> + <%= link_to "Sign Up", new_registration_path %> + <%= link_to "Log in", new_session_path %> + <% end %> + <%= yield %> +
+ diff --git a/app/views/main/index.html.erb b/app/views/main/index.html.erb new file mode 100644 index 00000000..bae11ff6 --- /dev/null +++ b/app/views/main/index.html.erb @@ -0,0 +1 @@ +

Homepage

diff --git a/app/views/password_mailer/password_reset.html.erb b/app/views/password_mailer/password_reset.html.erb new file mode 100644 index 00000000..45954945 --- /dev/null +++ b/app/views/password_mailer/password_reset.html.erb @@ -0,0 +1 @@ +<%= link_to "Reset your password", edit_password_reset_url(token: params[:token]) %> diff --git a/app/views/password_resets/edit.html.erb b/app/views/password_resets/edit.html.erb new file mode 100644 index 00000000..333edd06 --- /dev/null +++ b/app/views/password_resets/edit.html.erb @@ -0,0 +1,23 @@ +

Reset Your Password

+ +<%= form_with model: @user, url: password_reset_path(token: params[:token]) do |form| %> + <% if form.object.errors.any? %> + <% form.object.errors.full_messages.each do |message| %> +
<%= message %>
+ <% end %> + <% end %> + +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation %> +
+ +
+ <%= form.submit "Reset Your Password" %> +
+<% end %> diff --git a/app/views/password_resets/new.html.erb b/app/views/password_resets/new.html.erb new file mode 100644 index 00000000..ea64ecff --- /dev/null +++ b/app/views/password_resets/new.html.erb @@ -0,0 +1,12 @@ +

Reset Your Password

+ +<%= form_with model: @user, url: password_reset_path do |form| %> +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ +
+ <%= form.submit "Reset password" %> +
+<% end %> diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb new file mode 100644 index 00000000..7a30272d --- /dev/null +++ b/app/views/passwords/edit.html.erb @@ -0,0 +1,28 @@ +

Update Password

+ +<%= form_with model: current_user, url: password_path do |form| %> + <% if form.object.errors.any? %> + <% form.object.errors.full_messages.each do |message| %> +
<%= message %>
+ <% end %> + <% end %> + +
+ <%= form.label :password_challenge, "Current Password" %> + <%= form.password_field :password_challenge %> +
+ +
+ <%= form.label :password, "New Password" %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation %> +
+ +
+ <%= form.submit "Update Password" %> +
+<% end %> diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb new file mode 100644 index 00000000..82160074 --- /dev/null +++ b/app/views/registrations/new.html.erb @@ -0,0 +1,28 @@ +

Sign Up

+ +<%= form_with model: @user, url: registration_path do |form| %> + <% if form.object.errors.any? %> + <% form.object.errors.full_messages.each do |message| %> +
<%= message %>
+ <% end %> + <% end %> + +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ +
+ <%= form.label :password_confirmation %> + <%= form.password_field :password_confirmation %> +
+ +
+ <%= form.submit "Sign Up" %> +
+<% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 00000000..426a07a5 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,19 @@ +

Log in

+ +<%= form_with model: @user, url: session_path do |form| %> +
+ <%= form.label :email %> + <%= form.email_field :email %> +
+ +
+ <%= form.label :password %> + <%= form.password_field :password %> +
+ +
+ <%= form.submit "Log in" %> +
+<% end %> + +<%= link_to "Forgot your password?", new_password_reset_path %> diff --git a/config/environments/development.rb b/config/environments/development.rb index e01622cf..1dde09d6 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -77,4 +77,8 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + config.action_mailer.delivery_method = :letter_opener + config.action_mailer.perform_deliveries = true end diff --git a/config/routes.rb b/config/routes.rb index 4283b751..96a018c0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,5 +4,10 @@ # TODO: authenticate with admin user mount MissionControl::Jobs::Engine, at: "/jobs" - # root "posts#index" + root "main#index" + + resource :registration, only: [:new, :create] + resource :session, only: [:new, :create, :destroy] + resource :password, only: [:edit, :update] + resource :password_reset, only: [:new, :create, :edit, :update] end diff --git a/db/migrate/20240628211820_create_users.rb b/db/migrate/20240628211820_create_users.rb index b023a1cb..f7377172 100644 --- a/db/migrate/20240628211820_create_users.rb +++ b/db/migrate/20240628211820_create_users.rb @@ -5,6 +5,7 @@ def change t.string :role t.boolean :mail_notifications_enabled, default: true, null: false t.boolean :in_app_notifications_enabled, default: true, null: false + t.string :password_digest, null: false t.timestamps end diff --git a/db/migrate/20240703041238_add_index_to_user_email.rb b/db/migrate/20240703041238_add_index_to_user_email.rb new file mode 100644 index 00000000..ff03658d --- /dev/null +++ b/db/migrate/20240703041238_add_index_to_user_email.rb @@ -0,0 +1,5 @@ +class AddIndexToUserEmail < ActiveRecord::Migration[7.1] + def change + add_index :users, :email, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 89a2649b..c6afd7ce 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_06_28_211903) do +ActiveRecord::Schema[7.1].define(version: 2024_07_03_041238) do create_table "conferences", force: :cascade do |t| t.string "name" t.datetime "created_at", null: false @@ -90,8 +90,10 @@ t.string "role" t.boolean "mail_notifications_enabled", default: true, null: false t.boolean "in_app_notifications_enabled", default: true, null: false + t.string "password_digest", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["email"], name: "index_users_on_email", unique: true end add_foreign_key "event_tags", "events"