Skip to content

Commit 75e933e

Browse files
authored
✨ Add Rails 8 style authentication (#183)
I started my own simple auth way back, but never finished (and never pushed more than the user model). Adding the default rails 8 auth as a starting point for other things. * Remove my first attempt, then run the rails 8 authentication generator * Customize the boilerplate: - Build out some user fixtures and tests - Use view components where practical - Change verbiage to be Tron-inspired ("Identify yourself, User") because I'm feeling retro * Changes: - Allow unauthenticated access on home page * Additions: - Add sign out button to navbar - Add a system test helper for authentication (the helper built into Rails 8.1 only works for controller tests)
1 parent 4752b6c commit 75e933e

27 files changed

+333
-40
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
module ApplicationCable
22
class Connection < ActionCable::Connection::Base
3+
identified_by :current_user
4+
5+
def connect
6+
set_current_user || reject_unauthorized_connection
7+
end
8+
9+
private
10+
def set_current_user
11+
if session = Session.find_by(id: cookies.signed[:session_id])
12+
self.current_user = session.user
13+
end
14+
end
315
end
416
end

app/components/navbar_component.html.erb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@
2626

2727
<%# Right %>
2828
<div class="sm:ml-6 sm:block">
29-
<div class="flex items-center">
29+
<div class="flex items-center space-x-4">
30+
<%# Admin link %>
31+
<% if helpers.authenticated? %>
32+
<%= button_to("Sign out", session_path, method: :delete, class: "text-solar1 hover:text-solar3 px-3 py-2 text-sm font-medium") %>
33+
<% end %>
34+
3035
<div data-controller="theme">
3136
<button class="hover:text-solar3" data-action="click->theme#toggle">
3237
<%= render IconComponent.new(name: "circle-half-stroke") %>
@@ -36,4 +41,4 @@
3641
</div>
3742
</div>
3843
</div>
39-
</nav>
44+
</nav>

app/controllers/application_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
class ApplicationController < ActionController::Base
2+
include Authentication
3+
24
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
35
allow_browser versions: :modern
46

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
module Authentication
2+
extend ActiveSupport::Concern
3+
4+
included do
5+
before_action :require_authentication
6+
helper_method :authenticated?
7+
end
8+
9+
class_methods do
10+
def allow_unauthenticated_access(**options)
11+
skip_before_action :require_authentication, **options
12+
end
13+
end
14+
15+
private
16+
def authenticated?
17+
resume_session
18+
end
19+
20+
def require_authentication
21+
resume_session || request_authentication
22+
end
23+
24+
def resume_session
25+
Current.session ||= find_session_by_cookie
26+
end
27+
28+
def find_session_by_cookie
29+
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
30+
end
31+
32+
def request_authentication
33+
session[:return_to_after_authenticating] = request.url
34+
redirect_to new_session_path
35+
end
36+
37+
def after_authentication_url
38+
session.delete(:return_to_after_authenticating) || root_url
39+
end
40+
41+
def start_new_session_for(user)
42+
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
43+
Current.session = session
44+
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
45+
end
46+
end
47+
48+
def terminate_session
49+
Current.session.destroy
50+
cookies.delete(:session_id)
51+
end
52+
end

app/controllers/home_controller.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
class HomeController < ApplicationController
2+
allow_unauthenticated_access
3+
24
def index
35
end
46
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
class PasswordsController < ApplicationController
2+
allow_unauthenticated_access
3+
before_action :set_user_by_token, only: %i[ edit update ]
4+
5+
def new
6+
end
7+
8+
def create
9+
if user = User.find_by(email_address: params[:email_address])
10+
PasswordsMailer.reset(user).deliver_later
11+
end
12+
13+
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
14+
end
15+
16+
def edit
17+
end
18+
19+
def update
20+
if @user.update(params.permit(:password, :password_confirmation))
21+
redirect_to new_session_path, notice: "Password has been reset."
22+
else
23+
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
24+
end
25+
end
26+
27+
private
28+
def set_user_by_token
29+
@user = User.find_by_password_reset_token!(params[:token])
30+
rescue ActiveSupport::MessageVerifier::InvalidSignature
31+
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
32+
end
33+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
class SessionsController < ApplicationController
2+
allow_unauthenticated_access only: %i[ new create ]
3+
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
4+
5+
def new
6+
end
7+
8+
def create
9+
if user = User.authenticate_by(params.permit(:email_address, :password))
10+
start_new_session_for user
11+
redirect_to after_authentication_url, notice: "Welcome back, User."
12+
else
13+
redirect_to new_session_path, alert: "Access denied."
14+
end
15+
end
16+
17+
def destroy
18+
terminate_session
19+
redirect_to new_session_path
20+
end
21+
end

app/mailers/passwords_mailer.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class PasswordsMailer < ApplicationMailer
2+
def reset(user)
3+
@user = user
4+
mail subject: "Reset your password", to: user.email_address
5+
end
6+
end

app/models/current.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class Current < ActiveSupport::CurrentAttributes
2+
attribute :session
3+
delegate :user, to: :session, allow_nil: true
4+
end

app/models/session.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class Session < ApplicationRecord
2+
belongs_to :user
3+
end

0 commit comments

Comments
 (0)