diff --git a/rails/Gemfile b/rails/Gemfile new file mode 100644 index 0000000..5e70b61 --- /dev/null +++ b/rails/Gemfile @@ -0,0 +1,6 @@ +source 'https://rubygems.org' + +gem 'fusionauth_client', '~> 1.51' +gem 'json', '~> 2.6' +gem 'optparse', '~> 0.4' +gem 'base64', '~> 0.2' \ No newline at end of file diff --git a/rails/README.md b/rails/README.md new file mode 100644 index 0000000..83aeda4 --- /dev/null +++ b/rails/README.md @@ -0,0 +1,74 @@ +# FusionAuth Import Script For Ruby On Rails + +A script to import user data from Rails-based authentication systems into FusionAuth. Features duplicate handling, verbose logging, and social account linking for OmniAuth integrations. + +## Prerequisites + +1. **FusionAuth Instance:** Running FusionAuth server (default: ) +2. **API Key:** FusionAuth API key with user import permissions +3. **Ruby:** Ruby 2.7 or higher with bundler +4. **Export File:** JSON export file from your Rails-based authentication system. Refer to the [FusionAuth Users documentation](https://fusionauth.io/docs/apis/users#import-users) for details. + +## Installation + +1. Install dependencies. + + ```bash + bundle install + ``` + +2. Make the script executable. + + ```bash + chmod +x import.rb + ``` + +3. (Optional) Make the wrapper script executable. + + ```bash + chmod +x import.sh + ``` + +## Usage + +```bash +./import.rb -k YOUR_API_KEY -u users_export_file.json +``` + +This imports users from the file into FusionAuth's default tenant and application. + +### Advanced Usage + +```bash +./import.rb \ + --fusionauth-api-key YOUR_API_KEY \ + --users-file users_export_file.json \ + --fusionauth-url http://localhost:9011 \ + --fusionauth-tenant-id YOUR_TENANT_ID \ + --source-system devise \ + --register-users app-id-1,app-id-2,app-id-3 \ + --link-social-accounts \ + --verbose +``` + +## Command Line Options + +| Option | Short | Required | Default | Description | +|--------|-------|----------|---------|-------------| +| `--users-file` | `-u` | No | `users.json` | Path to the exported JSON user file | +| `--fusionauth-api-key` | `-k` | **Yes** | - | FusionAuth API key | +| `--fusionauth-url` | `-f` | No | `http://localhost:9011` | FusionAuth instance URL | +| `--fusionauth-tenant-id` | `-t` | No | - | Tenant Id (required if multiple tenants exist) | +| `--source-system` | `-s` | No | Auto-detected | Source system: `devise`, `rails_auth`, or `omniauth` | +| `--register-users` | `-r` | No | - | Comma-separated list of application Ids | +| `--link-social-accounts` | `-l` | No | `false` | Link social accounts for OmniAuth users | +| `--verbose` | `-v` | No | `false` | Enable detailed logging | +| `--help` | `-h` | No | - | Show help message | + +## Supported Authentication Systems + +**Devise:** Imports users, encrypted passwords, and user metadata. Preserves email confirmation status and account locking. + +**OmniAuth:** Imports social accounts with identity provider linking. Specify social provider and provider user Id in the `user[x].data` field. + +**Rails In-Built Authentication:** Imports users, confirmation status, and sign-in tracking data. diff --git a/rails/export-scripts/built-in-auth/export_users_for_fusionauth.rb b/rails/export-scripts/built-in-auth/export_users_for_fusionauth.rb new file mode 100644 index 0000000..2d48835 --- /dev/null +++ b/rails/export-scripts/built-in-auth/export_users_for_fusionauth.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby + +require_relative '../config/environment' +require 'json' +require 'securerandom' + +puts "Starting user export for Rails Authentication users..." +puts "Found #{User.count} users to export" + +users_data = User.all.map do |user| + puts "Exporting user: #{user.email}" + + # Parse bcrypt hash according to FusionAuth requirements: + # Example: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy + # Should be split to: + # factor: 10 + # salt: N9qo8uLOickgx2ZMRZoMye (first 22 chars after factor) + # password: IjZAgcfl7p92ldGxad68LJZdL17lhWy (remaining chars) + + bcrypt_factor = 10 # default + bcrypt_salt = "" + bcrypt_password = "" + + if user.password_digest&.match(/^\$2[aby]\$(\d+)\$(.+)$/) + bcrypt_factor = $1.to_i + salt_and_hash = $2 + + bcrypt_salt = salt_and_hash[0, 22] + bcrypt_password = salt_and_hash[22..-1] + end + + user_data = { + email: user.email, + username: user.email, + fullName: user.name, + password: bcrypt_password, + encryptionScheme: "bcrypt", + factor: bcrypt_factor, + salt: bcrypt_salt, + passwordChangeRequired: false, + verified: user.confirmed?, + active: user.confirmed?, + registrations: [ + { + id: SecureRandom.uuid, + applicationId: "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e", + verified: user.confirmed?, + roles: ["user"] + } + ], + # Additional user data + data: { + migrated_from: "rails_authentication", + original_id: user.id + } + } + + user_data +end + +# Save to JSON file +filename = "users_export.json" + +File.open(filename, 'w') do |file| + file.write(JSON.pretty_generate(users_data)) +end + +puts "\nExport complete!" +puts "Saved #{users_data.length} users to #{filename}" +puts "Total users exported: #{users_data.count}" \ No newline at end of file diff --git a/rails/export-scripts/built-in-auth/users_export.json b/rails/export-scripts/built-in-auth/users_export.json new file mode 100644 index 0000000..82745d8 --- /dev/null +++ b/rails/export-scripts/built-in-auth/users_export.json @@ -0,0 +1,30 @@ +{ + "users": [ + { + "email": "sarah.johnson@techcorp.com", + "username": "sarah.johnson@techcorp.com", + "fullName": "Sarah Johnson", + "password": "ANzpvmDwOpcY4GU0fpNDsCrB6l9Ad62", + "encryptionScheme": "bcrypt", + "factor": 12, + "salt": "jTZOY/BQkbKJNMaCuy39Cu", + "passwordChangeRequired": false, + "verified": true, + "active": true, + "registrations": [ + { + "id": "72efaea3-267a-49cc-a6f0-aa8f0b096b29", + "applicationId": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e", + "verified": true, + "roles": [ + "user" + ] + } + ], + "data": { + "migrated_from": "rails_authentication", + "original_id": 1 + } + } + ] +} \ No newline at end of file diff --git a/rails/export-scripts/devise/export_users_for_fusionauth.rb b/rails/export-scripts/devise/export_users_for_fusionauth.rb new file mode 100755 index 0000000..cd5dfcb --- /dev/null +++ b/rails/export-scripts/devise/export_users_for_fusionauth.rb @@ -0,0 +1,71 @@ +#!/usr/bin/env ruby + +require_relative 'config/environment' +require 'json' +require 'securerandom' + +puts "Starting user export for Devise users..." +puts "Found #{User.count} users to export" + +users_data = User.all.map do |user| + puts "Exporting user: #{user.email}" + + # Parse bcrypt hash according to FusionAuth requirements: + # Example: $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy + # Should be split to: + # factor: 10 + # salt: N9qo8uLOickgx2ZMRZoMye (first 22 chars after factor) + # password: IjZAgcfl7p92ldGxad68LJZdL17lhWy (remaining chars) + + bcrypt_factor = 10 # default + bcrypt_salt = "" + bcrypt_password = "" + + if user.encrypted_password&.match(/^\$2[aby]\$(\d+)\$(.+)$/) + bcrypt_factor = $1.to_i + salt_and_hash = $2 + + bcrypt_salt = salt_and_hash[0, 22] + bcrypt_password = salt_and_hash[22..-1] + end + + user_data = { + email: user.email, + username: user.email, + password: bcrypt_password, + encryptionScheme: "bcrypt", + factor: bcrypt_factor, + salt: bcrypt_salt, + passwordChangeRequired: false, + verified: user.respond_to?(:confirmed?) ? user.confirmed? : true, + active: true, + registrations: [ + { + id: SecureRandom.uuid, + applicationId: "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e", + verified: user.respond_to?(:confirmed?) ? user.confirmed? : true, + roles: ["user"] + } + ], + data: { + source_system: "devise", + original_user_id: user.id, + locked_at: user.respond_to?(:locked_at) ? user.locked_at : nil, + confirmation_token: user.respond_to?(:confirmation_token) ? user.confirmation_token : nil, + last_sign_in_ip: user.respond_to?(:last_sign_in_ip) ? user.last_sign_in_ip : nil, + current_sign_in_ip: user.respond_to?(:current_sign_in_ip) ? user.current_sign_in_ip : nil + } + } + + user_data +end + +# Write to file with proper FusionAuth format +export_data = { users: users_data } +filename = "users_export.json" +File.write(filename, JSON.pretty_generate(export_data)) + +puts "" +puts "Export completed successfully!" +puts "File saved as: #{filename}" +puts "Total users exported: #{users_data.count}" \ No newline at end of file diff --git a/rails/export-scripts/devise/users_export.json b/rails/export-scripts/devise/users_export.json new file mode 100644 index 0000000..2e3175a --- /dev/null +++ b/rails/export-scripts/devise/users_export.json @@ -0,0 +1,33 @@ +{ + "users": [ + { + "email": "jennifer.adams@techstart.com", + "username": "jennifer.adams@techstart.com", + "password": "wSMHfHu84ns5n4WeGY0Jn.ZwZsLj3zC", + "encryptionScheme": "bcrypt", + "factor": 12, + "salt": "Oa88b1GmziTesQzGkMSYyu", + "passwordChangeRequired": false, + "verified": true, + "active": true, + "registrations": [ + { + "id": "cc09b582-d35b-44b1-8331-f8c329006079", + "applicationId": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e", + "verified": true, + "roles": [ + "user" + ] + } + ], + "data": { + "source_system": "devise", + "original_user_id": 1, + "locked_at": null, + "confirmation_token": null, + "last_sign_in_ip": null, + "current_sign_in_ip": null + } + } + ] +} \ No newline at end of file diff --git a/rails/export-scripts/omniauth/export_users_for_fusionauth.rb b/rails/export-scripts/omniauth/export_users_for_fusionauth.rb new file mode 100644 index 0000000..1e3950a --- /dev/null +++ b/rails/export-scripts/omniauth/export_users_for_fusionauth.rb @@ -0,0 +1,54 @@ +#!/usr/bin/env ruby + +# Simple User Export Script for OmniAuth Users +# Exports users as plain JSON + +require_relative 'config/environment' +require 'json' +require 'securerandom' + +puts "Starting user export for OmniAuth users..." +puts "Found #{User.count} users to export" + +users_data = User.all.map do |user| + puts "Exporting user: #{user.email} (#{user.provider})" + + { + email: user.email, + username: user.email, + fullName: user.name, + password: SecureRandom.alphanumeric(12) + "!", + verified: true, + active: user.active, + imageUrl: user.image_url, + registrations: [ + { + id: SecureRandom.uuid, + applicationId: "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e", + verified: true, + roles: ["user"] + } + ], + data: { + source_system: "omniauth", + original_user_id: user.id, + oauth_provider: user.provider, + oauth_uid: user.uid, + locked_at: nil, + confirmation_token: nil, + last_sign_in_ip: nil, + current_sign_in_ip: nil + } + } +end + +# Write to file with proper FusionAuth format +export_data = { users: users_data } +filename = "users_export.json" +File.write(filename, JSON.pretty_generate(export_data)) + +puts "" +puts "Export completed successfully!" +puts "File saved as: #{filename}" +puts "Total users exported: #{users_data.count}" +puts "Providers: #{users_data.group_by { |u| u[:data][:oauth_provider] }.transform_values(&:count)}" \ No newline at end of file diff --git a/rails/export-scripts/omniauth/users_export.json b/rails/export-scripts/omniauth/users_export.json new file mode 100644 index 0000000..2e9efd1 --- /dev/null +++ b/rails/export-scripts/omniauth/users_export.json @@ -0,0 +1,33 @@ +{ + "users": [ + { + "email": "alexandra.kim@techventures.com", + "username": "alexandra.kim@techventures.com", + "fullName": "Alexandra Kim", + "password": "oceghPW47j1f!", + "verified": true, + "active": true, + "imageUrl": "https://via.placeholder.com/80/667eea/ffffff?text=AK", + "registrations": [ + { + "id": "d068cd77-4c94-470f-bc83-399b1b63023a", + "applicationId": "e9fdb985-9173-4e01-9d73-ac2d60d1dc8e", + "verified": true, + "roles": [ + "user" + ] + } + ], + "data": { + "source_system": "omniauth", + "original_user_id": 1, + "oauth_provider": "google_oauth2", + "oauth_uid": "108234567890123456789", + "locked_at": null, + "confirmation_token": null, + "last_sign_in_ip": null, + "current_sign_in_ip": null + } + } + ] +} diff --git a/rails/import.rb b/rails/import.rb new file mode 100755 index 0000000..58f47b5 --- /dev/null +++ b/rails/import.rb @@ -0,0 +1,333 @@ +#!/usr/bin/env ruby -w + +require 'date' +require 'json' +require 'fusionauth/fusionauth_client' +require 'optparse' +require 'securerandom' +require 'set' + +# Option handling +options = {} + +# Default options +options[:usersfile] = "users.json" +options[:fusionauthurl] = "http://localhost:9011" + +OptionParser.new do |opts| + opts.banner = "Usage: import.rb [options]" + + opts.on("-u", "--users-file USERS_FILE", "The exported JSON user data file from Rails auth systems. Defaults to users.json.") do |file| + options[:usersfile] = file + end + + opts.on("-f", "--fusionauth-url FUSIONAUTH_URL", "The location of the FusionAuth instance. Defaults to http://localhost:9011.") do |fusionauthurl| + options[:fusionauthurl] = fusionauthurl + end + + opts.on("-k", "--fusionauth-api-key API_KEY", "The FusionAuth API key.") do |fusionauthapikey| + options[:fusionauthapikey] = fusionauthapikey + end + + opts.on("-t", "--fusionauth-tenant-id TENANT_ID", "The FusionAuth tenant id. Required if more than one tenant exists.") do |tenantid| + options[:tenantid] = tenantid + end + + opts.on("-s", "--source-system SOURCE", "The source authentication system (devise, rails_auth, omniauth). Auto-detected if not specified.") do |source| + options[:sourcesystem] = source + end + + opts.on("-r", "--register-users APPLICATION_IDS", "A comma separated list of existing application IDs. All users will be registered for these applications.") do |appids| + options[:appids] = appids + end + + opts.on("-l", "--link-social-accounts", "Link social accounts for OmniAuth users after import.") do |linksocial| + options[:linksocial] = true + end + + opts.on("-v", "--verbose", "Enable verbose logging.") do |verbose| + options[:verbose] = true + end + + opts.on("-h", "--help", "Prints this help.") do + puts opts + exit + end +end.parse! + +# Validate required options +if options[:fusionauthapikey].nil? + puts "Error: FusionAuth API key is required. Use -k or --fusionauth-api-key" + exit 1 +end + +users_file = options[:usersfile] +$fusionauth_url = options[:fusionauthurl] +$fusionauth_api_key = options[:fusionauthapikey] +$fusionauth_tenant_id = options[:tenantid] +$verbose = options[:verbose] + +puts "FusionAuth Importer : Rails Authentication Systems" +puts " > User file: #{users_file}" +puts " > FusionAuth URL: #{$fusionauth_url}" +puts " > Tenant ID: #{$fusionauth_tenant_id || 'default'}" +puts "" + +# Identity Provider mappings for social accounts +# # ids pulled from https://github.com/FusionAuth/fusionauth-java-client/blob/master/src/main/java/io/fusionauth/domain/provider/IdentityProviderType.java +IDP_MAPPINGS = { + "google_oauth2" => "82339786-3dff-42a6-aac6-1f1ceecb6c46", # Google +} + +def detect_source_system(user_data) + return nil if user_data.empty? + + first_user = user_data.first + + # Check data field for explicit source system + if first_user['data'] && first_user['data']['source_system'] + return first_user['data']['source_system'] + end + + # Auto-detect based on structure + if first_user['data'] && first_user['data']['oauth_provider'] + return 'omniauth' + elsif first_user['password'] && first_user['encryptionScheme'] == 'bcrypt' + if first_user['fullName'] + return 'rails_auth' + else + return 'devise' + end + end + + return 'unknown' +end + +def log_verbose(message) + puts " > #{message}" if $verbose +end + +def add_additional_registrations(users, app_ids) + return users if app_ids.nil? || app_ids.empty? + + additional_app_ids = app_ids.split(',').map(&:strip) + log_verbose("Adding registrations for applications: #{additional_app_ids.join(', ')}") + + users.each do |user| + # Initialize registrations array if it doesn't exist + user['registrations'] ||= [] + + # Add registrations for additional applications + additional_app_ids.each do |app_id| + # Check if user is already registered for this application + unless user['registrations'].any? { |reg| reg['applicationId'] == app_id } + additional_registration = { + 'id' => SecureRandom.uuid, + 'applicationId' => app_id, + 'verified' => user['verified'] || true, + 'roles' => ['user'] + } + user['registrations'] << additional_registration + log_verbose("Added registration for #{user['email']} to application #{app_id}") + end + end + end + + users +end + +def import_users(users) + puts " > Importing #{users.length} users to FusionAuth..." + + import_request = { + 'users' => users, + 'validateDbConstraints' => false + } + + client = FusionAuth::FusionAuthClient.new($fusionauth_api_key, $fusionauth_url) + client.set_tenant_id($fusionauth_tenant_id) if $fusionauth_tenant_id + + response = client.import_users(import_request) + + if response.was_successful + puts " > Import successful!" + return true + else + puts " > Import failed. Status code: #{response.status}" + puts " > Error response: #{response.error_response}" + return false + end +end + + + +def link_social_accounts(social_users, user_id_mapping) + return if social_users.empty? + + puts " > Linking #{social_users.length} social accounts..." + + client = FusionAuth::FusionAuthClient.new($fusionauth_api_key, $fusionauth_url) + client.set_tenant_id($fusionauth_tenant_id) if $fusionauth_tenant_id + + linked_count = 0 + + social_users.each do |user| + provider = user['data']['oauth_provider'] + oauth_uid = user['data']['oauth_uid'] + email = user['email'] + + # Skip if provider doesn't need linking + next if provider == 'developer' + + identity_provider_id = IDP_MAPPINGS[provider] + unless identity_provider_id + puts " > Warning: No identity provider mapping for #{provider}. Skipping #{email}." + next + end + + # Get the user ID from our mapping + fusionauth_user_id = user_id_mapping[email] + unless fusionauth_user_id + puts " > Warning: User ID not found for #{email}. Skipping link." + next + end + + # Create the link + link_request = { + 'identityProviderId' => identity_provider_id, + 'identityProviderUserId' => oauth_uid, + 'userId' => fusionauth_user_id, + 'displayName' => email + } + + log_verbose("Linking #{email} (#{provider}) to FusionAuth user #{fusionauth_user_id}") + + response = client.create_user_link(link_request) + + if response.was_successful + log_verbose("Successfully linked #{email}") + linked_count += 1 + else + puts " > Failed to link #{email}:" + puts " Status: #{response.status}" + puts " Error: #{response.error_response}" if response.error_response + log_verbose("Link request: #{link_request.to_json}") + end + end + + puts " > Successfully linked #{linked_count} social accounts" +end + +# Main execution +begin + # Read and parse the users file + unless File.exist?(users_file) + puts "Error: Users file '#{users_file}' not found." + exit 1 + end + + file_content = File.read(users_file) + data = JSON.parse(file_content) + + # Extract users array + users_data = data['users'] || data + + if users_data.empty? + puts "Error: No users found in the file." + exit 1 + end + + # Detect source system + source_system = options[:sourcesystem] || detect_source_system(users_data) + puts " > Detected source system: #{source_system}" + puts "" + + # Validate users and collect social accounts + valid_users = [] + social_users = [] + duplicate_emails = [] + emails_seen = Set.new + + users_data.each do |user| + email = user['email'] + + # Check for duplicates + if emails_seen.include?(email) + duplicate_emails << email + next + end + emails_seen.add(email) + + # Generate user ID if not present + user['id'] ||= SecureRandom.uuid + + # Collect social users for later linking + if source_system == 'omniauth' && user['data'] && user['data']['oauth_provider'] + social_users << user + end + + valid_users << user + end + + # Report duplicates + if duplicate_emails.any? + puts " > Warning: Found #{duplicate_emails.length} duplicate emails:" + duplicate_emails.each { |email| puts " - #{email}" } + puts "" + end + + puts " > Processing #{valid_users.length} valid users" + + # Build user ID mapping for social account linking + user_id_mapping = {} + valid_users.each do |user| + user_id_mapping[user['email']] = user['id'] + end + + # Add additional application registrations if specified + if options[:appids] + puts " > Adding additional application registrations..." + valid_users = add_additional_registrations(valid_users, options[:appids]) + end + + # Import users in chunks of 10,000 + chunk_size = 10_000 + total_imported = 0 + + valid_users.each_slice(chunk_size) do |chunk| + if import_users(chunk) + total_imported += chunk.length + else + puts "Error: Import failed for chunk. Stopping." + exit 1 + end + end + + puts "" + puts " > Successfully imported #{total_imported} users" + + # Link social accounts if requested and applicable + if options[:linksocial] && source_system == 'omniauth' && social_users.any? + puts "" + link_social_accounts(social_users, user_id_mapping) + end + + puts "" + puts "Import completed successfully!" + puts " > Total users imported: #{total_imported}" + puts " > Duplicates skipped: #{duplicate_emails.length}" + + if source_system == 'omniauth' && !options[:linksocial] && social_users.any? + puts "" + puts "Note: #{social_users.length} social accounts detected." + puts "Use the --link-social-accounts flag to link them to identity providers." + end + +rescue JSON::ParserError => e + puts "Error: Invalid JSON file. #{e.message}" + exit 1 +rescue => e + puts "Error: #{e.message}" + puts e.backtrace if $verbose + exit 1 +end \ No newline at end of file diff --git a/rails/import.sh b/rails/import.sh new file mode 100755 index 0000000..c66a731 --- /dev/null +++ b/rails/import.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# FusionAuth Import Script Wrapper +# This script makes it easier to run the Ruby import script + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Check if bundle is available +if ! command -v bundle &> /dev/null; then + echo "Error: Bundler is not installed. Please install it with: gem install bundler" + exit 1 +fi + +# Change to the script directory +cd "$SCRIPT_DIR" + +# Check if gems are installed +if [ ! -d ".bundle" ] && [ ! -f "Gemfile.lock" ]; then + echo "Installing required gems..." + bundle install +fi + +# Run the import script with all passed arguments +bundle exec ./import.rb "$@" \ No newline at end of file