FEATURE: Allow site admin to mark a user's password as expired (#27314)

This commit adds the ability for site administrators to mark users'
passwords as expired. Note that this commit does not add any client side
interface to mark a user's password as expired.

The following changes are introduced in this commit:

1. Adds a `user_passwords` table and `UserPassword` model. While the
   `user_passwords` table is currently used to only store expired
   passwords, it will be used in the future to store a user's current
   password as well.

2. Adds a `UserPasswordExpirer.expire_user_password` method which can
   be used from the Rails console to mark a user's password as expired.

3. Updates `SessionsController#create` to check that the user's current
   password has not been marked as expired after confirming the
   password. If the password is determined to be expired based on the
   existence of a `UserPassword` record with the `password_expired_at`
   column set, we will not log the user in and will display a password
   expired notice. A forgot password email is automatically send out to
   the user as well.
This commit is contained in:
Alan Guo Xiang Tan
2024-06-04 15:42:53 +08:00
committed by GitHub
parent 30f55cd64b
commit e97ef7e9af
14 changed files with 370 additions and 15 deletions

View File

@ -15,6 +15,7 @@ class SessionController < ApplicationController
allow_in_staff_writes_only_mode :email_login
ACTIVATE_USER_KEY = "activate_user"
FORGOT_PASSWORD_EMAIL_LIMIT_PER_DAY = 6
def csrf
render json: { csrf: form_authenticity_token }
@ -332,8 +333,10 @@ class SessionController < ApplicationController
rate_limit_second_factor!(user)
if user.present?
# If their password is correct
unless user.confirm_password?(params[:password])
password = params[:password]
# If their password is incorrect
if !user.confirm_password?(password)
invalid_credentials
return
end
@ -347,6 +350,18 @@ class SessionController < ApplicationController
# User signed on with username and password, so let's prevent the invite link
# from being used to log in (if one exists).
Invite.invalidate_for_email(user.email)
# User's password has expired so they need to reset it
if user.password_expired?(password)
begin
enqueue_password_reset_for_user(user)
rescue RateLimiter::LimitExceeded
# Just noop here as user would have already been sent the forgot password email more than once
end
render json: { error: I18n.t("login.password_expired") }
return
end
else
invalid_credentials
return
@ -622,15 +637,7 @@ class SessionController < ApplicationController
end
if user
RateLimiter.new(nil, "forgot-password-login-day-#{user.username}", 6, 1.day).performed!
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,
)
enqueue_password_reset_for_user(user)
else
RateLimiter.new(
nil,
@ -897,4 +904,23 @@ class SessionController < ApplicationController
allowed_domains.split("|").include?(hostname)
end
def enqueue_password_reset_for_user(user)
RateLimiter.new(
nil,
"forgot-password-login-day-#{user.username}",
FORGOT_PASSWORD_EMAIL_LIMIT_PER_DAY,
1.day,
).performed!
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
end