Opium: require auth only for some routes?

I’m experimenting with authentication in Opium and wonder: is it possible to protect only some routes with authentication? My experiments so far suggest that all requests pass trough all middleware layers, including the one demanding authentication, before the request is handled. An Opium app is composed like the code below and I initially had hoped that the location of the authentication layer in the stack would permit to handle some routes before and others after the authentication layer but this does not seem to be the case. Authentication is not special – you could also consider logging and might want to do logging only for some routes.

let main () =
  Logs.set_reporter (Logs_fmt.reporter ());
  Logs.set_level (Some Logs.Info);
  O.App.empty
  |> O.App.middleware O.Middleware.logger
  |> O.App.middleware auth_middleware
  |> O.App.post "/login" login
  |> O.App.get "/" (index ~authenticated:true)
  |> O.App.cmd_name "Quad" |> O.App.run_command
2 Likes

In a session module, I have a function with this signature:

val auth
  : (Opium.Request.t -> Opium.Response.t Lwt.t) -> Opium.Request.t -> Opium.Response.t Lwt.t

And this is how I use it in my routes:

App.get "/the/route" @@ Session.auth (fun req ->  … )
3 Likes

Interesting. So auth is the middleware, correct? Can it be used for scopes (path components) like /api/...? So every route under /api/... would get run through auth.

I currently believe a middleware is global and hence not scoped but it would be good to hear from @rgrinberg.

No, it is not a real middleware, it’s just a wrapper which return either a redirection to the login page or process the request itself. The full implementation is here:

let auth
  : (Opium.Request.t -> Opium.Response.t Lwt.t) -> Opium.Request.t -> Opium.Response.t Lwt.t
  = fun f req ->
    match Request.cookie "session" req with

    | None -> login () (* No session value provided, redirect to the login page *)
    | Some k ->
      try
        let id = Int64.of_string k in
        let t =
          (* Here is the middleware *) 
          Db_model.query req @@
          Db_model.Session.get_user id in

        match%lwt t with
        | None -> login ()
        | Some user ->
          let () = Logs.info (fun m -> m "Got the user %s" user.login) in
          f req
      with Failure _ -> login ()

The code is not published as it is just a toy for me and not a real project (but I could on demand)

2 Likes

Interesting. Would be great to hear from Opium experts about whether it’s possible. (It is in ReWeb :slight_smile:

A rewrite of Opium’s router is in progress, and as part of the plan for the new implementation, we would like to provide an API to compose routers (and more generally rock applications), which will address the “scoping” use case.

In the mean time, here’s a function I use in some of my projects:

let scope ?(route = "") ?(middlewares = []) router routes =
  ListLabels.fold_left
    routes
    ~init:router
    ~f:(fun router (meth, subroute, action) ->
      let filters =
        ListLabels.map ~f:(fun m -> m.Rock.Middleware.filter) middlewares
      in
      let service = Rock.Filter.apply_all filters action in
      Router.add
        router
        ~action:service
        ~meth
        ~route:(Route.of_string (route ^ subroute)))

Which can be used to scope routes with a list of middlewares and a prefix. For instance:

let router : Rock.Handler.t Router.t = Router.empty

let router =
  scope
    router
    ~middlewares:
      [ User_auth_middleware.redirect_if_user_is_authenticated
      ; User_auth_middleware.fetch_current_user
      ]
    ~route:"/users"
    [ `GET, "/register", User_registration_handler.new_
    ; `POST, "/register", User_registration_handler.create
    ; `GET, "/login", User_session_handler.new_
    ; `POST, "/login", User_session_handler.create
    ]

let router =
  scope
    router
    ~middlewares:
      [ User_auth_middleware.require_authenticated_user
      ; User_auth_middleware.fetch_current_user
      ]
    ~route:"/users"
    [ `DELETE, "/logout", User_session_handler.delete
    ; `GET, "/settings", User_settings_handler.edit
    ]

There hasn’t been a concensus on the best API for this, but hopefully we’ll come up with an acceptable solution soon and this will be integrated in Opium directly.

5 Likes

Cool. In ReWeb the equivalent (just swapping ‘filter’ with ‘middleware’) would be:

let reg_login_server = function
  | `GET, ["register"] -> User.Registration.new_
  | `POST, ["register"] -> User.Registration.create
  | `GET, ["login"] -> User.Session.new_
  | `POST, ["login"] -> User.Session.create
  | _ -> not_found

let auth_server = function
  | `DELETE, ["logout"] -> User.Session.delete
  | `GET, ["settings"] -> User.Settings.edit
  | _ -> not_found

let users_server = function
  | meth, ["register" | "login"] as path ->
    User.Filter.redirect_if_authenticated @@ reg_login_server @@ (meth, path)
  | meth, _user :: ["logout" | "settings"] as path ->
    User.Filter.require_authenticated @@ auth_server @@ (meth, path)
  | _ ->
    not_found

let server = function
  | meth, "users" :: path ->
    User.Filter.fetch_current_user @@ users_server @@ (meth, path)
  | _ ->
    not_found

This would handle the routes:

GET /users/register
POST /users/register
GET /users/login
POST /users/login
GET /users/yawar/settings (username can be any identifier of course)
DELETE /users/yawar/logout

We can nest scopes at an arbitrary level, as you can imagine. The downside is you give up modelling routes at the value level and they become patterns (which has its upsides too of course).

1 Like