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