gem "stripe"
# Scheduling
gem "whenever", require: false
+# 2FA
+gem "devise-two-factor"
+gem "rqrcode"
# Use Kredis to get higher-level data types in Redis [https://github.com/rails/kredis]
# gem "kredis"
#api stuff
childprocess (5.1.0)
logger (~> 1.5)
chronic (0.10.2)
+ chunky_png (1.4.0)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crass (1.0.6)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
+ devise-two-factor (6.0.0)
+ activesupport (~> 7.0)
+ devise (~> 4.0)
+ railties (~> 7.0)
+ rotp (~> 6.0)
dotenv (3.1.2)
drb (2.2.1)
erubi (1.13.0)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.3.7)
+ rotp (6.3.0)
rouge (4.3.0)
+ rqrcode (2.2.0)
+ chunky_png (~> 1.0)
+ rqrcode_core (~> 1.0)
+ rqrcode_core (1.2.0)
rubocop (1.66.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
capybara
debug
devise
+ devise-two-factor
dotenv
hcaptcha
httparty
rails (~> 7.2.1)
redcarpet
rouge
+ rqrcode
rubocop-rails-omakase
selenium-webdriver
sprockets-rails
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: [:first_name, :last_name])
devise_parameter_sanitizer.permit(:account_update, keys: [:first_name, :last_name])
+ devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
end
--- /dev/null
+# This controller is for setup of 2FA
+class TwoFactorController < ApplicationController
+ before_action :authenticate_user!
+
+ def new
+ current_user.generate_two_factor_secret_if_missing!
+ @qr_code_uri = current_user.two_factor_qr_code_uri
+ @qr_code = RQRCode::QRCode.new(@qr_code_uri).as_svg(
+ offset: 0,
+ color: '000',
+ shape_rendering: 'crispEdges',
+ module_size: 4
+ )
+ @manual_entry_code = current_user.otp_secret.upcase.scan(/.{4}/).join(' ')
+ end
+
+ def create
+ if current_user.validate_and_consume_otp!(params[:otp_attempt])
+ current_user.enable_two_factor!
+ current_user.generate_otp_backup_codes!
+ redirect_to backup_codes_two_factor_path, notice: '2FA has been enabled. Please save your backup codes.'
+ else
+ flash.now[:alert] = 'Invalid OTP code.'
+ render :new
+ end
+ end
+
+ def backup_codes
+ @backup_codes = current_user.otp_backup_codes
+ end
+
+ def destroy
+ current_user.disable_two_factor!
+ redirect_to root_path, notice: '2FA has been disabled.'
+ end
+end
\ No newline at end of file
--- /dev/null
+class Users::SessionsController < Devise::SessionsController
+ def create
+ user = User.find_by_email(params[:user][:email])
+
+ if user && user.valid_password?(params[:user][:password])
+ if user.otp_required_for_login?
+ session[:user_id] = user.id
+ redirect_to users_two_factor_authentication_path
+ else
+ sign_in(user)
+ set_flash_message!(:notice, :signed_in)
+ respond_with user, location: after_sign_in_path_for(user)
+ end
+ else
+ flash[:alert] = "Invalid email or password."
+ redirect_to new_user_session_path
+ end
+ end
+end
\ No newline at end of file
--- /dev/null
+# This controller is for login with 2FA
+class Users::TwoFactorAuthenticationController < ApplicationController
+ def show
+ if session[:user_id].nil?
+ redirect_to new_user_session_path
+ end
+ @user = User.find_by(id: session[:user_id])
+ redirect_to new_user_session_path if @user.nil? ||
[email protected]_required_for_login?
+ end
+
+ def create
+ user = User.find_by(id: session[:user_id])
+ if user && user.validate_and_consume_otp!(params[:otp_attempt])
+ sign_in user
+ session.delete(:user_id)
+ redirect_to root_path, notice: 'Signed in successfully.'
+ else
+ flash.now[:alert] = 'Invalid two-factor code.'
+ render :show
+ end
+ end
+end
\ No newline at end of file
--- /dev/null
+module TwoFactorHelper
+end
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
- :confirmable, :lockable
+ :confirmable, :lockable, :two_factor_authenticatable, :two_factor_backupable,
+ otp_secret_encryption_key: Rails.application.credentials.active_record_encryption[:primary_key]
+
+ encrypts :otp_secret
+ attr_accessor :otp_plain_secret
validates :first_name, presence: true
validates :last_name, presence: true
send_welcome_email
end
+ def generate_two_factor_secret_if_missing!
+ return unless otp_secret.nil?
+ update!(otp_secret: User.generate_otp_secret)
+ end
+
+ def enable_two_factor!
+ update!(otp_required_for_login: true)
+ end
+
+ def disable_two_factor!
+ update!(
+ otp_required_for_login: false,
+ otp_secret: nil,
+ otp_backup_codes: nil
+ )
+ end
+
+ def two_factor_qr_code_uri
+ issuer = 'Your App Name'
+ label = "#{issuer}:#{email}"
+ otp_provisioning_uri(label, issuer: issuer)
+ end
+
+ def generate_otp_backup_codes!
+ codes = []
+ 10.times do
+ codes << SecureRandom.hex(8)
+ end
+ update!(otp_backup_codes: codes)
+ end
+
+ # Getter and setter for otp_backup_codes
+ def otp_backup_codes
+ return [] if super.nil?
+ JSON.parse(super)
+ end
+
+ def otp_backup_codes=(codes)
+ super(codes.to_json)
+ end
+
private
def sync_with_buttondown
ButtondownService.new.subscribe(self)
<%= f.submit "Update" %>
</div>
<% end %>
+ <% if current_user.otp_required_for_login? %>
+ <%= link_to "Disable 2FA", two_factor_path, method: :delete, data: { confirm: "Are you sure you want to disable 2FA?" }, class: "button" %>
+ <% else %>
+ <%= link_to "Enable 2FA", new_two_factor_path, class: "button" %>
+ <% end %>
<%= button_to "Log out", destroy_user_session_path, method: :delete %>
<% if current_user&.admin? or current_user&.support_type? %>
<h3>Account tools</h3>
<%= f.submit "Log in" %>
</div>
<% end %>
+
+ <p>
+ If you have two-factor authentication enabled and were redirected here,
+ please <%= link_to "enter your two-factor code", users_two_factor_authentication_path %>.
+ </p>
<% end %>
<%= render template: 'layouts/user_page_template' %>
\ No newline at end of file
<div class="container">
<%= image_tag "growchart.svg", class: "porter" %>
<h3>Below you can join mind reader and sign up to receive new dispatches via email.</h3>
- <p>Joining by email means you never have to visit this (admittedly beautiful) website ever again. You’ll receive new posts straight in your email inbox. Bookmarks, however, are sent in digest to subscribers only – so if you’d like to see those, come back or keep reading.</p>
- <p>Optionally, once you’ve signed up and confirmed your email address you are able to subscribe to mind reader financially by entering any non $0 AUD amount you wish. You are under no obligation to do this! Paid subscribers may opt to receive a digest of bookmarks and a monthly update from me on the state of the world that regular members miss out on.</p>
+ <p>Joining by email means you never have to visit this (admittedly beautiful) website ever again. You’ll receive new posts straight in your email inbox. Bookmarks are, optionally, sent in digest to paid subscribers.</p>
+ <p>Optionally, once you’ve signed up and confirmed your email address you are able to subscribe to mind reader financially by entering any non $0 AUD amount you wish – you can do this just once, or annually. You are under no obligation to do this! Paid subscribers may opt to receive a digest of bookmarks and a monthly update from me on the state of the world that regular members miss out on.</p>
<p>If you’re looking to cancel an existing membership or subscription, <%= link_to "please click here", subscriptions_path %>.</p>
<%= image_tag "aidan_arrow.svg", class: "aidans_arrow" %>
- <p>To get started with mind reader membership, first register for an account on this website. Don’t worry, I’ll guide you through each step of the process! Just click on that button below:</p>
- <p><%= link_to "Get started, free forever", new_user_registration_path, class: "button" %></p>
+ <p>To get started with mind reader membership – <em>always free</em> – first register for an account on this website. Don’t worry, I’ll guide you through each step of the process! Just click on that button below:</p>
+ <p><%= link_to "Get started, free forever", new_user_registration_path, class: "button" %> <%= link_to "Log back in", new_user_session_path, class: "button" %></p>
+ <p><em>You can subscribe, unsubscribe, pay, cancel, and never hear from me again without ever contacting a human – note, though, if you pay for the service account deletion is manual to ensure all your details are securely erased. You can always one-click opt-out of communication and services.</em></p>
</div>
</div>
--- /dev/null
+<div class="container">
+ <h2>Your Backup Codes</h2>
+</div>
+
+<div class="post">
+ <div class="container">
+ <p>Please save these backup codes in a secure location. You will need them if you lose access to your authenticator app.</p>
+
+ <ul>
+ <% @backup_codes.each do |code| %>
+ <li><%= code %></li>
+ <% end %>
+ </ul>
+
+ <p>Each code can only be used once. If you use a backup code to sign in, a new set of codes will be generated.</p>
+
+ <%= link_to "I have saved these codes", edit_user_registration_path, class: "button" %>
+ </div>
+</div>
\ No newline at end of file
--- /dev/null
+<h1>TwoFactor#create</h1>
+<p>Find me in app/views/two_factor/create.html.erb</p>
--- /dev/null
+<h1>TwoFactor#destroy</h1>
+<p>Find me in app/views/two_factor/destroy.html.erb</p>
--- /dev/null
+<div class="container">
+ <h2>Enable two-factor authentication</h2>
+ <%= link_to "Cancel two-factor setup...", edit_user_registration_path %>
+</div>
+
+<div class="post">
+ <div class="container">
+ <p>Scan this QR code with your authenticator app:</p>
+
+ <%= @qr_code.html_safe %>
+
+ <p>Or manually enter this code:</p>
+
+ <code><%= @manual_entry_code %></code>
+
+ <p>Enter these details in your authenticator app:</p>
+ <ul>
+ <li><strong>Account:</strong> <%= current_user.email %></li>
+ <li><strong>Key:</strong> <%= @manual_entry_code %></li>
+ <li><strong>Time based:</strong> Yes</li>
+ <li><strong>Issuer:</strong> arelpe</li>
+ </ul>
+
+ <%= form_tag two_factor_path, method: :post do %>
+ <%= label_tag :otp_attempt, "Enter the code from your authenticator app:" %>
+ <%= text_field_tag :otp_attempt, nil, autocomplete: 'off' %>
+ <%= submit_tag "Enable 2FA" %>
+ <% end %>
+ </div>
+</div>
\ No newline at end of file
--- /dev/null
+<div class="container">
+ <h2>Two-Factor Authentication</h2>
+</div>
+
+<div class="post">
+ <div class="container">
+ <%= form_tag(users_two_factor_authentication_path, method: :post) do %>
+ <div>
+ <%= label_tag :otp_attempt, "Enter your two-factor code" %>
+ <%= text_field_tag :otp_attempt, nil, autocomplete: 'off', inputmode: 'numeric', pattern: '[0-9]*' %>
+ </div>
+
+ <%= submit_tag "Verify" %>
+ <% end %>
+ </div>
+</div>
\ No newline at end of file
#
config.time_zone = "Australia/Adelaide"
# config.eager_load_paths << Rails.root.join("extras")
+ #2FA Encryption
+ config.active_record.encryption.primary_key = Rails.application.credentials.active_record_encryption[:primary_key]
+ config.active_record.encryption.deterministic_key = Rails.application.credentials.active_record_encryption[:deterministic_key]
+ config.active_record.encryption.key_derivation_salt = Rails.application.credentials.active_record_encryption[:key_derivation_salt]
end
end
-9kYVka9CFCj1yyYXajvTfcLa2BGRH44UtuAChP+O+StY/AtHrBFePFG9llTUtAi/OUIT/eIWgdlKRRGFBsq0lTlVucsqN+C+34Xs7YRz9nfRvrPb+liyWZIR0k81TkrU8CuN31aVlp/I2ZZeBEONbe/2ISbBKpXA1GScvveXCzhH93PZKJh2R1sDg+IGXAYeY39H71/pmJ0vwcmcHrrs/V5Fdvb6Ze17hBiS5NKuetEa9EvbLXzxTk8Vo8Bl9yqZkaoFaTvhiomraQUFP92TvCbjtzp7JESWU2uosyd0fAI+qwZXT/AIWLVfKSJ62oz9SygpJMvNJHi4SyuS9LcnN34ce9VHwQBB1/nf4lqqrJiXI7EP2LkISpprqTkqXOrf1mrklQ6uKNl/yI2Z/KoR8C/tE/Or--QXMk23jZC7frOfwO--M9QxrCetYHjpBOk9X/fZAQ==
\ No newline at end of file
+R6pLqAgOKbr+y07Is0RC9w3wQKbkr2h2KZO4RNpGCPAaE+X9ZK6i3AdB0XYfDiBRfvtj05EhYDJ+Yr72985yeeRhnppVqkwNK1mWp+SGfY+KVvaKI7HFYG8igI0LthDAdsyeoA2+ElpZgjHCCLqz36gRKSY/KEbT/y0NukHdQRzsWFHwqgY85vQK9gRfDBpM72h4iPwb9TOPvMtJuVwfaIAdcDY0OqaQpTlFIGjh/bT9njHuO2h4mJ6dCBJYoFR0+8oCpd8hHWHtxeB4bsqKspfxrNvFTYEDQUnNwJEbQGNl14577rhEshQ2WfgUVFGzTzfEpzQmFZZJtnWhJuJbgVaxUwH5STUKWEWNY5/gD2vgp1oj0aUO6eiyOjz5YCkUieR2RV3hVF+yvOMb9NTt2EBYjJGeoqngzKi/Hq0xnLHIRmkoFHPvuC1RHn0Ucg1Nez4cmWppoOK3xm/b0H2lKsWi97vkkii38lqtfmmRFtsdXf4AvFOQ2NWpFlLKLwHT87/bU32XIzPObwErw/wv9irxrBMAlDDqOTqtqd6k3liddoNGCqUqHLxSC0IoH/G2tdg1PzoKBvtIVyjtctYtUnta1vgjpz727WCSSYO6gDTOxTkRmwc++DcBAzOJFkYSz8gFm02TjHaBhzXQeqw=--fmjiwM3a6xuvqqTn--wwZXJd1Zcs5AJV/lQbpCtw==
\ No newline at end of file
# Number of authentication tries before locking an account if lock_strategy
# is failed attempts.
config.maximum_attempts = 6
-
+
# Time interval to unlock the account if :time is enabled as unlock_strategy.
# config.unlock_in = 1.hour
end
end
-resources :mailing_lists, only: [:index] do
+ resources :mailing_lists, only: [:index] do
collection do
post 'subscribe'
delete 'unsubscribe'
post 'resync_from_buttondown'
end
end
+ resource :two_factor, only: [:new, :create, :destroy], controller: 'two_factor' do
+ get 'backup_codes', on: :member
+ end
resources :subscriptions, only: [:new, :create, :index]
resources :pages
get 'importer', to: 'posts#importer'
get 'export', to: 'posts#export'
post 'import', to: 'posts#import'
resources :api_keys
- devise_for :users, controllers: { registrations: 'users/registrations', confirmations: 'users/confirmations' }
+ devise_for :users, controllers: {
+ registrations: 'users/registrations',
+ confirmations: 'users/confirmations',
+ sessions: 'users/sessions'
+ }
+ get 'users/two_factor_authentication', to: 'users/two_factor_authentication#show'
+ post 'users/two_factor_authentication', to: 'users/two_factor_authentication#create'
resources :posts
get '/feed', to: 'pubview#rss', as: 'rss', defaults: { format: 'rss' }
get '/feed/dispatches', to: 'pubview#dispatches_rss', as: 'dispatches_rss', defaults: { format: 'rss' }
--- /dev/null
+class AddTwoFactorToUsers < ActiveRecord::Migration[7.2]
+ def change
+ add_column :users, :otp_secret, :string
+ add_column :users, :otp_required_for_login, :boolean
+ end
+end
--- /dev/null
+class ChangeOtpSecretToText < ActiveRecord::Migration[7.2]
+ def up
+ change_column :users, :otp_secret, :text
+ end
+
+ def down
+ change_column :users, :otp_secret, :string
+ end
+end
--- /dev/null
+class AddOtpBackupCodesToUsers < ActiveRecord::Migration[7.2]
+ def change
+ add_column :users, :otp_backup_codes, :text
+ end
+end
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2024_10_08_010447) do
+ActiveRecord::Schema[7.2].define(version: 2024_10_09_212849) do
create_table "api_keys", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "key"
t.datetime "created_at", null: false
t.string "buttondown_status", default: "unactivated"
t.string "subscription_type"
t.string "support_type"
+ t.text "otp_secret"
+ t.boolean "otp_required_for_login"
+ t.text "otp_backup_codes"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
require "test_helper"
-class SubscriptionsControllerTest < ActionDispatch::IntegrationTest
+class TwoFactorControllerTest < ActionDispatch::IntegrationTest
test "should get new" do
- get subscriptions_new_url
+ get two_factor_new_url
assert_response :success
end
test "should get create" do
- get subscriptions_create_url
+ get two_factor_create_url
assert_response :success
end
- test "should get view" do
- get subscriptions_view_url
+ test "should get destroy" do
+ get two_factor_destroy_url
assert_response :success
end
end