[Users] Consolidate password confirmation into singular route (#813)

This commit is contained in:
Donovan Daniels 2024-12-18 08:02:02 -06:00 committed by GitHub
parent a0b51e40bc
commit 32a1367cfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 122 additions and 156 deletions

View File

@ -165,6 +165,7 @@ class ApplicationController < ActionController::Base
def set_current_user def set_current_user
SessionLoader.new(request).load SessionLoader.new(request).load
session.send(:load!) unless session.send(:loaded?)
end end
def reset_current_user def reset_current_user
@ -173,6 +174,14 @@ class ApplicationController < ActionController::Base
CurrentUser.safe_mode = Danbooru.config.safe_mode? CurrentUser.safe_mode = Danbooru.config.safe_mode?
end end
def requires_reauthentication
return redirect_to(new_session_path(url: request.fullpath)) if CurrentUser.user.is_anonymous?
last_authenticated_at = session[:last_authenticated_at]
if last_authenticated_at.blank? || Time.zone.parse(last_authenticated_at) < 1.hour.ago
redirect_to(confirm_password_session_path(url: request.fullpath))
end
end
def user_access_check(method) def user_access_check(method)
if !CurrentUser.user.send(method) || CurrentUser.user.is_banned? || IpBan.is_banned?(CurrentUser.ip_addr) if !CurrentUser.user.send(method) || CurrentUser.user.is_banned? || IpBan.is_banned?(CurrentUser.ip_addr)
access_denied access_denied

View File

@ -3,17 +3,14 @@
module Maintenance module Maintenance
module User module User
class ApiKeysController < ApplicationController class ApiKeysController < ApplicationController
before_action :requires_reauthentication
before_action :member_only before_action :member_only
before_action :authenticate!, except: [:show] before_action :load_apikey
rescue_from ::SessionLoader::AuthenticationFailure, with: :authentication_failed
respond_to :html respond_to :html
def show def show
end end
def view
end
def update def update
@api_key.regenerate! @api_key.regenerate!
redirect_to(user_api_key_path(CurrentUser.user), notice: "API key regenerated") redirect_to(user_api_key_path(CurrentUser.user), notice: "API key regenerated")
@ -24,19 +21,10 @@ module Maintenance
redirect_to(CurrentUser.user) redirect_to(CurrentUser.user)
end end
protected private
def authenticate! def load_apikey
if ::User.authenticate(CurrentUser.user.name, params[:user][:password]) == CurrentUser.user @api_key = CurrentUser.user.api_key || ApiKey.generate!(CurrentUser.user)
@api_key = CurrentUser.user.api_key || ApiKey.generate!(CurrentUser.user)
@password = params[:user][:password]
else
raise ::SessionLoader::AuthenticationFailure
end
end
def authentication_failed
redirect_to(user_api_key_path(CurrentUser.user), notice: "Password was incorrect.")
end end
end end
end end

View File

@ -6,26 +6,31 @@ class SessionsController < ApplicationController
end end
def create def create
sparams = params.fetch(:session, {}).slice(:url, :name, :password, :remember)
if RateLimiter.check_limit("login:#{request.remote_ip}", 15, 12.hours) if RateLimiter.check_limit("login:#{request.remote_ip}", 15, 12.hours)
DanbooruLogger.add_attributes("user.login" => "rate_limited") DanbooruLogger.add_attributes("user.login" => "rate_limited")
return redirect_to(new_session_path, :notice => "Username/Password was incorrect") return redirect_to(new_session_path, notice: "Username/Password was incorrect")
end end
session_creator = SessionCreator.new(session, cookies, params[:name], params[:password], request.remote_ip, params[:remember], request.ssl?) session_creator = SessionCreator.new(request, session, cookies, sparams[:name], sparams[:password], sparams[:remember].to_s.truthy?)
if session_creator.authenticate if session_creator.authenticate
url = params[:url] if params[:url] && params[:url].start_with?("/") && !params[:url].start_with?("//") url = sparams[:url] if sparams[:url] && sparams[:url].start_with?("/") && !sparams[:url].start_with?("//")
DanbooruLogger.add_attributes("user.login" => "success") DanbooruLogger.add_attributes("user.login" => "success")
redirect_to(url || posts_path, :notice => "You are now logged in") redirect_to(url || posts_path)
else else
RateLimiter.hit("login:#{request.remote_ip}", 6.hours) RateLimiter.hit("login:#{request.remote_ip}", 6.hours)
DanbooruLogger.add_attributes("user.login" => "fail") DanbooruLogger.add_attributes("user.login" => "fail")
redirect_to(new_session_path, :notice => "Username/Password was incorrect") redirect_back(fallback_location: new_session_path, notice: "Username/Password was incorrect")
end end
end end
def destroy def destroy
session.delete(:user_id) session.delete(:user_id)
cookies.delete :remember cookies.delete(:remember)
redirect_to(posts_path, :notice => "You are now logged out") session.delete(:last_authenticated_at)
redirect_to(posts_path, notice: "You are now logged out")
end
def confirm_password
end end
end end

View File

@ -1,8 +1,6 @@
div#c-sessions { div#c-sessions {
div#a-new { div#a-new {
label#remember-label { label[for=session_remember] {
display: inline; display: inline;
font-weight: normal; font-weight: normal;
font-style: italic; font-style: italic;

View File

@ -237,7 +237,7 @@ div#c-users {
margin-bottom: 1em; margin-bottom: 1em;
} }
.session_form { .simple_form.session {
box-sizing: border-box; box-sizing: border-box;
max-width: 360px; max-width: 360px;
margin: 0; margin: 0;
@ -302,4 +302,4 @@ div#c-users {
span { display: list-item; } span { display: list-item; }
.password-warning { font-weight: bold; } .password-warning { font-weight: bold; }
} }
} }

View File

@ -1,16 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class SessionCreator class SessionCreator
attr_reader :session, :cookies, :name, :password, :ip_addr, :remember, :secure attr_reader :request, :session, :cookies, :name, :password, :remember
def initialize(session, cookies, name, password, ip_addr, remember = false, secure = false) def initialize(request, session, cookies, name, password, remember = false)
@request = request
@session = session @session = session
@cookies = cookies @cookies = cookies
@name = name @name = name
@password = password @password = password
@ip_addr = ip_addr
@remember = remember @remember = remember
@secure = secure
end end
def authenticate def authenticate
@ -18,12 +17,13 @@ class SessionCreator
user = User.find_by_name(name) user = User.find_by_name(name)
session[:user_id] = user.id session[:user_id] = user.id
session[:last_authenticated_at] = Time.now.utc.to_s
session[:ph] = user.password_token session[:ph] = user.password_token
user.update_column(:last_ip_addr, ip_addr) unless user.is_blocked? user.update_column(:last_ip_addr, request.remote_ip) unless user.is_blocked?
if remember if remember
verifier = ActiveSupport::MessageVerifier.new(Danbooru.config.remember_key, serializer: JSON, digest: "SHA256") verifier = ActiveSupport::MessageVerifier.new(Danbooru.config.remember_key, serializer: JSON, digest: "SHA256")
cookies.encrypted[:remember] = {value: verifier.generate("#{user.id}:#{user.password_token}", purpose: "rbr", expires_in: 14.days), expires: Time.now + 14.days, httponly: true, same_site: :lax, secure: Rails.env.production?} cookies.encrypted[:remember] = { value: verifier.generate("#{user.id}:#{user.password_token}", purpose: "rbr", expires_in: 14.days), expires: Time.now + 14.days, httponly: true, same_site: :lax, secure: Rails.env.production? }
end end
return true return true
else else

View File

@ -1,12 +1,35 @@
<div id="c-maintenance-user-api-keys"> <div id="c-maintenance-user-api-keys">
<div id="a-show"> <div id="a-show">
<h1>API Key</h1> <h1>API Key</h1>
<p>You must re-enter your password to view or change your API key.</p>
<%= custom_form_for CurrentUser.user, url: view_user_api_key_path(CurrentUser.user), method: :post do |f| %> <h2><b>Your API key is like your password</b></h2>
<%= f.input :password, :as => :password, :input_html => {:autocomplete => "off"} %> <p>
<%= f.button :submit, "Submit" %> Anyone who has it has full access to your account. Don't give your API key
<% end %> to third-party apps you don't trust, and don't post your API key in public places.
</p>
<table class="striped">
<thead>
<tr>
<th>API Key</th>
<th>Created</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr id="api-key-<%= @api_key.id %>">
<td id="api-key"><code><%= @api_key.key %></code></td>
<td id="api-key-created"><%= compact_time(@api_key.created_at) %></td>
<td id="api-key-updated"><%= compact_time(@api_key.updated_at) %></td>
<td>
<%= button_to("Regenerate", user_api_key_path(CurrentUser.user), method: :put) %>
<%= button_to("Delete", user_api_key_path(CurrentUser.user), method: :delete) %>
</td>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>

View File

@ -1,38 +0,0 @@
<div id="c-maintenance-user-api-keys">
<div id="a-view">
<h1>API Key</h1>
<p>
<h2><b>Your API key is like your password</b></h2>
Anyone who has it has full access to your account. Don't give your API key
to third-party apps you don't trust, and don't post your API key in public places.
</p>
<table class="striped">
<thead>
<tr>
<th>API Key</th>
<th>Created</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td id="api-key"><code><%= @api_key.key %></code></td>
<td id="api-key-created"><%= compact_time @api_key.created_at %></td>
<td id="api-key-updated"><%= compact_time @api_key.updated_at %></td>
<td>
<%= button_to "Regenerate", user_api_key_path(CurrentUser.user), method: :put, params: { 'user[password]': @password } %>
<%= button_to "Delete", user_api_key_path(CurrentUser.user), method: :delete, params: { 'user[password]': @password } %>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<% content_for(:page_title) do %>
API Key
<% end %>

View File

@ -0,0 +1,16 @@
<% content_for(:page_title) do %>
Confirm Password
<% end %>
<%= render "secondary_links" %>
<div id="c-sessions"><div id="a-confirm-password">
<h1>Confirm password</h1>
<p>You must re-enter your password to continue.</p>
<%= simple_form_for(:session, url: session_path) do |f| %>
<%= f.input(:url, as: :hidden, input_html: { value: params[:url] }) %>
<%= f.input(:name, as: :hidden, input_html: { value: CurrentUser.user.name }) %>
<%= f.input(:password, hint: link_to("Forgot password?", new_maintenance_user_password_reset_path), input_html: { autocomplete: "current-password" }) %>
<%= f.submit("Continue") %>
<% end %>
</div></div>

View File

@ -1,38 +1,22 @@
<div id="c-sessions"><div id="a-new"> <div id="c-sessions"><div id="a-new">
<%= form_tag(session_path, :class => "simple_form session_form") do %> <%= simple_form_for(:session, url: session_path) do |f| %>
<h1>Sign in</h1> <h1>Sign in</h1>
<%= f.input(:url, as: :hidden, input_html: { value: params[:url] }) %>
<%= hidden_field_tag "url", params[:url] %> <%= f.input(:name, label: "Username") %>
<%= f.input(:password) %>
<div class="input"> <%= f.input(:remember, as: :boolean, input_html: { checked: "checked" }) %>
<label for="name">Username</label> <%= f.submit("Continue") %>
<%= text_field_tag :name %>
</div>
<div class="input">
<label for="password">Password</label>
<%= password_field_tag :password %>
</div>
<div class="input">
<%= check_box_tag :remember, "1", true %>
<label for="remember" id="remember-label">Remember</label>
</div>
<div class="input">
<%= submit_tag "Submit", :data => { :disable_with => "Signing in..." } %>
</div>
<% end %> <% end %>
<section class="session_info"> <section class="session_info">
<h3> <h3>
Don't have an account?<br /> Don't have an account?<br />
<%= link_to "Sign up here.", new_user_path() %> <%= link_to("Sign up here.", new_user_path) %>
</h3> </h3>
<br /> <br />
<h3><%= link_to "Reset Password", new_maintenance_user_password_reset_path %></h2> <h3><%= link_to("Reset Password", new_maintenance_user_password_reset_path) %></h3>
<h3><%= link_to "Login Reminder", new_maintenance_user_login_reminder_path %></h2> <h3><%= link_to("Login Reminder", new_maintenance_user_login_reminder_path) %></h3>
</section> </section>
</div></div> </div></div>

View File

@ -267,7 +267,9 @@ Rails.application.routes.draw do
end end
resource :related_tag, :only => [:show, :update] resource :related_tag, :only => [:show, :update]
match "related_tag/bulk", to: "related_tags#bulk", via: [:get, :post] match "related_tag/bulk", to: "related_tags#bulk", via: [:get, :post]
resource :session, only: [:new, :create, :destroy] resource :session, only: %i[new create destroy] do
get :confirm_password, on: :collection
end
resources :stats, only: [:index] resources :stats, only: [:index]
resources :tags, constraints: id_name_constraint do resources :tags, constraints: id_name_constraint do
resource :correction, :only => [:new, :create, :show], :controller => "tag_corrections" resource :correction, :only => [:new, :create, :show], :controller => "tag_corrections"
@ -291,9 +293,7 @@ Rails.application.routes.draw do
resources :uploads resources :uploads
resources :users do resources :users do
resource :password, :only => [:edit], :controller => "maintenance/user/passwords" resource :password, :only => [:edit], :controller => "maintenance/user/passwords"
resource :api_key, :only => [:show, :view, :update, :destroy], :controller => "maintenance/user/api_keys" do resource :api_key, only: %i[show update destroy], controller: "maintenance/user/api_keys"
post :view
end
resources :staff_notes, only: [:index, :new, :create], controller: "admin/staff_notes" resources :staff_notes, only: [:index, :new, :create], controller: "admin/staff_notes"
collection do collection do

View File

@ -108,7 +108,7 @@ class ApplicationControllerTest < ActionDispatch::IntegrationTest
token = css_select("form input[name=authenticity_token]").first["value"] token = css_select("form input[name=authenticity_token]").first["value"]
# login # login
post session_path, params: { authenticity_token: token, name: @user.name, password: "6cQE!wbA" } post session_path, params: { authenticity_token: token, session: { name: @user.name, password: "6cQE!wbA" } }
assert_redirected_to posts_path assert_redirected_to posts_path
# try to submit a form with cookies but without the csrf token # try to submit a form with cookies but without the csrf token
@ -124,7 +124,7 @@ class ApplicationControllerTest < ActionDispatch::IntegrationTest
should "succeed" do should "succeed" do
user = create(:user, password: "6cQE!wbA") user = create(:user, password: "6cQE!wbA")
post session_path, params: { name: user.name, password: "6cQE!wbA" } post session_path, params: { session: { name: user.name, password: "6cQE!wbA" } }
get edit_user_path(user) get edit_user_path(user)
assert_response :success assert_response :success

View File

@ -8,61 +8,42 @@ module Maintenance
context "An api keys controller" do context "An api keys controller" do
setup do setup do
@user = create(:privileged_user, password: "6cQE!wbA") @user = create(:privileged_user, password: "6cQE!wbA")
ApiKey.generate!(@user) @api_key = ApiKey.generate!(@user)
end end
context "#show" do context "show action" do
should "render" do should "let a user see their own API keys" do
get_auth maintenance_user_api_key_path, @user, params: {user_id: @user.id} get_auth maintenance_user_api_key_path(@user.id), @user
assert_response :success assert_response :success
assert_select "#api-key-#{@api_key.id}", count: 1
end
should "not let a user see API keys belonging to other users" do
get_auth maintenance_user_api_key_path(@user.id), create(:user)
assert_response :success
assert_select "#api-key-#{@api_key.id}", count: 0
end
should "redirect to the confirm password page if the user hasn't recently authenticated" do
post session_path, params: { session: { name: @user.name, password: @user.password } }
travel_to 2.hours.from_now do
get maintenance_user_api_key_path(@user.id)
end
assert_redirected_to confirm_password_session_path(url: maintenance_user_api_key_path(@user.id))
end end
end end
context "#view" do context "update action" do
context "with a correct password" do
should "succeed" do
post_auth view_maintenance_user_api_key_path(user_id: @user.id), @user, params: { user: { password: "6cQE!wbA" } }
assert_response :success
end
# hard to test this in integrationtest
# context "if the user doesn't already have an api key" do
# setup do
# ::User.any_instance.stubs(:api_key).returns(nil)
# cookies[:user_name] = @user.name
# cookies[:password_hash] = @user.bcrypt_cookie_password_hash
# end
# should "generate one" do
# ApiKey.expects(:generate!)
# assert_difference("ApiKey.count", 1) do
# post view_maintenance_user_api_key_path(user_id: @user.id), params: { user: { password: "6cQE!wbA" } }
# end
# assert_not_nil(@user.reload.api_key)
# end
# end
should "not generate another API key if the user already has one" do
assert_difference("ApiKey.count", 0) do
post_auth view_maintenance_user_api_key_path(user_id: @user.id), @user, params: { user: { password: "6cQE!wbA" } }
end
end
end
end
context "#update" do
should "regenerate the API key" do should "regenerate the API key" do
old_key = @user.api_key old_key = @user.api_key
put_auth maintenance_user_api_key_path, @user, params: {user_id: @user.id, user: { password: "6cQE!wbA" } } put_auth maintenance_user_api_key_path, @user
assert_not_equal(old_key.key, @user.reload.api_key.key) assert_not_equal(old_key.key, @user.reload.api_key.key)
end end
end end
context "#destroy" do context "destroy action" do
should "delete the API key" do should "delete the API key" do
delete_auth maintenance_user_api_key_path, @user, params: {user_id: @user.id, user: { password: "6cQE!wbA" } } delete_auth maintenance_user_api_key_path, @user
assert_nil(@user.reload.api_key) assert_nil(@user.reload.api_key)
end end
end end

View File

@ -15,7 +15,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
should "create a new session" do should "create a new session" do
user = create(:user) user = create(:user)
post session_path, params: { name: user.name, password: "6cQE!wbA" } post session_path, params: { session: { name: user.name, password: "6cQE!wbA" } }
user.reload user.reload
assert_redirected_to(posts_path) assert_redirected_to(posts_path)
@ -34,7 +34,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
should "fail when provided an invalid password" do should "fail when provided an invalid password" do
user = create(:user, password: "6cQE!wbA", password_confirmation: "6cQE!wbA") user = create(:user, password: "6cQE!wbA", password_confirmation: "6cQE!wbA")
post session_path, params: { name: user.name, password: "yyy" } post session_path, params: { session: { name: user.name, password: "yyy" } }
assert_nil(session[:user_id]) assert_nil(session[:user_id])
assert_equal("Username/Password was incorrect", flash[:notice]) assert_equal("Username/Password was incorrect", flash[:notice])
@ -45,7 +45,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
should "clear the session" do should "clear the session" do
user = create(:user) user = create(:user)
post session_path, params: { name: user.name, password: "6cQE!wbA" } post session_path, params: { session: { name: user.name, password: "6cQE!wbA" } }
assert_not_nil(session[:user_id]) assert_not_nil(session[:user_id])
delete_auth(session_path, user) delete_auth(session_path, user)

View File

@ -95,7 +95,7 @@ end
class ActionDispatch::IntegrationTest class ActionDispatch::IntegrationTest
def method_authenticated(method_name, url, user, options) def method_authenticated(method_name, url, user, options)
post session_path, params: { name: user.name, password: user.password } post session_path, params: { session: { name: user.name, password: user.password } }
self.send(method_name, url, **options) self.send(method_name, url, **options)
end end