diff --git a/lib/vmpooler/api/helpers.rb b/lib/vmpooler/api/helpers.rb index 5ce6d7e..a618d15 100644 --- a/lib/vmpooler/api/helpers.rb +++ b/lib/vmpooler/api/helpers.rb @@ -4,6 +4,60 @@ module Vmpooler module Helpers + def protected! + return if authorized? + + content_type :json + + result = { 'ok' => false } + + headers['WWW-Authenticate'] = 'Basic realm="Authentication required"' + halt 401, JSON.pretty_generate(result) + end + + def authorized? + @auth ||= Rack::Auth::Basic::Request.new(request.env) + + if @auth.provided? and @auth.basic? and @auth.credentials + username, password = @auth.credentials + + if authenticate(Vmpooler::API.settings.config[:auth], username, password) + return true + end + end + + return false + end + + def authenticate(auth, username_str, password_str) + case auth['provider'] + when 'ldap' + require 'rubygems' + require 'net/ldap' + + ldap = Net::LDAP.new( + :host => auth[:ldap]['host'], + :port => auth[:ldap]['port'] || 389, + :encryption => { + :method => :start_tls, + :tls_options => { :ssl_version => 'TLSv1' } + }, + :base => auth[:ldap]['base'], + :auth => { + :method => :simple, + :username => "#{auth[:ldap]['user_object']}=#{username_str},#{auth[:ldap]['base']}", + :password => password_str + } + ) + + if ldap.bind + return true + end + end + + return false + end + def mean(list) s = list.map(&:to_f).reduce(:+).to_f (s > 0 && list.length > 0) ? s / list.length.to_f : 0 diff --git a/lib/vmpooler/api/reroute.rb b/lib/vmpooler/api/reroute.rb index 72832f5..0592de4 100644 --- a/lib/vmpooler/api/reroute.rb +++ b/lib/vmpooler/api/reroute.rb @@ -11,6 +11,18 @@ module Vmpooler call env.merge("PATH_INFO" => "/api/v#{api_version}/summary") end + post '/token/?' do + call env.merge("PATH_INFO" => "/api/v#{api_version}/token") + end + + get '/token/:token/?' do + call env.merge("PATH_INFO" => "/api/v#{api_version}/token/#{params[:token]}") + end + + delete '/token/:token/?' do + call env.merge("PATH_INFO" => "/api/v#{api_version}/token/#{params[:token]}") + end + get '/vm/?' do call env.merge("PATH_INFO" => "/api/v#{api_version}/vm") end diff --git a/lib/vmpooler/api/v1.rb b/lib/vmpooler/api/v1.rb index ae8ed8e..3ab536f 100644 --- a/lib/vmpooler/api/v1.rb +++ b/lib/vmpooler/api/v1.rb @@ -173,6 +173,74 @@ module Vmpooler JSON.pretty_generate(result) end + get "#{api_prefix}/token/:token/?" do + content_type :json + + status 404 + result = { 'ok' => false } + + if Vmpooler::API.settings.config[:auth] + status 401 + + protected! + + token = Vmpooler::API.settings.redis.hgetall('vmpooler__token__' + params[:token]) + + if not token.nil? and not token.empty? + status 200 + result = { 'ok' => true, params[:token] => token } + else + status 404 + end + end + + JSON.pretty_generate(result) + end + + delete "#{api_prefix}/token/:token/?" do + content_type :json + + status 404 + result = { 'ok' => false } + + if Vmpooler::API.settings.config[:auth] + status 401 + + protected! + + if Vmpooler::API.settings.redis.del('vmpooler__token__' + params[:token]).to_i > 0 + status 200 + result['ok'] = true + end + end + + JSON.pretty_generate(result) + end + + post "#{api_prefix}/token" do + content_type :json + + status 404 + result = { 'ok' => false } + + if Vmpooler::API.settings.config[:auth] + status 401 + + protected! + + o = [('a'..'z'), ('0'..'9')].map(&:to_a).flatten + result['token'] = o[rand(25)] + (0...31).map { o[rand(o.length)] }.join + + Vmpooler::API.settings.redis.hset('vmpooler__token__' + result['token'], 'user', @auth.username) + Vmpooler::API.settings.redis.hset('vmpooler__token__' + result['token'], 'timestamp', Time.now) + + status 200 + result['ok'] = true + end + + JSON.pretty_generate(result) + end + get "#{api_prefix}/vm/?" do content_type :json diff --git a/spec/vmpooler/api/v1_spec.rb b/spec/vmpooler/api/v1_spec.rb new file mode 100644 index 0000000..f6d295e --- /dev/null +++ b/spec/vmpooler/api/v1_spec.rb @@ -0,0 +1,162 @@ +require 'spec_helper' +require 'rack/test' + +module Vmpooler + class API + module Helpers + def authenticate(auth, username_str, password_str) + username_str == 'admin' and password_str == 's3cr3t' + end + end + end +end + +describe Vmpooler::API::V1 do + include Rack::Test::Methods + + def app() + Vmpooler::API + end + + describe '/token' do + let(:redis) { double('redis') } + let(:prefix) { '/api/v1' } + + before do + app.settings.set :config, config + app.settings.set :redis, redis + end + + describe 'GET /token/:token' do + context '(auth not configured)' do + let(:config) { { auth: false } } + + it 'returns a 404' do + get "#{prefix}/token/this" + + expect(last_response).not_to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(404) + end + end + + context '(auth configured)' do + before do + allow(redis).to receive(:hgetall).and_return 'atoken' + end + + let(:config) { { auth: true } } + + it 'returns a 401 if not authed' do + get "#{prefix}/token/this" + + expect(last_response).not_to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(401) + end + + it 'returns a token if authed' do + authorize 'admin', 's3cr3t' + + get "#{prefix}/token/this" + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => true, 'this' => 'atoken'})) + expect(last_response.status).to eq(200) + end + end + end + + describe 'POST /token' do + context '(auth not configured)' do + let(:config) { { auth: false } } + + it 'returns a 404' do + post "#{prefix}/token" + + expect(last_response).not_to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(404) + end + end + + context '(auth configured)' do + before do + allow(redis).to receive(:hset).and_return '1' + end + + let(:config) { { auth: true } } + + it 'returns a 401 if not authed' do + post "#{prefix}/token" + + expect(last_response).not_to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(401) + end + + it 'returns a token if authed' do + authorize 'admin', 's3cr3t' + + post "#{prefix}/token" + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(JSON.parse(last_response.body)['ok']).to eq(true) + expect(JSON.parse(last_response.body)['token'].length).to be(32) + expect(last_response.status).to eq(200) + end + end + end + + describe 'DELETE /token/:token' do + context '(auth not configured)' do + let(:config) { { auth: false } } + + it 'returns a 404' do + delete "#{prefix}/token/this" + + expect(last_response).not_to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(404) + end + end + + context '(auth configured)' do + before do + allow(redis).to receive(:del).and_return '1' + end + + let(:config) { { auth: true } } + + it 'returns a 401 if not authed' do + delete "#{prefix}/token/this" + + expect(last_response).not_to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => false})) + expect(last_response.status).to eq(401) + end + + it 'deletes a token if authed' do + authorize 'admin', 's3cr3t' + + delete "#{prefix}/token/this" + + expect(last_response).to be_ok + expect(last_response.header['Content-Type']).to eq('application/json') + expect(last_response.body).to eq(JSON.pretty_generate({'ok' => true})) + expect(last_response.status).to eq(200) + end + end + end + + end + +end