In the previous article we created a User record using the Rails console.
You are now ready for the next step: setting up endpoints for authentication. Feel free to refer to the part 5 branch of the GitHub repository if needed.
This is the 5th installment of my Developing A Cross-Platform iOS & Android Social Media App series.
We are now going to be setting up the authenthication endpoints for our API. By the end of this article, you will have the following two endpoints:
/users/register
: Takes in email
, password
and password_confirmation
as parameters. If all params are valid, then creates a User
record and returns the User
in the json response.
/users/login
: Takes in an existing email
and password
and if correct, it will return that user's information.
Note: we will never send plain text passwords to this API, even if it is over HTTPS. It is not that much more effort to salt and hash a password. We won't ever need to know the original password so we should not store it anywhere. But for testing these endpoints, it doesn't matter much.
In simple terms, a controller is a Ruby file with methods that recieve parameters and outputs a response. In other words, it connects the frontend to the database. It does much more than that, but this is a solid high level perspective of it. We can route urls such as https://localhost:3000/users/register
to methods in a controller such as the register
method. We will cover more on this soon.
Rails provides us a command to create a controller automatically. Lets run $ rails generate controller users
$ rails generate controller users
create app/controllers/users_controller.rb
invoke test_unit
create test/controllers/users_controller_test.rb
This created a User Controller and a User Controller Test. Note: We will cover tests soon
Open the app/controllers/users_controller.rb
file:
class UsersController < ApplicationController
end
From this file we can see that we have a UsersController
that inherits the ApplicationController
from app/controllers/application_controller.rb
. We will cover the purpose of ApplicationController
in a future post.
Right now, the Users Controller is a blank canvas, lets add a register method stub:
class ApplicationController < ActionController::API
def register
end
end
A user can pass any amount of parameters to an endpoint. In a perfect world, we can just have User.new(params)
which will assign the parameters to the respective attributes. But what if we had an admin
attribute and a user adds admin=true
to the request params? The user could assign any attribute they want in that scenario. To prevent unwanted parameters to be passed to User.new()
, we will be using strong parameters.
The controller has a variable called params
that refers to the parameters passed to it. We have two important variables that we can use with the params
variable: require
and permit
. Here is what they do:
require
: Throws an error if this key doesn't exist in the params
permit
: These are optional keys, it won't throw an error if it doesn't exist but it also will filter out anything that isn't listed in here.
The only attributes we want to pass to User.new()
are email
, password
, and password_confirmation
. So lets create a private method called user_params
to setup our strong parameters:
class ApplicationController < ActionController::API
def register
end
private
def user_params
params.require(:user).permit(
:email,
:password,
:password_confirmation,
)
end
end
The user_params
method can now be called instead of params
. We now can be confident that when we call User.new(user_params[:user])
the only attributes that will be assigned are email
, password
and password_confirmation
.
Lets take a look at example payloads that can be passed to a request and what the user_params
would be. Here is an example JSON payload:
{
"user": {
"email": "[email protected]",
"password": "123456",
"password_confirmation": "123456",
"admin": true
}
}
Here is what user_params
would return:
{
"user": {
"email": "[email protected]",
"password": "123456",
"password_confirmation": "123456"
}
}
Notice how the admin key/value is removed. Lets look at this example:
{
"email": "[email protected]",
"password": "123456",
"password_confirmation": "123456"
}
All of the attributes are there but it is not nested under a user
key. So this means that require
would raise an exception since user
does not exist in params
.
Now that we have our user_params
method, we can now finish our register endpoint. Lets update the register
method:
def register
user = User.new(user_params)
end
Now we need to save the new user. We need to handle a success and error state:
def register
user = User.new(user_params)
if user.valid? && user.save
# render success state
end
# render error state
end
In order for a success state to be rendered, the method will attempt to save the user after it verifies that it is valid. If the user is invalid or if the save faiils, it will render an error state.
We need to replace the render comments with actual renders. If registration is successful, we will return the user.
def register
user = User.create(user_params)
if user.valid? && user.save
render json: user,
status: 201
return
end
# render error state
end
This line does a lot for us, so lets disect it line by line:
render
: this method tells the endpoint what we are going to render as the response
json: user,
: this sets the response to be a JSON and automatically converts the user model into JSON. In a future part, we will go over serializers which will let us customize the user response.
status: 201
: 201 is a HTTP status for a successful creation. You can view more HTTP statuses here.
return
: Since the render
method does not stop the next lines of code from being executed, we need to use return
so that we dont end up with a double render error.
If the user is invalid or the save fails, User.errors
will provide error info. Update your register method to render a response when an error exists:
def register
user = User.create(user_params)
if user.valid? && user.save
render json: user,
status: 201
return
end
render json: user.errors,
status: 400
end
We used a 400
status response because invalid params falls under the general bad request
category. We don't need a return
as it is already at the end of the method and no code will be executed after this render. Your completed users_controller.rb
should now look like:
class ApplicationController < ActionController::API
def register
user = User.create(user_params)
if user.valid? && user.save
render json: user,
status: 201
return
end
render json: user.errors,
status: 401
end
private
def user_params
params.require(:user).permit(
:email,
:password,
:password_confirmation,
)
end
end
We have one final step for our register endpoint to be ready: configuring the routes file. Open config/routes.rb
:
Rails.application.routes.draw do
devise_for :users
end
We will be creating our own API-only auth routes, so delete devise_for :users
. For the registration route, we want to use /user/register
. Since the user will be sending data and expecting a user to be created, we will use a POST
request.
Rails.application.routes.draw do
post 'users/register', to: 'users#register'
end
Here is a breakdown of what is going on:
post
: This tells us what HTTP request method needs to be made. post
is usually for creating data, get
is for retrieving data, and delete
for deleting data. Mozilla has documentation on HTTP request methods.
'users/register',
: this tells Rails what the url should be. So this line would create the route: http://localhost:3000/users/register
to:
: This tells Rails that when a user sends a request to the users/register
url, call the action after to:
'users#register'
: This is the controller and action that the endpoint would direct to. So users
would look for users_controller
and register
looks for the register
method inside users_controller
Note: there is a much cleaner approach for basic CRUD endpoints, we will cover this in the future
Once you save the file, verify that the route has been created by running $ rake routes
$ rake routes
Prefix Verb URI Pattern Controller#Action
users_register POST /users/register(.:format) users#register
You'll see a lot more routes, but this is the only one we care about. If you see this, you're ready to test the endpoint!
For this tutorial, we will use curl
to hit our endpoints. But it is highly suggested to take a look at a REST client such as Insominia
$ curl -H "Content-Type: application/json" -X POST -d '{"user":{"email": "[email protected]","password":"123456","password_confirmation": "123456"}}' http://localhost:3000/users/register
{"id":"666b9e2b-da4a-4dbb-8fcc-aecc760c2f6f","email":"[email protected]","created_at":"2020-02-09T17:19:33.376Z","updated_at":"2020-02-09T17:19:33.376Z"}%
A little hard to read, but you can pipe the JSON response and pretty print it using json_pp
by appending | json_pp
to the end of it:
$ curl -H "Content-Type: application/json" -X POST -d '{"user":{"email": "[email protected]","password":"123456","password_confirmation": "123456"}}' http://localhost:3000/users/register | json_pp
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 236 0 148 100 88 743 442 --:--:-- --:--:-- --:--:-- 747
{
"id" : "4edc9a20-1e61-4a82-bb97-ee6647ff8212",
"updated_at" : "2020-02-09T17:24:53.319Z",
"created_at" : "2020-02-09T17:24:53.319Z",
"email" : "[email protected]"
}
Here is a breakdown of what is going on:
curl
: This is the command that we will be using to send data to our server and recieve a response. You can learn more about curl by running man curl
on your terminal.
-H
: This is the option for the curl command to set the Header of the request
"Content-Type: application/json"
: This is a header that tells the API we are serving JSON data to it. We are using the application/json
MIME type.
-X
: is used to set the request method that we are about to call.
POST
: This is the request method we are setting for our request.
-d
: This is an option for us to send data to the curl command so that it can include it in the request
'{"user": ...}'
: this is the data that is being served
http://localhost:3000/users/register
: This is the url that we are sending the request to. We are running the server locally and it runs on port 3000
on default.
| json_pp
: We pipe the result of the curl command to json_pp
so that it can pretty print the json response.
Awesome! Our endpoint works and we can create new users. You can verify this in the Rails console:
$ rails c
Loading development environment (Rails 6.0.2.1)
2.6.5 :001 > User.find_by(email: "[email protected]")
User Load (0.9ms) SELECT "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "[email protected]"], ["LIMIT", 1]]
=> #<User id: "4edc9a20-1e61-4a82-bb97-ee6647ff8212", email: "[email protected]", created_at: "2020-02-09 17:24:53", updated_at: "2020-02-09 17:24:53">
We have one more endpoint left: the login endpoint.
For the login endpoint, we will take email
and password
as parameters and return the user if the credentials are correct. We will make this action return a JWT token in the future, but this is suffice for now.
Remember that we can find a user using the find_by
command:
2.6.5 :001 > user = User.find_by(email: "[email protected]")
User Load (0.9ms) SELECT "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "[email protected]"], ["LIMIT", 1]]
=> #<User id: "4edc9a20-1e61-4a82-bb97-ee6647ff8212", email: "[email protected]", created_at: "2020-02-09 17:24:53", updated_at: "2020-02-09 17:24:53">
We can also validate the password using valid_password?
2.6.5 :005 > user.valid_password?("123456")
=> true
With this information, we can create the login endpoint in users_controller.rb
:
def login
email = params[:user][:email]
password = params[:user][:password]
user = User.find_by(email: email)
is_valid = user && user.valid_password?(password)
unless is_valid
render json: {
status: 'error',
message: 'Invalid credentials'
}, status: 400 and return
end
return render json: user,
status: 200
end
We extract the email and password into variables by pulling them from the user
object that is passed in through the JSON request body. params[:user]
returns the user object and [:email]
pulls the value of the email
key inside the user object. So our request body would look like:
{
"user": {
"email": "[email protected]",
"password": "123123"
}
}
Alternatively we could just do:
{
"email": "[email protected]",
"password": "123123"
}
But I like to keep my code as consistent as possible. It helps with readability and maintainability, not only for you, but for other developers as well. You should always write clean code that others can pickup and understand.
However, we won't get an error if we do go that route above. We are not using the user_params
method, so .requre(:user)
won't be ran. We won't need to filter out any params via .require
and .permit
as we are directly extracting the email
and password
keys from the params
hash.
User.find_by(email: email)
we find the user with the associated email, keep in mind that if nothing is found, it will return nil
which is falsey
.
user && user.valid_password?(password)
is equal to true if user
exists (not nil
) and if the valid_password?
password method returns true
. Note: valid_password?
will not run if user
does not exist so we won't get an error calling nil.valid_password?
. This is because Ruby knows that it won't need to run the whole conditional statement if the first conditional is false
.
unless is_valid
is the same as if !is_valid
. Ruby focuses on being easy to read, so according to the Rubocop Style Guide, we should use unless
. So unless is_valid
is true
, run the nested render
method.
We handle both an
invalid email
andinvalid password
error under a singleInvalid credentials'
error becuase it will allow someone to bruteforce a list of emails and passwords easier. A hacker could send requests through proxies and log any email that returns anInvalid Email
response. They could also figure out which emails exist and bruteforce a specific account as long as it keeps getting anInvalid password
error. You may or may not ever come into this issue but it is a simple adjustment and protects your endpoints.follo
Your entire app/controllers/users_controller.rb
file should now look like:
class UsersController < ApplicationController
def register
user = User.create(user_params)
if user.valid? && user.save
render json: user,
adapter: :json ,
status: 201 and return
end
render json: user.errors, status: 400
end
def login
email = params[:user][:email]
password = params[:user][:password]
user = User.find_by(email: email)
is_valid = user && user.valid_password?(password)
unless is_valid
render json: {
status: 'error',
message: 'Invalid credentials'
}, status: 400 and return
end
return render json: user,
status: 200
end
private
def user_params
params.require(:user).permit(
:email,
:password,
:password_confirmation,
)
end
end
We need to add the new login route to our config/routes.rb
file:
Rails.application.routes.draw do
post 'users/register', to: 'users#register'
post 'users/login', to: 'users#login'
end
We are using a post
for this as well. If you chose to not have a user
object for the login, I still recommend a post
instead of a get
request. The get
request will show the email
and password
params in the url itself: users/login/?email="[email protected]"&password="123456"
.
It is a bad habit to include any sensitive data in a
get
request because the whole url may end up in logs, or history. Apost
isn't any more secure than aget
request other than the sensitive data is moved to the request body. When dealing with sensitive data, it should always go throughHTTPS
. The request method does not secure it, but SSL does.
Run rake routes
to verify that the route has been created.
$ rake routes
Prefix Verb URI Pattern Controller#Action
users_register POST /users/register(.:format) users#register
users_login POST /users/login(.:format) users#login
Make sure your server is running using $ rails s
and enter the following curl
command to test if our endpoint works. Be sure to update the login credentials to the right credentials.
curl -H "Content-Type: application/json" -X POST -d '{"user":{"email": "[email protected]","password":"123456"}}' http://localhost:3000/users/login | json_pp
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 202 0 148 100 54 135 49 0:00:01 0:00:01 --:--:-- 135
{
"created_at" : "2020-02-09T17:24:53.319Z",
"email" : "[email protected]",
"updated_at" : "2020-02-09T17:24:53.319Z",
"id" : "4edc9a20-1e61-4a82-bb97-ee6647ff8212"
}
It works! Now that we got our endpoints in order, check out the Rails server terminal to see what it outputted once we ran that login command:
Started POST "/users/login" for ::1 at 2020-02-09 10:45:24 -0800
Processing by UsersController#login as */*
Parameters: {"user"=>{"email"=>"[email protected]", "password"=>"[FILTERED]"}}
User Load (1.0ms) SELECT "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2 [["email", "[email protected]"], ["LIMIT", 1]]
↳ app/controllers/users_controller.rb:15:in `login'
Completed 200 OK in 159ms (Views: 8.6ms | ActiveRecord: 1.9ms | Allocations: 3940)
This is where you will see realtime errors and logs. From this we can tell a POST
request was made to users/login
and follow along to see which controller it called, parameters that were passed, SQL queries that were made, the line where the response was rendered, the status code, and more. This is very useful information that we will come across in the future.
You have now made your first Rails endpoints! We will be improving these endpoints in the next part by implementing API Versions.