# 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.12.1 # authors: saint # url: https://gitea.federated.computer/saint/discourse-md5_authentication.git # require 'digest' after_initialize do # 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 params.require(:login) params.require(:password) return invalid_credentials if params[:password].length > User.max_password_length user = User.find_by_username_or_email(normalized_login_param) raise Discourse::ReadOnly if staff_writes_only_mode? && !user&.staff? rate_limit_second_factor!(user) if user.present? password = params[:password] 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}" 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" # Set password using Discourse's current standards, ensuring correct hashing, with exception check for the same password as that alaedy stored in Discourse user.password = password # Set other attributes user.active = true user.approved = true # user.email_confirmed = true user.approved_at = Time.now user.approved_by_id = 1 user.custom_fields['custom_password_md5'] = nil user.save! # Generate a new token and hash it token = SecureRandom.hex(20) token_hash = EmailToken.hash_token(token) # Create a confirmed e-mail token EmailToken.create!( user_id: user.id, email: user.email, token_hash: token_hash, confirmed: true ) Rails.logger.debug("Generated token for user #{user.username}: #{token}") # # 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 } # ) # 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 # 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}" invalid_credentials return end # 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 # 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) # 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 invalid_credentials return end if payload = login_error_check(user) return render json: payload end second_factor_auth_result = authenticate_second_factor(user) return render(json: @second_factor_failure_payload) unless second_factor_auth_result.ok if user.active && user.email_confirmed? login(user, second_factor_auth_result) else not_activated(user) end end end end