diff options
| -rw-r--r-- | Gemfile | 2 | ||||
| -rw-r--r-- | Gemfile.lock | 2 | ||||
| -rw-r--r-- | app/controllers/application_controller.rb | 1 | ||||
| -rw-r--r-- | app/controllers/concerns/authentication.rb | 52 | ||||
| -rw-r--r-- | app/controllers/passwords_controller.rb | 26 | ||||
| -rw-r--r-- | app/controllers/sessions_controller.rb | 21 | ||||
| -rw-r--r-- | app/models/current.rb | 4 | ||||
| -rw-r--r-- | app/models/session.rb | 3 | ||||
| -rw-r--r-- | app/models/user.rb | 6 | ||||
| -rw-r--r-- | app/views/passwords/edit.html.erb | 9 | ||||
| -rw-r--r-- | app/views/passwords/new.html.erb | 8 | ||||
| -rw-r--r-- | app/views/sessions/new.html.erb | 11 | ||||
| -rw-r--r-- | config/routes.rb | 2 | ||||
| -rw-r--r-- | db/migrate/20251029004408_create_users.rb | 11 | ||||
| -rw-r--r-- | db/migrate/20251029004409_create_sessions.rb | 11 | ||||
| -rw-r--r-- | db/schema.rb | 35 |
16 files changed, 203 insertions, 1 deletions
@@ -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 |
