From 7b1ceb76595837f81248c7f634fe2abe653ecf7d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 16 Jul 2024 18:05:53 +0200 Subject: [PATCH] Add basic sessions generator (#52328) * Add basic sessions generator * Excess CR * Use required fields * Add sessions generator test * Fix generated migration * Test migration content * Appease rubocop * Add CHANGELOG --- railties/CHANGELOG.md | 19 ++++++ .../lib/rails/generators/rails/sessions/USAGE | 6 ++ .../rails/sessions/sessions_generator.rb | 34 ++++++++++ .../controllers/concerns/authentication.rb | 64 +++++++++++++++++++ .../controllers/sessions_controller.rb | 22 +++++++ .../sessions/templates/models/current.rb | 5 ++ .../sessions/templates/models/session.rb | 5 ++ .../rails/sessions/templates/models/user.rb | 4 ++ .../templates/views/sessions/new.html.erb | 15 +++++ .../generators/sessions_generator_test.rb | 53 +++++++++++++++ 10 files changed, 227 insertions(+) create mode 100644 railties/lib/rails/generators/rails/sessions/USAGE create mode 100644 railties/lib/rails/generators/rails/sessions/sessions_generator.rb create mode 100644 railties/lib/rails/generators/rails/sessions/templates/controllers/concerns/authentication.rb create mode 100644 railties/lib/rails/generators/rails/sessions/templates/controllers/sessions_controller.rb create mode 100644 railties/lib/rails/generators/rails/sessions/templates/models/current.rb create mode 100644 railties/lib/rails/generators/rails/sessions/templates/models/session.rb create mode 100644 railties/lib/rails/generators/rails/sessions/templates/models/user.rb create mode 100644 railties/lib/rails/generators/rails/sessions/templates/views/sessions/new.html.erb create mode 100644 railties/test/generators/sessions_generator_test.rb diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 92fac863c5..fb25e175b1 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,22 @@ +* Add sessions generator to give a basic start to an authentication system using database-tracked sessions. + + + # Generate with... + bin/rails sessions + + # Generated files + app/models/current.rb + app/models/user.rb + app/models/session.rb + app/controllers/sessions_controller.rb + app/views/sessions/new.html.erb + db/migrate/xxxxxxx_create_users.rb + db/migrate/xxxxxxx_create_sessions.rb + + + *DHH* + + * Add not-null type modifier to migration attributes. diff --git a/railties/lib/rails/generators/rails/sessions/USAGE b/railties/lib/rails/generators/rails/sessions/USAGE new file mode 100644 index 0000000000..07c7f9a483 --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/USAGE @@ -0,0 +1,6 @@ +Description: + Generates a basic sessions system with user authentication. + +Example: + `bin/rails generate sessions` + diff --git a/railties/lib/rails/generators/rails/sessions/sessions_generator.rb b/railties/lib/rails/generators/rails/sessions/sessions_generator.rb new file mode 100644 index 0000000000..699529c02d --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/sessions_generator.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Rails + module Generators + class SessionsGenerator < Base # :nodoc: + def create_session_files + template "models/session.rb", File.join("app/models/session.rb") + template "models/user.rb", File.join("app/models/user.rb") + template "models/current.rb", File.join("app/models/current.rb") + + template "controllers/sessions_controller.rb", File.join("app/controllers/sessions_controller.rb") + template "controllers/concerns/authentication.rb", File.join("app/controllers/concerns/authentication.rb") + + template "views/sessions/new.html.erb", File.join("app/views/sessions/new.html.erb") + end + + def configure_application + gsub_file "app/controllers/application_controller.rb", /(class ApplicationController < ActionController::Base)/, "\\1\n include Authentication" + route "resource :session" + end + + def enable_bcrypt + # FIXME: Make more resilient in case the default comment has been removed + gsub_file "Gemfile", /# gem "bcrypt"/, 'gem "bcrypt"' + execute_command :bundle, "" + end + + def add_migrations + generate "migration CreateUsers email_address:string!:uniq password_digest:string! --force" + generate "migration CreateSessions user:references token:token ip_address:string user_agent:string --force" + end + end + end +end diff --git a/railties/lib/rails/generators/rails/sessions/templates/controllers/concerns/authentication.rb b/railties/lib/rails/generators/rails/sessions/templates/controllers/concerns/authentication.rb new file mode 100644 index 0000000000..4dee1b96bf --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/templates/controllers/concerns/authentication.rb @@ -0,0 +1,64 @@ +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? + Current.session.present? + end + + def require_authentication + resume_session || request_authentication + end + + + def resume_session + if session = find_session_by_cookie + set_current_session session + end + end + + def find_session_by_cookie + if token = cookies.signed[:session_token] + Session.find_by(token: token) + end + 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| + set_current_session session + end + end + + def set_current_session(session) + Current.session = session + cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax } + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_token) + end +end + diff --git a/railties/lib/rails/generators/rails/sessions/templates/controllers/sessions_controller.rb b/railties/lib/rails/generators/rails/sessions/templates/controllers/sessions_controller.rb new file mode 100644 index 0000000000..782a0a47df --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/templates/controllers/sessions_controller.rb @@ -0,0 +1,22 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." } + + def new + redirect_to root_url if authenticated? + 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 +end diff --git a/railties/lib/rails/generators/rails/sessions/templates/models/current.rb b/railties/lib/rails/generators/rails/sessions/templates/models/current.rb new file mode 100644 index 0000000000..644a437fcb --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/templates/models/current.rb @@ -0,0 +1,5 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end + diff --git a/railties/lib/rails/generators/rails/sessions/templates/models/session.rb b/railties/lib/rails/generators/rails/sessions/templates/models/session.rb new file mode 100644 index 0000000000..7cf469ce9c --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/templates/models/session.rb @@ -0,0 +1,5 @@ +class Session < ApplicationRecord + has_secure_token + belongs_to :user +end + diff --git a/railties/lib/rails/generators/rails/sessions/templates/models/user.rb b/railties/lib/rails/generators/rails/sessions/templates/models/user.rb new file mode 100644 index 0000000000..0088fb37fa --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/templates/models/user.rb @@ -0,0 +1,4 @@ +class User < ApplicationRecord + has_secure_password validations: false + has_many :sessions, dependent: :destroy +end diff --git a/railties/lib/rails/generators/rails/sessions/templates/views/sessions/new.html.erb b/railties/lib/rails/generators/rails/sessions/templates/views/sessions/new.html.erb new file mode 100644 index 0000000000..6bd9d722ed --- /dev/null +++ b/railties/lib/rails/generators/rails/sessions/templates/views/sessions/new.html.erb @@ -0,0 +1,15 @@ +<%% if alert = flash[:alert] %> +
<%%= alert %>
+<%% end %> + +<%%= form_with url: session_url do |form| %> +
+ <%%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %> +
+ +
+ <%%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %> +
+ + <%%= form.submit "Sign in" %> +<%% end %> diff --git a/railties/test/generators/sessions_generator_test.rb b/railties/test/generators/sessions_generator_test.rb new file mode 100644 index 0000000000..3b4837774f --- /dev/null +++ b/railties/test/generators/sessions_generator_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "generators/generators_test_helper" +require "rails/generators/rails/app/app_generator" +require "rails/generators/rails/sessions/sessions_generator" + +class SessionsGeneratorTest < Rails::Generators::TestCase + include GeneratorsTestHelper + + def setup + Rails.application = TestApp::Application + Rails.application.config.root = Pathname(destination_root) + end + + def teardown + Rails.application = Rails.application.instance + end + + def test_session_generator + self.class.tests Rails::Generators::AppGenerator + run_generator([destination_root]) + + self.class.tests Rails::Generators::SessionsGenerator + run_generator + + assert_file "app/models/user.rb" + assert_file "app/models/current.rb" + assert_file "app/models/session.rb" + assert_file "app/controllers/sessions_controller.rb" + assert_file "app/controllers/concerns/authentication.rb" + assert_file "app/views/sessions/new.html.erb" + + assert_file "app/controllers/application_controller.rb" do |content| + assert_match(/include Authentication/, content) + end + + assert_file "Gemfile" do |content| + assert_match(/\ngem "bcrypt"/, content) + end + + assert_file "config/routes.rb" do |content| + assert_match(/resource :session/, content) + end + + assert_migration "db/migrate/create_sessions.rb" do |content| + assert_match(/t.references :user, null: false, foreign_key: true/, content) + end + + assert_migration "db/migrate/create_users.rb" do |content| + assert_match(/t.string :password_digest, null: false/, content) + end + end +end