diff --git a/Gemfile b/Gemfile index 81b24ff..a62f8ee 100644 --- a/Gemfile +++ b/Gemfile @@ -39,3 +39,5 @@ end gem 'chroma' gem 'solid_queue', '~> 1.0' + +gem "bcrypt", "~> 3.1" diff --git a/Gemfile.lock b/Gemfile.lock index 988ab87..6615946 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,6 +81,7 @@ GEM babel-source (>= 4.0, < 6) execjs (~> 2.0) base64 (0.2.0) + bcrypt (3.1.20) benchmark (0.4.0) bigdecimal (3.1.8) bindex (0.8.1) @@ -363,6 +364,7 @@ PLATFORMS DEPENDENCIES annotaterb + bcrypt (~> 3.1) bootsnap chroma csv diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 1cc97c6..abaef27 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -2,5 +2,17 @@ module ApplicationCable class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + set_current_user || reject_unauthorized_connection + end + + private + def set_current_user + if session = Session.find_by(id: cookies.signed[:session_id]) + self.current_user = session.user + end + end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 57926f3..54431ec 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,7 @@ # Copyright (C) 2024 Manuel Bustillo class ApplicationController < ActionController::Base + include Authentication after_action :set_csrf_cookie skip_before_action :verify_authenticity_token, if: :development_swagger? diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb new file mode 100644 index 0000000..5275e10 --- /dev/null +++ b/app/controllers/concerns/authentication.rb @@ -0,0 +1,57 @@ +# Copyright (C) 2024 Manuel Bustillo + +module Authentication + extend ActiveSupport::Concern + + included do + before_action :require_authentication + helper_method :authenticated? + end + + class_methods do + def allow_unauthenticated_access(**options) + skip_before_action :require_authentication, **options + end + end + + private + def authenticated? + resume_session + end + + def require_authentication + resume_session || request_authentication + end + + + def resume_session + Current.session ||= find_session_by_cookie + end + + def find_session_by_cookie + Session.find_by(id: cookies.signed[:session_id]) + end + + + def request_authentication + session[:return_to_after_authenticating] = request.url + redirect_to new_session_path + end + + def after_authentication_url + session.delete(:return_to_after_authenticating) || root_url + end + + + def start_new_session_for(user) + user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| + Current.session = session + cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax } + end + end + + def terminate_session + Current.session.destroy + cookies.delete(:session_id) + end +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb new file mode 100644 index 0000000..1189710 --- /dev/null +++ b/app/controllers/passwords_controller.rb @@ -0,0 +1,30 @@ +# Copyright (C) 2024 Manuel Bustillo + +class PasswordsController < ApplicationController + allow_unauthenticated_access + before_action :set_user_by_token, only: :update + + def create + if user = User.find_by(email_address: params[:email_address]) + PasswordsMailer.reset(user).deliver_later + end + + render json: {}, status: :created + end + + def update + if @user.update(params.permit(:password, :password_confirmation)) + render json: {}, status: :ok + else + render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def set_user_by_token + @user = User.find_by_password_reset_token!(params[:token]) + rescue ActiveSupport::MessageVerifier::InvalidSignature + render json: { errors: ['Password reset link is invalid or has expired.'] }, status: :unprocessable_entity + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..aa74562 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,21 @@ +# Copyright (C) 2024 Manuel Bustillo + +class SessionsController < ApplicationController + allow_unauthenticated_access only: :create + rate_limit to: 10, within: 3.minutes, only: :create, + with: -> { render json: { errors: ['Rate limit exceeded'] }, status: :too_many_requests } + + def create + if user = User.authenticate_by(params.permit(:email_address, :password)) + start_new_session_for user + render json: {}, status: :created + else + render json: { errors: ['Invalid email address or password'] }, status: :unauthorized + end + end + + def destroy + terminate_session + render json: {}, status: :ok + end +end diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb new file mode 100644 index 0000000..f3692f0 --- /dev/null +++ b/app/mailers/passwords_mailer.rb @@ -0,0 +1,8 @@ +# Copyright (C) 2024 Manuel Bustillo + +class PasswordsMailer < ApplicationMailer + def reset(user) + @user = user + mail subject: "Reset your password", to: user.email_address + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..f7b1ab9 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,6 @@ +# Copyright (C) 2024 Manuel Bustillo + +class Current < ActiveSupport::CurrentAttributes + attribute :session + delegate :user, to: :session, allow_nil: true +end diff --git a/app/models/session.rb b/app/models/session.rb new file mode 100644 index 0000000..9ba68b4 --- /dev/null +++ b/app/models/session.rb @@ -0,0 +1,24 @@ +# Copyright (C) 2024 Manuel Bustillo + +# == Schema Information +# +# Table name: sessions +# +# id :bigint not null, primary key +# ip_address :string +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint not null +# +# Indexes +# +# index_sessions_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# +class Session < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..6c752a9 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,22 @@ +# Copyright (C) 2024 Manuel Bustillo + +# == Schema Information +# +# Table name: users +# +# id :bigint not null, primary key +# email_address :string not null +# password_digest :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_users_on_email_address (email_address) UNIQUE +# +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + + normalizes :email_address, with: ->(e) { e.strip.downcase } +end diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb new file mode 100644 index 0000000..9b536ff --- /dev/null +++ b/app/views/passwords_mailer/reset.html.erb @@ -0,0 +1,6 @@ +<%# Copyright (C) 2024 Manuel Bustillo %> + +

+ You can reset your password within the next 15 minutes on + <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. +

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb new file mode 100644 index 0000000..0ce9c12 --- /dev/null +++ b/app/views/passwords_mailer/reset.text.erb @@ -0,0 +1,4 @@ +<%# Copyright (C) 2024 Manuel Bustillo %> + +You can reset your password within the next 15 minutes on this password reset page: +<%= edit_password_url(@user.password_reset_token) %> diff --git a/config/routes.rb b/config/routes.rb index c9d7ee6..ed59be2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ # Copyright (C) 2024 Manuel Bustillo Rails.application.routes.draw do + resource :session, only: %i[create destroy] + resources :passwords, param: :token, only: %w[create update] mount Rswag::Ui::Engine => '/api-docs' mount Rswag::Api::Engine => '/api-docs' resources :groups, only: :index diff --git a/db/migrate/20241118232609_create_users.rb b/db/migrate/20241118232609_create_users.rb new file mode 100644 index 0000000..b1f1381 --- /dev/null +++ b/db/migrate/20241118232609_create_users.rb @@ -0,0 +1,13 @@ +# Copyright (C) 2024 Manuel Bustillo + +class CreateUsers < ActiveRecord::Migration[8.0] + def change + create_table :users do |t| + t.string :email_address, null: false + t.string :password_digest, null: false + + t.timestamps + end + add_index :users, :email_address, unique: true + end +end diff --git a/db/migrate/20241118232618_create_sessions.rb b/db/migrate/20241118232618_create_sessions.rb new file mode 100644 index 0000000..3cc4be3 --- /dev/null +++ b/db/migrate/20241118232618_create_sessions.rb @@ -0,0 +1,13 @@ +# Copyright (C) 2024 Manuel Bustillo + +class CreateSessions < ActiveRecord::Migration[8.0] + def change + create_table :sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :ip_address + t.string :user_agent + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c54ec05..02eeea6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_11_11_063741) do +ActiveRecord::Schema[8.0].define(version: 2024_11_18_232618) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -60,6 +60,15 @@ ActiveRecord::Schema[8.0].define(version: 2024_11_11_063741) do t.index ["tables_arrangement_id"], name: "index_seats_on_tables_arrangement_id" end + create_table "sessions", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "ip_address" + t.string "user_agent" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_sessions_on_user_id" + end + create_table "solid_queue_blocked_executions", force: :cascade do |t| t.bigint "job_id", null: false t.string "queue_name", null: false @@ -188,10 +197,19 @@ ActiveRecord::Schema[8.0].define(version: 2024_11_11_063741) do t.string "name", null: false end + create_table "users", force: :cascade do |t| + t.string "email_address", null: false + t.string "password_digest", 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 + end + add_foreign_key "groups", "groups", column: "parent_id" add_foreign_key "guests", "groups" add_foreign_key "seats", "guests" add_foreign_key "seats", "tables_arrangements", on_delete: :cascade + add_foreign_key "sessions", "users" add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade diff --git a/spec/requests/passwords_spec.rb b/spec/requests/passwords_spec.rb new file mode 100644 index 0000000..c83a450 --- /dev/null +++ b/spec/requests/passwords_spec.rb @@ -0,0 +1,42 @@ +# Copyright (C) 2024 Manuel Bustillo + +require 'swagger_helper' + +RSpec.describe 'passwords', type: :request do + path '/passwords' do + post('send a password (re)set email') do + tags 'Passwords' + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, schema: { + type: :object, + required: [:email_address], + properties: { + email_address: { type: :string, format: :email } + } + } + + response_empty_201 + end + end + + path '/passwords/{token}' do + parameter name: 'token', in: :path, type: :string, description: 'token' + put('update password') do + tags 'Passwords' + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[password password_confirmation], + properties: { + password: { type: :string }, + password_confirmation: { type: :string } + } + } + + response_empty_200 + response_422 + end + end +end diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb new file mode 100644 index 0000000..f836a93 --- /dev/null +++ b/spec/requests/sessions_spec.rb @@ -0,0 +1,31 @@ +# Copyright (C) 2024 Manuel Bustillo + +require 'swagger_helper' + +RSpec.describe 'sessions', type: :request do + path '/session' do + delete('delete session') do + tags 'Sessions' + produces 'application/json' + response_empty_200 + end + + post('create session') do + tags 'Sessions' + consumes 'application/json' + produces 'application/json' + parameter name: :body, in: :body, schema: { + type: :object, + required: %i[email_address password], + properties: { + email_address: { type: :string, format: :email }, + password: { type: :string } + } + } + + response_empty_201 + response_401 + response_429 + end + end +end diff --git a/spec/swagger_response_helper.rb b/spec/swagger_response_helper.rb index 1f84ebd..d0812dd 100644 --- a/spec/swagger_response_helper.rb +++ b/spec/swagger_response_helper.rb @@ -9,6 +9,22 @@ module SwaggerResponseHelper end end + def response_429 + response(429, 'Rate limit exceeded') do + produces 'application/json' + error_schema + xit + end + end + + def response_401 + response(401, 'Unauthorized') do + produces 'application/json' + error_schema + xit + end + end + def response_empty_200 response(200, 'Success') do produces 'application/json'