require 'P4' require 'rack/auth/basic' require 'p4_web_services_auth/version' require 'pathname' require 'json' # Authorization middleware # # This sets up a basic 'auth' scheme, backed by P4, with tokens stored in # a temporary token directory. Each application with this middleware applied # gets their own token store. We rely on using P4 tickets as passwords for # calls between services. # # To apply to a modular Sinatra application: # # class App < Sinatra::Base # use ServicesAuth::Middleware, settings: settings # end # # Settings *must* have a `token_path` configured, readable only by the system # user executing the application. # # If there are paths that should *not* be authenticated, you can add them # to the unauthenticated_paths array. In general we always add '/v1/sessions' # to this path, and that's about it. # # See also: https://confluence.perforce.com:8443/display/WS/Authentication+in+Web+Services module P4WebServicesAuth # Sinatra Middleware component to take the HTTP Basic authentication request # and pass that along to the internal Auth class methods class AuthMiddleware def initialize(app, options = {}) @app = app @unauthenticated_paths = [ {method: 'POST', path: '/v1/sessions'}, {method:'GET', path: %r{^/v1/sessions/.*}} ] if options[:unauthenticated_paths] @unauthenticated_paths.concat(options[:unauthenticated_paths]) end @settings = options[:settings] # These methods generally exist in all projects, so just expose them. # Create a new session, which basically signs into the Perforce server. # # If the Accept on the request is text/plain, then we just return our # security token. If it's application/json, we'll return the ticket in # a JSON fragment. # # TODO allow host locked tickets @app.class.post '/v1/sessions' do user = params[:user] password = params[:password] # may be a p4 ticket token = nil options = { user: user, password: password, host: settings.p4['host'], port: settings.p4['port'] } options[:charset] = settings.p4['charset'] if settings.p4.key?('charset') P4Util.open(options) do |p4| token = Auth.create_session(p4, password, settings) end # We signal a 401 when login/passwords are generally invalid. Since this # is an unauthenticated request, you shouldn't be able to tell if the # login doesn't exist or the password is incorrect. halt 401 unless token output = nil if Auth.accept_json?(request.env['HTTP_ACCEPT']) content_type 'application/json' output = { 'token' => token, 'p4Ticket' => Auth.read_token(token, settings)['ticket'] }.to_json else content_type 'text/plain' output = token end output end @app.class.get '/v1/sessions/:token' do |token| h = Auth.read_token(token, settings) h.delete('ticket') h end @app.class.delete '/v1/sessions/:token' do |token| Auth.delete_session(token, settings) '' end end def call(env) return @app.call(env) if unauthenticated_path?(env) auth = Rack::Auth::Basic::Request.new(env) if auth.provided? && auth.basic? && P4WebServicesAuth::Auth.credentials_valid?(auth.credentials, @settings) env['AUTH_CREDENTIALS'] = auth.credentials return @app.call(env) end unauthenticated_error end def unauthenticated_path?(env) @unauthenticated_paths.any? do |pathspec| (env['REQUEST_METHOD'] == pathspec.method) && ((pathspec.path.is_a?(String) && pathspec.path = env['PATH_INFO']) || (pathspec.path.is_a?(Regexp) && pathspec.path.match(env['PATH_INFO']))) end end def unauthenticated_error [ 403, { 'Content-Type' => 'text/plain', 'Content-Length' => '0', 'WWW-Authenticate' => 'Basic realm="Perforce Web API"' }, [] ] end end # "Session" handling that basically uses Perforce on the backend and stores # the p4 ticket in a GUID file. We then pass the GUID around 'publicly' since # that's really only relevant to this one instance. We can then fetch the # p4 ticket and pass that to other services internally without "exposing" it. module Auth def self.accept_json?(str) str =~ /application\/json/ end # Double checks that really only the current user can write to the token # directory. If it doesn't exist, we just print warnings to stdout. # # These checks are only intended to work on Linux and OS X. def self.validate_token_dir(token_dir) warn_if_tmp_dir(token_dir) return unless File.exist?(token_dir) if !File.owned?(token_dir) puts "The current user does not own security token dir: #{token_dir}" else mode = File.stat(token_dir).mode warn_illegal_privileges(mode, token_dir) end end # If the login/password combination is valid, we'll create a new session and # return the session ID (which is just a GUID). # # This assumes that the p4 connection already has the user name set. # # If the password is a ticket, that'll be stored as is. If it's a password # (currently determined if it's not a 32 character alphanumeric string) # we'll create a ticket and store that. # # Settings is the Sinatra application configuration. def self.create_session(p4, password, settings) ticket = nil if Auth.p4_ticket?(password) ticket = password else ticket = ticket_from_login(p4) end return if ticket.nil? results = p4.run_user('-o') save_token(user_info(p4, results, ticket), settings) end # Returns true if the string looks like a Perforce authentication ticket. def self.p4_ticket?(str) /^[a-zA-Z0-9]{32,}$/.match(str) != nil end # We only count uuids that were returned via SecureRandom.uuid used to # generate internal security tokens. def self.uuid?(str) /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/ .match(str) end # Saves the information stored in the user_info hash to a new randomly # generated ticket file, and returns that new ticket file's name. # # user_info should have the following values: # * login # * email # * full_name # * ticket def self.save_token(user_info, settings) unless File.directory?(settings.token_path) Pathname.new(settings.token_path).mkpath FileUtils.chmod(0700, settings.token_path) end token = SecureRandom.uuid token_path = Pathname.new(settings.token_path) + token File.open(token_path, 'w', 0600) do |file| file.write(JSON.generate(user_info)) end token end # Removes the session file # # Assumes blindly that we're supposed to be able to do this. The caller # should ensure that the token is deletable. def self.delete_session(token, settings) token_path = Pathname.new(settings.token_path) + token File.delete(token_path) if File.exist?(token_path) end # Takes an array with [login, password], and returns true if the password # is a legal token for that login, or looks like a p4 ticket. def self.credentials_valid?(credentials, settings) user = credentials.first password = credentials.last # Can't really determine much at this point, just return true. If we're # a token, it's a UUID and we'll learn more about that later. return true unless Auth.uuid?(password) user_info = read_token(password, settings) if user_info return user_info['user'] == user else false end end def self.read_token(token, settings) token_path = Pathname.new(settings.token_path) + token if File.exist?(token_path) File.open(token_path, 'r') do |file| return JSON.parse(file.read) end end nil end private def self.warn_if_tmp_dir(token_dir) if token_dir.start_with?('/tmp') puts "Your token directory is using the default '/tmp' location, "\ 'please reconfigure to a reliable location' end end def self.warn_illegal_privileges(mode, token_dir) warn_unless_user_rwx(mode, token_dir) warn_no_group_write(mode, token_dir) warn_no_group_read(mode, token_dir) warn_no_group_execute(mode, token_dir) warn_no_other_write(mode, token_dir) warn_no_other_read(mode, token_dir) warn_no_other_execute(mode, token_dir) end # Check owner read/write/execute - should all be there def self.warn_unless_user_rwx(mode, token_dir) unless (mode & 0400 == 0400) && (mode & 0200 == 0200) && (mode & 0100 == 0100) puts "The token_path '#{token_dir}' should allow the owner read, "\ 'write and execute privileges' end end def self.warn_no_group_write(mode, token_dir) if (mode & 0040 == 0040) puts "The token_path '#{token_dir}' should not have group write access" end end def self.warn_no_group_read(mode, token_dir) if (mode & 0020 == 0020) puts "The token_path '#{token_dir}' should not have group read access" end end def self.warn_no_group_execute(mode, token_dir) if (mode & 0010 == 0010) puts "The token_path '#{token_dir}' should not have group "\ 'execute access' end end def self.warn_no_other_write(mode, token_dir) if (mode & 0004 == 0004) puts "The token_path '#{token_dir}' should not have other write access" end end def self.warn_no_other_read(mode, token_dir) if (mode & 0002 == 0002) puts "The token_path '#{token_dir}' should not have other read access" end end def self.warn_no_other_execute(mode, token_dir) if (mode & 0001 == 0001) puts "The token_path '#{token_dir}' should not have other "\ 'execute access' end end # We want special error handling here to return 4xx codes instead of 5xx # in the face of an invalid password. Temporarily drop to lowest # exception level, and just return nil when login doesn't work. def self.ticket_from_login(p4) results = nil p4.at_exception_level(P4::RAISE_NONE) do results = p4.run_login('-p') end auth_ok = raise_unless_auth_error(p4) if !auth_ok nil else p4.password = results[0] end end def self.raise_unless_auth_error(p4) if Auth.error?(p4) msg = p4.messages[0] if msg.msgid == 7205 || # invalid user msg.msgid == 7206 # invalid password return false else Auth.raise_error(p4) end end true end def self.user_info(p4, results, ticket) { user: p4.user, email: results[0]['Email'], full_name: results[0]['FullName'], ticket: ticket } end # Check for P4 errors def self.error?(p4) !p4.errors.empty? end # Raise an exception if necessary on a P4 Error def self.raise_error(p4) err = p4.messages.find { |m| m.severity > 2 } fail P4WebServicesAuth::P4Error.new(err.msgid, err.severity, err.to_s) end end # Any error we get from the Perforce server is generally encapsulated in # a P4Error, which allows us to get some basic diagnostic information from # the Perforce server out to the user. class P4Error < RuntimeError # All error codes must be greater than this number. ERROR_CODE_BASE = 15_360 attr_accessor :message_code, :message_severity, :message_text # Class method to create a valid P4Error object from a simple # string. Used when we get errors for which no P4::Message object # is available. def self.default_error(message_text) P4Error.new(ERROR_CODE_BASE, P4::E_FAILED, message_text) end def initialize(code, severity, text) @message_code = code @message_severity = severity @message_text = text end def to_s @message_text end end module P4Util # Creates your p4 connection using some common forms. # # If you call open with a block, this will call connect before your block # executes, and disconnect afterwards. # # If you do not call open with a block, it is up to the caller to connect # and disconnect. (It's assumed you are calling w/o a block because you want # to manage when the connection actually needs to happen.) def self.open(options = {}) p4 = create_p4(options) # Again, if we're calling using the block, we'll connect and disconnect. # Otherwise, just return the created p4 object. if block_given? begin p4.connect yield p4 rescue P4Exception raise make_p4_error(p4) end else return p4 end ensure p4.disconnect if block_given? && p4 && p4.connected? end PROPERTIES = [ :password, :port, :user, :api_level, :charset, :client, :host, :handler, :maxlocktime, :maxresults, :maxscanrows, :prog, :ticketfile ] def self.create_p4(options) p4 = P4.new init_p4(p4) PROPERTIES.each do|key| p4.method(key.to_s + '=').call(options[key]) if options[key] end # From the WTF department, if you think you don't want a charset set, # make damn sure P4Ruby isn't going to get confused. p4.charset = nil if p4.charset == 'none' # Make P4Ruby only raise exceptions if there are errors. Warnings # (such as 'no such file(s)' don't get the same treatment. p4.exception_level = P4::RAISE_ERRORS p4 end # Before we do anything, clear out any environment variables that may have # leaked in from your environment # # Some of our HTTP APIs (e.g, Git Fusion) *do* utilize environment setups # for a system user, that may be installed alongside this API. We want this # system to always explicitly state which configuration to use. (So, it's # not just a concern of our development environments.) def self.init_p4(p4) p4.client = 'invalid' p4.port = '' p4.host = '' p4.password = '' end def self.make_p4_error(p4) if p4.messages && p4.messages.first m = p4.messages.first P4WebServicesAuth::P4Error.new(m.msgid, m.severity, m.to_s) else P4WebServicesAuth::P4Error.default_error($ERROR_INFO.to_s) end end end end
# | Change | User | Description | Committed | |
---|---|---|---|---|---|
#6 | 13972 | tjuricek |
Removing old microservice implementations. The system is now mostly a monolith. Eventually there will be a websocket service. |
||
#5 | 13481 | tjuricek |
Tests for the p4 web api and p4 project services now pass against a development setup both in and out of the docker cluster. Note that configuration *has not* been finalized, so conventions to dealing with development vs production need to be organized a bit. |
||
#4 | 13474 | tjuricek | Corrected regressions that broke the API and Project services specs. | ||
#3 | 13470 | tjuricek |
Phoenix notification services, client API, including new phoenix_updater This is an interim commit containing a first pass implementation of the phoenix_updater. Notably missing parts: - The Qt API doesn't yet actually interact with the phoenix_updater - The phoenix_services web service doesn't filter out notifications I *may* end up creating another web application *just* to filter out notifications, since this may end up taking up a lot of background workers. |
||
#2 | 13441 | tjuricek |
As an interim step, allow the application/json Accept type to return the p4d ticket for the server. In the future, there will likely be an adjustment to the POST request to store a separate host we attempt to generate a ticket for. Right now, these are host unlocked tickets, and not the production solution. |
||
#1 | 13412 | tjuricek |
Initial version of the web-services mainline. This is a collection of several projects, that will likely often get released together, though many of them may not always be relevant. See the README for more information. |