diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 0ff5442f4..749265c23 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,4 +1,20 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + include Authentication::SessionLookup + + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + private + def find_verified_user + if verified_session = find_session_by_cookie + verified_session.user + else + reject_unauthorized_connection + end + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d12a..6a5708320 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,5 @@ class ApplicationController < ActionController::Base + include Authentication + + allow_browser versions: :modern end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 000000000..1a0d6f376 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,80 @@ +module Authentication + extend ActiveSupport::Concern + include SessionLookup + + included do + before_action :require_authentication + helper_method :signed_in? + + protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? } + end + + class_methods do + def require_unauthenticated_access(**options) + allow_unauthenticated_access **options + before_action :redirect_signed_in_user_to_root, **options + end + + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + before_action :restore_authentication, **options + end + end + + private + def signed_in? + Current.user.present? + end + + def require_authentication + restore_authentication || request_authentication + end + + def restore_authentication + if session = find_session_by_cookie + resume_session session + end + end + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_url + end + + def redirect_signed_in_user_to_root + redirect_to root_url if signed_in? + end + + def start_new_session_for(user) + user.sessions.start!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + authenticated_as session + end + end + + def resume_session(session) + session.resume user_agent: request.user_agent, ip_address: request.remote_ip + authenticated_as session + end + + def authenticated_as(session) + Current.user = session.user + set_authenticated_by(:session) + cookies.signed.permanent[:session_token] = { value: session.token, httponly: true, same_site: :lax } + end + + def post_authenticating_url + session.delete(:return_to_after_authenticating) || root_url + end + + def reset_authentication + cookies.delete(:session_token) + end + + def set_authenticated_by(method) + @authenticated_by = method.to_s.inquiry + end + + def authenticated_by + @authenticated_by ||= "".inquiry + end +end diff --git a/app/controllers/concerns/authentication/session_lookup.rb b/app/controllers/concerns/authentication/session_lookup.rb new file mode 100644 index 000000000..b180f217f --- /dev/null +++ b/app/controllers/concerns/authentication/session_lookup.rb @@ -0,0 +1,7 @@ +module Authentication::SessionLookup + def find_session_by_cookie + if token = cookies.signed[:session_token] + Session.find_by(token: token) + end + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 000000000..b16c149f9 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,32 @@ +class SessionsController < ApplicationController + allow_unauthenticated_access only: %i[ new create ] + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render_rejection :too_many_requests } + + def new + end + + def create + if user = User.active.authenticate_by(user_params) + start_new_session_for user + redirect_to post_authenticating_url + else + render_rejection :unauthorized + end + end + + def destroy + reset_authentication + + redirect_to root_url + end + + private + def user_params + params.require(:user).permit(:email_address, :password) + end + + def render_rejection(status) + flash[:alert] = "Too many requests or unauthorized." + render :new, status: status + end +end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb new file mode 100644 index 000000000..309f8b2eb --- /dev/null +++ b/app/helpers/sessions_helper.rb @@ -0,0 +1,2 @@ +module SessionsHelper +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..7fc0114e2 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,7 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user + + def organization + user.organization + end +end diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 000000000..89daaaea0 --- /dev/null +++ b/app/models/organization.rb @@ -0,0 +1,3 @@ +class Organization < ApplicationRecord + has_many :users +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 000000000..c70b6fa10 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,19 @@ +class Session < ApplicationRecord + ACTIVITY_REFRESH_RATE = 1.hour + + has_secure_token + + belongs_to :user + + before_create { self.last_active_at ||= Time.now } + + def self.start!(user_agent:, ip_address:) + create! user_agent: user_agent, ip_address: ip_address + end + + def resume(user_agent:, ip_address:) + if last_active_at.before?(ACTIVITY_REFRESH_RATE.ago) + update! user_agent: user_agent, ip_address: ip_address, last_active_at: Time.now + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..cac399be3 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,24 @@ +class User < ApplicationRecord + belongs_to :organization + has_many :sessions, dependent: :destroy + + has_secure_password validations: false + + scope :active, -> { where(active: true) } + + def current? + self == Current.user + end + + def deactivate + transaction do + sessions.delete_all + update! active: false, email_address: deactived_email_address + end + end + + private + def deactived_email_address + email_address&.gsub(/@/, "-deactivated-#{SecureRandom.uuid}@") + end +end diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 000000000..4974b7d12 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,9 @@ +<%= form_with model: User.new, url: session_path do |f| %> + <%= f.label :email_address %> + <%= f.email_field :email_address %> + + <%= f.label :password %> + <%= f.password_field :password %> + + <%= f.submit "Sign in" %> +<% end %> diff --git a/app/views/sessions/show.html.erb b/app/views/sessions/show.html.erb new file mode 100644 index 000000000..41d87d0e1 --- /dev/null +++ b/app/views/sessions/show.html.erb @@ -0,0 +1,3 @@ +

Hello <%= Current.user.name %>

+ +<%= button_to "Sign out", session_path, method: :delete %> diff --git a/config/environments/test.rb b/config/environments/test.rb index adbb4a6f4..ff03ce7a6 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -61,4 +61,7 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + # Load test helpers + config.autoload_paths += %w[ test/test_helpers ] end diff --git a/config/routes.rb b/config/routes.rb index a125ef085..899385096 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,7 @@ Rails.application.routes.draw do - # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html + root "sessions#show" + + resource :session - # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. - # Can be used by load balancers and uptime monitors to verify that the app is live. get "up" => "rails/health#show", as: :rails_health_check - - # Defines the root path route ("/") - # root "posts#index" end diff --git a/db/migrate/20240621144855_create_organizations.rb b/db/migrate/20240621144855_create_organizations.rb new file mode 100644 index 000000000..ec00d3169 --- /dev/null +++ b/db/migrate/20240621144855_create_organizations.rb @@ -0,0 +1,9 @@ +class CreateOrganizations < ActiveRecord::Migration[8.0] + def change + create_table :organizations do |t| + t.string :name, null: false, index: { unique: true } + + t.timestamps + end + end +end diff --git a/db/migrate/20240621144902_create_users.rb b/db/migrate/20240621144902_create_users.rb new file mode 100644 index 000000000..7839f7ebe --- /dev/null +++ b/db/migrate/20240621144902_create_users.rb @@ -0,0 +1,14 @@ +class CreateUsers < ActiveRecord::Migration[8.0] + def change + create_table :users do |t| + t.references :organization, null: false, foreign_key: true + + t.string :name, null: false + t.string :email_address, null: false, index: { unique: true } + t.string :password_digest, null: false + t.boolean :active, null: false, default: true + + t.timestamps + end + end +end diff --git a/db/migrate/20240621150944_create_sessions.rb b/db/migrate/20240621150944_create_sessions.rb new file mode 100644 index 000000000..c80b2ca2d --- /dev/null +++ b/db/migrate/20240621150944_create_sessions.rb @@ -0,0 +1,13 @@ +class CreateSessions < ActiveRecord::Migration[8.0] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :token, null: false, index: { unique: true } + t.string :ip_address + t.string :user_agent + t.datetime :last_active_at, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 000000000..e785beea5 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,47 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 2024_06_21_150944) do + create_table "organizations", force: :cascade do |t| + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_organizations_on_name", unique: true + end + + create_table "sessions", force: :cascade do |t| + t.integer "user_id", null: false + t.string "token", null: false + t.string "ip_address" + t.string "user_agent" + t.datetime "last_active_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token"], name: "index_sessions_on_token", unique: true + t.index ["user_id"], name: "index_sessions_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.integer "organization_id", null: false + t.string "name", null: false + t.string "email_address", null: false + t.string "password_digest", null: false + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_users_on_email_address", unique: true + t.index ["organization_id"], name: "index_users_on_organization_id" + end + + add_foreign_key "sessions", "users" + add_foreign_key "users", "organizations" +end diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 000000000..1bf75a3be --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,156 @@ + + + + Your browser is not supported (406) + + + + + + +
+
+ + + +
+ + + + + Go back + +
+

🇺🇸 Upgrade to a supported web browser. Splat requires a modern web browser. Please use one of the browsers listed below and make sure auto-updates are enabled.

+

🇪🇸 Actualiza a un navegador web compatible. Splat requiere un navegador web moderno. Utiliza uno de los navegadores listados a continuación y asegúrate de que las actualizaciones automáticas estén habilitadas.

+

🇫🇷 Mettez à jour vers un navigateur web pris en charge. Splat nécessite un navigateur web moderne. Veuillez utiliser l'un des navigateurs répertoriés ci-dessous et assurez-vous que les mises à jour automatiques sont activées.

+

🇮🇳 समर्थित वेब ब्राउज़र में अपग्रेड करें। Splat को एक आधुनिक वेब ब्राउज़र की आवश्यकता है। कृपया नीचे सूचीबद्ध ब्राउज़रों में से कोई एक का उपयोग करें और सुनिश्चित करें कि स्वचालित अपडेट्स सक्षम हैं।

+

🇩🇪 Aktualisieren Sie auf einen unterstützten Webbrowser. Splat erfordert einen modernen Webbrowser. Verwenden Sie bitte einen der unten aufgeführten Browser und stellen Sie sicher, dass automatische Updates aktiviert sind.

+

🇧🇷 Atualize para um navegador compatível. O Splat requer um navegador moderno. Por favor, use um dos navegadores listados abaixo e certifique-se de que as atualizações automáticas estão ativadas.

+

Safari 17.2+, Chrome 119+, Firefox 121+, Opera 104+

+
+
+ + diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 000000000..ab9bc8603 --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + ALLOWED_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15" + DISALLOWED_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0" + + test "new" do + get new_session_url + assert_response :success + end + + test "new denied with incompatible browser" do + get new_session_url, env: { "HTTP_USER_AGENT" => DISALLOWED_BROWSER } + assert_select "svg", message: /Your browser is not supported/ + end + + test "new allowed with compatible browser" do + get new_session_url, env: { "HTTP_USER_AGENT" => ALLOWED_BROWSER } + assert_select "svg", message: /Your browser is not supported/, count: 0 + end + + test "create with valid credentials" do + post session_url, params: { user: { email_address: "david@37signals.com", password: "secret123456" } } + + assert_redirected_to root_url + assert parsed_cookies.signed[:session_token] + end + + test "create with invalid credentials" do + post session_url, params: { user: { email_address: "david@37signals.com", password: "wrong" } } + + assert_response :unauthorized + assert_nil parsed_cookies.signed[:session_token] + end + + test "destroy" do + sign_in :kevin + + delete session_url + + assert_redirected_to root_url + assert_not cookies[:session_token].present? + end +end diff --git a/test/fixtures/organizations.yml b/test/fixtures/organizations.yml new file mode 100644 index 000000000..a3486ab04 --- /dev/null +++ b/test/fixtures/organizations.yml @@ -0,0 +1,2 @@ +37signals: + name: 37signals diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 000000000..6003e3a41 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,19 @@ +<% digest = BCrypt::Password.create("secret123456") %> + +david: + organization: 37signals + name: David + email_address: david@37signals.com + password_digest: <%= digest %> + +jz: + organization: 37signals + name: JZ + email_address: jz@37signals.com + password_digest: <%= digest %> + +kevin: + organization: 37signals + name: Kevin + email_address: kevin@37signals.com + password_digest: <%= digest %> diff --git a/test/models/organization_test.rb b/test/models/organization_test.rb new file mode 100644 index 000000000..3f86f1cd4 --- /dev/null +++ b/test/models/organization_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class OrganizationTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 000000000..5c07f4900 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 0c22470ec..37e5e0a22 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,6 @@ module ActiveSupport # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - # Add more helper methods to be used by all tests here... + include SessionTestHelper end end diff --git a/test/test_helpers/session_test_helper.rb b/test/test_helpers/session_test_helper.rb new file mode 100644 index 000000000..53f04d420 --- /dev/null +++ b/test/test_helpers/session_test_helper.rb @@ -0,0 +1,16 @@ +module SessionTestHelper + def parsed_cookies + ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash) + end + + def sign_in(user) + user = users(user) unless user.is_a? User + post session_url, params: { user: { email_address: user.email_address, password: "secret123456" } } + assert cookies[:session_token].present? + end + + def sign_out + delete session_url + assert_not cookies[:session_token].present? + end +end