Authenticated routes with Hanami 2
As I was building out the admin area of this blog, I wanted to make sure only authenticated people could hop in an edit things.
The first iteration of this, was based on HTTP BasicAuth. To get this to work, I built a small module that uses Rack::Auth::Basic
β https://github.com/rack/rack/blob/main/lib/rack/auth/basic.rb.
It looked like:
class AuthenticatedAdminAction
def self.call(action:)
action = ->(env) { Admin::Container["actions.#{action}"].(env) }
basic_auth_username = Hanami.app.settings.basic_auth_username
basic_auth_password = Hanami.app.settings.basic_auth_password
if basic_auth_username && basic_auth_password
Rack::Auth::Basic.new(action) do |username, password|
username == basic_auth_username &&
password == basic_auth_password
end
else
Rack::Auth::Basic.new(action_proc) { |_username, _password| true }
end
end
Then, on each path that I wanted to authenticate, I would adjust the to:
argument call this method:
Auth = AuthenticatedAdminAction
get "/", to: Auth.call(action: "index")
Which looks nice, and works well! However, something changed in the way Safari on iOS and macOS handles auto-filling usernames and passwords for HTTP Basic Auth that meant it would log me out every few minutes and I would need to re-open 1Password to log back in!
I thought this may have been a beta-season thing (after seeing a few other cases of it online), so I stuck it out for a while. But after many months of being on a stable version of Sonoma, the problem persisted!
So, onwards with a more stable and scalable form of authentication. It didn't need to be fancy, it just needed to let me create a session with as little hassle as possible. For this reason, I didn't opt for something like OmniAuth or even Warden and instead built a simple token-based auth system that emails me a login link.
The above code changes slightly, to now call some custom middleware with the user in the session, but is now a zillion times more reliable as Safari doesn't seem to forget who I am every 2 seconds π.
The middleware now looks something like:
module Middleware
class Authenticate
def initialize(app, auth_proc)
@app = app
@auth_proc = auth_proc
end
def call(env)
session = env["rack.session"]
return [403, {"Content-Type" => "text/html"}, ["Unauthorized | <a href=\"/admin/login\">Login</>"]] unless @auth_proc.call(session[:user_id])
@app.call(env)
end
endend
Next steps
The next thing I want to look at is wrapping a block of routes in an authentication block, e.g:
AuthenticatedThing.call do
get "/pages", to: "pages.index"
get "/pages/new", to: "pages.new"
get "/pages/:slug/edit", to: "pages.edit"
end
mainly to make it easier to see which routes are authenticated and avoid accidentally missing some routes.
in posts
Tagged