[add] initial commit

This commit is contained in:
2021-02-24 03:22:52 -05:00
commit c8c91b21d9
57 changed files with 2707 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
class ApplicationController < ActionController::API
end

View File

@@ -0,0 +1,220 @@
# frozen_string_literal: true
require 'resolv'
class ClientsController < ApplicationController
before_action :parse_api_key_header, except: [:show_public_ipv4]
before_action :authenticate_client, except: [:show_public_ipv4]
before_action :authorize_perm_r, only: [:show_ipv4]
before_action :authorize_perm_w, only: %i[update_ipv4 destroy_ipv4]
before_action :authorize_perm_x, only: %i[create update index show destroy]
before_action :client_authorized, except: [:show_public_ipv4]
# URL Methods
# POST /admin/client
def create
client_params = params.permit(:name, :description, :permission, :public_ip)
return head(405) unless client_params.key?(:name)
client = Client.find_by name: client_params[:name]
status_code = client ? 200 : 201
# create new client or update existing
if client.nil?
client ||= Client.new_client(client_params)
else
# return 406 if request is trying to remove perm_x from last admin account to prevent lockout
# TODO: move this into the model (update)
return head(406) if removing_last_admin?(client, client_params)
client.update_client(client_params)
end
api_key = client.gen_api_key
client.save
json_response({ name: client.name, api_key: api_key }, status_code)
end
# PUT /admin/client
def update
client_params = params.permit(:name, :description, :permission, :public_ip)
return head(405) unless client_params.key?(:name)
client = Client.find_by name: client_params[:name]
return head(404) unless client
# return 406 if request is trying to remove perm_x from last admin account to prevent lockout
# TODO: move this into the model (update)
return head(406) if removing_last_admin?(client, client_params)
# update client
client.update_client(client_params)
client.save
head(200)
end
# GET /admin/clients
def index
clients = []
Client.find_each do |client|
clients.append admin_read_object(client)
end
json_response(clients)
end
# GET /admin/clients/:name
def show
# return 404 if requested client does not exist
client = Client.find_by name: params[:name]
return head(404) if client.nil?
json_response(admin_read_object(client))
end
# DELETE /admin/clients/:name
def destroy
client = Client.find_by(name: params[:name])
# return 404 if requested client does not exist
return head(404) unless client
# return 406 if request is trying to remove last admin account to prevent lockout
# TODO: move this into the model (delete)
return head(406) if client.admin? && Client.number_of_admins <= 1
client.delete
head(200)
end
# PUT /client
def update_ipv4
ipv4 = request_ip
# return 406 if body with invalid ipv4 is present
# TODO: move this into the model (update)
return head(406) unless ipv4
@requesting_client.ipv4 = ipv4
@requesting_client.save
json_response(client_read_object(@requesting_client))
end
# DELETE /client
def destroy_ipv4
prev_data = client_read_object(@requesting_client)
@requesting_client.ipv4 = ''
@requesting_client.save
json_response(prev_data)
end
# GET /clients/:id
def show_ipv4
# return 404 if requested client does not exist
client = Client.find_by name: params[:name]
return head(404) if client.nil?
json_response(client_read_object(client))
end
# GET /public/:id
def show_public_ipv4
# check if client exist and is public else return 401 (don't leak existence)
client = Client.find_by name: params[:name]
return head(401) if client.nil? || !client.public_ip
json_response(client_read_object(client))
end
private
# helper methods
# respond with json object and status
def json_response(object, status = :ok)
render json: object, status: status
end
# checks if request is trying to remove the last admin
def removing_last_admin?(client, params)
return false unless params[:permission].is_a?(String)
client.admin? && !Client.parse_perm_string(params[:permission])[:x] && Client.number_of_admins <= 1
end
def client_read_object(client)
{
name: client.name,
ipv4: client.ipv4
}
end
def admin_read_object(client)
{
name: client.name,
description: client.description,
permission: client.permission,
public_ip: client.public_ip,
ipv4: client.ipv4
}
end
def request_ip
ipv4_params = params.permit(:ipv4)
if ipv4_params.key?(:ipv4)
return nil unless ipv4_params[:ipv4].match(Resolv::IPv4::Regex)
ipv4_params[:ipv4]
else
request.remote_ip
end
end
# before_action methods
# check if the api is valid and has the appropriate prefix
def parse_api_key_header
# TODO: move api_key checks and formatting to helper class
# check if api key is present and can be split into 3 parts: <prefix>.<name_id>.<api_key>
# NOTE: .compact is more for got measure than really necessary, but this ensures no nil values in the array
api_key_header = request.headers[RynDNS::Application::API_KEY_HEADER].split('.').compact unless
request.headers[RynDNS::Application::API_KEY_HEADER].nil?
return head(401) if api_key_header.nil? || api_key_header.length < 3
prefix, @name_id, @api_key = api_key_header
# check if prefix matches (do this securely to prevent timing attacks on the prefix)
return head(403) unless ActiveSupport::SecurityUtils.secure_compare(prefix,
RynDNS::Application::API_KEY_PREFIX)
end
# authenticate the client with the supplied api key
def authenticate_client
# load the request client else return 403
@requesting_client = Client.find_by name_id: @name_id
return head(403) unless @requesting_client
# compare api_key with hash value else return 403
return head(403) unless @requesting_client.api_key == @api_key
end
# check if requesting client has read permission
def authorize_perm_r
@authorized = @requesting_client.read?
end
# check if requesting client has write permission
def authorize_perm_w
@authorized = @requesting_client.write?
end
# check if requesting client has admin permission
def authorize_perm_x
@authorized = @requesting_client.admin?
end
# check if request is authorized
def client_authorized
return head(403) unless @authorized
end
end

View File

View File

@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end

View File

@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end

86
app/models/client.rb Normal file
View File

@@ -0,0 +1,86 @@
# frozen_string_literal: true
class Client < ApplicationRecord
def self.new_client(params)
perm_string = params[:permission] || 'w' # create write client by default
perm_rwx = Client.parse_perm_string(perm_string)
new(params.except(:permission)).tap do |client|
client.name_id = Digest::SHA1.hexdigest(client.name + SecureRandom.base64(8))[0, 8]
client.perm_r = perm_rwx[:r]
client.perm_w = perm_rwx[:w]
client.perm_x = perm_rwx[:x]
end
end
def update_client(params)
perm_rwx = Client.parse_perm_string(params[:permission])
update(params.except(:permission))
return unless perm_rwx
self.perm_r = perm_rwx[:r]
self.perm_w = perm_rwx[:w]
self.perm_x = perm_rwx[:x]
end
def gen_api_key
api_key = SecureRandom.hex(32)
self.api_key = api_key
"#{RynDNS::Application::API_KEY_PREFIX}.#{self.name_id}.#{api_key}"
end
def api_key
@api_key ||= BCrypt::Password.new(api_key_hash)
end
def api_key=(new_api_key)
@api_key = BCrypt::Password.create(new_api_key) # TODO: benchmark cost on target hw
self.api_key_hash = @api_key
end
def permission
"#{'r' if perm_r}#{'w' if perm_w}#{'x' if perm_x}"
end
def read?
perm_r
end
def write?
perm_w
end
def admin?
perm_x
end
def self.number_of_admins
Client.where({ perm_x: true }).count
end
def self.admins?
Client.number_of_admins >= 1
end
# helper functions
def self.parse_perm_string(perm_string)
return nil unless perm_string.is_a?(String)
perm_rwx = { r: false, w: false, x: false }
return perm_rwx unless perm_string
perm_string.each_char { |c| perm_rwx[c.to_sym] = true unless perm_rwx[c.to_sym].nil? }
perm_rwx
end
# validations
validates_presence_of :name, :name_id, :api_key_hash
validates :name, length: { in: 3..20 }, uniqueness: true
validates :name_id, length: { is: 8 }, uniqueness: true
validates :description, length: { maximum: 200 }
validates :ipv4, length: { maximum: 15 }
validates_format_of :name, with: /\A([0-9a-zA-z_\-.@]){3,20}\z/i
end

View File

0
app/views/layouts/.keep Normal file
View File