##User accounts with Sinatra (no BCrypt)
A very basic schema for a user typically looks like this:
def change
create_table :users do |t|
t.string :username
t.string :email
t.string :password
t.timestamps
end
end
Users can have plenty of other information about them or their account stored in their table, of course-- full_name
, hometown
, is_private
. If ever stumped about what to include in the schema, think of apps you use and the information included in your profile.
class User < ActiveRecord::Base
validates :username, :presence => true,
:uniqueness => true
validates :email, :presence => true,
:uniqueness => true
:format => {:with => /\w+@\w+\.\w+/)
validates :password, :presence => true
end
Validations mean a User object won't ever be saved unless they meet this criteria. The above code says that a User cannot exist in the database unless it has
- a username (which is unique)
- an email address (which is unique and is formatted based on a RegEx (this is a very loose RegEx example that says "at least one word character (letter, number, underscore) followed by an "@", followed by at least one word character followed by a ".", followed by at least one word character"))
- a password
###Make the routes
Routes will depend on your UX design. Should login and sign up be on separate pages? If creating an account requires a lot of fields, each should probably have its own page:
get '/login' do
erb :login
end
get '/new_account' do
erb :new_account
end
If creating an account is not much different from logging in, however, it may make more sense to consolidate both forms on the same page.
get '/login' do
erb :login
end
Regardless, each form will need to POST to its own route.
post '/login' do
# params are passed in from the form
# if user is authenticated,
# "logs user in"
# (which really just means setting the session cookie)
# redirects the user somewhere (maybe '/' ?)
# else if user auth fails,
# redirects user, probably back to '/login'
end
post '/new_account' do
# (params are passed in from the form)
user = User.create(params[:user])
session[:user_id] = user.id
# redirects user somewhere (maybe '/' ?)
end
And of course you'll need:
get '/logout' do
session[:user_id] = nil
redirect_to '/'
end
Might your users also have a profile page? Maybe something like:
get '/users/:username' do
erb :user
end
The Sinatra skeleton contains a "helpers" folder under "app". Methods saved in this folder are accessible from all your controllers and views.
Create a file called user_helper.rb
(or something akin to that), and create a method current_user
:
def current_user
User.find(session[:user_id]) if session[:user_id]
end
If a user has signed in and session[:user_id]
has been set, calling this method will return the relevant User object. Else, the method will return nil
.
There are a few other ways to write code that produces the same results. This one is the simplest.
Users should probably be able to sign in or logout or access their profile page at any time, regardless of which page they're on, right? (Probably right.) Put those babies in a header above your <%= yield %>
statement in your layout.erb
view.
<body>
<div class="header">
<h5><a href="/">Home</a></h5>
<% if current_user %>
<h5><a href='/logout'>Logout</a></h5>
<h5>Signed in as: <a href="/users/<%= current_user.name %>"><%= current_user.name %></a></h5>
<% else %>
<h5><a href="/login">Login</a></h5>
<% end %>
</div>
<div class="container">
<%= yield %>
</div>
</body>
The above specifies that if the current_user
helper method returns true (meaning, a user has logged in):
- display a "Logout" link
- display that they are "Signed in as [username]" (and the username links to their profile page)
Otherwise, just display a link to the login page (and to the "create an account" page, if you've chosen to separate them).
You know how to create forms, so I won't belabor the point here. Things to remember:
- When naming fields, match them to the database table column names (e.g.:
username
,email
,password
) - Get fancy and put them in a hash
user[username]
,user[email]
,user[password]
will POST:params => {user => {username: < username >, email: < email >, password: < password > }
- NOTE: there is no colon (":") used in the input field names. It's just
user[username]
.
- make sure your form actions match the appropriate routes!
- Get in the habit of using
autofocus
in forms, usually in whatever is the first input field on the entire page. It makes your users happier, even if they don't realize it at the time.
So now that data can be sent through, let's build out those POST routes.
New accounts are fairly straight-forward, since we're not doing anything with password encryption (BCrypt) just yet. Take in the form params and use them to create a new User, then set the session cookie:
post '/new_account' do
user = User.create(params[:user])
session[:user_id] = user.id
redirect '/'
end
You can choose to redirect them wherever it makes the most sense to you to send your users after they've made a new account. Maybe it's back to the home page? Maybe it's to their profile page (redirect "/users/#{user.username}"
? Maybe it's something fancier? Totally up to you.
Logging in is a little tricker, since we'll need to make sure the user has submitted the correct password.
If your login form takes in a username and password, the process should go:
- Find the user based on
params[:user][:username]
- Check if
user.password
matchesparams[:user][:password]
- If it matches, redirect the user to wherever (see above).
- If it doesn't match, you'll probably want to just send them back to the login page so they can try again.
- (You can get fancy and show an error message on the login page so the user knows why they've been sent back there!)
post '/login' do
user = User.find_by_username(params[:user][:username])
if user.password == params[:user][:password]
session[:user_id] = user.id
redirect '/'
else
redirect '/login'
end
end
Except, oh man, model code in the controller! This can be refactored to:
post '/login' do
if user = User.authenticate(params[:user])
session[:user_id] = user.id
redirect '/'
else
redirect '/login'
end
end
class User < ActiveRecord::Base
def self.authenticate(params)
user = User.find_by_name(params[:username])
(user && user.password == params[:password]) ? user : nil
end
end
This creates a User class method authenticate
which takes in a params
hash. The controller sends this method params[:user]
as that hash.
Why a class method? Because you need to find a specific user, but you don't want to make the controller do that work. The model should do that work, but without a specific user (yeah, it gets kind of circular), you can't use an instance method… so you have to use a class method.
Speaking of! What is this class method doing?
The first line is trying to find a specific user based on the username that was submitted via the login form, and storing whatever it finds in the local variable user
.
The second line is saying:
- IF a user was found (because if the submitted username didn't actually exist in the database,
User.find_by…
would have returnednil
, which is the same asfalse
- AND IF that found user's password matches the password that was submitted in the form
- THEN this function will return
user
- ELSE this function will return
nil
So if you look back at the refactored route, it's saying:
- If
User.authenticate
returns a user, store that user in a local variableuser
, set the session cookie for this user and redirect to the root path - Else redirect to the login page so the user can try again
Maybe your app has routes you only want logged-in users to access. I.e., only logged-in users can create blog posts or upload photos or submit links or leave comments (etc.).
Let's pretend this is a blogging app, and you have a route for the page that contains the form used to create a new post:
get '/create_post' do
end
Because you have that helper method, you can do something like:
get '/create_post' do
if current_user
erb :create_post
else
redirect '/login'
end
end
which just says, if current_user
returns a user, load this page and show the form for creating a new post. Otherwise, if current_user
returns nil
, redirect the user to the login page.
- If a non-logged in user clicks on a link to a protected route (meaning, only logged-in users can see that page) and is redirected to the login page, and then the user successfully signs in… wouldn't it be nice if the user could be redirected to the page they were trying to access in the first place?
- How can the app know when to display an error message?
- Remember: you can store ~ 4 Kb in a session
- If a user is logged in, are there still pages that user shouldn't be able to access? (Think about editing a profile page. Should users be able to access the profile edit page of other users? (Easy answer: no)).
This is amazing! Thank you for sharing!