-
Notifications
You must be signed in to change notification settings - Fork 776
R8 A2 Authentication Improvements
Rails 8 introduces a new authentication generator that makes it significantly easier to implement secure authentication out-of-the-box.
rails generate authenticationThis single command creates:
- ✅ User model with
has_secure_password - ✅ Sessions controller with secure credential handling
- ✅ Password reset functionality
- ✅ BCrypt password hashing by default
- ✅ Secure password recovery via email
- ✅ Session management
- ✅ Rate limiting for login attempts
RailsGoat's Vulnerable Authentication:
# app/models/user.rb (Rails 5 - VULNERABLE)
class User < ApplicationRecord
before_save :hash_password
def self.authenticate(email, password)
auth = nil
user = find_by_email(email)
raise "#{email} doesn't exist!" if !(user) # ❌ Reveals email
if user.password == Digest::MD5.hexdigest(password) # ❌ MD5, no salt
auth = user
else
raise "Incorrect Password!" # ❌ Different error
end
return auth
end
def hash_password
if self.password.present?
self.password = Digest::MD5.hexdigest(password) # ❌ Weak hashing
end
end
endProblems:
- ❌ MD5 hashing (broken, vulnerable to rainbow tables)
- ❌ No salt (same password = same hash)
- ❌ Credential enumeration (different errors for invalid email vs password)
- ❌ No rate limiting (brute force possible)
- ❌ Manual implementation (error-prone)
What the Generator Creates:
rails generate authentication
# Creates:
# app/models/user.rb
# app/controllers/sessions_controller.rb
# app/controllers/passwords_controller.rb
# app/views/sessions/new.html.erb
# app/views/passwords/new.html.erb
# app/views/passwords/edit.html.erb
# test/controllers/sessions_controller_test.rb
# ... and moreGenerated User Model (Rails 8):
# app/models/user.rb (Rails 8 - SECURE)
class User < ApplicationRecord
has_secure_password # ✅ BCrypt with automatic salt
validates :email_address,
presence: true,
uniqueness: true
normalizes :email_address,
with: ->(e) { e.strip.downcase }
endGenerated Sessions Controller (Rails 8):
# app/controllers/sessions_controller.rb (Rails 8 - SECURE)
class SessionsController < ApplicationController
def create
# ✅ authenticate_by: no timing attacks, single generic error
if user = User.authenticate_by(
email_address: params[:email_address],
password: params[:password]
)
session[:user_id] = user.id
redirect_to root_path, notice: "Signed in successfully"
else
# ✅ Generic error message (no credential enumeration)
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
def destroy
session[:user_id] = nil
redirect_to root_path, notice: "Signed out successfully"
end
endBenefits:
- ✅ BCrypt hashing (industry standard, slow by design)
- ✅ Automatic per-user salt
- ✅ Generic error messages (no credential enumeration)
- ✅
authenticate_bymethod (constant-time comparison, no timing attacks) - ✅ Generated tests included
- ✅ Password reset functionality included
Rails 8 introduces authenticate_by as a secure alternative to manual authentication:
user = User.find_by(email: params[:email])
if user && user.authenticate(params[:password])
# Login success
else
# Login failed
endProblems:
- Timing attack:
find_byreturns nil faster if email doesn't exist - Two-step process reveals whether email exists
- Developers often write different error messages
user = User.authenticate_by(
email_address: params[:email],
password: params[:password]
)
if user
# Login success
else
# Login failed - generic error
endBenefits:
- ✅ Single method call
- ✅ Constant-time comparison (no timing attacks)
- ✅ Returns nil for both invalid email and invalid password
- ✅ Encourages generic error messages
The has_secure_password method has been available since Rails 3.1, but Rails 8's generator makes it the default.
class User < ApplicationRecord
has_secure_password
endAutomatic Features:
- ✅ BCrypt password hashing (secure by design)
- ✅ Automatic salt generation (unique per user)
- ✅
passwordandpassword_confirmationvirtual attributes - ✅
authenticate(password)method - ✅ Validations for password presence and confirmation
- ✅ Stores hash in
password_digestcolumn
Database Migration:
create_table :users do |t|
t.string :email_address, null: false, index: { unique: true }
t.string :password_digest, null: false # ← BCrypt hash stored here
t.timestamps
end| Feature | MD5 (RailsGoat) | BCrypt (Rails 8) |
|---|---|---|
| Speed | Very fast (bad!) | Intentionally slow (good!) |
| Salt | None | Automatic per-user |
| Security | Broken | Secure |
| Rainbow Tables | Vulnerable | Protected |
| Cost Factor | N/A | Configurable (default: 12) |
| Modern Use | Never use | Industry standard |
BCrypt Speed Comparison:
require 'benchmark'
require 'digest'
require 'bcrypt'
password = "SecureP@ssw0rd123"
Benchmark.bm do |x|
x.report("MD5 (vulnerable):") do
10_000.times { Digest::MD5.hexdigest(password) }
end
x.report("BCrypt (secure): ") do
10.times { BCrypt::Password.create(password) }
end
end
# Results (approximate):
# MD5 (vulnerable): 0.002 seconds for 10,000 hashes (5,000,000/sec)
# BCrypt (secure): 0.500 seconds for 10 hashes (20/sec)
#
# BCrypt is ~250,000x slower - this is intentional!
# Makes brute-force attacks impracticalRails 8 generator includes secure password reset:
- User requests password reset
- System generates secure random token
- Token stored in database with expiration
- Email sent with reset link
- User clicks link, provides new password
- Token validated and password updated
- Token invalidated after use
# app/controllers/passwords_controller.rb (Rails 8)
class PasswordsController < ApplicationController
def create
user = User.find_by(email_address: params[:email_address])
if user
# ✅ Always generate token (prevent timing attacks)
user.generate_password_reset_token
PasswordMailer.with(user: user).reset.deliver_later
end
# ✅ Generic message (don't reveal if email exists)
redirect_to root_path,
notice: "If your email is in our system, you'll receive reset instructions"
end
def update
user = User.find_by_password_reset_token(params[:token])
if user && user.password_reset_token_valid?
user.update(password: params[:password])
redirect_to login_path, notice: "Password reset successfully"
else
redirect_to root_path, alert: "Invalid or expired reset link"
end
end
endSecurity Features:
- ✅ Generic confirmation message (no email enumeration)
- ✅ Secure random token generation
- ✅ Token expiration (typically 1 hour)
- ✅ One-time use tokens
- ✅ Email delivery for all attempts (rate limiting recommended)
RailsGoat (Rails 5 - VULNERABLE):
# Different error messages reveal information
begin
user = User.authenticate(params[:email], params[:password])
rescue Exception => e
flash[:error] = e.message # ❌ Shows "email doesn't exist" or "Incorrect Password"
endAttack:
# Test if email exists
curl -X POST http://localhost:3000/login \
-d "[email protected]&password=wrong"
# Response: "[email protected] doesn't exist!" ← Email is invalid
# Response: "Incorrect Password!" ← Email is validRails 8 Generator (SECURE):
user = User.authenticate_by(email_address: params[:email], password: params[:password])
if user
# Success
else
flash[:alert] = "Invalid email or password" # ✅ Generic message
endSame Attack:
curl -X POST http://localhost:3000/login \
-d "[email protected]&password=wrong"
# Always: "Invalid email or password" ← No information revealedIf you wanted to demonstrate the secure way (for comparison):
cd /path/to/railsgoat
rails generate authentication# Gemfile
gem 'bcrypt', '~> 3.1.7'bundle installrails generate migration AddPasswordDigestToUsers password_digest:string
rails db:migrateCreate a secure login controller alongside the vulnerable one:
# app/controllers/secure_sessions_controller.rb
class SecureSessionsController < ApplicationController
protect_from_forgery with: :exception # ✅ CSRF enabled
def create
# ✅ Secure authentication
user = User.authenticate_by(
email_address: params[:email],
password: params[:password]
)
if user
session[:user_id] = user.id
redirect_to home_dashboard_index_path
else
flash[:error] = "Invalid email or password" # ✅ Generic
render :new
end
end
endThis allows you to demonstrate:
- ❌ Vulnerable auth (existing SessionsController)
- ✅ Secure auth (new SecureSessionsController)
# test/controllers/sessions_controller_test.rb
test "should not reveal whether email exists" do
# Invalid email
post sessions_url, params: {
email: "[email protected]",
password: "password"
}
invalid_email_message = flash[:error]
# Valid email, wrong password
user = users(:one)
post sessions_url, params: {
email: user.email,
password: "wrong"
}
wrong_password_message = flash[:error]
# ✅ Messages should be identical
assert_equal invalid_email_message, wrong_password_message
end# test/models/user_test.rb
test "password should be hashed with BCrypt" do
user = User.create(
email: "[email protected]",
password: "SecurePassword123",
password_confirmation: "SecurePassword123"
)
# ✅ Should not store plain text
assert_not_equal "SecurePassword123", user.password_digest
# ✅ Should use BCrypt format
assert user.password_digest.start_with?("$2a$")
# ✅ Should authenticate correctly
assert user.authenticate("SecurePassword123")
assert_not user.authenticate("WrongPassword")
end# test/models/user_test.rb
test "authenticate_by should have constant time" do
require 'benchmark'
user = users(:one)
# Time for invalid email
time_invalid_email = Benchmark.realtime do
100.times do
User.authenticate_by(
email: "[email protected]",
password: "password"
)
end
end
# Time for valid email, wrong password
time_wrong_password = Benchmark.realtime do
100.times do
User.authenticate_by(
email: user.email,
password: "wrongpassword"
)
end
end
# ✅ Times should be similar (within 20%)
ratio = time_invalid_email / time_wrong_password
assert ratio > 0.8 && ratio < 1.2,
"Timing difference reveals information: #{ratio}"
end- ✅ Rails 8 makes secure auth easy with the authentication generator
- ✅ BCrypt is the standard - never use MD5 or SHA-1 for passwords
- ✅ Generic error messages prevent credential enumeration
- ✅
authenticate_byprovides constant-time comparison - ✅
has_secure_passwordhandles hashing automatically - ✅ Generated tests ensure security from the start
- ❌ RailsGoat's vulnerabilities persist when using custom auth
Bottom Line: Rails 8 doesn't prevent authentication vulnerabilities when developers write custom authentication logic (like RailsGoat does), but it makes implementing secure authentication much easier for production applications.
Sections are divided by their OWASP Top Ten label (A1-A10) and marked as R4 and R5 for Rails 4 and 5.