diff --git a/plugin.rb b/plugin.rb index 63a5439..9f58b1c 100644 --- a/plugin.rb +++ b/plugin.rb @@ -4,247 +4,205 @@ # name: discourse-md5_authentication # about: A plugin to authenticate users with MD5 passwords from legacy systems -# version: 0.13 -# authors: saint +# version: 0.14 +# 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 contain the MD5 authentication logic - module LegacyMd5Authentication + # Extend the SessionController class to include our custom authentication logic + class ::SessionController + prepend Module.new { + # Override the create method to add our custom authentication checks + def create + # Ensure required parameters are present + params.require(:login) + params.require(:password) - # Constants - ITOA64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + # Validate the length of the password + return invalid_credentials if params[:password].length > User.max_password_length - def to64(value, length) - result = String.new - length.times do - result << ITOA64[value & 0x3f] - value >>= 6 - Rails.logger.warn "to64 result: #{result}" - end - result - end + # Find the user by username or email + user = User.find_by_username_or_email(normalized_login_param) - def gossamer_md5_crypt(password, legacy_hash) - # Extract the salt from the legacy hash - parts = legacy_hash.split('$') - salt = parts[2] + # 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? - # Limit the salt to 8 characters - salt = salt[0, 8] + # Apply rate limiting for second factor authentication + rate_limit_second_factor!(user) - magic = "$GT$" - Rails.logger.warn "MD5 magic: #{magic}" + if user.present? + # Retrieve the provided password and custom MD5 password hash from user custom fields + password = params[:password] + custom_password_md5 = user.custom_fields['custom_password_md5'] - ctx = Digest::MD5.new - ctx.update(password) - ctx.update(magic) - ctx.update(salt) + # Log the presence of custom MD5 hash for debugging + Rails.logger.warn "Check for MD5 password in custom field" + if custom_password_md5.present? + Rails.logger.warn "MD5 password is present custom_password_md5: #{custom_password_md5} password: #{password}" - final = Digest::MD5.new - final.update(password) - final.update(salt) - final.update(password) - final_digest = final.digest + # Verify the provided password against the stored MD5 hash + if verify_gossamer_password(password, custom_password_md5) + # If MD5 hash matches, update the user's password and other attributes + Rails.logger.warn "MD5 matches" - password_length = password.length + # 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['custom_password_md5'] = nil # Clear the custom MD5 field + user.save! - while password_length > 0 - ctx.update(final_digest[0, [password_length, 16].min]) - password_length -= 16 - end + # Generate a new token and hash it + token = SecureRandom.hex(20) + token_hash = EmailToken.hash_token(token) - 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 + # 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("Generated token for user #{user.username}: #{token}") - final_digest = ctx.digest - Rails.logger.warn "MD5 final_digest: #{final_digest}" + Rails.logger.warn "Updated user: #{user.id}" + else + # If MD5 hash does not match, log the failed login attempt + Rails.logger.warn "MD5 Password incorrect for user: #{user.id}" + invalid_credentials + return + end - 1000.times do |i| - ctx1 = Digest::MD5.new - if i & 1 != 0 - Rails.logger.warn "AAA" - ctx1.update(password) - else - Rails.logger.warn "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 "CCC" - ctx1.update(final_digest) - else - Rails.logger.warn "DDD" - ctx1.update(password) - end - final_digest = ctx1.digest - end - Rails.logger.warn "MD6 final_digest: #{final_digest}" - - result = String.new - Rails.logger.warn "A result: #{result}" - result << to64((final_digest[0].ord << 16) | (final_digest[6].ord << 8) | final_digest[12].ord, 4) - Rails.logger.warn "B result: #{result}" - result << to64((final_digest[1].ord << 16) | (final_digest[7].ord << 8) | final_digest[13].ord, 4) - Rails.logger.warn "C result: #{result}" - result << to64((final_digest[2].ord << 16) | (final_digest[8].ord << 8) | final_digest[14].ord, 4) - Rails.logger.warn "D result: #{result}" - result << to64((final_digest[3].ord << 16) | (final_digest[9].ord << 8) | final_digest[15].ord, 4) - Rails.logger.warn "E result: #{result}" - result << to64((final_digest[4].ord << 16) | (final_digest[10].ord << 8) | final_digest[5].ord, 4) - Rails.logger.warn "F result: #{result}" - result << to64(final_digest[11].ord, 2) - Rails.logger.warn "G result: #{result}" - - Rails.logger.warn "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.warn "Check for MD5 password in custom field" - if custom_password_md5.present? - # MD5 password is present - Rails.logger.warn "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.warn "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.warn("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.warn "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.warn "User changes saved: #{user.username}" - # else - # Rails.logger.warn "User changes FAILED: #{user.errors.full_messages}" - # invalid_credentials - # return - # end - - - Rails.logger.warn "Updated user: #{user.id}" - else - # MD5 doesn't match, so we have a failed login attempt. - Rails.logger.warn "MD5 Password incorrect for user: #{user.id}" + elsif !user.confirm_password?(password) + # If no MD5 hash is present and the provided password is incorrect + Rails.logger.warn "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.warn "Password incorrect for user: #{user.id}" + # 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) + + # Check if the user's password has expired + if user.password_expired?(password) + render json: { error: "expired", reason: "expired" } + return + end + else + # If no user is found with the provided credentials 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 + # Check for any login errors + if payload = login_error_check(user) + return render json: payload 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) + # 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 - # User's password has expired so they need to reset it - if user.password_expired?(password) - render json: { error: "expired", reason: "expired" } - return + # 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 - 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 +# 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 "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 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.warn "MD6 final_digest: #{final_digest}" + + result = String.new + Rails.logger.warn "A result: #{result}" + result << to64((final_digest[0].ord << 16) | (final_digest[6].ord << 8) | final_digest[12].ord, 4) + Rails.logger.warn "B result: #{result}" + result << to64((final_digest[1].ord << 16) | (final_digest[7].ord << 8) | final_digest[13].ord, 4) + Rails.logger.warn "C result: #{result}" + result << to64((final_digest[2].ord << 16) | (final_digest[8].ord << 8) | final_digest[14].ord, 4) + Rails.logger.warn "D result: #{result}" + result << to64((final_digest[3].ord << 16) | (final_digest[9].ord << 8) | final_digest[15].ord, 4) + Rails.logger.warn "E result: #{result}" + result << to64((final_digest[4].ord << 16) | (final_digest[10].ord << 8) | final_digest[5].ord, 4) + Rails.logger.warn "F result: #{result}" + result << to64(final_digest[11].ord, 2) + Rails.logger.warn "G result: #{result}" + + Rails.logger.warn "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 diff --git a/plugin.rb.bck b/plugin.rb.bck new file mode 100644 index 0000000..63a5439 --- /dev/null +++ b/plugin.rb.bck @@ -0,0 +1,250 @@ +# 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.13 +# 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.warn "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 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.warn "MD5 final_digest: #{final_digest}" + + 1000.times do |i| + ctx1 = Digest::MD5.new + if i & 1 != 0 + Rails.logger.warn "AAA" + ctx1.update(password) + else + Rails.logger.warn "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 "CCC" + ctx1.update(final_digest) + else + Rails.logger.warn "DDD" + ctx1.update(password) + end + final_digest = ctx1.digest + end + Rails.logger.warn "MD6 final_digest: #{final_digest}" + + result = String.new + Rails.logger.warn "A result: #{result}" + result << to64((final_digest[0].ord << 16) | (final_digest[6].ord << 8) | final_digest[12].ord, 4) + Rails.logger.warn "B result: #{result}" + result << to64((final_digest[1].ord << 16) | (final_digest[7].ord << 8) | final_digest[13].ord, 4) + Rails.logger.warn "C result: #{result}" + result << to64((final_digest[2].ord << 16) | (final_digest[8].ord << 8) | final_digest[14].ord, 4) + Rails.logger.warn "D result: #{result}" + result << to64((final_digest[3].ord << 16) | (final_digest[9].ord << 8) | final_digest[15].ord, 4) + Rails.logger.warn "E result: #{result}" + result << to64((final_digest[4].ord << 16) | (final_digest[10].ord << 8) | final_digest[5].ord, 4) + Rails.logger.warn "F result: #{result}" + result << to64(final_digest[11].ord, 2) + Rails.logger.warn "G result: #{result}" + + Rails.logger.warn "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.warn "Check for MD5 password in custom field" + if custom_password_md5.present? + # MD5 password is present + Rails.logger.warn "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.warn "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.warn("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.warn "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.warn "User changes saved: #{user.username}" + # else + # Rails.logger.warn "User changes FAILED: #{user.errors.full_messages}" + # invalid_credentials + # return + # end + + + Rails.logger.warn "Updated user: #{user.id}" + else + # MD5 doesn't match, so we have a failed login attempt. + Rails.logger.warn "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.warn "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 +