Skip to content

Commit 4782d24

Browse files
committed
Implement advanced login
1 parent d62fa09 commit 4782d24

File tree

11 files changed

+167
-12
lines changed

11 files changed

+167
-12
lines changed

app/assets/stylesheets/application.bootstrap.scss

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,16 @@ input {
191191
}
192192
}
193193
}
194+
195+
.checkbox {
196+
margin-top: -10px;
197+
margin-bottom: 10px;
198+
span {
199+
margin-left: 20px;
200+
font-weight: normal;
201+
}
202+
}
203+
#session_remember_me {
204+
width: auto;
205+
margin-left: 0;
206+
}

app/controllers/sessions_controller.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def create
66
user = User.find_by(email: params[:session][:email].downcase)
77
if user && user.authenticate(params[:session][:password])
88
reset_session
9+
params[:session][:remember_me] == "1" ? remember(user) : forget(user)
910
log_in user
1011
redirect_to user
1112
else
@@ -15,7 +16,7 @@ def create
1516
end
1617

1718
def destroy
18-
log_out
19+
log_out if logged_in?
1920
redirect_to root_url, status: :see_other
2021
end
2122
end

app/helpers/sessions_helper.rb

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
module SessionsHelper
2+
23
# Logs in the given user.
34
def log_in(user)
45
session[:user_id] = user.id
6+
# Guard against session replay attacks.
7+
# See https://bit.ly/33UvK0w for more.
8+
session[:session_token] = user.session_token
9+
end
10+
11+
# Remembers a user in a persistent session.
12+
def remember(user)
13+
user.remember
14+
cookies.permanent.encrypted[:user_id] = user.id
15+
cookies.permanent[:remember_token] = user.remember_token
516
end
617

7-
# Returns the current logged-in user (if any).
18+
# Returns the user corresponding to the remember token cookie.
819
def current_user
9-
if session[:user_id]
10-
@current_user ||= User.find_by(id: session[:user_id])
20+
if (user_id = session[:user_id])
21+
user = User.find_by(id: user_id)
22+
if user && session[:session_token] == user.session_token
23+
@current_user = user
24+
end
25+
elsif (user_id = cookies.encrypted[:user_id])
26+
user = User.find_by(id: user_id)
27+
if user && user.authenticated?(cookies[:remember_token])
28+
log_in user
29+
@current_user = user
30+
end
1131
end
1232
end
1333

@@ -16,9 +36,17 @@ def logged_in?
1636
!current_user.nil?
1737
end
1838

39+
# Forgets a persistent session.
40+
def forget(user)
41+
user.forget
42+
cookies.delete(:user_id)
43+
cookies.delete(:remember_token)
44+
end
45+
1946
# Logs out the current user.
2047
def log_out
48+
forget(current_user)
2149
reset_session
2250
@current_user = nil
2351
end
24-
end
52+
end

app/models/user.rb

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,44 @@
11
class User < ApplicationRecord
2+
attr_accessor :remember_token
23
before_save { self.email = email.downcase }
34
validates :name, presence: true, length: { maximum: 50 }
45
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
56
validates :email, presence: true, length: { maximum: 255 },
67
format: { with: VALID_EMAIL_REGEX },
7-
uniqueness: { case_sensitive: false }
8+
uniqueness: true
89
has_secure_password
910
validates :password, presence: true, length: { minimum: 6 }
10-
1111
# Returns the hash digest of the given string.
1212
def User.digest(string)
13-
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
13+
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
14+
BCrypt::Engine.cost
1415
BCrypt::Password.create(string, cost: cost)
1516
end
17+
# Returns a random token.
18+
def User.new_token
19+
SecureRandom.urlsafe_base64
20+
end
21+
# Remembers a user in the database for use in persistent sessions.
22+
def remember
23+
self.remember_token = User.new_token
24+
update_attribute(:remember_digest, User.digest(remember_token))
25+
remember_digest
26+
end
27+
28+
# Returns a session token to prevent session hijacking.
29+
# We reuse the remember digest for convenience.
30+
def session_token
31+
remember_digest || remember
32+
end
33+
34+
# Returns true if the given token matches the digest.
35+
def authenticated?(remember_token)
36+
return false if remember_digest.nil?
37+
BCrypt::Password.new(remember_digest).is_password?(remember_token)
38+
end
39+
40+
# Forgets a user.
41+
def forget
42+
update_attribute(:remember_digest, nil)
43+
end
1644
end

app/views/sessions/new.html.erb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
<%= f.email_field :email, class: 'form-control' %>
88
<%= f.label :password %>
99
<%= f.password_field :password, class: 'form-control' %>
10+
<%= f.label :remember_me, class: "checkbox inline" do %>
11+
<%= f.check_box :remember_me %>
12+
<span>Remember me on this computer</span>
13+
<% end %>
1014
<%= f.submit "Log in", class: "btn btn-primary" %>
1115
<% end %>
1216
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class AddRememberDigestToUsers < ActiveRecord::Migration[8.0]
2+
def change
3+
add_column :users, :remember_digest, :string
4+
end
5+
end

db/schema.rb

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
require "test_helper"
2+
3+
class SessionsHelperTest < ActionView::TestCase
4+
def setup
5+
@user = users(:michael)
6+
remember(@user)
7+
end
8+
9+
test "current_user returns right user when session is nil" do
10+
assert_equal @user, current_user
11+
assert is_logged_in?
12+
end
13+
test "current_user returns nil when remember digest is wrong" do
14+
@user.update_attribute(:remember_digest, User.digest(User.new_token))
15+
assert_nil current_user
16+
end
17+
end

test/integration/users_login_test.rb

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
require "test_helper"
22

3-
class UsersLogin < ActionDispatch::IntegrationTest
3+
class UsersLoginTest < ActionDispatch::IntegrationTest
44
def setup
55
@user = users(:michael)
66
end
77
end
88

9-
class InvalidPasswordTest < UsersLogin
9+
class InvalidPasswordTest < UsersLoginTest
1010
test "login path" do
1111
get login_path
1212
assert_template "sessions/new"
@@ -22,7 +22,7 @@ class InvalidPasswordTest < UsersLogin
2222
end
2323
end
2424

25-
class ValidLogin < UsersLogin
25+
class ValidLogin < UsersLoginTest
2626
def setup
2727
super
2828
post login_path, params: { session: { email: @user.email,
@@ -64,4 +64,45 @@ class LogoutTest < Logout
6464
count: 0
6565
assert_select "a[href=?]", user_path(@user), count: 0
6666
end
67+
68+
test "login with valid information followed by logout" do
69+
post login_path, params: { session: { email: @user.email,
70+
password: "password" } }
71+
assert is_logged_in?
72+
assert_redirected_to @user
73+
follow_redirect!
74+
assert_template "users/show"
75+
assert_select "a[href=?]", login_path, count: 0
76+
assert_select "a[href=?]", logout_path
77+
assert_select "a[href=?]", user_path(@user)
78+
delete logout_path
79+
assert_response :see_other
80+
assert_not is_logged_in?
81+
assert_redirected_to root_url
82+
# Simulate a user clicking logout in a second window.
83+
delete logout_path
84+
follow_redirect!
85+
assert_select "a[href=?]", login_path
86+
assert_select "a[href=?]", logout_path,
87+
count: 0
88+
assert_select "a[href=?]", user_path(@user), count: 0
89+
end
90+
test "should still work after logout in second window" do
91+
delete logout_path
92+
assert_redirected_to root_url
93+
end
94+
end
95+
96+
class RememberingTest < UsersLoginTest
97+
test "login with remembering" do
98+
log_in_as(@user, remember_me: "1")
99+
assert_not cookies[:remember_token].blank?
100+
end
101+
test "login without remembering" do
102+
# Log in to set the cookie.
103+
log_in_as(@user, remember_me: "1")
104+
# Log in again and verify that the cookie is deleted.
105+
log_in_as(@user, remember_me: "0")
106+
assert cookies[:remember_token].blank?
107+
end
67108
end

test/models/user_test.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,7 @@ def setup
5656
@user.password = @user.password_confirmation = "a" * 5
5757
assert_not @user.valid?
5858
end
59+
test "authenticated? should return false for a user with nil digest" do
60+
assert_not @user.authenticated?("")
61+
end
5962
end

0 commit comments

Comments
 (0)