janko / rodauth-rails

Rails integration for Rodauth authentication framework
https://github.com/jeremyevans/rodauth
MIT License
565 stars 40 forks source link

Logout with JSON API not working while generated view works #276

Closed FelipeBodelon closed 4 months ago

FelipeBodelon commented 5 months ago

I'm implementing a new authentication flow on a SPA with session cookies using rodauth-rail JSON endpoints (no JWT). So far so good (mostly, some CORS issues), except that the logout endpoint doesn't seem to be working properly.

Using email auth or google oauth it seems to be generating a session and remember token cookie correctly, but when trying to sign out via JSON API from the SPA, I get a 200 status but no response data and the session doesn't appear to be cleared. The only effect I see is that the remember cookie is removed. Here are the server logs and response info:

16:59:56 web.1  | Started POST "/auth/logout" for 127.0.0.1 at 2024-02-14 16:59:56 -0300
16:59:56 web.1  |   Sequel (0.4ms)  DELETE FROM "account_active_session_keys" WHERE (("account_id" = 40) AND (("last_use" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -86400))) OR ("created_at" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -2592000)))))
16:59:56 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
16:59:56 web.1  |   Sequel (2.6ms)  UPDATE "account_active_session_keys" SET "last_use" = CURRENT_TIMESTAMP WHERE (("account_id" = 40) AND ("session_id" IN ('0SHT8pTTMB7slSRwlzPddw67__JbfvPGsQ0tJhY_Ca0')))
16:59:56 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
16:59:56 web.1  | HTTP Origin header (http://localhost:3001) didn't match request.base_url (http://localhost:3000)
16:59:56 web.1  | Processing by RodauthApp#call as JSON
16:59:56 web.1  |   Parameters: {"global_logout"=>true}
16:59:56 web.1  |   TRANSACTION (0.2ms)  BEGIN
16:59:56 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
16:59:56 web.1  |   Sequel (0.4ms)  DELETE FROM "account_remember_keys" WHERE ("id" IS NULL)
16:59:56 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
16:59:56 web.1  |   Sequel (0.4ms)  DELETE FROM "account_active_session_keys" WHERE ("account_id" IS NULL)
16:59:56 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
16:59:56 web.1  |   TRANSACTION (0.3ms)  COMMIT
16:59:56 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
16:59:56 web.1  | Completed 200 OK in 9ms (ActiveRecord: 4.2ms | Allocations: 4338)

image image

On the other hand, if I try to logout through Rodauth methods in generated Rails views (using the same route but with html instead of json), the session seems to end perfectly. Here are the server logs for that event:

17:17:12 web.1  | Started POST "/auth/logout" for 127.0.0.1 at 2024-02-14 17:17:12 -0300
17:17:12 web.1  |   Sequel (0.3ms)  DELETE FROM "account_active_session_keys" WHERE (("account_id" = 40) AND (("last_use" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -86400))) OR ("created_at" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -2592000)))))
17:17:12 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
17:17:12 web.1  |   Sequel (1.8ms)  UPDATE "account_active_session_keys" SET "last_use" = CURRENT_TIMESTAMP WHERE (("account_id" = 40) AND ("session_id" IN ('0IbiO73Ftnbx19sR3bbKs1gZaeUEbT0WHdFqPRP70Jc')))
17:17:12 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
17:17:12 web.1  | Processing by RodauthApp#call as HTML
17:17:12 web.1  |   Parameters: {"authenticity_token"=>"[FILTERED]", "commit"=>"Logout"}
17:17:12 web.1  |   TRANSACTION (0.2ms)  BEGIN
17:17:12 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
17:17:12 web.1  |   Sequel (0.2ms)  DELETE FROM "account_active_session_keys" WHERE (("account_id" = 40) AND ("session_id" IN ('0IbiO73Ftnbx19sR3bbKs1gZaeUEbT0WHdFqPRP70Jc')))
17:17:12 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
17:17:12 web.1  |   TRANSACTION (1.5ms)  COMMIT
17:17:12 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
17:17:12 web.1  | Redirected to /auth/login
17:17:12 web.1  | Completed 302 Found in 6ms (ActiveRecord: 4.0ms | Allocations: 3236)

It seems to me that the JSON request is not working in my case, maybe because of some CORS issues. Here is the request on the frontend:

async function signOut() {
  return await apiClient.post(
    'auth/logout',
    { globalLogout: true },
    {
      withCredentials: true,
    },
  );
}

Keys are auto transformed to snake_case, and as you can see, I've tried global logout. Content type and accept headers are also set to json as default.

For more info about my project configs I included them in this discussion.

Thanks in advance!

janko commented 5 months ago

I couldn't reproduce this. I created a fresh API-only Rails app, added the cookie session middleware, and created a basic Rodauth setup:

# app/misc/rodauth_main.rb
require "sequel/core"

class RodauthMain < Rodauth::Rails::Auth
  configure do
    enable :login_password_requirements_base, :login, :logout, :remember, :json, :active_sessions

    db Sequel.sqlite(extensions: :activerecord_connection, keep_reference: false)
    convert_token_id_to_integer? true
    only_json? true

    rails_controller { RodauthController }
    account_status_column :status
    account_password_hash_column :password_hash

    after_login { remember_login }
    extend_remember_deadline? true
  end
end
# app/misc/rodauth_app.rb
class RodauthApp < Rodauth::Rails::App
  configure RodauthMain

  route do |r|
    rodauth.load_memory

    r.rodauth

    r.get("session") { session.to_hash.to_json }
  end
end

I then ran the following script that uses rack-test to make requests, and it shows the session is being cleared after logout:

require_relative "config/environment"
require "rack/test"

ApplicationRecord.transaction do
  Account.create!(email: "user@example.com", password: "secret123", status: "verified")

  session = Rack::Test::Session.new(Rails.application)
  session.header "Host", "localhost"
  session.header "Content-Type", "application/json"
  session.header "Accept", "application/json"

  session.post "/login", JSON.generate({ login: "user@example.com", password: "secret123" })
  puts session.get("/session").body
  session.post "/logout", JSON.generate({ global_logout: true })
  puts session.get("/session").body

  raise ActiveRecord::Rollback
end
{"session_id":"8e608c2f8501cee39c83c7e2f1be3573","account_id":1,"active_session_id":"cpt19Tj6IrWZ8kM07_dawNQkopb7jO0-TVhtdfXgHH8","authenticated_by":["password"],"remember_deadline_extended_at":1707987690}
{"session_id":"17be6b30de1bb31956cbf77482810e2f"}

If you still think this is a bug in rodauth-rails or Rodauth, please share a fresh Rails app that demonstrates the problem, ideally making HTTP requests in Ruby.

janko commented 5 months ago

Note that my attempt didn't take into account any CORS settings you have configured. If you suspect cookies aren't properly being sent or something, then please present a JS script using axios and going through the auth flow.

FelipeBodelon commented 5 months ago

This test doesn't really reflect my situation. My Rails backend is not API only and my rodauth settings has only_json? false. The logout from Rails works removing the session, the JSON API one doesn't.

As stated, you can clearly see that the HTML logout clearly gets the session and account IDs and removes the account_active_session_key:

20:59:12 web.1  | Started POST "/auth/logout" for 127.0.0.1 at 2024-02-15 20:59:12 -0300
20:59:12 web.1  |   Sequel (0.5ms)  DELETE FROM "account_active_session_keys" WHERE (("account_id" = 40) AND (("last_use" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -86400))) OR ("created_at" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -2592000)))))
20:59:12 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
20:59:12 web.1  |   Sequel (6.7ms)  UPDATE "account_active_session_keys" SET "last_use" = CURRENT_TIMESTAMP WHERE (("account_id" = 40) AND ("session_id" IN ('mXAAK3XsnB81BnE34bXWDr7PEG6bRIsTNLinmtapm_Q')))
20:59:12 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
20:59:12 web.1  | Processing by RodauthApp#call as HTML
20:59:12 web.1  |   Parameters: {"authenticity_token"=>"[FILTERED]", "commit"=>"Logout"}
20:59:12 web.1  |   TRANSACTION (0.3ms)  BEGIN
20:59:12 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
20:59:12 web.1  |   Sequel (0.5ms)  DELETE FROM "account_active_session_keys" WHERE (("account_id" = 40) AND ("session_id" IN ('mXAAK3XsnB81BnE34bXWDr7PEG6bRIsTNLinmtapm_Q')))
20:59:12 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
20:59:12 web.1  |   TRANSACTION (1.8ms)  COMMIT
20:59:12 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
20:59:12 web.1  | Redirected to /auth/login
20:59:12 web.1  | Completed 302 Found in 10ms (ActiveRecord: 9.8ms | Allocations: 3172)

While the API JSON logout clearly gets the session and account IDs for the request, but on RodauthApp#callas JSON the same IDs are set to NULL. No CORS or request errors present (it returns 200 with a success message):

21:00:25 web.1  | Started POST "/auth/logout" for 127.0.0.1 at 2024-02-15 21:00:25 -0300
21:00:25 web.1  |   Sequel (0.3ms)  SELECT CAST(current_setting('server_version_num') AS integer) AS v
21:00:25 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
21:00:25 web.1  |   Sequel (0.4ms)  DELETE FROM "account_active_session_keys" WHERE (("account_id" = 40) AND (("last_use" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -86400))) OR ("created_at" < (CAST(CURRENT_TIMESTAMP AS timestamp) + make_interval(secs := -2592000)))))
21:00:25 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
21:00:25 web.1  |   Sequel (1.8ms)  UPDATE "account_active_session_keys" SET "last_use" = CURRENT_TIMESTAMP WHERE (("account_id" = 40) AND ("session_id" IN ('3KHGw_YlFiveNOVv9ffvKZx39B80se8xSCzwzCqcOlU')))
21:00:25 web.1  |   ↳ app/misc/rodauth_app.rb:10:in `block in <class:RodauthApp>'
21:00:25 web.1  | HTTP Origin header (http://localhost:3001) didn't match request.base_url (http://localhost:3000)
21:00:26 web.1  | Processing by RodauthApp#call as JSON
21:00:26 web.1  |   Parameters: {"global_logout"=>true}
21:00:26 web.1  |   TRANSACTION (0.2ms)  BEGIN
21:00:26 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
21:00:26 web.1  |   Sequel (0.3ms)  DELETE FROM "account_remember_keys" WHERE ("id" IS NULL)
21:00:26 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
21:00:26 web.1  |   Sequel (0.2ms)  DELETE FROM "account_active_session_keys" WHERE ("account_id" IS NULL)
21:00:26 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
21:00:26 web.1  |   TRANSACTION (0.1ms)  COMMIT
21:00:26 web.1  |   ↳ app/misc/rodauth_app.rb:12:in `block in <class:RodauthApp>'
21:00:26 web.1  | Completed 200 OK in 6ms (ActiveRecord: 3.4ms | Allocations: 4373)

So cookies are being set, just not being used on the JSON logout action.

janko commented 5 months ago

I think the key piece of information is this:

HTTP Origin header (http://localhost:3001) didn't match request.base_url (http://localhost:3000)

I assumed it came from rack-cors and is harmless, but it's coming from Rails' CSRF protection, which gets called before the logout action. I bet that your base controller is configured to handle unverified requests with :reset_session or :null_session, and that forgery_protection_origin_check is enabled (it seems to be disabled by default).

So, the request first calls rodauth.check_active_session, which works normally. It then reaches r.rodauth, which starts processing the logout action, where rodauth-rails will call Rails' #verify_authenticity_token. The CSRF check determines there is an origin mismatch (the printed warning), clears the session, but proceeds with the request. Rodauth doesn't know the session was cleared and continues processing the logout action, but the account ID from session comes back nil.

Do you get this issue only when making requests to Rodauth endpoints? When you make authenticated JSON POST requests to Rails endpoints, the session doesn't get cleared, and your controller correctly retrieves the authenticated account? Concretely, rodauth.logged_in? or rodauth.authenticated? returns truthy value in your controller actions?

FelipeBodelon commented 5 months ago

That's exactly it! Still trying to figure out the issue, since CSRF protection seems to be getting mixed up. I have protect_from_forgery prepend: true in my application_controller. I'm trying to understand how this messes up with rack-cors or rodauth actions in general.

As my frontend can't possibly append an authenticity-token the way Rails does with its views and forms, should I disable all forgery protection for all JSON API actions, or at least rodauth actions, as rack-cors should be handling that? I'm finding the issues persist even when disabling protect_from_forgery. And as you said, forgery_protection_origin_check is disabled by default, and I haven't changed that.

I find it weird that all other requests don't share this issue but rodauth actions do (mainly having issues with /google/callback and /logout)

Do you get this issue only when making requests to Rodauth endpoints? When you make authenticated JSON POST requests to Rails endpoints, the session doesn't get cleared, and your controller correctly retrieves the authenticated account? Concretely, rodauth.logged_in? or rodauth.authenticated? returns truthy value in your controller actions?

This is all correct

Could this be a library behaviour at all? Do you have examples of Rails apps (without json only or API mode enabled) making use of the JSON features securely?

FelipeBodelon commented 5 months ago

Thanks for the help BTW. Would you suggest I try the JWT approach instead?

FelipeBodelon commented 5 months ago

Ok as expected disabling rodauth's check_csrf? solved ALL my issues, confirming it wasn't my default CORS behaviour. Tho I wish to know if this is safe. Was not aware of the config as it is from base rodauth.

janko commented 4 months ago

And as you said, forgery_protection_origin_check is disabled by default, and I haven't changed that.

That's odd, because the only way this warning could be displayed is if forgery_protection_origin_check is enabled. I see now that in the official demo app Rails.application.config.action_controller.forgery_protection_origin_check returns true, even though I haven't changed anything. I now realized I misunderstood the defaults, I thought it was enabled only on Rails 5.0, but it's actually enabled starting from Rails 5.0.

I think this needs to be disabled when your frontend lives on a separate host, otherwise I cannot see how the CSRF check can possibly pass, even if you do pass the CSRF token. I don't understand how your controller actions are then passing the CSRF check (and don't end up with a null session) if you're not passing the token. If it's really only the Rodauth endpoints, then that would indicate a bug in rodauth-rails as you mentioned, because it calls controllers internally.

Would you suggest I try the JWT approach instead?

Typically you'd use either JSON with session cookie, which would then include CSRF protection, or JWT. If you're using JSON with session cookie (default json feature), then I thought without CSRF protection you're still vulnerable to remote form submits (since that skips CORS), but I realized that form submits probably cannot make JSON requests. In that case the only way to make a cross-origin JSON request is via JS, and then you're protected with CORS. So, if you want to support both JSON and HTML mode, then this should probably be a safe setting:

check_csrf? { !use_json? }

If this really is safe, then maybe Rodauth should make it the default.

Do you have examples of Rails apps (without json only or API mode enabled) making use of the JSON features securely?

I unfortunately don't have experience with JSON API authentication. I wanted to make an example app for some time, but I think it would be best if it's born out of production (probably from someone else than me).