Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add user authentication #3

Merged
merged 1 commit into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
end
60 changes: 60 additions & 0 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
module Authentication
extend ActiveSupport::Concern

included do
before_action :require_authentication
helper_method :authenticated?, :current_user
end

class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
end

private

def current_user
Current.user
end

def authenticated?
Current.session.present?
end

def require_authentication
resume_session || request_authentication
end


def resume_session
Current.session = find_session_by_cookie
end

def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id])
end


def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_url
end

def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end


def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
end
end

def terminate_session
Current.session.destroy
cookies.delete(:session_id)
end
end
34 changes: 34 additions & 0 deletions app/controllers/passwords_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class PasswordsController < ApplicationController
allow_unauthenticated_access

before_action :set_user_by_token, only: %i[edit update]

def new
end

def create
if user = User.find_by(email_address: params[:email_address])
PasswordsMailer.reset(user).deliver_later
end

redirect_to new_session_url, notice: "Password reset instructions sent (if user with that email address exists)."
end

def edit
end

def update
if @user.update(params.permit(:password, :password_confirmation))
redirect_to new_session_url, notice: "Password has been reset."
else
redirect_to edit_password_url(params[:token]), alert: "Passwords did not match."
end
end

private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_url, alert: "Password reset link is invalid or has expired."
end
end
11 changes: 8 additions & 3 deletions app/controllers/posts_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class PostsController < ApplicationController
before_action :set_post, only: %i[ show edit update destroy ]
before_action :authorize_user!, only: %i[ edit update destroy ]

# GET /posts
def index
Expand Down Expand Up @@ -45,9 +46,13 @@ def set_post
@post = Post.find(params.expect(:id))
end


def post_params
# TODO: merge user_id from current user once authentication is built.
params.expect(post: [ :title, :message, :user_id ])
params.expect(post: [ :title, :message, :user_id ]).merge(user_id: current_user.id)
end

def authorize_user!
return if @post.user == current_user

redirect_to posts_path
end
end
28 changes: 28 additions & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[new create]

rate_limit to: 10, within: 3.minutes, only: :create, with: :redirect_to_new

def new
end

def create
if user = User.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_url, alert: "Try another email address or password."
end
end

def destroy
terminate_session
redirect_to new_session_url
end

private

def redirect_to_new
redirect_to new_session_url, alert: "Try again later."
end
end
6 changes: 6 additions & 0 deletions app/mailers/passwords_mailer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end
4 changes: 4 additions & 0 deletions app/models/current.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
delegate :user, to: :session, allow_nil: true
end
3 changes: 3 additions & 0 deletions app/models/session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
class Session < ApplicationRecord
belongs_to :user
end
9 changes: 8 additions & 1 deletion app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,24 @@ class User < ApplicationRecord
#-----------------------------------------------------------------------------

has_many :posts, dependent: :destroy
has_many :sessions, dependent: :destroy

#-----------------------------------------------------------------------------
# Validations
#-----------------------------------------------------------------------------

validates :name, presence: true
validates :email, presence: true, uniqueness: true
validates :email_address, presence: true, uniqueness: true

#-----------------------------------------------------------------------------
# Authentication
#-----------------------------------------------------------------------------

has_secure_password

#-----------------------------------------------------------------------------
# Attributes
#-----------------------------------------------------------------------------

normalizes :email_address, with: ->(e) { e.strip.downcase }
end
21 changes: 21 additions & 0 deletions app/views/passwords/edit.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>

<h1 class="font-bold text-4xl">Update your password</h1>

<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>

<div class="my-5">
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>

<div class="inline">
<%= form.submit "Save", class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
</div>
17 changes: 17 additions & 0 deletions app/views/passwords/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>

<h1 class="font-bold text-4xl">Forgot your password?</h1>

<%= form_with url: passwords_path, class: "contents" do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>

<div class="inline">
<%= form.submit "Email reset instructions", class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
<% end %>
</div>
4 changes: 4 additions & 0 deletions app/views/passwords_mailer/reset.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<p>
You can reset your password within the next 15 minutes on
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
</p>
2 changes: 2 additions & 0 deletions app/views/passwords_mailer/reset.text.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
You can reset your password within the next 15 minutes on this password reset page:
<%= edit_password_url(@user.password_reset_token) %>
3 changes: 0 additions & 3 deletions app/views/posts/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@
<%= form.rich_textarea :message, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>

<%# TODO: Remove once user authentication is added %>
<%= form.hidden_field :user_id, value: User.first.id %>

<div class="inline">
<%= form.submit class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>
Expand Down
31 changes: 31 additions & 0 deletions app/views/sessions/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<div class="mx-auto md:w-2/3 w-full">
<% if alert = flash[:alert] %>
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
<% end %>

<% if notice = flash[:notice] %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>

<h1 class="font-bold text-4xl">Sign in</h1>

<%= form_with url: session_url, class: "contents" do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>

<div class="my-5">
<%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72, class: "block shadow rounded-md border border-gray-400 outline-none px-3 py-2 mt-2 w-full" %>
</div>

<div class="col-span-6 sm:flex sm:items-center sm:gap-4">
<div class="inline">
<%= form.submit "Sign in", class: "rounded-lg py-3 px-5 bg-blue-600 text-white inline-block font-medium cursor-pointer" %>
</div>

<div class="mt-4 text-sm text-gray-500 sm:mt-0">
<%= link_to "Forgot password?", new_password_path, class: "text-gray-700 underline" %>
</div>
</div>
<% end %>
</div>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker

resources :passwords, param: :token
resources :posts
resource :session


root "posts#index"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
class CreateActiveStorageTables < ActiveRecord::Migration[8.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# This migration comes from action_text (originally 20180528164100)
class CreateActionTextTables < ActiveRecord::Migration[6.0]
class CreateActionTextTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :email_address, null: false
t.string :password_digest, null: false
t.string :name
t.string :password_digest

t.timestamps
end
add_index :users, :email, unique: true
add_index :users, :email_address, unique: true
end
end
11 changes: 11 additions & 0 deletions db/migrate/20241003060918_create_sessions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class CreateSessions < ActiveRecord::Migration[8.0]
def change
create_table :sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :ip_address
t.string :user_agent

t.timestamps
end
end
end
18 changes: 14 additions & 4 deletions db/schema.rb

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

4 changes: 2 additions & 2 deletions db/seeds.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
puts "Creating User <name=Test User email[email protected] password=test1234"
User.create(name: "Test User", email: "[email protected]", password: "test1234")
puts "Creating User <name=Test User email_address[email protected] password=test1234"
User.create(name: "Test User", email_address: "[email protected]", password: "test1234")
Loading