require 'minitest/autorun' require 'prism' require 'support' require 'tmpdir' class AuthSessionsController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser def create user = AuthUser.find_by(email: params['email']) if user && user.authenticate(params['password']) sign_in(user) halt(200, after_authentication_url) else halt(401, 'invalid credentials') end end def destroy sign_out halt(200, 'signed out') end end class AuthDashboardController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in' before_action :require_authentication def index halt(200, "hello #{current_user.email}") end end class AuthApiController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser before_action :require_authentication def index halt(200, "hello #{current_user.email}") end end class AuthRememberSessionsController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, remember_cookie: :remember_token def create user = AuthUser.find_by(email: params['email']) if user && user.authenticate(params['password']) sign_in(user, remember: params['remember'] == '1') halt(200, 'signed in') else halt(401, 'invalid credentials') end end def destroy sign_out halt(200, 'signed out') end end class AuthRememberDashboardController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_cookie: :remember_token before_action :require_authentication def index halt(200, "hello #{current_user.email}") end end # `authenticate_with` is declared only on the base class here, the way a # real app declares it once on ApplicationController - proving the setting # (and its unauthenticated_redirect_path) reaches a subclass that never # calls `authenticate_with` itself. class AuthBaseController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in' end class AuthInheritedController < AuthBaseController before_action :require_authentication def index halt(200, "hello #{current_user.email}") end end class AuthMultiSessionsController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession def create user = AuthUser.find_by(email: params['email']) if user && user.authenticate(params['password']) sign_in(user, remember: params['remember'] == '1') halt(200, 'signed in') else halt(401, 'invalid credentials') end end def destroy sign_out halt(200, 'signed out') end end class AuthMultiDashboardController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_sessions: :AuthLoginSession before_action :require_authentication def index halt(200, "hello #{current_user.email}") end def other_devices halt(200, other_login_sessions.count.to_s) end def sign_out_others sign_out_all_other_sessions halt(200, other_login_sessions.count.to_s) end def revoke # Deliberately the raw String params['id'] gives, not params['id'].to_i - # a real controller (see examples/blog's LoginSessionsController) has no # reason to convert it itself; revoke_login_session! must do that on its # own or every real HTTP request would silently no-op (see its comment). ok = revoke_login_session!(params['id']) halt(ok ? 200 : 404, ok ? 'revoked' : 'not found') end end class AuthCappedSessionsController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession, max_remember_sessions: 2 def create user = AuthUser.find_by(email: params['email']) if user && user.authenticate(params['password']) sign_in(user, remember: true) halt(200, 'signed in') else halt(401, 'invalid credentials') end end end class AuthCappedDashboardController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_sessions: :AuthLoginSession, max_remember_sessions: 2 before_action :require_authentication def index halt(200, "hello #{current_user.email}") end end class AuthZeroCappedSessionsController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, remember_sessions: :AuthLoginSession, max_remember_sessions: 0 def create user = AuthUser.find_by(email: params['email']) if user && user.authenticate(params['password']) sign_in(user, remember: true) halt(200, 'signed in') else halt(401, 'invalid credentials') end end end class AuthZeroCappedDashboardController < Prism::Controller include Prism::Authenticatable authenticate_with model: :AuthUser, unauthenticated_redirect_path: '/sign_in', remember_sessions: :AuthLoginSession, max_remember_sessions: 0 before_action :require_authentication def index halt(200, "hello #{current_user.email}") end end class AuthenticatableApp < Prism::Application enable_sessions(secret: 'auth-secret') post '/sign_in', to: 'auth_sessions#create' post '/sign_out', to: 'auth_sessions#destroy' get '/dashboard', to: 'auth_dashboard#index' get '/api/me', to: 'auth_api#index' get '/inherited/me', to: 'auth_inherited#index' post '/remember/sign_in', to: 'auth_remember_sessions#create' post '/remember/sign_out', to: 'auth_remember_sessions#destroy' get '/remember/dashboard', to: 'auth_remember_dashboard#index' post '/multi/sign_in', to: 'auth_multi_sessions#create' post '/multi/sign_out', to: 'auth_multi_sessions#destroy' get '/multi/dashboard', to: 'auth_multi_dashboard#index' get '/multi/other_devices', to: 'auth_multi_dashboard#other_devices' post '/multi/sign_out_others', to: 'auth_multi_dashboard#sign_out_others' post '/multi/revoke/:id', to: 'auth_multi_dashboard#revoke' post '/capped/sign_in', to: 'auth_capped_sessions#create' get '/capped/dashboard', to: 'auth_capped_dashboard#index' post '/zero-capped/sign_in', to: 'auth_zero_capped_sessions#create' get '/zero-capped/dashboard', to: 'auth_zero_capped_dashboard#index' end class AuthenticatableTest < Minitest::Test include TestSupport def setup @dir = Dir.mktmpdir('prism-authenticatable') @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) @store.create_table(:auth_users, %w[id email password_digest remember_token]) @store.create_table(:auth_login_sessions, %w[id user_id token user_agent ip_address]) user_class = Class.new(Prism::Orm::Model) do table_name :auth_users has_secure_password has_secure_token :remember_token end user_class.store = @store Object.const_set(:AuthUser, user_class) login_session_class = Class.new(Prism::Orm::Model) do table_name :auth_login_sessions has_secure_token :token end login_session_class.store = @store Object.const_set(:AuthLoginSession, login_session_class) AuthUser.create!(email: 'foksiu@example.com', password: 'hunter2') end def teardown FileUtils.remove_entry(@dir) Object.send(:remove_const, :AuthUser) if Object.const_defined?(:AuthUser) Object.send(:remove_const, :AuthLoginSession) if Object.const_defined?(:AuthLoginSession) end def call(method, path, **opts) AuthenticatableApp.call(env_for(method, path, **opts)) end def session_cookie_from(headers) cookie_named(headers, 'prism.session') end def cookie_named(headers, name) line = headers['Set-Cookie'].split("\n").find { |c| c.start_with?("#{name}=") } line&.split(';')&.first end def sign_in_cookie _, headers, = call('POST', '/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: 'email=foksiu@example.com&password=hunter2') session_cookie_from(headers) end def test_wrong_password_is_rejected status, = call('POST', '/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: 'email=foksiu@example.com&password=wrong') assert_equal 401, status end def test_dashboard_without_session_redirects_to_configured_path status, headers, = call('GET', '/dashboard') assert_equal 302, status assert_equal '/sign_in', headers['Location'] end def test_api_without_session_halts_with_401 status, = call('GET', '/api/me') assert_equal 401, status end def test_sign_in_then_dashboard_sees_current_user cookie = sign_in_cookie status, _, body = call('GET', '/dashboard', cookies: cookie) assert_equal 200, status assert_equal 'hello foksiu@example.com', body.join end def test_sign_out_forgets_current_user cookie = sign_in_cookie _, sign_out_headers, = call('POST', '/sign_out', cookies: cookie) fresh_cookie = session_cookie_from(sign_out_headers) status, = call('GET', '/dashboard', cookies: fresh_cookie) assert_equal 302, status end def test_return_to_url_is_remembered_across_the_redirect _, headers, = call('GET', '/dashboard') refute_nil headers['Location'] cookie = session_cookie_from(headers) _, sign_in_headers, body = call('POST', '/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, cookies: cookie, body: 'email=foksiu@example.com&password=hunter2') assert_equal '/dashboard', body.join refute_nil sign_in_headers['Set-Cookie'] end def test_authenticate_with_declared_on_a_base_controller_redirects_a_subclass_without_a_session status, headers, = call('GET', '/inherited/me') assert_equal 302, status assert_equal '/sign_in', headers['Location'] end def test_authenticate_with_declared_on_a_base_controller_sees_current_user_in_a_subclass cookie = sign_in_cookie status, _, body = call('GET', '/inherited/me', cookies: cookie) assert_equal 200, status assert_equal 'hello foksiu@example.com', body.join end def test_signing_in_without_remember_sets_no_remember_cookie _, headers, = call('POST', '/remember/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: 'email=foksiu@example.com&password=hunter2') assert_nil cookie_named(headers, 'remember_token') end def test_remember_cookie_alone_survives_a_lost_session_cookie _, sign_in_headers, = call('POST', '/remember/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: 'email=foksiu@example.com&password=hunter2&remember=1') remember_cookie = cookie_named(sign_in_headers, 'remember_token') refute_nil remember_cookie # No session cookie at all here - simulates a brand new browser session # (browser restart) that only still has the persistent cookie. status, dashboard_headers, body = call('GET', '/remember/dashboard', cookies: remember_cookie) assert_equal 200, status assert_equal 'hello foksiu@example.com', body.join # The fast path (session) is repopulated for the next request on this # same browser, so it does not need to re-verify the cookie every time. refute_nil session_cookie_from(dashboard_headers) end def test_dashboard_rejects_a_request_with_no_cookies_at_all status, = call('GET', '/remember/dashboard') assert_equal 302, status end def test_dashboard_rejects_a_tampered_remember_cookie _, sign_in_headers, = call('POST', '/remember/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: 'email=foksiu@example.com&password=hunter2&remember=1') remember_cookie = cookie_named(sign_in_headers, 'remember_token') tampered = remember_cookie.sub('remember_token=', 'remember_token=tampered') status, = call('GET', '/remember/dashboard', cookies: tampered) assert_equal 302, status end def test_sign_out_rotates_the_token_so_the_old_remember_cookie_stops_working _, sign_in_headers, = call('POST', '/remember/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: 'email=foksiu@example.com&password=hunter2&remember=1') remember_cookie = cookie_named(sign_in_headers, 'remember_token') session_cookie = session_cookie_from(sign_in_headers) call('POST', '/remember/sign_out', cookies: session_cookie) status, = call('GET', '/remember/dashboard', cookies: remember_cookie) assert_equal 302, status end def multi_sign_in(remember: true) _, headers, = call('POST', '/multi/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }, body: "email=foksiu@example.com&password=hunter2&remember=#{remember ? '1' : '0'}") headers end def test_remember_sessions_rejects_both_options_at_once error = assert_raises(ArgumentError) do Class.new(Prism::Controller) do include Prism::Authenticatable authenticate_with model: :AuthUser, remember_cookie: :remember_token, remember_sessions: :AuthLoginSession end end assert_match(/either remember_cookie: or remember_sessions:/, error.message) end def test_remember_sessions_creates_its_own_row_per_sign_in assert_equal 0, AuthLoginSession.count multi_sign_in assert_equal 1, AuthLoginSession.count assert_equal AuthUser.find_by(email: 'foksiu@example.com').id, AuthLoginSession.first.user_id end def test_remember_sessions_captures_user_agent_and_ip_when_the_model_has_those_columns env = env_for('POST', '/multi/sign_in', headers: { 'Content-Type' => 'application/x-www-form-urlencoded', 'User-Agent' => 'DeviceScanner/9.0' }, body: 'email=foksiu@example.com&password=hunter2&remember=1') env['REMOTE_ADDR'] = '198.51.100.42' AuthenticatableApp.call(env) login_session = AuthLoginSession.first assert_equal 'DeviceScanner/9.0', login_session.user_agent assert_equal '198.51.100.42', login_session.ip_address end def test_remember_session_cookie_alone_survives_a_lost_session_cookie headers = multi_sign_in remember_cookie = cookie_named(headers, 'remember_session_token') refute_nil remember_cookie status, dashboard_headers, body = call('GET', '/multi/dashboard', cookies: remember_cookie) assert_equal 200, status assert_equal 'hello foksiu@example.com', body.join refute_nil session_cookie_from(dashboard_headers) end def test_two_remembered_browsers_are_independent_login_sessions browser_a = cookie_named(multi_sign_in, 'remember_session_token') browser_b = cookie_named(multi_sign_in, 'remember_session_token') refute_equal browser_a, browser_b assert_equal 2, AuthLoginSession.count status_a, = call('GET', '/multi/dashboard', cookies: browser_a) status_b, = call('GET', '/multi/dashboard', cookies: browser_b) assert_equal 200, status_a assert_equal 200, status_b end def test_signing_out_of_one_browser_leaves_the_other_remembered_browser_signed_in headers_a = multi_sign_in browser_a_remember = cookie_named(headers_a, 'remember_session_token') browser_a_session = session_cookie_from(headers_a) browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token') call('POST', '/multi/sign_out', cookies: browser_a_session) status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember) assert_equal 302, status_a status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember) assert_equal 200, status_b end def test_other_login_sessions_excludes_the_current_browser browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token') cookie_named(multi_sign_in, 'remember_session_token') cookie_named(multi_sign_in, 'remember_session_token') status, _, body = call('GET', '/multi/other_devices', cookies: browser_a_remember) assert_equal 200, status assert_equal '2', body.join end def test_sign_out_all_other_sessions_leaves_only_the_current_browser browser_a_remember = cookie_named(multi_sign_in, 'remember_session_token') browser_b_remember = cookie_named(multi_sign_in, 'remember_session_token') status, _, body = call('POST', '/multi/sign_out_others', cookies: browser_a_remember) assert_equal 200, status assert_equal '0', body.join assert_equal 1, AuthLoginSession.count status_b, = call('GET', '/multi/dashboard', cookies: browser_b_remember) assert_equal 302, status_b status_a, = call('GET', '/multi/dashboard', cookies: browser_a_remember) assert_equal 200, status_a end