Did I mentioned You should always use already-built solutions, especially for production apps. Building something from scratch is super insecure, and in my opinion, is an antipattern. If you want to keep credentials on your side there are devise or rodauth But you should consider keeping it outside the app with auth0 firebase auth or supabase Don’t do this yourself.
But this is the second part of building accounts-related stuff from scratch. In the previous post, I added the possibility to create new accounts in the system. Today I want to show you how to implement authentication.
I need new endpoint where I can login into my app
require_relative "controller"
module Auth
class App
def call(env)
request = Rack::Request.new(env)
case request.path_info
when "/auth"
case request.request_method
when "POST"
Controller.new.create(request)
else
[405, {"Content-Type" => "text/plain"}, ["Method Not Allowed"]]
end
else
[404, {"Content-Type" => "text/plain"}, ["Not Found"]]
end
end
end
end
In the controller I can verify if credentials are correct
account = Auth::Repository.new.find_by_email!(params["email"])
if account&.authenticate(params["password"])
Method authenticate if from ActiveRecord (I mentioned, ActiveRecord makes too much). But under the hood, it looks like that:
def authenticate(unencrypted_password)
BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self
end
Ok, but I’m not going to send credentials with every request. I should use a token in header. So endpoint should also return a token. I’m going to use JSON Web Token (JWT). There is ruby gem for JWT. For this, I create a separate service.
module Auth
class JsonWebToken
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, ENV["SECRET_KEY"])
end
def self.decode(token)
decoded = JWT.decode(token, ENV["SECRET_KEY"])[0]
HashWithIndifferentAccess.new decoded
end
end
end
As you can see token will expire after 24 hours. I’m not going to implement logic for refreshing them after the token expired you have to fulfill credentials again. This is whole controller:
require_relative "../db/records/account"
require_relative "./json_web_token"
require_relative "./repository"
module Auth
class Controller
def create(request)
params = JSON.parse(request.body.read)
account = Auth::Repository.new.find_by_email!(params["email"])
if account&.authenticate(params["password"])
token = Auth::JsonWebToken.encode(account_id: account.id)
time = Time.now + 86400
response = {
token: token,
exp: time.strftime("%m-%d-%Y %H:%M"),
}
[201, {"content-type" => "text/plain"}, [response.to_json]]
else
[401, {"content-type" => "text/plain"}, ["Unauthorized"]]
end
rescue Auth::RecordNotFound
[401, {"content-type" => "text/plain"}, ["Unauthorized"]]
end
end
end
I was thinking where is a good place to verify tokens. In the main app? In each app? Maybe in controllers. I decided to write middleware for rack. But if you think this isn’t a good place email me, please.
require "rack"
require_relative "auth/verify_and_set_account"
class AuthMiddleware
def initialize(app)
@app = app
end
def call(env)
req = Rack::Request.new(env)
return @app.call(env) if req.path == "/"
return @app.call(env) if req.path == "/environment"
return @app.call(env) if req.path.start_with?("/auth")
return @app.call(env) if req.path.start_with?("/accounts") && req.request_method == "POST"
env["account_id"] = Auth::VerifyAndSetAccount.new.call(env)
@app.call(env)
end
end
First I ignore paths that are public, then I call my service, where I decode token, but also check if the user exists in DB. Then I return user_id and it passes in every request to the app. So information about user_id is available everywhere.
header = env["HTTP_AUTHORIZATION"]
header = header.split(' ').last if header
decoded = Auth::JsonWebToken.decode(header)
Auth::Repository.new.find(id: decoded["account_id"])
decoded["account_id"]
In the end, I have to add middleware in config.ru
require_relative "app"
require_relative "auth_middleware"
...
use AuthMiddleware
run MyApp.new
One of the cons (or pros depending on the point of view) of this solution is, nothing changes from the perspective of a test, we authorize users on requests, but nowhere in the app. Of course, I write spec for middleware. But this is not the same.
Since I don’t align frontend, I can check these changes with curl.
First I need to create a user:
curl -X POST http://localhost:9292/accounts -d '{"email":"foo@bar.ex","password":"qqrq"}'
Then login into app:
curl -X POST http://localhost:9292/auth -H 'Content-Type: application/json' -d '{"email":"foo@bar.ex","password":"qqrq"}'
The response should look like that:
{"token":"exampletoken","exp":"07-31-2023 17:21"}
And now I can check regular endpoints:
curl -X GET http://localhost:9292/bikes -H 'Authorization: exampletoken'
From now, If we try to use API without or with wrong token it should return Unauthorized error.
Like always, the whole code is on github