Table of Contents generated with DocToc
- Ruby on Rails 6: Authenticating Users in a Rails Application
My notes from Pluralsight course
Versions:
rbenv local
# 2.7.2
rails --version
# Rails 6.1.6
- Will be building a news feed website using Google's RSS platform
- Will limit how much content users can access depending on whether they're logged in:
- Logged in users can view full articles
- Guest users can only see limited content
Start by scaffolding a new Rails project, then start server, then verify at http://localhost:3000/
:
rails new news
bin/rails s
Generate a homepage controller with an index method. This generates controller, view, helper, and styles:
bin/rails g controller Home index
create app/controllers/home_controller.rb
route get 'home/index'
invoke erb
create app/views/home
create app/views/home/index.html.erb
invoke test_unit
create test/controllers/home_controller_test.rb
invoke helper
create app/helpers/home_helper.rb
invoke test_unit
invoke assets
invoke scss
create app/assets/stylesheets/home.scss
Simple home view:
<!-- news/app/views/home/index.html.erb -->
<h1>Home</h1>
<p>This is the home page!</p>
Generator added route entry for get home page, let's also add a root
entry so the home page is the default view:
# news/config/routes.rb
Rails.application.routes.draw do
get 'home/index'
end
Should look like this:
To get rss news displaying on home page, add rss to Gemfile and bundle install
:
# news/Gemfile
# Family of libraries that support various formats of XML feeds
gem 'rss'
Add a view helper to return rss feed for google news:
Example url for search for "macbook": https://news.google.com/rss/search?q=macbook&hl=en-CA&gl=CA&ceid=CA%3Aen
How to figure out google news rss urls.
The RSS::Parser.parse(rss)
method returns an array of items containing title, description, and publication date:
# news/app/helpers/home_helper.rb
module HomeHelper
def articles(query)
require 'rss'
require 'open-uri'
# instructor's US feed
# url = "https://news.google.com/rss/search?cf=all*h1=en-US&pz=1&q=#{query}&gl=US&ceid=US:en"
# my Canadian feed
url = "https://news.google.com/rss/search?q=#{query}&hl=en-CA&gl=CA&ceid=CA%3Aen"
open(url) do |rss|
RSS::Parser.parse(rss)
end
end
end
Update home view to use the articles
helper method to search for "Google", and then iterate over the results.
Note about raw view helper from Rails:
This method outputs without escaping a string. Since escaping tags is now default, this can be used when you don't want Rails to automatically escape tags. This is not recommended if the data is coming from the user's input.
<!-- news/app/views/home/index.html.erb -->
<div class="articles">
<h1>Articles</h1>
<% articles('Google').items.each do |item| %>
<div class="article">
<h2><%= item.title %></h2>
<p><%= raw item.description %></p>
<p><%= item.pubDate %></p>
</div>
<% end %>
</div>
Add some custom styles to make each article look like a card:
// news/app/assets/stylesheets/home.scss
.articles {
display: flex;
flex-wrap: wrap;
background-color: rgb(241, 241, 241);
h1 {
flex: 0 0 100%;
text-align: center;
}
}
.article {
margin: 10px;
padding: 20px;
flex: 1 0 300px;
background-color: white;
box-shadow: 0 0 5px rgba(0,0,0,0.5);
border-radius: 3px;
display: flex;
flex-direction: column;
justify-content: space-between;
h2 {
margin: 0;
}
p {
margin: 5px 0;
}
}
Now homepage looks something like this:
Generate a user model with username and password_digest fields. Note field name password_digest
, not simply password
, this will be used to interact with bcrypt gem:
bin/rails g model user username password_digest
invoke active_record
create db/migrate/20220702133354_create_users.rb
create app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml
Generate users
controller with new
and create
methods:
bin/rails g controller users new create
create app/controllers/users_controller.rb
route get 'users/new'
get 'users/create'
invoke erb
create app/views/users
create app/views/users/new.html.erb
create app/views/users/create.html.erb
invoke test_unit
create test/controllers/users_controller_test.rb
invoke helper
create app/helpers/users_helper.rb
invoke test_unit
invoke assets
invoke scss
create app/assets/stylesheets/users.scss
Add bcrypt gem to Gemfile and install it.
# news/Gemfile
# Simple wrapper for safely handling passwords
gem 'bcrypt'
After bcrypt is installed, use has_secure_password
macro on user model. This tells Rails that the password field should be run through bcrypt before being saved in database in field named password_digest
. See the docs for this method.
# news/app/models/user.rb
class User < ApplicationRecord
has_secure_password
end
Run bin/rails db:migrate
to migrate db schema to get user table created:
== 20220702133354 CreateUsers: migrating ======================================
-- create_table(:users)
-> 0.0032s
== 20220702133354 CreateUsers: migrated (0.0045s) =============================
Use resources
method in router to define all routes for user resource all one line. This includes viewing an individual user, viewing all users, and creating a new user:
# news/config/routes.rb
Rails.application.routes.draw do
resources :users
get 'home/index'
root 'home#index'
end
The "new user" view has a form for the user model using the form_for view helper method.
<!-- news/app/views/users/new.html.erb -->
<h1>Users#new</h1>
<%= @user.errors.count %>
<%= form_for(@user) do |f| %>
<%= f.label :username %>
<%= f.text_field :username, placeholder: :username %>
<%= f.label :password %>
<%= f.password_field :password, placeholder: :password %>
<%= submit_tag "Create" %>
<% end %>
Define action methods in user controller:
# news/app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
@users = User.all
end
def new
@user = User.new
end
def create
@user = User.new(user_params)
if @user.save
redirect_to @user, alert: "User created successfully."
else
p @user.errors.count
# should have used render to ensure instance var for form still populated?
# redirect_to makes brand new request and goes through controller action (new) in this case
redirect_to new_user_path, alert: "Error creating user."
end
end
def user_params
params.require(:user).permit(:username, :password, :salt, :encrypted_password)
end
end
Look at the schema generated from running create user migration - notice password_digest
field which will contain the user's password after its run through bcrypt encryption:
# news/db/schema.rb
ActiveRecord::Schema.define(version: 2022_07_02_133354) do
create_table "users", force: :cascade do |t|
t.string "username"
t.string "password_digest"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
end
Start server with bin/rails s
and navigate to http://localhost:3000/users/new
to create a new user:
Fill out the form for "test_user" and some password, and click Create. User will be created. Browser submits POST to /users
endpoint which gets mapped to create
action in Users controller.
Network tab from browser:
After user creation will get error about Show action not defined because the user controller create
method is attempting to redirect to the show view with this line. Redirect means a new http request so it will go back through the controller, expecting to find a show
method:
redirect_to @user, alert: "User created successfully."
Rails server output. Notice bcrypt
has taken plain-text password from form, and saved it in password_digest
field in users
table as hashed value "$2a$12$kF40GpcaLKt3zhn95PHkheKzAhZj9G/jD4odJ8rl8fAzDAcJ/Y1rq". We didn't have to write this code.
Then notice it's attempting redirect to GET "/users/1".
Started POST "/users" for ::1 at 2022-07-03 09:44:31 -0400
Processing by UsersController#create as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "user"=>{"username"=>"test_user", "password"=>"[FILTERED]"}, "commit"=>"Create"}
TRANSACTION (0.1ms) begin transaction
↳ app/controllers/users_controller.rb:12:in `create'
User Create (3.1ms) INSERT INTO "users" ("username", "password_digest", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["username", "test_user"], ["password_digest", "$2a$12$kF40GpcaLKt3zhn95PHkheKzAhZj9G/jD4odJ8rl8fAzDAcJ/Y1rq"], ["created_at", "2022-07-03 13:44:31.437865"], ["updated_at", "2022-07-03 13:44:31.437865"]]
↳ app/controllers/users_controller.rb:12:in `create'
TRANSACTION (1.4ms) commit transaction
↳ app/controllers/users_controller.rb:12:in `create'
Redirected to http://localhost:3000/users/1
Completed 302 Found in 330ms (ActiveRecord: 4.6ms | Allocations: 2673)
Started GET "/users/1" for ::1 at 2022-07-03 09:44:31 -0400
AbstractController::ActionNotFound (The action 'show' could not be found for UsersController
...
Browser error for show view:
To fix error, need to define show
method in users controller. Make use of params[:id]
, which will contain for example 1
given a url of /users/1
:
# news/app/controllers/users_controller.rb
def show
@user = User.find(params[:id])
end
And also need a corresponding show view, using time_ago_in_words view helper to convert created_at into human readable version:
<!-- news/app/views/users/show.html.erb -->
<h1>Show</h1>
<p><%= @user.username %></p>
<p>Created <%= time_ago_in_words(@user.created_at) %> ago.</p>
Refresh show view http://localhost:3000/users/1
should now render in browser:
Define login route:
Rails.application.routes.draw do
resources :users
# new login route defined here
get 'home/login'
get 'home/index'
root 'home#index'
end
Add login
method to home controller (will come back to fill in implementation later):
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
end
def login
end
end
Add login view with simple form:
<!-- news/app/views/home/login.html.erb -->
<h1>Login</h1>
<form action="/home/login" method="POST">
<input type="text" name="username" placeholder="username">
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
Try to login by navigating to http://localhost:3000/home/login
, filling out the form with test_user
created earlier, and clicking "Login" button to submit the form:
When clicking the submit button, browser attempts to POST to "/home/login" route but will get a 404 error because we haven't defined this route yet (only defined get
in router).
Rails server output:
Started POST "/home/login" for ::1 at 2022-07-03 10:26:31 -0400
ActionController::RoutingError (No route matches [POST] "/home/login"):
Browser displays error:
Fix this by adding a post entry for login in the router:
# news/config/routes.rb
Rails.application.routes.draw do
resources :users
get 'home/login'
post 'home/login'
get 'home/index'
root 'home#index'
end
Try to login/submit the form again (can simply refresh http://localhost:3000/home/login
since we're already in the middle of POST):
This time get an error about authenticity token. Rails server output:
Started POST "/home/login" for ::1 at 2022-07-03 10:29:53 -0400
Processing by HomeController#login as HTML
Parameters: {"username"=>"test_user", "password"=>"[FILTERED]"}
Can't verify CSRF token authenticity.
Completed 422 Unprocessable Entity in 0ms (Allocations: 430)
ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken):
actionpack (6.1.6) lib/action_controller/metal/request_forgery_protection.rb:211:in `handle_unverified_request'
...
Browser shows:
To fix this, add authenticity_token
as a hidden field in login form. Note use of hidden_field_tag view helper to generate a hidden html input field:
<!-- news/app/views/home/login.html.erb -->
<h1>Login</h1>
<form action="/home/login" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="username" placeholder="username">
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
Refresh get view in browser http://localhost:3000/home/login
, dev tools shows hidden field:
Try to submit login form again, this time it "works", but remember controller login method does nothing for now, so the default is to render the same view again. Rails server output:
Started POST "/home/login" for ::1 at 2022-07-03 10:37:11 -0400
Processing by HomeController#login as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "username"=>"test_user", "password"=>"[FILTERED]"}
Rendering layout layouts/application.html.erb
Rendering home/login.html.erb within layouts/application
Rendered home/login.html.erb within layouts/application (Duration: 0.3ms | Allocations: 119)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 9.3ms | Allocations: 3773)
Completed 200 OK in 10ms (Views: 9.8ms | Allocations: 4188)
To implement home controller login
method, it's first useful to see what parameters are available.
Start by simply setting instance var @params
in controller:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
end
def login
@params = params
end
end
Add some temp debug to the login view:
<h1>Login</h1>
<form action="/home/login" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="username" placeholder="username">
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
<%= debug(params) %>
When first visiting GET http://localhost:3000/home/login
, params simply contain controller and action:
--- !ruby/object:ActionController::Parameters
parameters: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
controller: home
action: login
permitted: false
But after submitting the form (recall since no controller code has been implemented yet, default action is to render the same view, which maintains the instance vars). Now we can see the params contain the usename and password from the form fields:
--- !ruby/object:ActionController::Parameters
parameters: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
authenticity_token: 42HkhdmbqDquljh1d_uYSXNN6XJORzGcu2CGO9RDBjhMR7TZbf9ghpA2F-TcX20hn0bkejQZOATJ9YQihuxUwg
username: test_user
password: abc123
controller: home
action: login
permitted: false
Now that we know what params are available, we can implement the login logic in home controller. This isn't the final version, for now, simply find the user by the username given in params, and set it as an instance variable:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
end
def login
if params["username"]
user = User.find_by(username: params[:username])
@user = user
end
end
end
Add debug in the login view to output the @user
instance variable set by controller:
<h1>Login</h1>
<form action="/home/login" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="username" placeholder="username">
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
<h2>Debug params</h2>
<%= debug(params) %>
<h2>Debug user</h2>
<%= debug(@user) %>
Then navigate to http://localhost:3000/home/login
and login:
Finally, need to authenticate user. Call authenticate
method on user instance, passing in the password from params.
class HomeController < ApplicationController
def index
end
def login
if params["username"]
user = User.find_by(username: params[:username])
@valid = user.authenticate(params[:password])
puts("=== LOGIN @valid = #{@valid}")
end
end
end
Note that authenticate
is a method added by Rails ActiveModel. If the given password is correct, will return a user model instance, otherwise, returns boolean false.
Can find information about a method in rails console bin/rails c
:
user = User.find_by(username: "test_user")
user.method(:authenticate).inspect
# => "#<Method: User(id: integer, username: string, password_digest: string, created_at: datetime, updated_at: datetime)(#<ActiveModel::SecurePassword::InstanceMethodsOnActivation:0x00007f7e040f67f0>)#authenticate(authenticate_password)(unencrypted_password) /Users/dbaron/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/activemodel-6.1.6/lib/active_model/secure_password.rb:120>"
user.method(:authenticate).owner
# => #<ActiveModel::SecurePassword::InstanceMethodsOnActivation:0x00007f7e040f67f0>
See Ruby docs for Method.
See Rails source (couldn't find docs) for authenticate. Note that authenticate
method is an alias for authenticate_password
given that the model instance has a password
attribute.
Now go back to home/login view, fill out login form with incorrect password for test_user
and check Rails server output. Note that valid is false:
Started POST "/home/login" for ::1 at 2022-07-09 07:40:36 -0400
Processing by HomeController#login as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "username"=>"test_user", "password"=>"[FILTERED]"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "test_user"], ["LIMIT", 1]]
↳ app/controllers/home_controller.rb:7:in `login'
=== LOGIN @valid = false
Rendering layout layouts/application.html.erb
Rendering home/login.html.erb within layouts/application
Rendered home/login.html.erb within layouts/application (Duration: 0.4ms | Allocations: 119)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 15.6ms | Allocations: 3773)
Completed 200 OK in 367ms (Views: 48.7ms | ActiveRecord: 0.1ms | Allocations: 4746)
Try again with correct password for test_user
, this time its the user model instance:
Started POST "/home/login" for ::1 at 2022-07-09 07:42:35 -0400
Processing by HomeController#login as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "username"=>"test_user", "password"=>"[FILTERED]"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "test_user"], ["LIMIT", 1]]
↳ app/controllers/home_controller.rb:7:in `login'
=== LOGIN @valid = #<User:0x00007f7d659c8e18>
Rendering layout layouts/application.html.erb
Rendering home/login.html.erb within layouts/application
Rendered home/login.html.erb within layouts/application (Duration: 0.6ms | Allocations: 119)
[Webpacker] Everything's up-to-date. Nothing to do
Rendered layout layouts/application.html.erb (Duration: 8.3ms | Allocations: 3773)
Completed 200 OK in 318ms (Views: 8.9ms | ActiveRecord: 0.1ms | Allocations: 4747)
Need to setup app to send email. This is controlled by config.action_mailer.XXX
settings in news/config/environments/development.rb
. Instructor put in values for a gmail account but didn't explain what this is - probably want to use env vars rather than hard-coded password.
Also need to set host url so that can generate absolute urls in email (will do this later).
# news/config/environments/development.rb
# https://discuss.rubyonrails.org/t/define-host-so-absolute-urls-work-in-development-and-test/75085
config.action_mailer.default_url_options = { host: 'localhost:3000' } # for absolute urls in email
config.action_mailer.asset_host = "http://localhost:3000" # for image URLs in HTML email
# for development ignore send errors, just need to see generated email in server output
config.action_mailer.raise_delivery_errors = false
config.action_mailer.perform_caching = false
# example SMTP but will need SendGrid or some other service to actually work
# credentials should be env vars
config.action_mailer.smtp_settings = {
address: 'smtp.gmail.com',
port: 587,
user_name: 'example',
password: 'example',
authentication: 'plain',
enable_starttls_auto: true
}
Docs on using gmail with Action Mailer, but need to change personal gmail settings to allow it and need to setup app password which is no longer recommended for security?
Maybe try free tier of SendGrid and configure it.
Update user table/model to have email and reset token fields:
bin/rails generate migration AddResetsToUser
# news/db/migrate/20220709121116_add_resets_to_user.rb
class AddResetsToUser < ActiveRecord::Migration[6.1]
def change
change_table :users do |t|
t.string :email
t.string :reset
end
end
end
reset
token will be emailed to user to verify person requesting a password reset is the same as person that received the email.
Run migration with bin/rails db:migrate
.
Use Rails console bin/rails c
to add email to the test user:
user = User.find_by(username: "test_user")
user.update(email: "exampleemail@gmail.com")
Generate a password controller to handle reset
and forgot
actions. This will also add router entries to expose GET urls for password/reset and password/forgot, and also generate views:
bin/rails generate controller password reset forgot
Output:
create app/controllers/password_controller.rb
route get 'password/reset'
get 'password/forgot'
invoke erb
create app/views/password
create app/views/password/reset.html.erb
create app/views/password/forgot.html.erb
invoke test_unit
create test/controllers/password_controller_test.rb
invoke helper
create app/helpers/password_helper.rb
invoke test_unit
invoke assets
invoke scss
create app/assets/stylesheets/password.scss
Also generate email templates with:
bin/rails generate mailer ResetMailer
Output:
create app/mailers/reset_mailer.rb
invoke erb
create app/views/reset_mailer
invoke test_unit
create test/mailers/reset_mailer_test.rb
create test/mailers/previews/reset_mailer_preview.rb
Implement forgot password view. This needs to prompt user for their email. If a user exists with this email address, the controller needs to generate a token for this user and email it to them.
<!-- news/app/views/password/forgot.html.erb -->
<h1>Forgot Password Form</h1>
<form action="/password/forgot" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="email" placeholder="email">
<button type="submit">Submit</button>
</form>
Try this out by navigating to http://localhost:3000/password/forgot
:
However, in order to submit form, need to add post
methods to router. Note that both get
and post
will be mapped to the same password controller actions. The difference will be that GET won't have any parameters because a form isn't being submitted, whereas POST will have parameters from form submission. We'll be checking for this in the action method:
# news/config/routes.rb
Rails.application.routes.draw do
get 'password/reset'
post 'password/reset'
get 'password/forgot'
post 'password/forgot'
resources :users
get 'home/login'
post 'home/login'
get 'home/index'
root 'home#index'
end
Then implement controller forgot
action. Check if email
param exists. Best practice would be if email not found, simply render the same page with an error, but for this app, will raise a not found exception. If user is found, generate a token, save it to user's reset
field, and render the token back in plain text for now (will be replaced by email sending later).
Note use of rendering plain text to browser is explained in Rails Guide Layout and Rendering.
Also useful to read options for render method.
A real implementation would encrypt token before saving in db???
class PasswordController < ApplicationController
def reset
end
def forgot
if params[:email]
user = User.find_by(email: params[:email]) or not_found
token = SecureRandom.hex(10)
user.reset = token
user.save
render plain: user.reset
end
end
def not_found
raise ActionController::RoutingError.new('Not Found')
end
end
Try it out: Navigate to http://localhost:3000/password/forgot
, enter email saved to test_user earlier exampleemail@gmail.com
and submit form.
Rails server output shows:
Started POST "/password/forgot" for ::1 at 2022-07-10 08:35:35 -0400
Processing by PasswordController#forgot as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "email"=>"exampleemail@gmail.com"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "exampleemail@gmail.com"], ["LIMIT", 1]]
↳ app/controllers/password_controller.rb:7:in `forgot'
TRANSACTION (0.1ms) begin transaction
↳ app/controllers/password_controller.rb:10:in `forgot'
User Update (3.8ms) UPDATE "users" SET "updated_at" = ?, "reset" = ? WHERE "users"."id" = ? [["updated_at", "2022-07-10 12:35:35.762773"], ["reset", "df549da1538d34ec8e83"], ["id", 1]]
↳ app/controllers/password_controller.rb:10:in `forgot'
TRANSACTION (1.0ms) commit transaction
↳ app/controllers/password_controller.rb:10:in `forgot'
Rendering text template
Rendered text template (Duration: 0.1ms | Allocations: 26)
Completed 200 OK in 148ms (Views: 21.8ms | ActiveRecord: 5.2ms | Allocations: 9883)
And browser displays plain text token:
Now implement reset
action. Use query_parameters
to extract token from url such as http://localhost:3000/password/reset?token=dcd7160251b020d8403b
. Render a "not found" page if token not provided. Then lookup the user who has their reset
field saved as this token. For now, just render the username back in plain text.
Note that params
can also be used to access the token from url. See Rails Guides parameters for explanation:
Rails collects all of the parameters sent along with the request in the params hash, whether they are sent as part of the query string, or the post body. The request object has three accessors that give you access to these parameters depending on where they came from. The query_parameters hash contains parameters that were sent as part of the query string while the request_parameters hash contains parameters sent as part of the post body. The path_parameters hash contains parameters that were recognized by the routing as being part of the path leading to this particular controller and action.
# news/app/controllers/password_controller.rb
class PasswordController < ApplicationController
def reset
# instructor used query_parameters but params works just as well didn't explain why one over the other
token = request.query_parameters[:token] or not_found
# token = request.params[:token]
# find the user who has this token saved in their `reset` field:
user = User.find_by(reset: token) or not_found
render plain: user.username
end
def forgot
if params[:email]
user = User.find_by(email: params[:email]) or not_found
token = SecureRandom.hex(10)
user.reset = token
user.save
render plain: user.reset
end
end
def not_found
raise ActionController::RoutingError.new('Not Found')
end
end
To try this, first use Rails console bin/rails c
to get the users token that we saved earlier:
user = User.find_by(username: "test_user")
# User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."username" = ? LIMIT ? [["username", "test_user"], ["LIMIT", 1]]
# => #<User id: 1, username: "test_user", password_digest: [FILTERED], created_at: "2022-07-03 13:44:31.437865000 +0000", updated_at: "2022-07-10 12:35:35.762773000 +0000", email: "ex...
user.reset
# => "df549da1538d34ec8e83"
Then navigate to the password reset page, passing in this token as a query parameter http://localhost:3000/password/reset?token=df549da1538d34ec8e83
. Browser will display back in plain text the username: test_user
.
Now implement the reset password form. Note the hidden field for reset token to ensure users can't spoof reset password requests.
<!-- news/app/views/password/reset.html.erb -->
<h1>Reset Password for <%= @user.username %></h1>
<form action="/password/reset" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<%= hidden_field_tag "token", @user.reset %>
<input type="password" name="password" placeholder="New Password">
<button type="submit">Submit</button>
</form>
Now go back and update reset
method in password controller. So far it's only handling the get
request. Update it to also check for presence of password
param, and if present, update the users password and remove the token. Render plain text success message as response.
Note I had to change or
to ||
for first line otherwise it immediately fails when posting form because request.query_parameters[:token]
will be nil.
# news/app/controllers/password_controller.rb
class PasswordController < ApplicationController
def reset
token = request.query_parameters[:token] || params[:token] || not_found
@user = User.find_by(reset: token) or not_found
if params[:password]
@user.password = params[:password]
@user.reset = nil
@user.save
render plain: "Successfully reset password."
end
end
def forgot
if params[:email]
user = User.find_by(email: params[:email]) or not_found
token = SecureRandom.hex(10)
user.reset = token
user.save
render plain: user.reset
end
end
def not_found
raise ActionController::RoutingError.new('Not Found')
end
end
Try it out: Navigate to reset password view passing in token for test_user: http://localhost:3000/password/reset?token=df549da1538d34ec8e83
:
Provide a new password and submit form. Rails server output:
Started POST "/password/reset" for ::1 at 2022-07-10 09:38:39 -0400
Processing by PasswordController#reset as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "token"=>"[FILTERED]", "password"=>"[FILTERED]"}
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."reset" = ? LIMIT ? [["reset", "df549da1538d34ec8e83"], ["LIMIT", 1]]
↳ app/controllers/password_controller.rb:6:in `reset'
TRANSACTION (0.1ms) begin transaction
↳ app/controllers/password_controller.rb:12:in `reset'
User Update (0.5ms) UPDATE "users" SET "password_digest" = ?, "updated_at" = ?, "reset" = ? WHERE "users"."id" = ? [["password_digest", "$2a$12$sLRK9PXXoCXzvKSrmsBem.um0X2nVeq5oxQ328Og7Ni8TSWIaf5pO"], ["updated_at", "2022-07-10 13:38:39.479365"], ["reset", nil], ["id", 1]]
↳ app/controllers/password_controller.rb:12:in `reset'
TRANSACTION (1.3ms) commit transaction
↳ app/controllers/password_controller.rb:12:in `reset'
Rendering text template
Rendered text template (Duration: 0.1ms | Allocations: 25)
Completed 200 OK in 306ms (Views: 0.5ms | ActiveRecord: 2.1ms | Allocations: 3018)
Browser response:
Update forgot
action of password controller to NOT return the token in plain text response (that was just for demonstration, otherwise any user could reset any other users password). Instead, return a response saying something like "password reset link has been emailed to you".
# news/app/controllers/password_controller.rb
class PasswordController < ApplicationController
def reset
token = request.query_parameters[:token] || params[:token] || not_found
@user = User.find_by(reset: token) or not_found
if params[:password]
@user.password = params[:password]
@user.reset = nil
@user.save
render plain: "Successfully reset password."
end
end
def forgot
if params[:email]
user = User.find_by(email: params[:email]) or not_found
token = SecureRandom.hex(10)
user.reset = token
user.save
render plain: "A link to reset your password has been sent to that email if it exists. "
end
end
def not_found
raise ActionController::RoutingError.new('Not Found')
end
end
Finally, we need to actually email the token to user.
Define data to be included in email in ResetMailer
. We need the user's email address, and need to construct the reset password url, which consists of the route and token parameter:
# news/app/mailers/reset_mailer.rb
class ResetMailer < ApplicationMailer
def reset_password
@user = params[:user]
@url = "#{password_reset_url}?token=#{params[:token]}"
mail(to: @user.email, subject: 'Reset Password for News App')
end
end
Next define the email template, which will have access to the instance variables set in ResetMailer
. Can use the same templating syntax as in any Rails view:
<!-- news/app/views/reset_mailer/reset_password.html.erb -->
<h1>Reset your password for News App</h1>
<p>
Hi <%= @user.username %>, your password has been requested to be reset.
Reset it by clicking <a href="<%= @url %>">here</a>.
</p>
To send the email, update forgot
method of PasswordController
to invoke ResetMailer
:
# news/app/controllers/password_controller.rb
class PasswordController < ApplicationController
def reset
token = request.query_parameters[:token] || params[:token] || not_found
@user = User.find_by(reset: token) or not_found
if params[:password]
@user.password = params[:password]
@user.reset = nil
@user.save
render plain: "Successfully reset password."
end
end
def forgot
if params[:email]
# Update user with a reset token
user = User.find_by(email: params[:email]) or not_found
token = SecureRandom.hex(10)
user.reset = token
user.save
# Email token to user
ResetMailer.with(user: user, token: token).reset_password.deliver_now
# A simple response to browser
render plain: "A link to reset your password has been sent to that email if it exists. "
end
end
def not_found
raise ActionController::RoutingError.new('Not Found')
end
end
Rails API for ActionMailer::Base.
Let's try it out, navigate to http://localhost:3000/password/forgot
and fill in email field with our example user created earlier exampleemail@gmail.com
:
Clicking Submit will run the forgot
method in PasswordController
. Email sending will fail because of invalid smtp credentials configured earlier, but Rails server output will show the generated html email:
Started POST "/password/forgot" for ::1 at 2022-07-16 08:36:45 -0400
Processing by PasswordController#forgot as HTML
Parameters: {"authenticity_token"=>"[FILTERED]", "email"=>"exampleemail@gmail.com"}
User Load (6.0ms) SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ? [["email", "exampleemail@gmail.com"], ["LIMIT", 1]]
↳ app/controllers/password_controller.rb:16:in `forgot'
TRANSACTION (0.1ms) begin transaction
↳ app/controllers/password_controller.rb:19:in `forgot'
User Update (1.8ms) UPDATE "users" SET "updated_at" = ?, "reset" = ? WHERE "users"."id" = ? [["updated_at", "2022-07-16 12:36:46.030071"], ["reset", "83f0fba53b4a01ddb806"], ["id", 1]]
↳ app/controllers/password_controller.rb:19:in `forgot'
TRANSACTION (1.8ms) commit transaction
↳ app/controllers/password_controller.rb:19:in `forgot'
Rendering layout layouts/mailer.html.erb
Rendering reset_mailer/reset_password.html.erb within layouts/mailer
Rendered reset_mailer/reset_password.html.erb within layouts/mailer (Duration: 1.2ms | Allocations: 149)
Rendered layout layouts/mailer.html.erb (Duration: 2.2ms | Allocations: 319)
ResetMailer#reset_password: processed outbound mail in 12.5ms
Delivered mail 62d2b0de1d381_4e3a39946385c@some-machine.local.mail (348.0ms)
Date: Sat, 16 Jul 2022 08:36:46 -0400
From: from@example.com
To: exampleemail@gmail.com
Message-ID: <62d2b0de1d381_4e3a39946385c@some-machine.local.mail>
Subject: Reset Password for News App
Mime-Version: 1.0
Content-Type: text/html;
charset=UTF-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<h1>Reset your password for News App</h1>
<p>
Hi test_user, your password has been requested to be reset.
Reset it by clicking <a href="http://localhost:3000/password/reset?token=83f0fba53b4a01ddb806">here</a>.
</p>
</body>
</html>
Rendering text template
Rendered text template (Duration: 0.1ms | Allocations: 25)
Completed 200 OK in 536ms (Views: 2.9ms | ActiveRecord: 10.6ms | Allocations: 24722)
Try the reset link by copying it from Rails server output and paste it in browser url http://localhost:3000/password/reset?token=83f0fba53b4a01ddb806
:
Need to make app "remember" the user login between pages.
Session: Secure storage on backend of webapp to keep track of each user's information.
Need to correlate each session on website to a user in the database.
Firstly, make username
and email
fields on User
model unique. Not strictly required for sessions but is good practice:
# news/app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :username, :email, uniqueness: true
end
Now use Rails built-in feature to setup sessions.
Any code added to ApplicationController
will be inherited by all other controllers. When app was initially scaffolded, it's an empty class:
# news/app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
end
Since all other controllers inherit from it, this is the best place to implement authentication and session management:
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
before_action :authorized
helper_method :current_user
helper_method :logged_in?
# Access current user from session at any time
def current_user
User.find_by(id: session[:user_id])
end
# Determine if user is logged in
def logged_in?
!current_user.nil?
end
# Force an unauthenticated user to login
def authorized
redirect_to '/home/login' unless logged_in?
end
end
Rails API for controller helper_method:
Declare a controller method as a helper. For example, the following makes the current_user and logged_in? controller methods available to the view.
Rails API for controller before_action:
Append a callback before actions. See _insert_callbacks for parameter details. If the callback renders or redirects, the action will not run. If there are additional callbacks scheduled to run after that callback, they are also cancelled.
current_user
methods finds user in database for user_id
stored in session. If no such user can be found, assume user has logged out.
Update HomeController login
method to save the logged in user_id in the session, if the user's password was valid:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
def index
end
def login
if params["username"]
user = User.find_by(username: params[:username])
@valid = user.authenticate(params[:password])
if @valid
session[:user_id] = user.id
end
end
end
end
Try to view homepage at http://localhost:3000
, but there's an error due to infinite redirect loop:
Issue is Application controller is redirecting to /home/login
if user not logged in, BUT its in a before_action
callback which will run for every controller including the HomeController, so that also redirects, creating infinite loop.
To fix this, need to specify an exception for HomeController to skip the before_action
callback that's specified in the ApplicationController:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
skip_before_action :authorized
def index
end
def login
if params["username"]
user = User.find_by(username: params[:username])
@valid = user.authenticate(params[:password])
if @valid
session[:user_id] = user.id
end
end
end
end
Try to view login page now http://localhost:3000/home/login
, this time should display login form, and http://localhost:3000
should display homepage with list of news articles. This is because both login
and index
are part of HomeController, which is no longer executing authorized
method from ApplicationController due to exception: skip_before_action :authorized
.
Create a test user in console:
u = User.create!(username: "test1", email: "test1@test.com", password: "abc123")
Fill in login form with valid values, submit. Check browser dev tools -> Application. Will show cookie _news_session
.
Right now the UI is responding with /home/login
view because we haven't defined any other behaviour, we can debug view the session id here:
<!-- news/app/views/home/login.html.erb -->
<h1>Login</h1>
<form action="/home/login" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="username" placeholder="username">
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
<%= debug(session[:user_id]) %>
After successful login:
Define logout
method in HomeController - simple implementation, remove user_id
from session
:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
# ...
def logout
session[:user_id] = nil
end
end
Create corresponding logout view that simply displays a success message:
<!-- news/app/views/home/logout.html.erb -->
<h1>Successfully logged out!</h1>
Ad route for logout:
Rails.application.routes.draw do
# ...
get 'home/logout'
# ...
end
Try this out by navigating to http://localhost:3000/home/logout
:
But to confirm user is really logged out, we should update some views to show the logged in user information, then if its no longer displayed, user can know they're really logged out.
First thing to do is make sure HomeController index
method will indeed enforce authentication, so only logged in users can view the news listing. Use only
parameter of skip_before_action
method to specify only login
and logout
methods will bypass auth:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
skip_before_action :authorized, only: [:login, :logout]
def index
end
def login
if params["username"]
user = User.find_by(username: params[:username])
@valid = user.authenticate(params[:password])
if @valid
puts("=== authentication successful for #{user.username}, populating session with #{user.id}")
session[:user_id] = user.id
else
puts("=== authentication failed for #{user.username}")
end
end
end
def logout
session[:user_id] = nil
end
end
Now try to access homepage at http://localhost:3000
and it redirects to login page http://localhost:3000/home/login
because we've just logged out.
Then submit a successful login and try to view homepage again, this time news cards are displayed.
Next feature: Let everyone view article listing, BUT only display article details if user is logged in. To do this, add index
method to list of exceptions in skip_before_action
of HomeController:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
skip_before_action :authorized, only: [:login, :logout, :index]
# ...
end
Then modify home view that lists all the articles. Add a condition in the expression that displays article description
to use the logged_in?
helper method defined in ApplicationController. Notice had to add parens (...)
around positive result of ternary, otherwise get template syntax error:
<div class="articles">
<h1>Articles</h1>
<% articles('Google').items.each do |item| %>
<div class="article">
<h2><%= item.title %></h2>
<p><%= logged_in? ? (raw item.description) : "You must be logged in to view this." %></p>
<p><%= item.pubDate %></p>
</div>
<% end %>
</div>
Logout by visiting http://localhost:3000/home/logout
, and then visit homepage at http://localhost:3000
, now the cards look like this:
Notice in Rails server output, multiple queries to users table - because logged_in?
helper methods invokes current_user
method in ApplicationController, which runs a query against User model. So its one query per article. Instructor did not address this - performance issue?
Started GET "/" for ::1 at 2022-07-17 08:28:28 -0400
Processing by HomeController#index as HTML
Rendering layout layouts/application.html.erb
Rendering home/index.html.erb within layouts/application
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ? [["LIMIT", 1]]
↳ app/controllers/application_controller.rb:10:in `current_user'
CACHE User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ? [["LIMIT", 1]]
↳ app/controllers/application_controller.rb:10:in `current_user'
CACHE User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ? [["LIMIT", 1]]
↳ app/controllers/application_controller.rb:10:in `current_user'
CACHE User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" IS NULL LIMIT ? [["LIMIT", 1]]
↳ app/controllers/application_controller.rb:10:in `current_user'
...
Add Login
link to home viw:
<!-- news/app/views/home/index.html.erb -->
<div class="articles">
<h1>Articles</h1>
<h2><a href="/home/login">Login</a></h2>
<div class="articles">
<% articles('Google').items.each do |item| %>
<div class="article">
<h2><%= item.title %></h2>
<p><%= logged_in? ? (raw item.description) : "You must be logged in to view this." %></p>
<p><%= item.pubDate %></p>
</div>
<% end %>
</div>
</div>
Add if/else conditional to home view to display Logout link instead of Login if user already logged in:
<div class="articles">
<h1>Articles</h1>
<h2>
<% if logged_in? %>
<a href="/home/logout">Logout</a>
<% else %>
<a href="/home/login">Login</a>
<% end %>
</h2>
<div class="articles">
<% articles('Google').items.each do |item| %>
<div class="article">
<h2><%= item.title %></h2>
<p><%= logged_in? ? (raw item.description) : "You must be logged in to view this." %></p>
<p><%= item.pubDate %></p>
</div>
<% end %>
</div>
</div>
Update login
method in home controller to redirect to homepage after a successful login:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
skip_before_action :authorized, only: [:login, :logout, :index]
def index
end
def login
if params["username"]
user = User.find_by(username: params[:username])
@valid = user.authenticate(params[:password])
if @valid
puts("=== authentication successful for #{user.username}, populating session with #{user.id}")
session[:user_id] = user.id
redirect_to '/'
else
puts("=== authentication failed for #{user.username}")
end
end
end
def logout
session[:user_id] = nil
end
end
How does Rails store the session data?
Three ways to store this data:
- Memory
- Database
- Encrypted cookies (course will focus on this)
Storage in memory is ok for small/prototype apps. In this case, user's cookie simply contains an identifier to correlate to the data stored in memory on the Rails server. eg:
- User with ID of
1
logs in. - Rails would create a new key in
Sessions
object such as123
- Rails would store the
user_id
of1
in sessions key123
on login:Sessions[123][user_id] = 1
- User's cookie would contain the value
123
, then when sent to server, Rails will pull fromSessions[cookie_value][user_id]
to determine who is the logged in user.
Pros
- simple setup
- easy to use
- no need to store any information on the client
Cons
- Doesn't scale - the more users, the more memory will be required to store all those sessions
- Doesn't support load balancing - if subsequent requests end up being fulfilled by different servers than the one that originally handled the login, those other servers won't have the same session key in memory.
- Persistence - if app goes down or has to be restarted, all sessions are lost, so user that was logged in will no longer be.
This works the same way as memory, except Sessions stored in database rather than in memory.
Pros
- No need to store info on client
- Session data persists between app restarts
- Supports load balancing - session data in db can be used across all app instances
Cons
- Harder to setup
- Needs additional migrations on db, or use object storage within file system
This is the Rails default method for session storage. Optimal for most use cases. Session data is not stored on server, rather, it's stored within each browser/client.
Will discuss security later: Session might store data you don't want user to see, or a malicious site could scrape the user's cookies.
Pros
- No need to store any session info on server
- Extremely persistent - even if server and database fail
- Supports load balancing and good performance - no matter how many app instances there are, session data will always be consistent because the user's browser is sending the cookie which contains it.
Cons
- Clients have to store their own session data
Storing session data securely on clients device
The cookie data is encrypted with a hash and salt, similar to how passwords are saved in database. When session is saved to user, it's encrypted using applications key. So only someone with access to server's key can decrypt session data.
Demo
Demo code to verify and decode a Rails session cookie. Instructor's code did not work for Rails 6, used this blog post instead.
Example decrypted logged in cookie:
{"session_id"=>"6a0eb63ebc79b573a88388907e0031f3", "_csrf_token"=>"zZfEUV7uY6UUHEZr4AqQbyaNJ_U63GqJR_uM3HhOF70=", "user_id"=>1}
Example decrypted logged out cookie:
{"session_id"=>"6a0eb63ebc79b573a88388907e0031f3", "_csrf_token"=>"zZfEUV7uY6UUHEZr4AqQbyaNJ_U63GqJR_uM3HhOF70="}
session_id
used to keep track of session. _csrf_token
used for secure form submission.
Every form rendered in browser has a hidden field named authenticity_token
. Eg - forgot password form at http://localhost:3000/password/forgot
:
This is populated by form_authenticity_token
in the forgot password view template:
<!-- news/app/views/password/forgot.html.erb -->
<form action="/password/forgot" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="email" placeholder="email">
<button type="submit">Submit</button>
</form>
If comment out the auth token and try to submit form, will get ActionController::InvalidAuthenticityToken
error.
This is a CSRF Token: Cross-site-request-forgery
Protect from CSRF attack where malicious code on an external site could trigger code on your site, eg: changing user's password. This is possible if malicious code can get access to secure session cookie, then it could submit form on user's behalf, pretending to be that user.
Defend against CSRF Attack:
- Use CORS (cross origin resource sharing) - block scripts from external sites (eg: script on evil.com trying to post to myapp.com would get blocked). But not all browsers will enforce the same domain rules?
- Same-Site cookies - blocks external sites from using your app's cookies. So would not allow evil.com to use any cookies that were generated by myapp.com. In this case, request would be allowed, but session cookie would not be sent so the request would appear as if from an unauthenticated user. But this depends on browser behavior to do this.
- CSRF Token - generated on server rendered template. So user must first visit our app's page to get this token generated, and prevents external sources from triggering actions because they didn't visit our page.
Last option is most reliable because doesn't depend on web browser to enforce it.
The CSRF token submitted by form is correlated with session token to verify that it's really the original user that received this token that is submitting the form.
Use flash to give user feedback if login fails. Used for alerts and messages.
Flash Storage: Rails built-in feature to allow variables to be carried forward to next action, such as a redirect. After redirect is followed, flash variable persists in session on new page. After variable is displayed in view, it disappears from session.
Trying to use ordinary view instance variables will NOT work because a redirect issues a new request. For example, the @alert
instance var in this case will not be displayed in login view on failed auth:
class HomeController < ApplicationController
skip_before_action :authorized, only: [:login, :logout, :index]
def index
p params
end
def login
if params["username"]
user = User.find_by(username: params[:username])
@valid = user && user.authenticate(params[:password])
if @valid
session[:user_id] = user.id
redirect_to '/'
else
# will NOT be displayed because redirect_to will cause browser to follow the Location header
# and issue a NEW request
@alert = "username or password incorrect!"
redirect_to '/home/login'
end
end
end
def logout
session[:user_id] = nil
end
end
<!-- news/app/views/home/login.html.erb -->
<h1>Login</h1>
<!-- Will NOT be displayed -->
<p><%= @alert %></p>
<form action="/home/login" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="username" placeholder="username">
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
Due to use of redirect, need to use flash storage instead of instance variables:
# news/app/controllers/home_controller.rb
class HomeController < ApplicationController
def login
if params["username"]
user = User.find_by(username: params[:username])
@valid = user && user.authenticate(params[:password])
if @valid
session[:user_id] = user.id
redirect_to '/'
else
flash.alert = "username or password incorrect!"
redirect_to '/home/login'
end
end
end
end
Display flash.alert
in view:
<!-- news/app/views/home/login.html.erb -->
<h1>Login</h1>
<p><%= flash.alert %></p>
<form action="/home/login" method="POST">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<input type="text" name="username" placeholder="username">
<input type="password" name="password" placeholder="password">
<button type="submit">Login</button>
</form>
<%= debug(session[:user_id]) %>
Try it by navigating to http://localhost:3000/home/login
and submitting invalid username/password:
Update user show view to also display user's email:
<!-- news/app/views/users/show.html.erb -->
<h1>Show</h1>
<p><%= @user.username %></p>
<p><%= @user.email %></p>
<p>Created <%= time_ago_in_words(@user.created_at) %> ago.</p>
Try it by visiting http://localhost:3000/users/1
:
Recall when a new user is created successfully, it redirects to the show user page with a flash message:
# news/app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
# flash used here to generate success message
redirect_to @user, alert: "User created successfully."
else
redirect_to new_user_path, alert: "Error creating user."
end
end
end
Update user show view to display the success alert:
<!-- news/app/views/users/show.html.erb -->
<h1>Show</h1>
<p><%= flash.alert %></p>
<p><%= @user.username %></p>
<p><%= @user.email %></p>
<p>Created <%= time_ago_in_words(@user.created_at) %> ago.</p>
Since this message is only set from creating a new user (test2/test2), navigate to http://localhost:3000/users/new
, fill out form to create a new user and submit:
Add email field to new user registration form:
<!-- news/app/views/users/new.html.erb -->
<h1>Users#new</h1>
<%= form_for(@user) do |f| %>
<%= f.label :username %>
<%= f.text_field :username, placeholder: :username %>
<%= f.label :email %>
<%= f.text_field :email, placeholder: :email %>
<%= f.label :password %>
<%= f.password_field :password, placeholder: :password %>
<%= submit_tag "Create" %>
<% end %>
Now navigating to http://localhost:3000/users/new
:
Let's check what user info is in db so far, from Rails console bin/rails c
:
User.all.each{ |u| puts("#{u.username}, #{u.email}") }
# User Load (0.2ms) SELECT "users".* FROM "users"
# test_user, exampleemail@gmail.com
# test1, test1@test.com
# test2,
Try to register a new user with an email that's already taken exampleemail@gmail.com
(use test3/test3):
For instructor, it allowed new user to be created with existing email. For me failed...
First, have to add email to permit_params in users controller:
# news/app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
redirect_to @user, alert: "User created successfully."
else
p @user.errors.count
# should have used render to ensure instance var still populated?
redirect_to new_user_path, alert: "Error creating user."
end
end
def user_params
params.require(:user).permit(:username, :email, :password, :salt, :encrypted_password)
end
end
Then I had a uniqueness validator on users model, so transaction gets rolled back when trying to create a new user with existing email, but no flash message displayed yet:
# news/app/models/user.rb
class User < ApplicationRecord
has_secure_password
validates :username, :email, uniqueness: true
end
Update new users view to display flash alert:
<!-- news/app/views/users/new.html.erb -->
<h1>Users#new</h1>
<p><%= flash.alert %>
<%= form_for(@user) do |f| %>
<%= f.label :username %>
<%= f.text_field :username, placeholder: :username %>
<%= f.label :email %>
<%= f.text_field :email, placeholder: :email %>
<%= f.label :password %>
<%= f.password_field :password, placeholder: :password %>
<%= submit_tag "Create" %>
<% end %>
Now user will see error message if try to use taken email, but its not very useful because doesn't tell user what's wrong:
Errors that occur when trying to create/save an ActiveRecord object are saved in an array attribute of that object: @obj.errors.full_messages
.
Update users controller to include these messages in flash alert when user creation fails:
# news/app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
redirect_to @user, alert: "User created successfully."
else
redirect_to new_user_path, alert: "Error creating user: #{@user.errors.full_messages.join("<br />")}"
end
end
def user_params
params.require(:user).permit(:username, :email, :password, :salt, :encrypted_password)
end
end
Then again try to register new user at http://localhost:3000/users/new
with email that's already taken: exampleemail@gmail.com
and username that's already taken: test_user
and click Create.
Notice that the html <br />
element that we populated in flash message is not rendering as html in view, its rendered as literal text. One way to fix this is to use raw
parameter in partial tag, but not recommended.
A better solution for displaying messages is to send entire array of error messages to flash in controller:
# news/app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
redirect_to @user, alert: "User created successfully."
else
redirect_to new_user_path, alert: @user.errors.full_messages
end
end
def user_params
params.require(:user).permit(:username, :email, :password, :salt, :encrypted_password)
end
end
Then modify the new user view to iterate these messages and display them, but only if the flash.alert list is populated, otherwise will get a view error trying to invoke .each
method on nil object:
<!-- news/app/views/users/new.html.erb -->
<h1>Users#new</h1>
<% flash.alert.present? && flash.alert.each do |error| %>
<div class="error"><%= error %></div>
<% end %>
<%= form_for(@user) do |f| %>
<%= f.label :username %>
<%= f.text_field :username, placeholder: :username %>
<%= f.label :email %>
<%= f.text_field :email, placeholder: :email %>
<%= f.label :password %>
<%= f.password_field :password, placeholder: :password %>
<%= submit_tag "Create" %>
<% end %>
Then again try to register new user at http://localhost:3000/users/new
with email that's already taken: exampleemail@gmail.com
and username that's already taken: test_user
and leave password blank, and click Create:
Optionally, add some styles for the error
css class in the users stylesheet:
/* news/app/assets/stylesheets/users.scss */
.error {
background-color: rgb(207, 40, 40);
color: #fff;
border-radius: 5px;
padding: 5px;
margin: 5px;
}
Furthermore, make the alert styling re-usable for both error and success messages, by having an .alert
style, with nesting for .error
and .success
:
.alert {
color: #fff;
border-radius: 5px;
padding: 5px;
margin: 5px;
font-weight: bold;
&.error {
background-color: rgb(207, 40, 40);
}
&.success {
background-color: rgb(53, 170, 23);
}
}
Modify the error display in new users view to include the alert
css class in addition to error
:
<!-- news/app/views/users/new.html.erb -->
<h1>Users#new</h1>
<% flash.alert.present? && flash.alert.each do |error| %>
<div class="alert error"><%= error %></div>
<% end %>
<%= form_for(@user) do |f| %>
<%= f.label :username %>
<%= f.text_field :username, placeholder: :username %>
<%= f.label :email %>
<%= f.text_field :email, placeholder: :email %>
<%= f.label :password %>
<%= f.password_field :password, placeholder: :password %>
<%= submit_tag "Create" %>
<% end %>
And update the users show view (where user taken to after creation) to show styled success message:
<!-- news/app/views/users/show.html.erb -->
<h1>Show</h1>
<div class="alert success"><%= flash.alert %></div>
<p><%= @user.username %></p>
<p><%= @user.email %></p>
<p>Created <%= time_ago_in_words(@user.created_at) %> ago.</p>
Then create a valid user, here is what success message looks like: