##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
endUsers 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
endValidations 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
endIf 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
endRegardless, 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 '/' ?)
endAnd of course you'll need:
get '/logout' do
session[:user_id] = nil
redirect_to '/'
endMight your users also have a profile page? Maybe something like:
get '/users/:username' do
erb :user
endThe 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]
endIf 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
autofocusin 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 '/'
endYou 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.passwordmatchesparams[: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
endExcept, 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
endclass User < ActiveRecord::Base
def self.authenticate(params)
user = User.find_by_name(params[:username])
(user && user.password == params[:password]) ? user : nil
end
endThis 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.authenticatereturns 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
endBecause you have that helper method, you can do something like:
get '/create_post' do
if current_user
erb :create_post
else
redirect '/login'
end
endwhich 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!