Skip to content

Commit 7b1ceb7

Browse files
authored
Add basic sessions generator (rails#52328)
* Add basic sessions generator * Excess CR * Use required fields * Add sessions generator test * Fix generated migration * Test migration content * Appease rubocop * Add CHANGELOG
1 parent 3ce3a4e commit 7b1ceb7

File tree

10 files changed

+227
-0
lines changed

10 files changed

+227
-0
lines changed

railties/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
* Add sessions generator to give a basic start to an authentication system using database-tracked sessions.
2+
3+
4+
# Generate with...
5+
bin/rails sessions
6+
7+
# Generated files
8+
app/models/current.rb
9+
app/models/user.rb
10+
app/models/session.rb
11+
app/controllers/sessions_controller.rb
12+
app/views/sessions/new.html.erb
13+
db/migrate/xxxxxxx_create_users.rb
14+
db/migrate/xxxxxxx_create_sessions.rb
15+
16+
17+
*DHH*
18+
19+
120
* Add not-null type modifier to migration attributes.
221

322

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Description:
2+
Generates a basic sessions system with user authentication.
3+
4+
Example:
5+
`bin/rails generate sessions`
6+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
module Rails
4+
module Generators
5+
class SessionsGenerator < Base # :nodoc:
6+
def create_session_files
7+
template "models/session.rb", File.join("app/models/session.rb")
8+
template "models/user.rb", File.join("app/models/user.rb")
9+
template "models/current.rb", File.join("app/models/current.rb")
10+
11+
template "controllers/sessions_controller.rb", File.join("app/controllers/sessions_controller.rb")
12+
template "controllers/concerns/authentication.rb", File.join("app/controllers/concerns/authentication.rb")
13+
14+
template "views/sessions/new.html.erb", File.join("app/views/sessions/new.html.erb")
15+
end
16+
17+
def configure_application
18+
gsub_file "app/controllers/application_controller.rb", /(class ApplicationController < ActionController::Base)/, "\\1\n include Authentication"
19+
route "resource :session"
20+
end
21+
22+
def enable_bcrypt
23+
# FIXME: Make more resilient in case the default comment has been removed
24+
gsub_file "Gemfile", /# gem "bcrypt"/, 'gem "bcrypt"'
25+
execute_command :bundle, ""
26+
end
27+
28+
def add_migrations
29+
generate "migration CreateUsers email_address:string!:uniq password_digest:string! --force"
30+
generate "migration CreateSessions user:references token:token ip_address:string user_agent:string --force"
31+
end
32+
end
33+
end
34+
end
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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+
Current.session.present?
18+
end
19+
20+
def require_authentication
21+
resume_session || request_authentication
22+
end
23+
24+
25+
def resume_session
26+
if session = find_session_by_cookie
27+
set_current_session session
28+
end
29+
end
30+
31+
def find_session_by_cookie
32+
if token = cookies.signed[:session_token]
33+
Session.find_by(token: token)
34+
end
35+
end
36+
37+
38+
def request_authentication
39+
session[:return_to_after_authenticating] = request.url
40+
redirect_to new_session_url
41+
end
42+
43+
def after_authentication_url
44+
session.delete(:return_to_after_authenticating) || root_url
45+
end
46+
47+
48+
def start_new_session_for(user)
49+
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
50+
set_current_session session
51+
end
52+
end
53+
54+
def set_current_session(session)
55+
Current.session = session
56+
cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax }
57+
end
58+
59+
def terminate_session
60+
Current.session.destroy
61+
cookies.delete(:session_token)
62+
end
63+
end
64+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
class SessionsController < ApplicationController
2+
allow_unauthenticated_access
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+
redirect_to root_url if authenticated?
7+
end
8+
9+
def create
10+
if user = User.authenticate_by(params.permit(:email_address, :password))
11+
start_new_session_for user
12+
redirect_to after_authentication_url
13+
else
14+
redirect_to new_session_url, alert: "Try another email address or password."
15+
end
16+
end
17+
18+
def destroy
19+
terminate_session
20+
redirect_to new_session_url
21+
end
22+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Current < ActiveSupport::CurrentAttributes
2+
attribute :session
3+
delegate :user, to: :session, allow_nil: true
4+
end
5+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class Session < ApplicationRecord
2+
has_secure_token
3+
belongs_to :user
4+
end
5+
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class User < ApplicationRecord
2+
has_secure_password validations: false
3+
has_many :sessions, dependent: :destroy
4+
end
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<%% if alert = flash[:alert] %>
2+
<div style="color:red"><%%= alert %></div>
3+
<%% end %>
4+
5+
<%%= form_with url: session_url do |form| %>
6+
<div>
7+
<%%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address] %>
8+
</div>
9+
10+
<div>
11+
<%%= form.password_field :password, required: true, autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %>
12+
</div>
13+
14+
<%%= form.submit "Sign in" %>
15+
<%% end %>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
require "generators/generators_test_helper"
4+
require "rails/generators/rails/app/app_generator"
5+
require "rails/generators/rails/sessions/sessions_generator"
6+
7+
class SessionsGeneratorTest < Rails::Generators::TestCase
8+
include GeneratorsTestHelper
9+
10+
def setup
11+
Rails.application = TestApp::Application
12+
Rails.application.config.root = Pathname(destination_root)
13+
end
14+
15+
def teardown
16+
Rails.application = Rails.application.instance
17+
end
18+
19+
def test_session_generator
20+
self.class.tests Rails::Generators::AppGenerator
21+
run_generator([destination_root])
22+
23+
self.class.tests Rails::Generators::SessionsGenerator
24+
run_generator
25+
26+
assert_file "app/models/user.rb"
27+
assert_file "app/models/current.rb"
28+
assert_file "app/models/session.rb"
29+
assert_file "app/controllers/sessions_controller.rb"
30+
assert_file "app/controllers/concerns/authentication.rb"
31+
assert_file "app/views/sessions/new.html.erb"
32+
33+
assert_file "app/controllers/application_controller.rb" do |content|
34+
assert_match(/include Authentication/, content)
35+
end
36+
37+
assert_file "Gemfile" do |content|
38+
assert_match(/\ngem "bcrypt"/, content)
39+
end
40+
41+
assert_file "config/routes.rb" do |content|
42+
assert_match(/resource :session/, content)
43+
end
44+
45+
assert_migration "db/migrate/create_sessions.rb" do |content|
46+
assert_match(/t.references :user, null: false, foreign_key: true/, content)
47+
end
48+
49+
assert_migration "db/migrate/create_users.rb" do |content|
50+
assert_match(/t.string :password_digest, null: false/, content)
51+
end
52+
end
53+
end

0 commit comments

Comments
 (0)