users: refactor login and authentication logic.

* Make authentication methods into User instance methods instead of
  class methods.
* Fix API key authentication to use a secure string comparison. Fixes a
  hypothetical (unlikely to be exploitable) timing attack.
* Move login logic from SessionCreator to SessionLoader.
This commit is contained in:
evazion
2020-03-25 03:41:27 -05:00
parent 64af957031
commit b2cf765d6d
14 changed files with 68 additions and 100 deletions

View File

@@ -14,7 +14,7 @@ class EmailsController < ApplicationController
def update def update
@user = authorize User.find(params[:user_id]), policy_class: EmailAddressPolicy @user = authorize User.find(params[:user_id]), policy_class: EmailAddressPolicy
if User.authenticate(@user.name, params[:user][:password]) if @user.authenticate_password(params[:user][:password])
@user.update(email_address_attributes: { address: params[:user][:email] }) @user.update(email_address_attributes: { address: params[:user][:email] })
else else
@user.errors[:base] << "Password was incorrect" @user.errors[:base] << "Password was incorrect"

View File

@@ -27,7 +27,7 @@ module Maintenance
end end
def authenticate! def authenticate!
if ::User.authenticate(CurrentUser.user.name, params[:user][:password]) == CurrentUser.user if CurrentUser.user.authenticate_password(params[:user][:password])
@api_key = CurrentUser.user.api_key || ApiKey.generate!(CurrentUser.user) @api_key = CurrentUser.user.api_key || ApiKey.generate!(CurrentUser.user)
@password = params[:user][:password] @password = params[:user][:password]
else else

View File

@@ -9,9 +9,7 @@ class PasswordsController < ApplicationController
def update def update
@user = authorize User.find(params[:user_id]), policy_class: PasswordPolicy @user = authorize User.find(params[:user_id]), policy_class: PasswordPolicy
if User.authenticate(@user.name, params[:user][:old_password]) if @user.authenticate_password(params[:user][:old_password]) || @user.authenticate_login_key(params[:user][:signed_user_id])
@user.update(password: params[:user][:password], password_confirmation: params[:user][:password_confirmation])
elsif @user.authenticate_login_key(params[:user][:signed_user_id])
@user.update(password: params[:user][:password], password_confirmation: params[:user][:password_confirmation]) @user.update(password: params[:user][:password], password_confirmation: params[:user][:password_confirmation])
else else
@user.errors[:base] << "Incorrect password" @user.errors[:base] << "Incorrect password"

View File

@@ -7,13 +7,12 @@ class SessionsController < ApplicationController
end end
def create def create
session_params = params[:session].presence || params name, password, url = params.fetch(:session, params).slice(:name, :password, :url).values
session_creator = SessionCreator.new(session, session_params[:name], session_params[:password], request.remote_ip) user = SessionLoader.new(request).login(name, password)
if session_creator.authenticate if user
url = session_params[:url] url = posts_path unless url&.start_with?("/")
url = posts_path if !url&.start_with?("/") respond_with(user, location: url, methods: [:api_token])
respond_with(session_creator.user, location: url, methods: [:api_token])
else else
flash.now[:notice] = "Password was incorrect" flash.now[:notice] = "Password was incorrect"
raise SessionLoader::AuthenticationFailure raise SessionLoader::AuthenticationFailure

View File

@@ -1,23 +0,0 @@
class SessionCreator
attr_reader :session, :name, :password, :ip_addr
attr_reader :user
def initialize(session, name, password, ip_addr)
@session = session
@name = name
@password = password
@ip_addr = ip_addr
end
def authenticate
if User.authenticate(name, password)
@user = User.find_by_name(name)
session[:user_id] = @user.id
@user.update_column(:last_ip_addr, ip_addr)
return true
else
return false
end
end
end

View File

@@ -9,6 +9,15 @@ class SessionLoader
@params = request.parameters @params = request.parameters
end end
def login(name, password)
user = User.find_by_name(name)&.authenticate_password(password)
return nil unless user
session[:user_id] = user.id
user.update_column(:last_ip_addr, request.remote_ip)
user
end
def load def load
CurrentUser.user = User.anonymous CurrentUser.user = User.anonymous
CurrentUser.ip_addr = request.remote_ip CurrentUser.ip_addr = request.remote_ip
@@ -50,8 +59,6 @@ class SessionLoader
authenticate_api_key(params[:login], params[:api_key]) authenticate_api_key(params[:login], params[:api_key])
elsif params[:login].present? && params[:password_hash].present? elsif params[:login].present? && params[:password_hash].present?
authenticate_legacy_api_key(params[:login], params[:password_hash]) authenticate_legacy_api_key(params[:login], params[:password_hash])
else
raise AuthenticationFailure
end end
end end
@@ -63,11 +70,8 @@ class SessionLoader
end end
def authenticate_api_key(name, api_key) def authenticate_api_key(name, api_key)
CurrentUser.user = User.authenticate_api_key(name, api_key) CurrentUser.user = User.find_by_name(name)&.authenticate_api_key(api_key)
raise AuthenticationFailure unless Currentuser.user.present?
if CurrentUser.user.nil?
raise AuthenticationFailure.new
end
end end
def authenticate_legacy_api_key(name, password_hash) def authenticate_legacy_api_key(name, password_hash)

View File

@@ -62,7 +62,7 @@ class UserDeletion
end end
def validate def validate
if !User.authenticate(user.name, password) if !user.authenticate_password(password)
raise ValidationError.new("Password is incorrect") raise ValidationError.new("Password is incorrect")
end end

View File

@@ -160,38 +160,29 @@ class User < ApplicationRecord
end end
end end
module PasswordMethods concerning :AuthenticationMethods do
def bcrypt_password
BCrypt::Password.new(bcrypt_password_hash)
end
def password=(new_password) def password=(new_password)
@password = new_password @password = new_password
self.bcrypt_password_hash = User.bcrypt(new_password) self.bcrypt_password_hash = BCrypt::Password.create(hash_password(new_password))
end end
end
module AuthenticationMethods
extend ActiveSupport::Concern
def authenticate_login_key(signed_user_id) def authenticate_login_key(signed_user_id)
signed_user_id.present? && id == Danbooru::MessageVerifier.new(:login).verify(signed_user_id) signed_user_id.present? && id == Danbooru::MessageVerifier.new(:login).verify(signed_user_id) && self
end
def authenticate_api_key(key)
api_key.present? && ActiveSupport::SecurityUtils.secure_compare(api_key.key, key) && self
end
def authenticate_password(password)
BCrypt::Password.new(bcrypt_password_hash) == hash_password(password) && self
end
def hash_password(password)
Digest::SHA1.hexdigest("choujin-steiner--#{password}--")
end end
module ClassMethods module ClassMethods
def authenticate(name, pass)
authenticate_hash(name, sha1(pass))
end
def authenticate_api_key(name, api_key)
key = ApiKey.where(:key => api_key).first
return nil if key.nil?
user = find_by_name(name)
return nil if user.nil?
return user if key.user_id == user.id
nil
end
def authenticate_hash(name, hash) def authenticate_hash(name, hash)
user = find_by_name(name) user = find_by_name(name)
if user && user.bcrypt_password == hash if user && user.bcrypt_password == hash
@@ -200,15 +191,6 @@ class User < ApplicationRecord
nil nil
end end
end end
def bcrypt(pass)
BCrypt::Password.create(sha1(pass))
end
def sha1(pass)
salt = "choujin-steiner"
Digest::SHA1.hexdigest("#{salt}--#{pass}--")
end
end end
end end
@@ -610,8 +592,6 @@ class User < ApplicationRecord
end end
include BanMethods include BanMethods
include PasswordMethods
include AuthenticationMethods
include LevelMethods include LevelMethods
include EmailMethods include EmailMethods
include BlacklistMethods include BlacklistMethods

View File

@@ -18,8 +18,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
put_auth user_password_path(@user), @user, params: { user: { old_password: "12345", password: "abcde", password_confirmation: "abcde" } } put_auth user_password_path(@user), @user, params: { user: { old_password: "12345", password: "abcde", password_confirmation: "abcde" } }
assert_redirected_to @user assert_redirected_to @user
assert_equal(nil, User.authenticate(@user.name, "12345")) assert_equal(false, @user.reload.authenticate_password("12345"))
assert_equal(@user, User.authenticate(@user.name, "abcde")) assert_equal(@user, @user.authenticate_password("abcde"))
end end
should "update the password when given a valid login key" do should "update the password when given a valid login key" do
@@ -27,24 +27,24 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
put_auth user_password_path(@user), @user, params: { user: { password: "abcde", password_confirmation: "abcde", signed_user_id: signed_user_id } } put_auth user_password_path(@user), @user, params: { user: { password: "abcde", password_confirmation: "abcde", signed_user_id: signed_user_id } }
assert_redirected_to @user assert_redirected_to @user
assert_equal(nil, User.authenticate(@user.name, "12345")) assert_equal(false, @user.reload.authenticate_password("12345"))
assert_equal(@user, User.authenticate(@user.name, "abcde")) assert_equal(@user, @user.authenticate_password("abcde"))
end end
should "not update the password when given an invalid old password" do should "not update the password when given an invalid old password" do
put_auth user_password_path(@user), @user, params: { user: { old_password: "3qoirjqe", password: "abcde", password_confirmation: "abcde" } } put_auth user_password_path(@user), @user, params: { user: { old_password: "3qoirjqe", password: "abcde", password_confirmation: "abcde" } }
assert_response :success assert_response :success
assert_equal(@user, User.authenticate(@user.name, "12345")) assert_equal(@user, @user.reload.authenticate_password("12345"))
assert_equal(nil, User.authenticate(@user.name, "abcde")) assert_equal(false, @user.authenticate_password("abcde"))
end end
should "not update the password when password confirmation fails for the new password" do should "not update the password when password confirmation fails for the new password" do
put_auth user_password_path(@user), @user, params: { user: { old_password: "12345", password: "abcde", password_confirmation: "qerogijqe" } } put_auth user_password_path(@user), @user, params: { user: { old_password: "12345", password: "abcde", password_confirmation: "qerogijqe" } }
assert_response :success assert_response :success
assert_equal(@user, User.authenticate(@user.name, "12345")) assert_equal(@user, @user.reload.authenticate_password("12345"))
assert_equal(nil, User.authenticate(@user.name, "abcde")) assert_equal(false, @user.authenticate_password("abcde"))
end end
end end
end end

View File

@@ -3,7 +3,7 @@ require 'test_helper'
class SessionsControllerTest < ActionDispatch::IntegrationTest class SessionsControllerTest < ActionDispatch::IntegrationTest
context "the sessions controller" do context "the sessions controller" do
setup do setup do
@user = create(:user) @user = create(:user, password: "password")
end end
context "new action" do context "new action" do
@@ -14,15 +14,27 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end end
context "create action" do context "create action" do
should "create a new session" do should "log the user in when given the correct password" do
post session_path, params: {:name => @user.name, :password => "password"} post session_path, params: { name: @user.name, password: "password" }
assert_redirected_to posts_path assert_redirected_to posts_path
assert_equal(@user.id, session[:user_id]) assert_equal(@user.id, session[:user_id])
assert_not_nil(@user.reload.last_ip_addr) assert_not_nil(@user.reload.last_ip_addr)
end end
should "not allow IP banned users to create a new session" do should "not log the user in when given an incorrect password" do
post session_path, params: { name: @user.name, password: "wrong"}
assert_response 401
assert_nil(nil, session[:user_id])
end
should "redirect the user when given an url param" do
post session_path, params: { name: @user.name, password: "password", url: tags_path }
assert_redirected_to tags_path
end
should "not allow IP banned users to login" do
create(:ip_ban, ip_addr: "1.2.3.4") create(:ip_ban, ip_addr: "1.2.3.4")
post session_path, params: { name: @user.name, password: "password" }, headers: { REMOTE_ADDR: "1.2.3.4" } post session_path, params: { name: @user.name, password: "password" }, headers: { REMOTE_ADDR: "1.2.3.4" }

View File

@@ -118,7 +118,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to User.last assert_redirected_to User.last
assert_equal("xxx", User.last.name) assert_equal("xxx", User.last.name)
assert_equal(User.last, User.authenticate("xxx", "xxxxx1")) assert_equal(User.last, User.last.authenticate_password("xxxxx1"))
assert_equal(nil, User.last.email_address) assert_equal(nil, User.last.email_address)
assert_no_enqueued_emails assert_no_enqueued_emails
end end
@@ -128,7 +128,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to User.last assert_redirected_to User.last
assert_equal("xxx", User.last.name) assert_equal("xxx", User.last.name)
assert_equal(User.last, User.authenticate("xxx", "xxxxx1")) assert_equal(User.last, User.last.authenticate_password("xxxxx1"))
assert_equal("webmaster@danbooru.donmai.us", User.last.email_address.address) assert_equal("webmaster@danbooru.donmai.us", User.last.email_address.address)
assert_enqueued_email_with UserMailer, :welcome_user, args: [User.last] assert_enqueued_email_with UserMailer, :welcome_user, args: [User.last]
end end

View File

@@ -18,15 +18,15 @@ class ApiKeyTest < ActiveSupport::TestCase
end end
should "authenticate via api key" do should "authenticate via api key" do
assert_not_nil(User.authenticate_api_key(@user.name, @api_key.key)) assert_equal(@user, @user.authenticate_api_key(@api_key.key))
end end
should "not authenticate with the wrong api key" do should "not authenticate with the wrong api key" do
assert_nil(User.authenticate_api_key(@user.name, "xxx")) assert_equal(false, @user.authenticate_api_key("xxx"))
end end
should "not authenticate with the wrong name" do should "not authenticate with the wrong name" do
assert_nil(User.authenticate_api_key("xxx", @api_key.key)) assert_equal(false, create(:user).authenticate_api_key(@api_key.key))
end end
should "have the same limits whether or not they have an api key" do should "have the same limits whether or not they have an api key" do

View File

@@ -43,7 +43,7 @@ class UserDeletionTest < ActiveSupport::TestCase
should "reset the password" do should "reset the password" do
@deletion.delete! @deletion.delete!
assert_nil(User.authenticate(@user.name, "password")) assert_equal(false, @user.authenticate_password("password"))
end end
should "remove any favorites" do should "remove any favorites" do

View File

@@ -39,11 +39,9 @@ class UserTest < ActiveSupport::TestCase
end end
end end
should "authenticate" do should "authenticate password" do
assert(User.authenticate(@user.name, "password"), "Authentication should have succeeded") assert_equal(@user, @user.authenticate_password("password"))
assert(!User.authenticate(@user.name, "password2"), "Authentication should not have succeeded") assert_equal(false, @user.authenticate_password("password2"))
assert(User.authenticate_hash(@user.name, User.sha1("password")), "Authentication should have succeeded")
assert(!User.authenticate_hash(@user.name, User.sha1("xxx")), "Authentication should not have succeeded")
end end
should "normalize its level" do should "normalize its level" do