Slide 1

Slide 1 text

Login Form from Scratch @takai   

Slide 2

Slide 2 text

Naoto Takai Cowboy Coder

Slide 3

Slide 3 text

How many people have developed a login form?

Slide 4

Slide 4 text

def create @login = Login.new(login_params) digest = Digest::SHA1.hexdigest(@login.password) if (user = User.find_by(email: @login.email, password_digest: digest)) session[:user] = user.id redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' render :new end end

Slide 5

Slide 5 text

 There are many, many pitfalls. You’re right, previous code is completely wrong.

Slide 6

Slide 6 text

def create @login = Login.new(login_params) digest = Digest::SHA1.hexdigest(@login.password) if (user = User.find_by(email: @login.email, password_digest: digest)) session[:user] = user.id redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' render :new end end

Slide 7

Slide 7 text

Use PBKDF2-HMAC-SHA512 instead of SHA1 or MD5.

Slide 8

Slide 8 text

NUM_OF_ITERATIONS = 20000 DIGEST_ALGORITHM = OpenSSL::Digest::SHA512.new KEY_LEN = DIGEST_ALGORITHM.length salt = SecureRandom.hex(32) OpenSSL::PKCS5.pbkdf2_hmac(password, salt, NUM_OF_ITERATIONS, KEY_LEN, DIGEST_ALGORITHM) # => "L\xBEv-\x83\xC2\x98\x1D..."  A minimum of 1,000 iterations is recommended in RFC 2898 (over ten years ago).  Use random and different salt for every password.

Slide 9

Slide 9 text

def create @login = Login.new(login_params) user = User.find_by(email: @login.email) || User.new(salt: '') digest_bytes = OpenSSL::PKCS5.pbkdf2_hmac(@login.password, user.salt, NUM_OF_ITERATIONS, KEY_LEN, DIGEST_ALGORITHM) password_digest = digest_bytes.unpack('H*').first if user.password_digest == password_digest session[:user] = user.id redirect_to @login.original_url || root_url ...

Slide 10

Slide 10 text

def create @login = Login.new(login_params) user = User.find_by(email: @login.email) || User.new(salt: '') digest_bytes = OpenSSL::PKCS5.pbkdf2_hmac(@login.password, user.salt, NUM_OF_ITERATIONS, KEY_LEN, DIGEST_ALGORITHM) password_digest = digest_bytes.unpack('H*').first if user.password_digest == password_digest session[:user] = user.id redirect_to @login.original_url || root_url ...

Slide 11

Slide 11 text

Use secure_compare instead of == to avoid a timing attack.  Timing attacks would not be viable in our case.

Slide 12

Slide 12 text

module Rack module Utils # Constant time string comparison. def secure_compare(a, b) return false unless bytesize(a) == bytesize(b) l = a.unpack("C*") r, i = 0, -1 b.each_byte { |v| r |= v ^ l[i+=1] } r == 0 end module_function :secure_compare  https://github.com/rack/rack/blob/rack-1.5/lib/rack/utils.rb#L398-L408

Slide 13

Slide 13 text

lhs = '7319bf2143beb945070723bf54965b2d' lhs2 = '7319bf2143beb945070723bf54965b2d' rhs = '1e64ac58521914da0e3804326e8a8a75' include Rack::Utils Benchmark.bm(12) do |x| x.report('#1 == (diff)') { i.times { lhs == rhs }} x.report('#2 == (same)') { i.times { lhs == lhs2 }} x.report('#3 sc (diff)') { i.times { secure_compare(lhs, rhs) }} x.report('#4 sc (same)') { i.times { secure_compare(lhs, lhs2) }} end __END__ user system total real #1 == (diff) 0.080000 0.000000 0.080000 ( 0.080617) #2 == (same) 0.090000 0.000000 0.090000 ( 0.085593) #3 sc (diff) 6.520000 0.070000 6.590000 ( 6.587122) #4 sc (same) 6.500000 0.060000 6.560000 ( 6.561539)

Slide 14

Slide 14 text

... digest_bytes = OpenSSL::PKCS5.pbkdf2_hmac(@login.password, user.salt, NUM_OF_ITERATIONS, KEY_LEN, DIGEST_ALGORITHM) password_digest = digest_bytes.unpack('H*').first if Rack::Utils.secure_compare(user.password_digest, password_digest) session[:user] = user.id redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' ...

Slide 15

Slide 15 text

... digest_bytes = OpenSSL::PKCS5.pbkdf2_hmac(@login.password, user.salt, NUM_OF_ITERATIONS, KEY_LEN, DIGEST_ALGORITHM) password_digest = digest_bytes.unpack('H*').first if Rack::Utils.secure_compare(user.password_digest, password_digest) session[:user] = user.id redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' ...

Slide 16

Slide 16 text

Reset session before login to avoid a session xation attack

Slide 17

Slide 17 text

Attacker Victim Web App issues a session id feeds the session id accesses with the session id logs in

Slide 18

Slide 18 text

... password_digest = digest_bytes.unpack('H*').first if Rack::Utils.secure_compare(user.password_digest, password_digest) reset_session session[:user] = user.id redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' render :new end end ...

Slide 19

Slide 19 text

... password_digest = digest_bytes.unpack('H*').first if Rack::Utils.secure_compare(user.password_digest, password_digest) reset_session session[:user] = user.id redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' render :new end end ...

Slide 20

Slide 20 text

Do not share session cookies between HTTP and HTTPS

Slide 21

Slide 21 text

Attacker Victim Secure zone data sensitive data sni cookies session: e8a744 session: e8a744 spoof session: e8a744

Slide 22

Slide 22 text

Attacker Victim Secure zone data sensitive data sni cookies session: 71fc6e session: e8a744 spoof session: e8a744

Slide 23

Slide 23 text

def set_login_cookies(user_id) key = SecureRandom.uuid secure_key = SecureRandom.uuid SessionStorage.set(key, user_id) SessionStorage.set(secure_key, user_id) cookies.signed[:user_session] = { expires: 1.week.from_now, value: key, httponly: true } cookies.signed[:secure_user_session] = { expires: 1.week.from_now, value: secure_key, httponly: true, secure: true } end

Slide 24

Slide 24 text

... password_digest = digest_bytes.unpack('H*').first if Rack::Utils.secure_compare(user.password_digest, password_digest) reset_session set_login_cookies(user.id) redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' render :new end end ...

Slide 25

Slide 25 text

... password_digest = digest_bytes.unpack('H*').first if Rack::Utils.secure_compare(user.password_digest, password_digest) reset_session set_login_cookies(user.id) redirect_to @login.original_url || root_url else @login.errors[:base] << 'Please enter a correct username and password.' render :new end end ...

Slide 26

Slide 26 text

Avoid open redirect issue with whitelist

Slide 27

Slide 27 text

Attacker Victim Attacker's site login form phishing page malicious url logs in redirection

Slide 28

Slide 28 text

... set_login_cookies(user.id) redirect_to sanitize_url(@login.original_url) || root_url else @login.errors[:base] << 'Please enter a correct username and password.' render :new end end ... def sanitize_url(url) URI.parse(url).host == request.host ? url : nil rescue nil end

Slide 29

Slide 29 text

BONUS: turn off autocomplete

Slide 30

Slide 30 text

== form_for :login, url: login_path do |f| .row .large-4.columns == f.label :email == f.text_field :email, autocomplete: 'off' .row .large-4.columns == f.label :password == f.password_field :password .row .large-4.columns == f.hidden_field :original_url == f.submit Login, class: 'button'

Slide 31

Slide 31 text

Thank you. @takai