Initial email verification request implementation (#205)

This commit is contained in:
Max Wofford
2025-05-05 11:46:31 -04:00
committed by GitHub
parent 7ccdc5acd2
commit 58ddd7197a
14 changed files with 204 additions and 7 deletions

View File

@@ -64,17 +64,17 @@ class SessionsController < ApplicationController
if params[:error].present?
Rails.logger.error "GitHub OAuth error: #{params[:error]}"
redirect_to root_path, alert: "Failed to authenticate with GitHub"
redirect_to my_settings_path, alert: "Failed to authenticate with GitHub"
return
end
@user = User.from_github_token(params[:code], redirect_uri, current_user)
if @user&.persisted?
redirect_to root_path, notice: "Successfully linked GitHub account!"
redirect_to my_settings_path, notice: "Successfully linked GitHub account!"
else
Rails.logger.error "Failed to link GitHub account"
redirect_to root_path, alert: "Failed to link GitHub account"
redirect_to my_settings_path, alert: "Failed to link GitHub account"
end
end
@@ -90,7 +90,49 @@ class SessionsController < ApplicationController
redirect_to root_path(sign_in_email: true), notice: "Check your email for a sign-in link!"
end
def add_email
unless current_user
redirect_to root_path, alert: "Please sign in first to add an email"
return
end
email = params[:email].downcase
if EmailAddress.exists?(email: email)
redirect_to my_settings_path, alert: "This email is already associated with an account"
return
end
if EmailVerificationRequest.exists?(email: email)
redirect_to my_settings_path, alert: "This email is already pending verification"
return
end
verification_request = current_user.email_verification_requests.create!(
email: email
)
if Rails.env.production?
EmailVerificationMailer.verify_email(verification_request).deliver_later
else
EmailVerificationMailer.verify_email(verification_request).deliver_now
end
redirect_to my_settings_path, notice: "Check your email to verify the new address!"
rescue ActiveRecord::RecordInvalid => e
redirect_to my_settings_path, alert: "Failed to add email: #{e.record.errors.full_messages.join(', ')}"
end
def token
verification_request = EmailVerificationRequest.valid.find_by(token: params[:token])
if verification_request
verification_request.verify!
redirect_to my_settings_path, notice: "Successfully verified your email address!"
return
end
# If no verification request found, try the old sign-in token system
valid_token = SignInToken.where(token: params[:token], used_at: nil)
.where("expires_at > ?", Time.current)
.first
@@ -100,7 +142,7 @@ class SessionsController < ApplicationController
session[:user_id] = valid_token.user_id
redirect_to root_path, notice: "Successfully signed in!"
else
redirect_to root_path, alert: "Invalid or expired sign-in link"
redirect_to root_path, alert: "Invalid or expired link"
end
end

View File

@@ -0,0 +1,9 @@
class CleanupExpiredEmailVerificationRequestsJob < ApplicationJob
queue_as :interval_10s
def perform
# Soft delete all expired and non-deleted verification requests in a single query
EmailVerificationRequest.expired.where(deleted_at: nil)
.update_all(deleted_at: Time.current)
end
end

View File

@@ -0,0 +1,17 @@
class EmailVerificationMailer < ApplicationMailer
# Subject can be set in your I18n file at config/locales/en.yml
# with the following lookup:
#
# en.email_verification_mailer.verify_email.subject
#
def verify_email(verification_request)
@verification_request = verification_request
@user = verification_request.user
@verification_url = auth_token_url(verification_request.token)
mail(
to: verification_request.email,
subject: "Verify your email address for Hackatime"
)
end
end

View File

@@ -0,0 +1,49 @@
class EmailVerificationRequest < ApplicationRecord
belongs_to :user
validates :email, presence: true,
uniqueness: { conditions: -> { where(deleted_at: nil) } },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token, presence: true, uniqueness: { conditions: -> { where(deleted_at: nil) } }
validates :expires_at, presence: true
before_validation :generate_token, on: :create
before_validation :set_expiration, on: :create
before_validation :downcase_email
scope :valid, -> { where("expires_at > ? AND deleted_at IS NULL", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
def expired?
expires_at <= Time.current
end
def soft_delete!
update!(deleted_at: Time.current)
end
def verify!
email_address = user.email_addresses.create!(
email: email,
source: :signing_in
)
soft_delete!
email_address
end
private
def generate_token
self.token ||= SecureRandom.urlsafe_base64(32)
end
def set_expiration
self.expires_at ||= 30.minutes.from_now
end
def downcase_email
self.email = email.downcase if email.present?
end
end

View File

@@ -7,8 +7,9 @@ class User < ApplicationRecord
validates :timezone, inclusion: { in: TZInfo::Timezone.all.map(&:identifier) }, allow_nil: false
has_many :heartbeats
has_many :email_addresses
has_many :sign_in_tokens
has_many :email_addresses, dependent: :destroy
has_many :email_verification_requests, dependent: :destroy
has_many :sign_in_tokens, dependent: :destroy
has_many :project_repo_mappings
has_many :hackatime_heartbeats,

View File

@@ -0,0 +1,17 @@
<h1>Verify your email address for Hackatime</h1>
<p>
Hi <%= @user.display_name %>,
</p>
<p>
You've requested to add <%= @verification_request.email %> to your Hackatime account.
Click the link below to verify this email address:
</p>
<p>
<%= link_to 'Verify email address', @verification_url %>
</p>
<p>
This link will expire in 30 minutes and can only be used once.
</p>
<p>
If you didn't request this email, you can safely ignore it.
</p>

View File

@@ -0,0 +1,12 @@
Verify your email address for Hackatime
Hi <%= @user.display_name %>,
You've requested to add <%= @verification_request.email %> to your Hackatime account.
Click the link below to verify this email address:
<%= @verification_url %>
This link will expire in 30 minutes and can only be used once.
If you didn't request this email, you can safely ignore it.

View File

@@ -52,6 +52,11 @@
Letter Opener
<% end %>
<% end %>
<% dev_tool(nil, "li") do %>
<%= link_to '/rails/mailers', class: "nav-item #{current_page?('/rails/mailers') ? 'active' : ''}" do %>
Mailers
<% end %>
<% end %>
<% admin_tool(nil, "li") do %>
<%= link_to avo_path, class: "nav-item #{current_page?(avo_path) ? 'active' : ''}" do %>
Avo

View File

@@ -104,6 +104,15 @@
<% else %>
<p>No email addresses found.</p>
<% end %>
<div class="add-email-form">
<%= form_tag add_email_auth_path, data: { turbo: false } do %>
<div class="field">
<%= email_field_tag :email, nil, placeholder: "Add another email address", required: true %>
</div>
<%= submit_tag "Add Email" %>
<% end %>
</div>
</section>
<section>

View File

@@ -45,6 +45,10 @@ Rails.application.configure do
cron: "0 10 * * *",
class: "ScanGithubReposJob"
},
cleanup_expired_email_verification_requests: {
cron: "* * * * *",
class: "CleanupExpiredEmailVerificationRequestsJob"
},
cache_active_user_graph_data_job: {
cron: "*/10 * * * *",
class: "Cache::ActiveUsersGraphDataJob",

View File

@@ -49,6 +49,7 @@ Rails.application.routes.draw do
get "/auth/github", to: "sessions#github_new", as: :github_auth
get "/auth/github/callback", to: "sessions#github_create"
post "/auth/email", to: "sessions#email", as: :email_auth
post "/auth/email/add", to: "sessions#add_email", as: :add_email_auth
get "/auth/token/:token", to: "sessions#token", as: :auth_token
get "/auth/close_window", to: "sessions#close_window", as: :close_window
delete "signout", to: "sessions#destroy", as: "signout"

View File

@@ -0,0 +1,13 @@
class CreateEmailVerificationRequests < ActiveRecord::Migration[8.0]
def change
create_table :email_verification_requests do |t|
t.string :email
t.references :user, null: false, foreign_key: true
t.string :token
t.datetime :expires_at
t.timestamps
end
add_index :email_verification_requests, :email, unique: true
end
end

View File

@@ -0,0 +1,5 @@
class AddDeletedAtToEmailVerificationRequests < ActiveRecord::Migration[8.0]
def change
add_column :email_verification_requests, :deleted_at, :datetime
end
end

15
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_05_05_045020) do
ActiveRecord::Schema[8.0].define(version: 2025_05_05_152654) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -79,6 +79,18 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_05_045020) do
t.index ["user_id"], name: "index_email_addresses_on_user_id"
end
create_table "email_verification_requests", force: :cascade do |t|
t.string "email"
t.bigint "user_id", null: false
t.string "token"
t.datetime "expires_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "deleted_at"
t.index ["email"], name: "index_email_verification_requests_on_email", unique: true
t.index ["user_id"], name: "index_email_verification_requests_on_user_id"
end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -312,6 +324,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_05_045020) do
add_foreign_key "api_keys", "users"
add_foreign_key "email_addresses", "users"
add_foreign_key "email_verification_requests", "users"
add_foreign_key "heartbeats", "users"
add_foreign_key "leaderboard_entries", "leaderboards"
add_foreign_key "leaderboard_entries", "users"