# plugins/discourse-md5_authentication/plugin.rb # frozen_string_literal: true # name: discourse-md5_authentication # about: A plugin to authenticate users with MD5 passwords from legacy systems # version: 0.21 # authors: saint@federated.computer # url: https://gitea.federated.computer/saint/discourse-md5_authentication.git # require 'digest' # enabled_site_setting :legacymd5password_auth_enabled after_initialize do # Define a module to hold the legacy MD5 authentication logic module LegacyMd5Authentication # Constants ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" # Override the create method to add our custom authentication checks def create Rails.logger.warn "MD5 -- AA -- start create" # Ensure required parameters are present params.require(:login) params.require(:password) # Validate the length of the password return invalid_credentials if params[:password].length > User.max_password_length # Find the user by username or email user = User.find_by_username_or_email(normalized_login_param) Rails.logger.warn "MD5 -- BB -- second" # Check if site is in staff writes-only mode and ensure user is staff if true raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff? # Apply rate limiting for second factor authentication rate_limit_second_factor!(user) if user.present? Rails.logger.warn "MD5 -- CC -- user.present" # Retrieve the provided password and custom MD5 password hash from user custom fields password = params[:password] custom_password_md5 = user.custom_fields['md5_password'] # Log the presence of custom MD5 hash for debugging Rails.logger.warn "MD5 -- Check for MD5 password in custom field" if custom_password_md5.present? # SCENARIO 1. : LEGACY MD5 HASH EXISTS Rails.logger.warn "MD5 -- 1. MD5 password is present custom_password_md5: #{custom_password_md5} password: #{password}" # Verify the provided password against the stored MD5 hash if verify_gossamer_password(password, custom_password_md5) # SCENARIO 1.1. : LEGACY MD5 HAS EXISTS AND MATCHES # If MD5 hash matches, update the user's password and other attributes Rails.logger.warn "MD5 -- 1.1. MD5 matches" # Set the user's password to the provided one and update other attributes user.password = password user.active = true user.approved = true user.approved_at = Time.now user.approved_by_id = 1 user.custom_fields['md5_password'] = nil # Clear the custom MD5 field user.save! Rails.logger.warn "MD5 -- DD -- user.present" # Generate a new token and hash it token = SecureRandom.hex(20) token_hash = EmailToken.hash_token(token) # Create a confirmed email token for the user EmailToken.create!( user_id: user.id, email: user.email, token_hash: token_hash, confirmed: true ) Rails.logger.warn("MD5 -- Generated token for user #{user.username}: #{token}") Rails.logger.warn "MD5 -- Updated user: #{user.id}" else # SCENARIO 1.2. : LEGACY MD5 HASH EXISTS BUT DOES NOT MATCH # Log the failed login attempt Rails.logger.warn "MD5 -- 1.2. MD5 Password (hash) exists but fails / incorrect for user: #{user.id}" if user.confirm_password?(password) # SCENARIO 1.2.1 : LEGACY MD5 HASH EXISTS BUT DOES NOT MATCH, BUT REAL PASSWORD WORKS -- NEW SUPPORT in v0.21 Rails.logger.warn "MD5 -- 1.2.1. Real Password Works for username: #{user.username} user: #{user.id}" # Update other attributes (other than password which is already correct) user.active = true user.approved = true user.approved_at = Time.now user.approved_by_id = 1 user.custom_fields['md5_password'] = nil # Clear the custom MD5 field user.save! Rails.logger.warn "MD5 -- DD -- user.present, cleared legacy MD5 field!" # Generate a new token and hash it token = SecureRandom.hex(20) token_hash = EmailToken.hash_token(token) # Create a confirmed email token for the user EmailToken.create!( user_id: user.id, email: user.email, token_hash: token_hash, confirmed: true ) Rails.logger.warn("MD5 -- Generated token for user #{user.username}: #{token}") Rails.logger.warn "MD5 -- Updated user: #{user.id}" else # SCENARIO 1.2.2 : LEGACY MD5 HASH EXISTS BUT DOES NOT MATCH, AND REAL PASSWORD DOES NOT MATCH OR IS NOT PRESENT Rails.logger.warn("MD5 -- 1.2.2. -- MD5 Password (hash) incorrect and no matching real password for username: #{user.username} user: #{user.id}") invalid_credentials return end end elsif !user.confirm_password?(password) # SCENARIO 2. : NO lEGACY MD5 HASH EXISTS AND REAL PASSWORD DOES NOT MATCH OR IS NOT PRESENT # If no MD5 hash is present and the provided password is incorrect Rails.logger.warn "MD5 -- 2. Password incorrect for user: #{user.id}" invalid_credentials return end # If the site requires user approval and the user is not yet approved if login_not_approved_for?(user) render json: login_not_approved return end # Invalidate any invite link for the user Invite.invalidate_for_email(user.email) # 3.3.0 # Check if the user's password has expired # if user.password_expired?(password) # render json: { error: "expired", reason: "expired" } # return # end else # No user is found with the provided credentials invalid_credentials return end # Check for any login errors if payload = login_error_check(user) return render json: payload end # Authenticate second factor if required second_factor_auth_result = authenticate_second_factor(user) return render(json: @second_factor_failure_payload) unless second_factor_auth_result.ok # If user is active and email is confirmed, proceed with login if user.active && user.email_confirmed? login(user, second_factor_auth_result) else not_activated(user) end end # Helper methods to handle MD5 password verification def to64(value, length) # Convert a value to a base64-like representation result = String.new length.times do result << ITOA64[value & 0x3f] value >>= 6 Rails.logger.warn "MD5 -- 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.warn "MD5 -- 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 # Process the password with various hashing operations 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 -- MD5 final_digest: #{final_digest}" 1000.times do |i| ctx1 = Digest::MD5.new if i & 1 != 0 Rails.logger.warn "MD5 -- AAA" ctx1.update(password) else Rails.logger.warn "MD5 -- 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.warn "MD5 -- CCC" ctx1.update(final_digest) else Rails.logger.warn "MD5 -- DDD" ctx1.update(password) end final_digest = ctx1.digest end Rails.logger.warn "MD5 -- MD5++ final_digest: #{final_digest}" result = String.new Rails.logger.warn "MD5 -- A result: #{result}" result << to64((final_digest[0].ord << 16) | (final_digest[6].ord << 8) | final_digest[12].ord, 4) Rails.logger.warn "MD5 -- B result: #{result}" result << to64((final_digest[1].ord << 16) | (final_digest[7].ord << 8) | final_digest[13].ord, 4) Rails.logger.warn "MD5 -- C result: #{result}" result << to64((final_digest[2].ord << 16) | (final_digest[8].ord << 8) | final_digest[14].ord, 4) Rails.logger.warn "MD5 -- D result: #{result}" result << to64((final_digest[3].ord << 16) | (final_digest[9].ord << 8) | final_digest[15].ord, 4) Rails.logger.warn "MD5 -- E result: #{result}" result << to64((final_digest[4].ord << 16) | (final_digest[10].ord << 8) | final_digest[5].ord, 4) Rails.logger.warn "MD5 -- F result: #{result}" result << to64(final_digest[11].ord, 2) Rails.logger.warn "MD5 -- G result: #{result}" Rails.logger.warn "MD5 -- Magic Salt Result #{magic}#{salt}$#{result}" "#{magic}#{salt}$#{result}" end def verify_gossamer_password(password, legacy_hash) # Verify the provided password against the stored MD5 hash generated_hash = gossamer_md5_crypt(password, legacy_hash) generated_hash == legacy_hash end end # Extend the SessionController class to include the LegacyMd5Authentication module # 3.3.0 class ::SessionController < ApplicationController class ::SessionController prepend LegacyMd5Authentication end end