DEV: Hash tokens stored from email_tokens (#14493)

DEV: Hash tokens stored from email_tokens (#14493)

This commit adds token_hash and scopes columns to email_tokens table. token_hash is a replacement for the token column to avoid storing email tokens in plaintext as it can pose a security risk. The new scope column ensures that email tokens cannot be used to perform a different action than the one intended.

To sum up, this commit:

  • Adds token_hash and scope to email_tokens

  • Reuses code that schedules critical_user_email

  • Refactors EmailToken.confirm and EmailToken.atomic_confirm methods

  • Periodically cleans old, unconfirmed or expired email tokens

diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 637172f..1c411fa 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -328,7 +328,7 @@ class Admin::UsersController < Admin::AdminController
   def activate
     guardian.ensure_can_activate!(@user)
     # ensure there is an active email token
-    @user.email_tokens.create(email: @user.email) unless @user.email_tokens.active.exists?
+    @user.email_tokens.create!(email: @user.email, scope: EmailToken.scopes[:signup]) if !@user.email_tokens.active.exists?
     @user.activate
     StaffActionLogger.new(current_user).log_user_activate(@user, I18n.t('user.activated_by_staff'))
     render json: success_json
diff --git a/app/controllers/finish_installation_controller.rb b/app/controllers/finish_installation_controller.rb
index 13b7b64..e84252c 100644
--- a/app/controllers/finish_installation_controller.rb
+++ b/app/controllers/finish_installation_controller.rb
@@ -33,7 +33,6 @@ class FinishInstallationController < ApplicationController
         send_signup_email
         redirect_confirm(@user.email)
       end
-
     end
   end
 
@@ -50,14 +49,10 @@ class FinishInstallationController < ApplicationController
   protected
 
   def send_signup_email
-    email_token = @user.email_tokens.unconfirmed.active.first
+    return if @user.active && @user.email_confirmed?
 
-    if email_token.present?
-      Jobs.enqueue(:critical_user_email,
-                   type: :signup,
-                   user_id: @user.id,
-                   email_token: email_token.token)
-    end
+    email_token = @user.email_tokens.create!(email: @user.email, scope: EmailToken.scopes[:signup])
+    EmailToken.enqueue_signup_email(email_token)
   end
 
   def redirect_confirm(email)
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 85c17e4..3cc4ecc 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -415,7 +415,10 @@ class InvitesController < ApplicationController
     Group.refresh_automatic_groups!(:admins, :moderators, :staff) if user.staff?
 
     if user.has_password?
-      send_activation_email(user) unless user.active
+      if !user.active
+        email_token = user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:signup])
+        EmailToken.enqueue_signup_email(email_token)
+      end
     elsif !SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins
       Jobs.enqueue(:invite_password_instructions_email, username: user.username)
     end
@@ -440,14 +443,4 @@ class InvitesController < ApplicationController
       end
     end
   end
-
-  def send_activation_email(user)
-    email_token = user.email_tokens.create!(email: user.email)
-
-    Jobs.enqueue(:critical_user_email,
-                 type: :signup,
-                 user_id: user.id,
-                 email_token: email_token.token
-    )
-  end
 end
diff --git a/app/controllers/session_controller.rb b/app/controllers/session_controller.rb
index 1f09922..76ca11f 100644
--- a/app/controllers/session_controller.rb
+++ b/app/controllers/session_controller.rb
@@ -339,7 +339,7 @@ class SessionController < ApplicationController
 
   def email_login_info
     token = params[:token]
-    matched_token = EmailToken.confirmable(token)
+    matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
     user = matched_token&.user
 
     check_local_login_allowed(user: user, check_login_via_email: true)
@@ -377,7 +377,7 @@ class SessionController < ApplicationController
 
   def email_login
     token = params[:token]
-    matched_token = EmailToken.confirmable(token)
+    matched_token = EmailToken.confirmable(token, scope: EmailToken.scopes[:email_login])
     user = matched_token&.user
 
     check_local_login_allowed(user: user, check_login_via_email: true)
@@ -388,7 +388,7 @@ class SessionController < ApplicationController
       return render(json: @second_factor_failure_payload)
     end
 
-    if user = EmailToken.confirm(token)
+    if user = EmailToken.confirm(token, scope: EmailToken.scopes[:email_login])
       if login_not_approved_for?(user)
         return render json: login_not_approved
       elsif payload = login_error_check(user)
@@ -444,7 +444,7 @@ class SessionController < ApplicationController
 
     user_presence = user.present? && user.human? && !user.staged
     if user_presence
-      email_token = user.email_tokens.create(email: user.email)
+      email_token = user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:password_reset])
       Jobs.enqueue(:critical_user_email, type: :forgot_password, user_id: user.id, email_token: email_token.token)
     end
 
diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
index 7850cf2..57c794f 100644
--- a/app/controllers/users/omniauth_callbacks_controller.rb
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -155,9 +155,8 @@ class Users::OmniauthCallbacksController < ApplicationController
         user.update!(password: SecureRandom.hex)
 
         # Ensure there is an active email token
-        unless EmailToken.where(email: user.email, confirmed: true).exists? ||
-          user.email_tokens.active.where(email: user.email).exists?
-          user.email_tokens.create!(email: user.email)
+        if !EmailToken.where(email: user.email, confirmed: true).exists? && !user.email_tokens.active.where(email: user.email).exists?
+          user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:signup])
         end
 
         user.activate
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index b4919f5..3e2a815 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -783,7 +783,6 @@ class UsersController < ApplicationController
     # no point doing anything else if we can't even find
     # a user from the token
     if @user
-
       if !secure_session["second-factor-#{token}"]
         second_factor_authentication_result = @user.authenticate_second_factor(params, secure_session)
         if !second_factor_authentication_result.ok
@@ -869,7 +868,7 @@ class UsersController < ApplicationController
 
   def confirm_email_token
     expires_now
-    EmailToken.confirm(params[:token])
+    EmailToken.confirm(params[:token], scope: EmailToken.scopes[:signup])
     render json: success_json
   end
 
@@ -895,7 +894,7 @@ class UsersController < ApplicationController
       RateLimiter.new(nil, "admin-login-min-#{request.remote_ip}", 3, 1.minute).performed!
 
       if user = User.with_email(params[:email]).admins.human_users.first
-        email_token = user.email_tokens.create(email: user.email)
+        email_token = user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:email_login])
         Jobs.enqueue(:critical_user_email, type: :admin_login, user_id: user.id, email_token: email_token.token)
         @message = I18n.t("admin_login.success")
       else
@@ -926,7 +925,7 @@ class UsersController < ApplicationController
       RateLimiter.new(nil, "email-login-min-#{user.id}", 3, 1.minute).performed!
 
       if user_presence
-        email_token = user.email_tokens.create!(email: user.email)
+        email_token = user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:email_login])
 
         Jobs.enqueue(:critical_user_email,
           type: :email_login,
@@ -996,7 +995,7 @@ class UsersController < ApplicationController
   def perform_account_activation
     raise Discourse::InvalidAccess.new if honeypot_or_challenge_fails?(params)
 
-    if @user = EmailToken.confirm(params[:token])
+    if @user = EmailToken.confirm(params[:token], scope: EmailToken.scopes[:signup])
       # Log in the user unless they need to be approved

[... diff too long, it was truncated ...]

GitHub sha: fa8cd629f1ad4c64308e3e5c1d93f3ae78b2815a

This commit appears in #14493 which was approved by ZogStriP. It was merged by udan11.