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.rbThis 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
endFrom 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
endA 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)
endNow 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
endIn 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
endThis 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
endWe 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#registerYou'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")
=> trueWith 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
endWe 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 emailandinvalid passworderror 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 Emailresponse. They could also figure out which emails exist and bruteforce a specific account as long as it keeps getting anInvalid passworderror. 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
endWe 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
getrequest because the whole url may end up in logs, or history. Apostisn't any more secure than agetrequest 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#loginMake 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.