aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRaghuram Subramani <raghus2247@gmail.com>2025-10-28 20:44:22 -0400
committerRaghuram Subramani <raghus2247@gmail.com>2025-10-28 20:47:58 -0400
commit176a8596c5a9fa49cc16d66ea4067e38a38f96c8 (patch)
tree65ebfd5749b1bfaf77836298c69912f55f5c2de0
parent09300c87a63a6f68c81fbed88dc50136ffe7789a (diff)
rails: g authentication
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock2
-rw-r--r--app/controllers/application_controller.rb1
-rw-r--r--app/controllers/concerns/authentication.rb52
-rw-r--r--app/controllers/passwords_controller.rb26
-rw-r--r--app/controllers/sessions_controller.rb21
-rw-r--r--app/models/current.rb4
-rw-r--r--app/models/session.rb3
-rw-r--r--app/models/user.rb6
-rw-r--r--app/views/passwords/edit.html.erb9
-rw-r--r--app/views/passwords/new.html.erb8
-rw-r--r--app/views/sessions/new.html.erb11
-rw-r--r--config/routes.rb2
-rw-r--r--db/migrate/20251029004408_create_users.rb11
-rw-r--r--db/migrate/20251029004409_create_sessions.rb11
-rw-r--r--db/schema.rb35
16 files changed, 203 insertions, 1 deletions
diff --git a/Gemfile b/Gemfile
index d5188d1..114685d 100644
--- a/Gemfile
+++ b/Gemfile
@@ -15,7 +15,7 @@ gem "turbo-rails"
gem "stimulus-rails"
# 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 ]
diff --git a/Gemfile.lock b/Gemfile.lock
index ee6745b..321687d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,6 +77,7 @@ GEM
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
+ bcrypt (3.1.20)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.18.6)
@@ -300,6 +301,7 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
+ bcrypt (~> 3.1.7)
bootsnap
brakeman
bundler-audit
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c353756..5f38f02 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -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
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
new file mode 100644
index 0000000..3538f48
--- /dev/null
+++ b/app/controllers/concerns/authentication.rb
@@ -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
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
new file mode 100644
index 0000000..b1123f7
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -0,0 +1,26 @@
+class PasswordsController < ApplicationController
+ allow_unauthenticated_access
+ before_action :set_user_by_token, only: %i[ edit update ]
+
+ def new
+ end
+
+ def edit
+ end
+
+ def update
+ if @user.update(params.permit(:password, :password_confirmation))
+ @user.sessions.destroy_all
+ 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
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
new file mode 100644
index 0000000..cf7fccd
--- /dev/null
+++ b/app/controllers/sessions_controller.rb
@@ -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_path, 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, status: :see_other
+ end
+end
diff --git a/app/models/current.rb b/app/models/current.rb
new file mode 100644
index 0000000..2bef56d
--- /dev/null
+++ b/app/models/current.rb
@@ -0,0 +1,4 @@
+class Current < ActiveSupport::CurrentAttributes
+ attribute :session
+ delegate :user, to: :session, allow_nil: true
+end
diff --git a/app/models/session.rb b/app/models/session.rb
new file mode 100644
index 0000000..cf376fb
--- /dev/null
+++ b/app/models/session.rb
@@ -0,0 +1,3 @@
+class Session < ApplicationRecord
+ belongs_to :user
+end
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..c88d5b0
--- /dev/null
+++ b/app/models/user.rb
@@ -0,0 +1,6 @@
+class User < ApplicationRecord
+ has_secure_password
+ has_many :sessions, dependent: :destroy
+
+ normalizes :email_address, with: ->(e) { e.strip.downcase }
+end
diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb
new file mode 100644
index 0000000..9f0c87c
--- /dev/null
+++ b/app/views/passwords/edit.html.erb
@@ -0,0 +1,9 @@
+<h1>Update your password</h1>
+
+<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
+
+<%= form_with url: password_path(params[:token]), method: :put do |form| %>
+ <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72 %><br>
+ <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72 %><br>
+ <%= form.submit "Save" %>
+<% end %>
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb
new file mode 100644
index 0000000..44efb2b
--- /dev/null
+++ b/app/views/passwords/new.html.erb
@@ -0,0 +1,8 @@
+<h1>Forgot your password?</h1>
+
+<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
+
+<%= form_with url: passwords_path do |form| %>
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
+ <%= form.submit "Email reset instructions" %>
+<% end %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb
new file mode 100644
index 0000000..ff641c4
--- /dev/null
+++ b/app/views/sessions/new.html.erb
@@ -0,0 +1,11 @@
+<%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
+<%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
+
+<%= form_with url: session_path do |form| %>
+ <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %><br>
+ <%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %><br>
+ <%= form.submit "Sign in" %>
+<% end %>
+<br>
+
+<%= link_to "Forgot password?", new_password_path %>
diff --git a/config/routes.rb b/config/routes.rb
index 48254e8..29b007b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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.
diff --git a/db/migrate/20251029004408_create_users.rb b/db/migrate/20251029004408_create_users.rb
new file mode 100644
index 0000000..71f2ff1
--- /dev/null
+++ b/db/migrate/20251029004408_create_users.rb
@@ -0,0 +1,11 @@
+class CreateUsers < ActiveRecord::Migration[8.1]
+ 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
diff --git a/db/migrate/20251029004409_create_sessions.rb b/db/migrate/20251029004409_create_sessions.rb
new file mode 100644
index 0000000..ec9efdb
--- /dev/null
+++ b/db/migrate/20251029004409_create_sessions.rb
@@ -0,0 +1,11 @@
+class CreateSessions < ActiveRecord::Migration[8.1]
+ 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
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..77c9795
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,35 @@
+# 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[8.1].define(version: 2025_10_29_004409) do
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "pg_catalog.plpgsql"
+
+ create_table "sessions", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "ip_address"
+ t.datetime "updated_at", null: false
+ t.string "user_agent"
+ t.bigint "user_id", null: false
+ t.index ["user_id"], name: "index_sessions_on_user_id"
+ end
+
+ create_table "users", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.string "email_address", null: false
+ t.string "password_digest", null: false
+ t.datetime "updated_at", null: false
+ t.index ["email_address"], name: "index_users_on_email_address", unique: true
+ end
+
+ add_foreign_key "sessions", "users"
+end