diff --git a/plugin.rb b/plugin.rb index ef6345f..5e07a93 100644 --- a/plugin.rb +++ b/plugin.rb @@ -4,7 +4,7 @@ # name: discourse-md5_authentication # about: A plugin to authenticate users with MD5 passwords from legacy systems -# version: 0.9.3 +# version: 0.9.5 # authors: saint # url: https://gitea.federated.computer/saint/discourse-md5_authentication.git @@ -12,102 +12,24 @@ after_initialize do class ::SessionController < ApplicationController + # Constants ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - def to64(value, length) - result = "" - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - 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 - ctx1.update(password) - else - ctx1.update(final_digest) - end - ctx1.update(salt) if i % 3 != 0 - ctx1.update(password) if i % 7 != 0 - if i & 1 != 0 - ctx1.update(final_digest) - else - ctx1.update(password) - end - final_digest = ctx1.digest - end - - result = '' - result << to64((final_digest[0].ord << 16) | (final_digest[6].ord << 8) | final_digest[12].ord, 4) - result << to64((final_digest[1].ord << 16) | (final_digest[7].ord << 8) | final_digest[13].ord, 4) - result << to64((final_digest[2].ord << 16) | (final_digest[8].ord << 8) | final_digest[14].ord, 4) - result << to64((final_digest[3].ord << 16) | (final_digest[9].ord << 8) | final_digest[15].ord, 4) - result << to64((final_digest[4].ord << 16) | (final_digest[10].ord << 8) | final_digest[5].ord, 4) - result << to64(final_digest[11].ord, 2) - - "#{magic}#{salt}$#{result}" - end - - def verify_gossamer_password(password, legacy_hash) - generated_hash = gossamer_md5_crypt(password, legacy_hash) - generated_hash == legacy_hash - end - 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? @@ -115,59 +37,157 @@ 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? - # MD5 password is present - Rails.logger.debug "MD5 password is present custom_password_md5: #{custom_password_md5} password: #{password}" + Rails.logger.debug "MD6 password is present. custom_password_md5: #{custom_password_md5}, password: #{password}" - if verify_gossamer_password(password, custom_password_md5) - # MD5 matches, so update the user's password to the new one and remove the custom field - Rails.logger.debug "MD5 matches" + # Extract the salt from the legacy hash + parts = custom_password_md5.split('$') + Rails.logger.debug "MD7" + salt = parts[2][0, 8] + Rails.logger.debug "MD8" + magic = "$GT$" + Rails.logger.debug "MD9" + 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 + + # 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 + 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) + end + ctx1.update(salt) if i % 3 != 0 + ctx1.update(password) if i % 7 != 0 + if i & 1 != 0 + ctx1.update(final_digest) + else + ctx1.update(password) + end + final_digest = ctx1.digest + 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 user.password = password user.custom_fields['custom_password_md5'] = nil user.save! Rails.logger.debug "Updated MD5 password for user: #{user.id}" else - # MD5 doesn't match, so we have a failed login attempt. - Rails.logger.debug "MD5 Password incorrect for user: #{user.id}" - invalid_credentials - return + Rails.logger.debug "MD5 password incorrect for user: #{user.id}" + return invalid_credentials 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}" - invalid_credentials - return + return invalid_credentials end - # If the site requires user approval and the user is not approved yet + # Handle user approval requirements if login_not_approved_for?(user) render json: login_not_approved return end - # User signed on with username and password, so let's prevent the invite link - # from being used to log in (if one exists). + # Invalidate invite link if user signed on with username and password Invite.invalidate_for_email(user.email) - # User's password has expired so they need to reset it + # Handle password expiration if user.password_expired?(password) render json: { error: "expired", reason: "expired" } return end else - invalid_credentials - return + Rails.logger.debug "User not found with login: #{params[:login]}" + return invalid_credentials end if payload = login_error_check(user) return render json: payload end + # Perform second factor authentication second_factor_auth_result = authenticate_second_factor(user) - return render(json: @second_factor_failure_payload) unless second_factor_auth_result.ok + 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 + # Handle active and email confirmed users if user.active && user.email_confirmed? login(user, second_factor_auth_result) else