Initial commit

This commit is contained in:
Yaser
2026-08-13 19:56:46 +03:30
commit de1d57a67b
474 changed files with 43185 additions and 0 deletions

0
lib/assets/.keep Normal file
View File

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
module Barong
# admin activities log writer class
class ActivityLogger
ACTION = { post: 'create', put: 'update', get: 'read', delete: 'delete', patch: 'update' }.freeze
def self.async_write(options = {})
@activities ||= Queue.new
@activities.push(options)
@thread ||= Thread.new do
begin
loop do
msg = @activities.pop
params = format_params(msg)
Rails.logger.info("Recording activity for user id: #{params[:user_id]}, topic: #{params[:topic]}," \
" action: #{params[:action]}, result: #{params[:result]}, data: #{params[:data]}")
Activity.create(params)
rescue StandardError => e
Rails.logger.error { "Failed to create activity with params: #{params}\n" \
"Inspect error: #{e.inspect}\n#{e.backtrace.join("\n")}" }
# If system catch Mysql2::Error::ConnectionError
# System will reconnect to DB and push message again to the activities queue
if e.is_a? (ActiveRecord::StatementInvalid)
ActiveRecord::Base.connection.reconnect!
sleep(0.1)
@activities.push(options)
end
end
end
end
end
def self.sync_write(options = {})
Activity.create(format_params(options))
end
def self.format_params(params)
topic = params[:topic].nil? && params[:path].split('admin/')[1].nil? ? 'general' : params[:topic] || params[:path].split('admin/')[1].split('/')[0]
{
user_id: params[:user_id],
target_uid: target_user(params[:payload]) || '',
user_ip: params[:user_ip],
user_agent: params[:user_agent],
topic: topic,
action: ACTION[params[:verb].downcase.to_sym] || 'system',
result: params[:result],
category: 'admin',
data: format_payload(params[:payload])
}
end
def self.format_payload(payload)
return unless payload
return payload.to_json unless valid_json?(payload.keys.first)
payload.keys.first
end
def self.target_user(payload)
# in case payload is missing || empty POST body: payload => {"null" => nil }
return if payload.nil? || payload.keys.first == "null"
if valid_json?(payload.keys.first)
payload = JSON.parse(payload.keys.first)
end
payload[:uid] || payload[:user_uid] || payload['uid'] || payload['user_uid']
end
def self.valid_json?(json)
JSON.parse(json)
true
rescue JSON::ParserError => e
false
end
end
end

61
lib/barong/amqp/config.rb Normal file
View File

@@ -0,0 +1,61 @@
# encoding: UTF-8
# frozen_string_literal: true
module AMQP
class Config
class <<self
def data
@data ||= Hashie::Mash.new(
YAML.safe_load(
ERB.new(File.read(Rails.root.join('config', 'amqp.yml'))).result
)
)
end
def connect
data[:connect]
end
def binding_exchange_id(id)
data[:binding][id][:exchange]
end
def binding_exchange(id)
eid = binding_exchange_id(id)
eid && exchange(eid)
end
def binding_queue(id)
queue data[:binding][id][:queue]
end
def binding_worker(id)
::Workers::AMQP.const_get(id.to_s.camelize).new
end
def routing_key(id)
binding_queue(id).first
end
def topics(id)
data[:binding][id][:topics].split(',')
end
def channel(id)
(data[:channel] && data[:channel][id]) || {}
end
def queue(id)
name = data[:queue][id][:name]
settings = { durable: data[:queue][id][:durable] }
[name, settings]
end
def exchange(id)
type = data[:exchange][id][:type]
name = data[:exchange][id][:name]
[type, name]
end
end
end
end

45
lib/barong/amqp/queue.rb Normal file
View File

@@ -0,0 +1,45 @@
# encoding: UTF-8
# frozen_string_literal: true
module AMQP
class Queue
class <<self
def connection
@connection ||= ::Bunny.new(AMQP::Config.connect).tap do |conn|
conn.start
end
end
def channel
@channel ||= connection.create_channel
end
def exchanges
@exchanges ||= { default: channel.default_exchange }
end
def exchange(id)
exchanges[id] ||= channel.send *AMQP::Config.exchange(id)
end
def publish(eid, payload, attrs={})
payload = JSON.dump payload
exchange(eid).publish(payload, attrs)
end
# enqueue = publish to direct exchange
def enqueue(id, payload, attrs={})
eid = ::AMQP::Config.binding_exchange_id(id) || :default
attrs.merge!({routing_key: AMQP::Config.routing_key(id)})
publish(eid, payload, attrs)
end
def enqueue_event(type, id, event, payload, opts={})
routing_key = [type, id, event].join('.')
serialized_data = JSON.dump(payload)
channel.exchange('peatio.events.ranger', type: 'topic').publish(serialized_data, routing_key: routing_key)
end
end
end
end

91
lib/barong/app.rb Normal file
View File

@@ -0,0 +1,91 @@
# frozen_string_literal: true
module Barong
class App
include ActiveSupport::Configurable
class Error < ::StandardError; end
class << self
def define
yield self
end
def set(key, default = nil, options = {})
value = fetch!(key, default)
validate!(key, value, options)
value = type!(key, value, options)
config[key] = value
end
def write(key, value)
config[key] = value
end
private
def fetch!(key, default)
if env(key)
return env(key)
elsif Rails.application.credentials[key]
return Rails.application.credentials[key]
elsif !default.nil?
return default
else
raise Error, "Config #{key} missing" if default.nil?
end
end
def env(key)
ENV['BARONG_' + key.to_s.upcase]
end
def validate!(key, value, options)
regex!(key, value, options[:regex]) if options[:regex]
values!(key, value, options[:values]) if options[:values]
end
def type!(key, value, options)
return value unless options[:type]
case options[:type]
when :array
return value.split(',').map { |v| v.squish }
when :bool
values!(key, value, %w(true false))
return value == 'true'
when :integer
regex!(key, value, /^\d+$/)
return value.to_i
when :path
return Rails.root.join(value).tap { |p| path!(key, p) }
when :regexp
return Regexp.new value
end
end
def path!(key, path)
unless File.exists?(path)
raise Error.new("#{key.to_s.upcase} path is invalid #{path.to_s}")
end
end
def regex!(key, value, regex)
unless regex =~ value
raise Error.new("#{key.to_s.upcase} does not match regex #{regex.inspect}")
end
end
def values!(key, value, values)
unless values.include?(value)
raise Error.new("#{key.to_s.upcase} invalid, enabled values: #{values.to_s}")
end
end
end
end
end

34
lib/barong/auth0/jwt.rb Normal file
View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
module Barong
module Auth0
class JWT
def self.verify(token)
::JWT.decode(token,
nil,
true, # Verify the signature of this token
algorithms: 'RS256',
iss: "https://#{Barong::App.config.auth0_domain}/",
verify_iss: true,
aud: Barong::App.config.auth0_client_id,
verify_aud: true
) do |header|
jwks_hash[header['kid']]
end
end
def self.jwks_hash
uri = "https://#{Barong::App.config.auth0_domain}/.well-known/jwks.json"
jwks_raw = Net::HTTP.get URI(uri)
jwks_keys = Array(JSON.parse(jwks_raw)['keys'])
Hash[jwks_keys.map do |k|
[
k['kid'],
OpenSSL::X509::Certificate.new(Base64.decode64(k['x5c'].first)).public_key
]
end
]
end
end
end
end

259
lib/barong/authorize.rb Normal file
View File

@@ -0,0 +1,259 @@
# frozen_string_literal: true
require 'barong/activity_logger'
module Barong
# AuthZ functionality
class Authorize
STATE_CHANGING_VERBS = %w[POST PUT PATCH DELETE TRACE].freeze
# Custom Error class to support error status and message
class AuthError < StandardError
attr_reader :code
# init an error with status and text to return in api response
def initialize(code)
super
@code = code
end
end
# init base request info, fetch black and white lists
def initialize(request, path)
@request = request
@path = path
@rules = lists['rules']
end
# main: switch between cookie and api key logic, return bearer token
def auth
auth_type = 'cookie'
auth_type = 'api_key' if api_key_headers?
auth_owner = method("#{auth_type}_owner").call
'Bearer ' + codec.encode(auth_owner.as_payload) # encoded user info
end
# cookies validations
def cookie_owner
validate_csrf!
error!({ errors: ['authz.invalid_session'] }, 401) unless session[:uid]
user = User.find_by!(uid: session[:uid])
Rails.logger.debug "User #{user} authorization via cookies"
validate_session!
unless user.state.in?(%w[active pending])
error!({ errors: ['authz.user_not_active'] }, 401)
end
validate_permissions!(user)
user # returns user(whose session is inside cookie)
end
def validate_session!
unless @request.env['HTTP_USER_AGENT'] == session[:user_agent] &&
Time.now.to_i < session[:expire_time] &&
find_ip.include?(remote_ip)
session.destroy
Rails.logger.debug("Session mismatch! Valid session is: { agent: #{session[:user_agent]}," \
" expire_time: #{session[:expire_time]}, ip: #{session[:user_ip]} }," \
" but request contains: { agent: #{@request.env['HTTP_USER_AGENT']}, ip: #{remote_ip} }")
error!({ errors: ['authz.client_session_mismatch'] }, 401)
end
session[:expire_time] = Time.now.to_i + Barong::App.config.session_expire_time
end
def find_ip
ip_addr = IPAddr.new(session[:user_ip])
if ip_addr.ipv4?
ip_addr.mask(16)
else
ip_addr.mask(96)
end
end
# api key validations
def api_key_owner
api_key = APIKeysVerifier.new(api_key_params)
# validate that nonce is a positive integer
error!({ errors: ['authz.nonce_not_valid_timestamp'] }, 401) if api_key_params[:nonce].to_i <= 0
# timestamp_window is a difference between server_time and nonce creation time
nonce_timestamp_window = ((Time.now.to_f * 1000).to_i - api_key_params[:nonce].to_i).abs
Rails.logger.debug("Api key authorization via key: #{api_key_params[:kid]} to path #{@path} \
with nonce: #{api_key_params[:nonce]} in a window of #{nonce_timestamp_window}")
# (server_time - nonce) should not be more than nonce lifetime
error!({ errors: ['authz.nonce_expired'] }, 401) if nonce_timestamp_window >= Barong::App.config.apikey_nonce_lifetime
# signature should be valid
error!({ errors: ['authz.invalid_signature'] }, 401) unless api_key.verify_hmac_payload?
current_api_key = APIKey.find_by_kid(api_key_params[:kid])
# corresponding Api Key should be active
error!({ errors: ['authz.apikey_not_active'] }, 401) unless current_api_key.active?
# here User is either User object or ServiceAccount object
user = current_api_key.key_holder_account
validate_user!(user)
validate_permissions!(user)
user # returns user(api key creator)
rescue ActiveRecord::RecordNotFound
error!({ errors: ['authz.unexistent_apikey'] }, 401)
end
def validate_csrf!
return unless Barong::App.config.csrf_protection && @request.env['REQUEST_METHOD'].in?(STATE_CHANGING_VERBS)
unless headers['X-CSRF-Token']
Rails.logger.info("CSRF attack warning! Missing token for uid: #{session[:uid]} in request to #{@path} by #{@request.env['REQUEST_METHOD']}")
error!({ errors: ['authz.missing_csrf_token'] }, 401)
end
unless headers['X-CSRF-Token'] == session[:csrf_token]
Rails.logger.info("CSRF attack warning! Token is not valid for uid: #{session[:uid]} in request to #{@path} by #{@request.env['REQUEST_METHOD']}")
error!({ errors: ['authz.csrf_token_mismatch'] }, 401)
end
end
def validate_permissions!(user)
# Caches Permission.all result to optimize
permissions = Rails.cache.fetch('permissions', expires_in: 5.minutes) { Permission.all.to_ary }
permissions.select! { |a| a.role == user.role && ( a.verb == @request.env['REQUEST_METHOD'] || a.verb == 'ALL' ) && @path.starts_with?(a.path) }
actions = permissions.blank? ? [] : permissions.pluck(:action).uniq
if permissions.blank? || actions.include?('DROP') || !actions.include?('ACCEPT')
log_activity(user.id, 'denied') if user.is_a?(User)
error!({ errors: ['authz.invalid_permission'] }, 401)
end
if actions.include?('AUDIT')
topic = permissions.select { |a| a.action == 'AUDIT' }[0].topic
log_activity(user.id, 'succeed', topic) if user.is_a?(User)
end
end
def log_activity(user_id, result, topic = nil)
if Rails.env.test?
ActivityLogger.sync_write(activity_params(user_id, result, topic))
else
ActivityLogger.async_write(activity_params(user_id, result, topic))
end
end
def activity_params(user_id, result, topic)
{
user_id: user_id,
result: result,
user_agent: @request.env['HTTP_USER_AGENT'],
user_ip: remote_ip,
path: @path,
topic: topic,
verb: @request.env['REQUEST_METHOD'],
payload: @request.params
}
end
# black/white list validation. takes ['block', 'pass'] as a parameter
def under_path_rules?(type)
return false if @rules[type].nil? # if no authz rules provided
@rules[type].each do |t|
return true if @path.starts_with?(t) # if request path is inside the rules list
end
false # default
end
def remote_ip
# default behaviour, IP from HTTP_X_FORWARDED_FOR
ip = @request.remote_ip
if Barong::App.config.gateway == 'akamai'
# custom header that contains only client IP
true_client_ip = @request.env['HTTP_TRUE_CLIENT_IP']
# take IP from TRUE_CLIENT_IP only if its not nil or empty
ip = true_client_ip unless true_client_ip.nil? || true_client_ip.empty?
end
return ip
end
private
# encode helper method
def codec
@_codec ||= Barong::JWT.new(key: Barong::App.config.keystore.private_key)
end
# fetch authz rules from yml
def lists
YAML.safe_load(
ERB.new(
File.read(
Barong::App.config.authz_rules_file
)
).result
)
end
# checks if api key headers are present in request
def api_key_headers?
return false if headers['X-Auth-Apikey'].nil? &&
headers['X-Auth-Nonce'].nil? &&
headers['X-Auth-Signature'].nil?
@api_key_headers = [headers['X-Auth-Apikey'], headers['X-Auth-Nonce'], headers['X-Auth-Signature']]
validate_headers?
end
def validate_user!(user)
unless user.state.in?(%w[active pending])
error!({ errors: ['authz.invalid_session'] }, 401)
end
return if skip_api_key_2fa?
if user.is_a?(User) && !user.otp
error!({ errors: ['authz.disabled_2fa'] }, 401)
end
end
def skip_api_key_2fa?
ENV['BARONG_SKIP_API_KEY_2FA'] == 'true'
end
# api key headers nil, blank validation
def validate_headers?
@api_key_headers.each do |k|
error!({ errors: ['authz.invalid_api_key_headers'] }, 422) if k.blank?
end
end
# converts header into hash of parameters
def api_key_params
{
'kid': headers['X-Auth-Apikey'],
'nonce': headers['X-Auth-Nonce'],
'signature': headers['X-Auth-Signature']
}
end
# custom error, calls AuthError class
def error!(text, code)
Rails.logger.debug "Error raised with code #{code} and error message #{text.to_json}"
raise AuthError.new(code), text.to_json
end
def headers
@request.headers
end
def session
@request.session
end
end
end

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
# Provides CORS variables validation.
module Barong
module CORS
# Main CORS policy logic
module Validations
Error = Class.new(StandardError)
class << self
def validate_origins(origins)
origins.split(',').each_with_object([]) do |origin, domains|
if origin == '*'
Rails.logger.info { "WARNING: API_CORS_ORIGIN is set to '*'" }
return '*'
elsif origin.match? %r{https?:\/\/([a-zA-Z0-9]+)(\.[a-zA-Z0-9]+)*(:^[0-9]*$+)?}
domains << origin
else
raise CORS::Validations::Error, "Set right origin domain name instead of #{origin}"
end
end
end
def validate_max_age(max_age)
if max_age.present? && max_age.match?(/^[0-9]*$/)
max_age
else
Rails.logger.info { 'WARNING: Incorect or missing API_CORS_MAX_AGE value. Using default value: 3600' }
'3600'
end
end
end
end
end
end

235
lib/barong/event_api.rb Normal file
View File

@@ -0,0 +1,235 @@
# frozen_string_literal: true
require 'active_support/concern'
require 'active_support/lazy_load_hooks'
# EventAPI provides interface to platform-wide notifications in RabbitMQ.
#
# Check docs/specs/event_api.md for more details.
module EventAPI
MAPPING = {
'sign-up' => 'system.user.email.confirmation.code',
'reset-password' => 'system.user.password.reset.token',
'enable-otp' => 'system.user.email.otp.enable',
'disable-otp' => 'system.user.email.otp.disable',
'change-password' => 'system.user.password.confirmation.code',
}.freeze
class << self
def notify(event_name, event_payload)
event_name = MAPPING.dig(event_name).present? ? MAPPING.dig(event_name) : event_name
raise('Dalan mailer event name is unknown') unless event_name.present?
arguments = [event_name, event_payload]
middlewares.each do |middleware|
returned_value = middleware.call(*arguments)
case returned_value
when Array then arguments = returned_value
else return returned_value
end
rescue StandardError => e
report_exception(e)
raise
end
end
def middlewares=(list)
@middlewares = list
end
def middlewares
@middlewares ||= []
end
end
module ActiveRecord
class Mediator
attr_reader :record
def initialize(record)
@record = record
end
def notify(partial_event_name, event_payload)
tokens = ['model']
tokens << record.class.event_api_settings.fetch(:prefix) { record.class.name.underscore.gsub(/\//, '_') }
tokens << partial_event_name.to_s
full_event_name = tokens.join('.')
::EventAPI.notify(full_event_name, event_payload)
end
def notify_record_created
notify(:created, record: record.as_json_for_event_api.compact)
end
def notify_record_updated
return if record.previous_changes.blank?
current_record = record
previous_record = record.dup
record.previous_changes.each { |attribute, values| previous_record.send("#{attribute}=", values.first) }
# Guarantee timestamps.
previous_record.created_at ||= current_record.created_at
previous_record.updated_at ||= current_record.created_at
after = current_record.as_json_for_event_api.compact
before = previous_record.as_json_for_event_api.compact.delete_if { |atr, val| after[atr] == val }
notify :updated, \
record: after,
changes: before.except(:updated_at)
end
end
module Extension
extend ActiveSupport::Concern
included do
# We add «after_commit» callbacks immediately after inclusion.
%i[create update].each do |event|
after_commit on: event, prepend: true do
if self.class.event_api_settings[:on]&.include?(event)
event_api.public_send("notify_record_#{event}d")
end
end
end
end
module ClassMethods
def acts_as_eventable(settings = {})
settings[:on] = %i[create update] unless settings.key?(:on)
@event_api_settings = event_api_settings.merge(settings)
end
def event_api_settings
@event_api_settings || superclass.instance_variable_get(:@event_api_settings) || {}
end
end
def event_api
@event_api ||= Mediator.new(self)
end
def as_json_for_event_api
as_json
end
end
end
# To continue processing by further middlewares return array with event name and payload.
# To stop processing event return any value which isn't an array.
module Middlewares
class << self
def application_name
Rails.application.class.name.split('::').first.underscore
end
def application_version
"#{application_name.camelize}::VERSION".constantize
end
end
class IncludeEventMetadata
def call(event_name, event_payload)
event_payload[:name] = event_name
[event_name, event_payload]
end
end
class GenerateJWT
def call(event_name, event_payload)
jwt_payload = {
iss: Middlewares.application_name,
jti: SecureRandom.uuid,
iat: Time.now.to_i,
exp: (Time.now + 1.hour).to_i,
event: event_payload
}
private_key = Barong::App.config.keystore.private_key
algorithm = 'RS256'
jwt = JWT::Multisig.generate_jwt jwt_payload, \
{ Middlewares.application_name.to_sym => private_key },
{ Middlewares.application_name.to_sym => algorithm }
[event_name, jwt]
rescue KeyError
raise 'No EVENT_API_JWT_PRIVATE_KEY found in env!'
end
end
class PrintToScreen
def call(event_name, event_payload)
Rails.logger.debug do
['',
'Produced new event at ' + Time.current.to_s + ': ',
'name = ' + event_name,
'payload = ' + event_payload.to_json,
''].join("\n")
end
[event_name, event_payload]
end
end
class PublishToRabbitMQ
extend Memoist
def call(event_name, event_payload)
Rails.logger.debug do
"\nPublishing #{routing_key(event_name)} (routing key) to #{exchange_name(event_name)} (exchange name).\n"
end
exchange = bunny_exchange(exchange_name(event_name))
exchange.publish(event_payload.to_json, routing_key: routing_key(event_name))
[event_name, event_payload]
end
private
def bunny_session
Bunny::Session.new(rabbitmq_credentials).tap do |session|
session.start
Kernel.at_exit { session.stop }
end
end
memoize :bunny_session
def bunny_channel
bunny_session.channel
end
memoize :bunny_channel
def bunny_exchange(name)
bunny_channel.direct(name)
end
memoize :bunny_exchange
def rabbitmq_credentials
return ENV['EVENT_API_RABBITMQ_URL'] if ENV['EVENT_API_RABBITMQ_URL'].present?
{
host: Barong::App.config.event_api_rabbitmq_host,
port: Barong::App.config.event_api_rabbitmq_port,
username: Barong::App.config.event_api_rabbitmq_username,
password: Barong::App.config.event_api_rabbitmq_password
}
end
def exchange_name(event_name)
"#{Middlewares.application_name}.events.#{event_name.split('.').first}"
end
def routing_key(event_name)
event_name.split('.').drop(1).join('.')
end
end
end
middlewares << Middlewares::IncludeEventMetadata.new
middlewares << Middlewares::GenerateJWT.new
middlewares << Middlewares::PrintToScreen.new
middlewares << Middlewares::PublishToRabbitMQ.new
end

29
lib/barong/geo_ip.rb Normal file
View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
module Barong
# MaxmindDB reader adapter
module GeoIP
class << self
attr_accessor :lang
# Usage: city = Barong::GeoIP.get(ip: ip, key: :city)
def info(ip:, key:)
record = reader.get(ip)
return unless record
case key.to_sym
when :country
return record['country']['names'][lang] if record['country']
when :continent
return record['continent']['names'][lang] if record['continent']
end
end
private
def reader
@reader ||= MaxMind::DB.new(Barong::App.config.maxminddb_path, mode: MaxMind::DB::MODE_MEMORY)
end
end
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
# json formatter for logs
class JSONLogFormatter < ::Logger::Formatter
def call(severity, time, _progname, msg)
JSON.dump(level: severity, time: time, message: msg) + "\n"
end
end

50
lib/barong/jwt.rb Normal file
View File

@@ -0,0 +1,50 @@
module Barong
class JWT
def initialize(options)
raise "Missing private key" unless options[:key]
@options = options.reverse_merge({
algoritm: 'RS256',
expire: Barong::App.config.jwt_expire_time,
sub: 'session',
iss: 'barong',
aud: %w[peatio barong]
})
end
def encode(payload)
::JWT.encode(merge_claims(payload),
@options[:key], @options[:algoritm])
end
def decode_and_verify(token, verify_options)
@verify_options = verify_options.reverse_merge({
verify_expiration: true,
verify_not_before: true,
iss: 'barong',
verify_iss: true,
verify_iat: true,
verify_jti: true,
aud: %w[peatio barong],
verify_aud: true,
sub: 'confirmation',
verify_sub: true,
algorithms: 'RS256'
})
payload, header = ::JWT.decode(token, @verify_options[:pub_key], true, @verify_options)
payload.keys.each { |k| payload[k.to_sym] = payload.delete(k) }
payload
end
def merge_claims(payload)
payload.reverse_merge({
iat: Time.now.to_i,
exp: (Time.now + @options[:expire]).to_i,
sub: @options[:sub],
iss: @options[:iss],
aud: @options[:aud],
jti: SecureRandom.hex(10)
})
end
end
end

49
lib/barong/keystore.rb Normal file
View File

@@ -0,0 +1,49 @@
# frozen_string_literal: true
module Barong
class KeyStore
class Fatal < StandardError; end
def initialize(private_key)
OpenSSL::PKey.read(private_key).tap do |key|
@private_key = key
@public_key = key.public_key
end
end
def public_key
@public_key
end
def private_key
@private_key
end
class << self
def open!(private_key_path)
pkeyio = File.open(private_key_path)
return OpenSSL::PKey.read(pkeyio).to_pem
rescue
raise Barong::KeyStore::Fatal
end
def read!(private_key)
return OpenSSL::PKey.read(private_key).to_pem
rescue
raise Barong::KeyStore::Fatal
end
def save!(key, path)
File.open(path, 'w+') { |file| file.write(key) }
rescue
raise Barong::KeyStore::Fatal
end
def generate
OpenSSL::PKey::RSA.generate(2048)
end
end
end
end

8
lib/barong/middleware.rb Normal file
View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
require 'barong/middleware/jwt_authenticator'
module Barong
# Barong Rack middlewares
module Middleware; end
end

View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
module Barong
module Middleware
# Authenticate a user by a bearer token
class JWTAuthenticator < Grape::Middleware::Base
def initialize(app, options)
super(app, options)
raise(Peatio::Auth::Error, 'Public key missing') unless options[:pubkey]
@keypub = options[:pubkey]
end
def before
return if request.path.include? 'swagger'
raise(Peatio::Auth::Error, 'Header Authorization missing') \
unless authorization_present?
token = request.headers['Authorization']
env[:current_payload] = authenticator.authenticate!(token)
end
private
# JWT Authenticator instance from peatio-core
#
# @return [Peatio::Auth::JWTAuthenticator]
def authenticator
@authenticator ||=
Peatio::Auth::JWTAuthenticator.new(@keypub)
end
def authorization_present?
request.headers.key?('Authorization')
end
# Request entity
#
# @return [Grape::Request]
def request
@request ||= Grape::Request.new(env)
end
end
end
end

19
lib/barong/mock_sms.rb Normal file
View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Barong
# empty sms service
class MockSMS
cattr_accessor :messages
self.messages = []
def initialize(_account_sid, _auth_token) end
def messages
self
end
def create(params)
self.class.messages << OpenStruct.new(params)
end
end
end

191
lib/barong/seed.rb Normal file
View File

@@ -0,0 +1,191 @@
module Barong
class Seed
class ConfigError < RuntimeError; end
def initialize
@result = []
end
def seeds
YAML.safe_load(
ERB.new(
File.read(
Barong::App.config.seeds_file
)
).result
)
end
def inspect
str = "Seeded users:\n"
str += @result.map do |user|
"Email: #{user[:email]}, password: #{user[:password]}"
end.join("\n")
return str
end
def logger
@logger ||= Logger.new(STDERR, progname: "db:seed")
end
def seed_provinces
logger.info "Seeding provinces"
seeds["provinces"].each_with_index do |province, index|
logger.info "---"
if Province.find_by(name: province["name"]).present?
logger.info "province '#{province['name']}' already exists"
next
end
province[:id] = index+1
Province.create!(province)
end
end
def seed_cities
logger.info "Seeding cities"
seeds["cities"].each_with_index do |city, index|
logger.info "---"
if City.find_by(name: city["name"]).present?
logger.info "province '#{city['name']}' already exists"
next
end
city[:id] = index+1
City.create!(city)
end
end
def seed_levels
logger.info "Seeding levels"
seeds["levels"].each_with_index do |level, index|
logger.info "---"
if Level.find_by(key: level["key"], value: level["value"]).present?
logger.info "Level '#{level['key']}:#{level['value']}' already exists"
next
end
level[:id] = index+1
Level.create!(level)
end
end
def seed_permissions
logger.info "Seeding permissions"
seeds["permissions"].each do |perm|
logger.info "---"
if Permission.find_by(role: perm["role"], verb: perm["verb"], path: perm["path"], action: perm["action"]).present?
logger.info "Permission for '#{perm['role']}' : '#{perm['verb']} to #{perm['path']}' already exists"
next
end
permission = Permission.new(perm)
unless permission.save
raise ConfigError.new("Can't create permission: #{permission.errors.full_messages.join('; ')}")
end
end
end
def seed_users
logger.info "Seeding users"
seeds["users"]&.each do |seed|
logger.info "---"
raise ConfigError.new("Email missing in users seed") if seed["email"].to_s.empty?
raise ConfigError.new("Level is missing for user #{seed["email"]}") unless seed["level"].is_a?(Integer)
# Skip existing users
if User.find_by(email: seed["email"]).present?
logger.info "User '#{seed['email']}' already exists"
@result.push(email: seed["email"])
next
end
user = User.new(seed)
user.password ||= SecureRandom.base64(30)
if user.save
logger.info "Created user for '#{user.email}'"
# Set correct level with labels
# levels = Level.where(id: 1..user.level)
levels = Level.where(id: 1..Level::LEVEL_ID_BOUNDS[user.level.to_s])
raise ConfigError.new("No enough levels found in database to grant the user to level #{user.level}") if levels.count < user.level
levels.find_each do |level|
user.labels.create(key: level.key, value: level.value, scope: 'private')
end
@result.push(email: user.email, password: user.password, level: user.level)
else
logger.error "Can't create user '#{user.email}': #{user.errors.full_messages.join('; ')}"
end
end
end
def seed_superadmin
logger.info "Seeding superadmin"
seeds["superadmin"]&.each do |seed|
logger.info "---"
raise ConfigError.new("Email missing in users seed") if seed["email"].to_s.empty?
raise ConfigError.new("Level is missing for user #{seed["email"]}") unless seed["level"].is_a?(Integer)
# Skip existing users
if User.find_by(email: seed["email"]).present?
logger.info "User '#{seed['email']}' already exists"
@result.push(email: seed["email"])
next
end
user = User.new(seed)
user.password ||= SecureRandom.base64(30)
if user.save
logger.info "Created user for '#{user.email}'"
# Set correct level with labels
# levels = Level.where(id: 1..user.level)
levels = Level.where(id: 1..Level::LEVEL_ID_BOUNDS[user.level.to_s])
raise ConfigError.new("No enough levels found in database to grant the user to level #{user.level}") if levels.count < user.level
levels.find_each do |level|
user.labels.create(key: level.key, value: level.value, scope: 'private')
end
@result.push(email: user.email, password: user.password, level: user.level)
else
logger.error "Can't create user '#{user.email}': #{user.errors.full_messages.join('; ')}"
end
end
end
def seed_restrictions
logger.info "Seeding restrictions"
return logger.info "Restrictions seed is empty!" if seeds["restrictions"].empty?
seeds["restrictions"].each do |seed|
logger.info "---"
if Restriction.find_by(category: seed["category"], scope: seed["scope"], value: seed["value"], state: seed["state"]).present?
logger.info "Restriction '#{seed['category']}' '#{seed['scope']} #{seed['value']}' #{seed['state']}' already exists"
next
end
raise ConfigError.new("category missing in restrictions seed") if seed["category"].nil?
raise ConfigError.new("scope is missing in restrictions seed") if seed["scope"].nil?
raise ConfigError.new("value is missing in restrictions seed") if seed["value"].nil?
raise ConfigError.new("state is missing in restrictions seed") if seed["state"].nil?
restriction = Restriction.new(
category: seed["category"],
scope: seed["scope"],
value: seed["value"],
state: seed["state"],
code: seed["code"]
)
unless restriction.save
raise ConfigError.new("Can't create restriction: #{restriction.errors.full_messages.join('; ')}")
end
end
end
end
end

49
lib/barong/string.rb Normal file
View File

@@ -0,0 +1,49 @@
class String
CHARACTER_MAPPING = {
'ك' => 'ک',
'دِ' => 'د',
'بِ' => 'ب',
'زِ' => 'ز',
'ذِ' => 'ذ',
'شِ' => 'ش',
'سِ' => 'س',
'ى' => 'ی',
'ي' => 'ی',
'١' => '۱',
'٢' => '۲',
'٣' => '۳',
'٤' => '۴',
'٥' => '۵',
'٦' => '۶',
'٧' => '۷',
'٨' => '۸',
'٩' => '۹',
'٠' => '۰'
}.freeze
def to_persian
gsub(Regexp.union(CHARACTER_MAPPING.keys), CHARACTER_MAPPING)
end
def percent_match(mold)
mold = mold.to_s.to_persian
str = self.to_persian
char_cost = (100.to_f / length).round(2)
matching = 0
str_index = 0
mold_index = 0
str.each_char.with_index do |_char, index|
break if mold[mold_index].blank?
if mold[mold_index] != str[str_index]
mold_index = index - 1 if mold.length < str.length
str_index = index - 1 if mold.length > str.length
else
matching += char_cost
end
str_index += 1
mold_index += 1
end
matching.round
end
end

27
lib/tasks/activities.rake Normal file
View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
namespace :activities do
desc "Delete activities for specific period. From and To parameters should be on next format 'YYYY-mm-dd'"
task :delete, [:from, :to] => [:environment] do |_, args|
if args[:from].present? && args[:to].present?
if valid_date?(args[:from]) && valid_date?(args[:to])
activities = Activity.where('DATE(created_at) >= ? AND DATE(created_at) <= ?', args[:from].to_date, args[:to].to_date)
puts "Found #{activities.count} activities to delete"
activities.delete_all
end
else
puts "There is no parameters (from, to)"
end
end
def valid_date?(date)
Date.parse(date)
true
rescue ArgumentError
puts "Invalid date #{date}, should be 'YYYY-mm-dd''"
false
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
# NOTE: only doing this in development as some production environments (Heroku)
# NOTE: are sensitive to local FS writes, and besides -- it's just not proper
# NOTE: to have a dev-mode tool do its thing in production.
if Rails.env.development? && defined?(Annotate)
task :set_annotation_options do
# You can override any of these by setting an environment variable of the
# same name.
Annotate.set_defaults \
"routes" => "false",
"position_in_routes" => "before",
"position_in_class" => "after",
"position_in_test" => "before",
"position_in_fixture" => "before",
"position_in_factory" => "before",
"position_in_serializer" => "before",
"show_foreign_keys" => "true",
"show_complete_foreign_keys" => "true",
"show_indexes" => "true",
"simple_indexes" => "false",
"model_dir" => "app/models",
"root_dir" => "",
"include_version" => "true",
"require" => "",
"exclude_tests" => "true",
"exclude_fixtures" => "true",
"exclude_factories" => "true",
"exclude_serializers" => "true",
"exclude_scaffolds" => "true",
"exclude_controllers" => "true",
"exclude_helpers" => "true",
"exclude_sti_subclasses" => "false",
"ignore_model_sub_dir" => "false",
"ignore_columns" => nil,
"ignore_routes" => nil,
"ignore_unknown_models" => "false",
"hide_limit_column_types" => "",
"hide_default_column_types" => "",
"skip_on_db_migrate" => "false",
"format_bare" => "true",
"format_rdoc" => "false",
"format_markdown" => "false",
"sort" => "false",
"force" => "false",
"trace" => "false",
"wrapper_open" => nil,
"wrapper_close" => nil,
"with_comment" => true
end
Annotate.load_tasks
end

8
lib/tasks/docker.rake Normal file
View File

@@ -0,0 +1,8 @@
namespace 'docker' do
desc "Build Application container"
task :build do
sh "docker build -t quay.io/openware/barong2:latest ."
end
end

29
lib/tasks/export.rake Normal file
View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
require 'yaml'
require 'csv'
namespace :export do
desc 'Export all users to csv file.'
task users: :environment do
count = 0
errors_count = 0
begin
CSV.open('barong_users.csv', 'w') do |csv|
csv << %w[uid email level role state]
User.find_each do |user|
csv << [user.uid, user.email, user.level, user.role, user.state]
count += 1
end
rescue StandardError => e
message = { error: e.message, email: user.email, uid: user.uid }
Rails.logger.error message
errors_count += 1
end
end
Kernel.puts "Exported #{count} users"
Kernel.puts "Errored #{errors_count}"
end
end

42
lib/tasks/generate.rake Normal file
View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
# To execute with optional user and api keys amount
# rake generate:users -- --n=1000
namespace 'generate' do
desc 'Build Application container'
task :users => :environment do
### Parse options
options = {}
o = OptionParser.new
o.banner = 'Usage: rake generate:users [-n=100]'
o.on('-n NUMBER', '--number NUMBER') { |num| options[:num] = num.to_i }
args = o.order!(ARGV) {}
o.parse!(args)
options[:num] = 10 if options[:num].nil?
result_arr = []
options[:num].times do |i|
email = Faker::Internet.email
passwd = Faker::Internet.password(min_length: 10, special_characters: true)
u = User.create(email: email, password: passwd, level: 3, state: 'active')
api_key = APIKey.create(user_id: u.id, kid: SecureRandom.hex(8), algorithm: 'HS256')
secret = SecureRandom.hex(16)
SecretStorage.store_secret(secret, api_key.kid)
result_arr.push({
'uid' => u.uid,
'email' => u.email,
'password' => passwd,
'kid' => api_key.kid,
'secret' => secret
})
end
File.write('tmp/payload.yml', result_arr.to_yaml)
end
end

71
lib/tasks/import.rake Normal file
View File

@@ -0,0 +1,71 @@
#frozen_string_literal: true
require 'csv'
namespace :import do
# Detailed instruction https://github.com/rubykube/barong/blob/master/docs/tasks/import.md
# Required fields for import users:
# - uid
# - email
#
# Usage:
# For import users: -> bundle exec rake import:users['file_name.csv']
desc 'Load users from csv file.'
task :users, [:config_load_path] => [:environment] do |_, args|
csv_table = File.read(Rails.root.join(args[:config_load_path]))
import_user_log = File.open('./log/import_users.log', 'w')
count = 0
CSV.parse(csv_table, :headers => true).map do |row|
row = row.to_h.compact.symbolize_keys!
defaults = { level: 0, role: 'member', state: 'pending', password: generate_password }
permitted_attr = %i[uid email role state]
User.new(row.slice(*permitted_attr)
.reverse_merge(defaults))
.save!
count += 1
rescue StandardError => e
message = { error: e.message, email: row[:email], uid: row[:uid] }
import_user_log.write(message.to_yaml + "\n")
end
import_user_log.close
Kernel.puts "Created #{count} members"
Kernel.puts "Errored #{CSV.parse(csv_table, headers: true).count - count}"
end
# Required fields for import referrals:
# - uid
# - referral_uid
#
# Usage:
# For import users: -> bundle exec rake import:fill_referals['file_name.csv']
desc 'users referral relation from csv file'
task :fill_referrals, [:config_load_path] => [:environment] do |_, args|
csv_table = File.read(Rails.root.join(args[:config_load_path]))
import_affiliates_log = File.open('./log/import_referrals.log', 'w')
count = 0
CSV.parse(csv_table, :headers => true).map do |row|
row = row.to_h.compact.symbolize_keys!
user = User.find_by_uid!(row[:uid])
target_user = User.find_by_uid(row[:referral_uid])
next unless target_user
user.update!(referral_id: target_user.id)
count += 1
rescue StandardError => e
message = { error: e.message, email: row[:email], uid: row[:uid], referral_uid: row[:referral_uid] }
import_affiliates_log.write(message.to_yaml + "\n")
end
import_affiliates_log.close
Kernel.puts "Created #{count} referrals"
end
def generate_password
chars = ('a'..'z').to_a
numbers = ('0'..'9').to_a
special = %w[! @ # $ % & / ( ) + ? *]
chars.sort_by { rand }.join[0..3] + chars.sort_by { rand }.join[0..3].upcase + numbers.sort_by { rand }.join[0..3] + special.sort_by { rand }.join[0..3]
end
end

23
lib/tasks/mailer.rake Normal file
View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'ostruct'
namespace 'mailer' do
desc 'Generate email to the user with UID'
task :send, [:uid] => [:environment] do |_t, args|
record = OpenStruct.new
record.user = User.find_by(uid: args[:uid])
record.domain = 'barong.io'
record.token = 'TEST_TOKEN'
params = {
subject: 'Test Message',
template_name: 'email_confirmation.en.html.erb',
record: record,
changes: nil,
user: User.first
}
Postmaster.process_payload(params).deliver_now
end
end

46
lib/tasks/migrate.rake Normal file
View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
namespace 'migrate' do
desc 'Migrate api keys from vault secrets to transit'
task "26-api-keys": [:environment] do
puts 'Migrating API keys from secrets to transit keys (new in 2.6)'
APIKey.find_in_batches do |keys|
keys.each do |key|
legacy_key_path = "secret/barong/api_key/#{key.kid}"
secret = TOTPService.read_data(legacy_key_path)
next unless secret
value = secret.data[:value]
next unless value
key.secret = value
key.save!
TOTPService.delete_data(legacy_key_path)
end
end
end
desc 'Move TOTP secrets to include the vault application as prefix'
task "26-totp": [:environment] do
puts 'Moving TOTP secrets to include the vault application as prefix (new in 2.6)'
User.find_in_batches do |users|
users.each do |u|
legacy_key_path = "totp/keys/#{u.uid}"
legacy_export_path = "totp/export/#{u.uid}"
next unless TOTPService.read_data(legacy_key_path)
params = TOTPService.read_data(legacy_export_path)
unless params
raise "Failed to export key (#{legacy_export_path})\n"\
"Make sure vault is running with this image: quay.io/openware/vault:1.5.3-openware\n"\
'And make sure the token has read access to /totp/export/*'
end
key_path = TOTPService.totp_key(u.uid)
TOTPService.write_data(key_path, params.data.merge(generate: false))
TOTPService.delete_data(legacy_key_path)
end
end
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
namespace :permissions do
desc 'Reload user permissions'
task reload: :environment do
Permission.transaction do
# Delete old permissions
Permission.delete_all
# Clean permissions cache
Rails.cache.delete('permissions')
# Load permissions
Barong::Seed.new.seed_permissions
end
end
end

24
lib/tasks/rotate.rake Normal file
View File

@@ -0,0 +1,24 @@
#frozen_string_literal: true
namespace :rotate do
desc 'Rotate profile keys'
task profiles: :environment do
Profile.find_each(batch_size: 100) do |profile|
profile.update({})
end
end
desc 'Rotate phones keys'
task phones: :environment do
Phone.find_each(batch_size: 100) do |phone|
phone.update({})
end
end
desc 'Rotate documents keys'
task documents: :environment do
Document.where.not(doc_number_encrypted: nil).find_each(batch_size: 100) do |document|
document.update({})
end
end
end

52
lib/tasks/superadmin.rake Normal file
View File

@@ -0,0 +1,52 @@
# frozen_string_literal: true
namespace :superadmin do
desc "create superadmin"
task create: :environment do
Kernel.puts "Seeding superadmin"
Barong::Seed.new().seeds["superadmin"]&.each do |seed|
Kernel.puts "---"
raise ConfigError.new("Email missing in users seed") if seed["email"].to_s.empty?
raise ConfigError.new("Level is missing for user #{seed["email"]}") unless seed["level"].is_a?(Integer)
# Skip existing users
if User.find_by(email: seed["email"], state: seed['state']).present?
Kernel.puts "User '#{seed['email']}' already exists"
next
elsif User.find_by(email: seed["email"]).present?
User.find_by(email: seed["email"]).update(state: seed['state'])
next
end
user = User.new(seed)
user.password ||= SecureRandom.base64(30)
if user.save
Kernel.puts "Created user for '#{user.email}'"
# Set correct level with labels
# levels = Level.where(id: 1..user.level)
levels = Level.where(id: 1..Level::LEVEL_ID_BOUNDS[user.level.to_s])
raise ConfigError.new("No enough levels found in database to grant the user to level #{user.level}") if levels.count < user.level
levels.find_each do |level|
user.labels.create(key: level.key, value: level.value, scope: 'private')
end
else
Kernel.puts "Can't create user '#{user.email}': #{user.errors.full_messages.join('; ')}"
end
end
end
task delete: :environment do
Kernel.puts "deleting superadmin"
Barong::Seed.new().seeds["superadmin"]&.each do |seed|
user = User.find_by(email: seed['email'])
user.update(state: 'inactive')
end
end
end