From 4472e9b0e399036e78e9d374ccd160c171975412 Mon Sep 17 00:00:00 2001 From: dsainty Date: Mon, 17 Jun 2024 18:43:07 +1000 Subject: [PATCH] v0.10.1 -- restored optimal design, resolved multiple issues, notable progress --- plugin.rb | 307 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 176 insertions(+), 131 deletions(-) diff --git a/plugin.rb b/plugin.rb index 465f1e1..e99080c 100644 --- a/plugin.rb +++ b/plugin.rb @@ -4,32 +4,132 @@ # name: discourse-md5_authentication # about: A plugin to authenticate users with MD5 passwords from legacy systems -# version: 0.9.6 +# version: 0.10.1 # authors: saint # url: https://gitea.federated.computer/saint/discourse-md5_authentication.git # require 'digest' after_initialize do - class ::SessionController < ApplicationController + # Define a module to contain the MD5 authentication logic + module LegacyMd5Authentication + # Constants ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + def to64(value, length) + result = String.new + length.times do + result << ITOA64[value & 0x3f] + value >>= 6 + Rails.logger.debug "to64 result: #{result}" + end + result + end + + def gossamer_md5_crypt(password, legacy_hash) + # Extract the salt from the legacy hash + parts = legacy_hash.split('$') + salt = parts[2] + + # Limit the salt to 8 characters + salt = salt[0, 8] + + magic = "$GT$" + Rails.logger.debug "MD5 magic: #{magic}" + + ctx = Digest::MD5.new + ctx.update(password) + ctx.update(magic) + ctx.update(salt) + + final = Digest::MD5.new + final.update(password) + final.update(salt) + final.update(password) + final_digest = final.digest + + password_length = password.length + + while password_length > 0 + ctx.update(final_digest[0, [password_length, 16].min]) + password_length -= 16 + end + + password_length = password.length + while password_length > 0 + if password_length & 1 != 0 + ctx.update("\x00") + else + ctx.update(password[0, 1]) + end + password_length >>= 1 + end + + final_digest = ctx.digest + Rails.logger.debug "MD5 final_digest: #{final_digest}" + + 1000.times do |i| + ctx1 = Digest::MD5.new + if i & 1 != 0 + Rails.logger.debug "AAA" + ctx1.update(password) + else + Rails.logger.debug "BBB" + ctx1.update(final_digest) + end + ctx1.update(salt) if i % 3 != 0 + ctx1.update(password) if i % 7 != 0 + if i & 1 != 0 + Rails.logger.debug "CCC" + ctx1.update(final_digest) + else + Rails.logger.debug "DDD" + ctx1.update(password) + end + final_digest = ctx1.digest + end + Rails.logger.debug "MD6 final_digest: #{final_digest}" + + result = String.new + Rails.logger.debug "A result: #{result}" + result << to64((final_digest[0].ord << 16) | (final_digest[6].ord << 8) | final_digest[12].ord, 4) + Rails.logger.debug "B result: #{result}" + result << to64((final_digest[1].ord << 16) | (final_digest[7].ord << 8) | final_digest[13].ord, 4) + Rails.logger.debug "C result: #{result}" + result << to64((final_digest[2].ord << 16) | (final_digest[8].ord << 8) | final_digest[14].ord, 4) + Rails.logger.debug "D result: #{result}" + result << to64((final_digest[3].ord << 16) | (final_digest[9].ord << 8) | final_digest[15].ord, 4) + Rails.logger.debug "E result: #{result}" + result << to64((final_digest[4].ord << 16) | (final_digest[10].ord << 8) | final_digest[5].ord, 4) + Rails.logger.debug "F result: #{result}" + result << to64(final_digest[11].ord, 2) + Rails.logger.debug "G result: #{result}" + + Rails.logger.debug "magic salt result #{magic}#{salt}$#{result}" + "#{magic}#{salt}$#{result}" + end + + def verify_gossamer_password(password, legacy_hash) + generated_hash = gossamer_md5_crypt(password, legacy_hash) + generated_hash == legacy_hash + end + end + + # Extend the SessionController create method to include our MD5 authentication logic + class ::SessionController < ApplicationController + prepend LegacyMd5Authentication + def create - # Require the necessary parameters params.require(:login) params.require(:password) - # Validate password length return invalid_credentials if params[:password].length > User.max_password_length - # Find user by username or email user = User.find_by_username_or_email(normalized_login_param) - # Check for read-only mode for non-staff users raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff? - # Apply rate limit for second factor authentication rate_limit_second_factor!(user) if user.present? @@ -37,162 +137,107 @@ after_initialize do custom_password_md5 = user.custom_fields['custom_password_md5'] # Check for MD5 password in custom field + Rails.logger.debug "Check for MD5 password in custom field" if custom_password_md5.present? - Rails.logger.debug "MD5 password is present. custom_password_md5: #{custom_password_md5}, password: #{password}" + # MD5 password is present + Rails.logger.debug "MD5 password is present custom_password_md5: #{custom_password_md5} password: #{password}" - # Extract the salt from the legacy hash - parts = custom_password_md5.split('$') - Rails.logger.debug "Split parts: #{parts.inspect}" + if verify_gossamer_password(password, custom_password_md5) + # MD5 matches, so update the user's password to the new one, remove the custom field and ensure user is set to active and approved + Rails.logger.debug "MD5 matches" - if parts.length >= 3 - salt = parts[2][0, 8] - else - Rails.logger.debug "Invalid MD5 format for custom_password_md5: #{custom_password_md5}" - return invalid_credentials - end - - magic = "$GT$" - Rails.logger.debug "MD5 magic: #{magic}, salt: #{salt}" - - # Create initial MD5 context - ctx = Digest::MD5.new - ctx.update(password) - ctx.update(magic) - ctx.update(salt) - - # Create final MD5 digest - final = Digest::MD5.new - final.update(password) - final.update(salt) - final.update(password) - final_digest = final.digest.dup # Ensure final_digest is not frozen - - # Perform password length operations - password_length = password.length - while password_length > 0 - ctx.update(final_digest[0, [password_length, 16].min]) - password_length -= 16 - end - - password_length = password.length - while password_length > 0 - if password_length & 1 != 0 - ctx.update("\x00") - else - ctx.update(password[0, 1]) - end - password_length >>= 1 - end - - final_digest = ctx.digest.dup # Ensure final_digest is not frozen - Rails.logger.debug "MD5 final_digest after initial operations: #{final_digest}" - - # Perform 1000 iterations of MD5 hashing - 1000.times do |i| - ctx1 = Digest::MD5.new - if i & 1 != 0 - ctx1.update(password) - else - ctx1.update(final_digest.dup) # Ensure final_digest is not frozen - end - ctx1.update(salt) if i % 3 != 0 - ctx1.update(password) if i % 7 != 0 - if i & 1 != 0 - ctx1.update(final_digest.dup) # Ensure final_digest is not frozen - else - ctx1.update(password) - end - final_digest = ctx1.digest.dup # Ensure final_digest is not frozen - end - - # Convert final digest to the hashed password format - result = '' - length = 4 - value = (final_digest[0].ord << 16) | (final_digest[6].ord << 8) | final_digest[12].ord - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - end - value = (final_digest[1].ord << 16) | (final_digest[7].ord << 8) | final_digest[13].ord - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - end - value = (final_digest[2].ord << 16) | (final_digest[8].ord << 8) | final_digest[14].ord - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - end - value = (final_digest[3].ord << 16) | (final_digest[9].ord << 8) | final_digest[15].ord - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - end - value = (final_digest[4].ord << 16) | (final_digest[10].ord << 8) | final_digest[5].ord - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - end - length = 2 - value = final_digest[11].ord - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - end - - entered_password_md5 = "#{magic}#{salt}$#{result}" - Rails.logger.debug "Generated MD5 hash: #{entered_password_md5}" - - # Verify the entered password MD5 hash against the stored hash - if entered_password_md5 == custom_password_md5 - Rails.logger.debug "MD5 hash matches for user: #{user.id}" - - # MD5 matches, update the user's password to the new one and remove the custom field + # Set password using Discourse's current standards, ensuring correct hashing. user.password = password - user.custom_fields['custom_password_md5'] = nil + + # Set other attributes + user.active = true + user.approved = true + # user.email_confirmed = true + user.approved_at = Time.now + user.approved_by_id = 1 + + # + # hashed_password = UserAuthenticator.new(nil).password_digest(password) + # user.update_columns( + # hashed_password: hashed_password + # ) + + # user.custom_fields['custom_password_md5'] = nil + + # user.active = true + # user.approved = true + + # # Initialize UserAuthenticator with user and session + # authenticator = UserAuthenticator.new(user, session) + + # # Generate a salted password hash for the new password + # hashed_password = authenticator.password_digest(password) + # Rails.logger.debug "NEW hashed_password #{hashed_password}" + + # # Update the user object with all changes + # user.assign_attributes( + # password_hash: hashed_password, + # # salt: authenticator.salt, + # # password_algorithm: authenticator.algorithm_name, + # active: true, + # approved: true, + # custom_fields: { 'custom_password_md5' => nil } + # ) + + # Save the changes user.save! - Rails.logger.debug "Updated MD5 password for user: #{user.id}" + + # if user.save + # Rails.logger.debug "User changes saved: #{user.username}" + # else + # Rails.logger.debug "User changes FAILED: #{user.errors.full_messages}" + # invalid_credentials + # return + # end + + + Rails.logger.debug "Updated user: #{user.id}" else - Rails.logger.debug "MD5 password incorrect for user: #{user.id}" - return invalid_credentials + # MD5 doesn't match, so we have a failed login attempt. + Rails.logger.debug "MD5 Password incorrect for user: #{user.id}" + invalid_credentials + return end elsif !user.confirm_password?(password) + # There is no MD5 password and the password was incorrect. Rails.logger.debug "Password incorrect for user: #{user.id}" - return invalid_credentials + invalid_credentials + return end - # Handle user approval requirements + # If the site requires user approval and the user is not approved yet if login_not_approved_for?(user) render json: login_not_approved return end - # Invalidate invite link if user signed on with username and password + # 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) - # Handle password expiration + # User's password has expired so they need to reset it if user.password_expired?(password) render json: { error: "expired", reason: "expired" } return end else - Rails.logger.debug "User not found with login: #{params[:login]}" - return invalid_credentials + invalid_credentials + return end if payload = login_error_check(user) return render json: payload end - # Perform second factor authentication second_factor_auth_result = authenticate_second_factor(user) - unless second_factor_auth_result.ok - Rails.logger.debug "Second factor authentication failed for user: #{user.id}" - return render(json: @second_factor_failure_payload) - end + return render(json: @second_factor_failure_payload) unless second_factor_auth_result.ok - # Handle active and email confirmed users if user.active && user.email_confirmed? login(user, second_factor_auth_result) else