Skip to content

Commit

Permalink
Create initial User and Session modules using the auth generator
Browse files Browse the repository at this point in the history
This is the result of running:

```
bin/rails generate authentication
```

which notably runs:

```
generate  migration CreateUsers email_address:string!:uniq password_digest:string! --force
generate  migration CreateSessions user:references ip_address:string user_agent:string --force
```

db migrations were then run.
  • Loading branch information
ScottGarman committed Dec 28, 2024
1 parent e9affce commit 002b71e
Show file tree
Hide file tree
Showing 23 changed files with 286 additions and 2 deletions.
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ gem "tailwindcss-rails"
gem "jbuilder"

# 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[ windows jruby ]
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
base64 (0.2.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin)
bcrypt_pbkdf (1.1.1-x86_64-darwin)
Expand Down Expand Up @@ -379,6 +380,7 @@ PLATFORMS
x86_64-linux-musl

DEPENDENCIES
bcrypt (~> 3.1.7)
bootsnap
brakeman
capybara
Expand Down
16 changes: 16 additions & 0 deletions app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user

def connect
set_current_user || reject_unauthorized_connection
end

private
def set_current_user
if session = Session.find_by(id: cookies.signed[:session_id])
self.current_user = session.user
end
end
end
end
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
52 changes: 52 additions & 0 deletions app/controllers/concerns/authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
module Authentication
extend ActiveSupport::Concern

included do
before_action :require_authentication
helper_method :authenticated?
end

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

private
def authenticated?
resume_session
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]) if cookies.signed[:session_id]
end

def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
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
33 changes: 33 additions & 0 deletions app/controllers/passwords_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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_path, 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_path, notice: "Password has been reset."
else
redirect_to edit_password_path(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_path, alert: "Password reset link is invalid or has expired."
end
end
21 changes: 21 additions & 0 deletions app/controllers/sessions_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }

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_path, alert: "Try another email address or password."
end
end

def destroy
terminate_session
redirect_to new_session_path
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
6 changes: 6 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy

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) %>
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
@@ -1,4 +1,6 @@
Rails.application.routes.draw do
resource :session
resources :passwords, param: :token
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
Expand Down
11 changes: 11 additions & 0 deletions db/migrate/20241228044347_create_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email_address, null: false
t.string :password_digest, null: false

t.timestamps
end
add_index :users, :email_address, unique: true
end
end
11 changes: 11 additions & 0 deletions db/migrate/20241228044348_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
20 changes: 19 additions & 1 deletion db/schema.rb

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

9 changes: 9 additions & 0 deletions test/fixtures/users.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<% password_digest = BCrypt::Password.create("password") %>

one:
email_address: one@example.com
password_digest: <%= password_digest %>

two:
email_address: two@example.com
password_digest: <%= password_digest %>
7 changes: 7 additions & 0 deletions test/mailers/previews/passwords_mailer_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
class PasswordsMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
def reset
PasswordsMailer.reset(User.take)
end
end
7 changes: 7 additions & 0 deletions test/models/user_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
require "test_helper"

class UserTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

0 comments on commit 002b71e

Please sign in to comment.