Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add basic authentication #51 #57

Merged
merged 7 commits into from
Oct 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ install(DIRECTORY launch test www

)
catkin_install_python(
PROGRAMS test/test_roswww.py test/test_single.py
PROGRAMS test/test_roswww.py test/test_single.py test/test_basic.py
DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}/test)

if (CATKIN_ENABLE_TESTING)
Expand Down
13 changes: 13 additions & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ Chat

After launching start_bridge.launch, let's open http://localhost:%PORT_OF_YOURCHOICE%/roswww/chat.html with a browser in two windows. Once you send a message from one of the windows, the message will be shown in both windows.

Basic Authentication
====================

To enable basic authentication, start a launch file with a `basic` and `basic_yaml` arguments

$ roslaunch roswww roswww.launch basic:=true basic_yaml:=%PATH_TO_YAML_FILE%

The example of `%PATH_TO_YAML_FILE%` like below, which a dictionary of %USER_NAME% and %PASSWORD.

# example passwrod file
user: password


Support, communication
==========================

Expand Down
5 changes: 5 additions & 0 deletions launch/roswww.launch
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@
<arg name="webpath" default="www"/> <!-- package webroot -->
<arg name="cached" default="true"/>
<arg name="single_package" default=""/>
<arg name="basic" default="false"/>
<arg name="basic_yaml" default=""/>
<arg name="basic_flag" value="--basic $(arg basic) --basic-yaml $(arg basic_yaml)" if="$(arg basic)" />
<arg name="basic_flag" value="" unless="$(arg basic)" />

<arg name="port" default="8085"/>
<arg name="start_port" default="$(arg port)" />
<arg name="end_port" default="$(arg port)" />

<node pkg="roswww" type="webserver.py" name="$(arg name)"
args="--name $(arg name) --webpath $(arg webpath) --cached $(arg cached)
$(arg basic_flag)
--single_package=$(arg single_package)
--port $(arg port) --start_port $(arg start_port) --end_port $(arg end_port)" />
</launch>
11 changes: 8 additions & 3 deletions script/webserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,22 @@ def parse_argument(argv):
parser.add_argument('-w', '--webpath', default='www', help='package relative path to web pages')
parser.add_argument('-s', '--single_package', default='', help='package name for single package mode')
parser.add_argument('--cached', default='true', help='static file is cached')
parser.add_argument('--basic', default='false', help='enable basic authentication')
parser.add_argument('--basic-yaml', default=None, help='basic key yaml file path')
parser.add_argument('--start_port', default=8000, type=int, help='setting up port scan range')
parser.add_argument('--end_port', default=9000, type=int, help='setting up port scan range')

parsed_args = parser.parse_args(argv)
cached = False if parsed_args.cached in [0, False, 'false', 'False'] else True
return parsed_args.name, parsed_args.webpath, (parsed_args.port, parsed_args.start_port, parsed_args.end_port), cached, parsed_args.single_package
basic = True if parsed_args.basic in [1, True, 'true', 'True'] else False
basic_yaml = parsed_args.basic_yaml
return parsed_args.name, parsed_args.webpath, (parsed_args.port, parsed_args.start_port, parsed_args.end_port), cached, parsed_args.single_package, basic, basic_yaml



if __name__ == '__main__':
rospy.init_node("webserver", disable_signals=True)
name, webpath, port, cached, single = parse_argument(rospy.myargv()[1:])
webserver = roswww.ROSWWWServer(name, webpath, port, cached, single_package=single)
name, webpath, port, cached, single, basic, basic_yaml = parse_argument(rospy.myargv()[1:])
webserver = roswww.ROSWWWServer(name, webpath, port, cached, single_package=single, basic=basic, basic_yaml=basic_yaml)
webserver.loginfo("Initialised")
webserver.spin()
70 changes: 68 additions & 2 deletions src/roswww/roswww_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,48 @@

import logging

import base64
import functools
import socket
import tornado.ioloop # rosbridge installs tornado
import tornado.web
import yaml

from .webrequest_handler import WebRequestHandler
from .utils import run_shellcommand, split_words, get_packages


def basic_auth(auth):
def decore(f):
def _request_auth(handler):
handler.set_header('WWW-Authenticate', 'Basic realm=roswww')
handler.set_status(401)
handler.finish()
return False

@functools.wraps(f)
def new_f(*args):
handler = args[0]
auth_header = handler.request.headers.get('Authorization')
if auth_header is None:
return _request_auth(handler)
if not auth_header.startswith('Basic '):
return _request_auth(handler)

auth_decoded = base64.decodestring(auth_header.split(' ', 1)[1])
username, password = auth_decoded.split(':', 1)

if auth(username, password):
f(*args)
else:
_request_auth(handler)
return new_f
return decore


class ROSWWWServer():

def __init__(self, name, webpath, ports, cached, single_package=None):
def __init__(self, name, webpath, ports, cached, single_package=None, basic=False, basic_yaml=None):
'''
:param str name: webserver name
:param str webpath: package relative path to web page source.
Expand All @@ -54,20 +87,53 @@ def __init__(self, name, webpath, ports, cached, single_package=None):
self._webpath = webpath
self._ports = ports
self._cached = cached
self._basic = basic
self._logger = self._set_logger()
self._packages = get_packages()
self._application = self._create_webserver(self._packages, single_package=single_package)
if self._basic:
if basic_yaml:
with open(basic_yaml) as f:
self._keys = yaml.safe_load(f)
else:
self._keys = {'admin': 'admin'}

def _create_webserver(self, packages, single_package=None):
'''
@type packages: {str, str}
@param packages: name and path of ROS packages.
'''
def _auth(username, password):
self.loginfo("User %s attempt to login"%(username))
if username in self._keys:
return self._keys[username] == password
return False

class NoCacheStaticFileHandler(tornado.web.StaticFileHandler):
def set_extra_headers(self, path):
self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')

file_handler = tornado.web.StaticFileHandler if self._cached else NoCacheStaticFileHandler
class BasicStaticFileHandler(tornado.web.StaticFileHandler):
@basic_auth(_auth)
def get(self, path, include_body=True):
super(BasicStaticFileHandler, self).get(path, include_body)

class BasicNoCacheStaticFileHandler(NoCacheStaticFileHandler):
@basic_auth(_auth)
def get(self, path, include_body=True):
super(BasicNoCacheStaticFileHandler, self).get(path, include_body)

if self._cached:
if self._basic:
file_handler = BasicStaticFileHandler
else:
file_handler = tornado.web.StaticFileHandler
else:
if self._basic:
file_handler = BasicNoCacheStaticFileHandler
else:
file_handler = NoCacheStaticFileHandler
self.loginfo("Configure webserver with cache:%s, basic:%s"%(self._cached, self._basic))

if single_package:
for package in packages:
Expand Down
29 changes: 29 additions & 0 deletions test/basic.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<launch>
<arg name="gui" default="false"/>
<arg name="test_option" default="--no-headless" if="$(arg gui)" />
<arg name="test_option" default="" unless="$(arg gui)" />
<arg name="name" default="roswww"/>
<arg name="port" default="8088"/> <!-- avoid to use apache default port -->
<arg name="webpath" default="www"/> <!-- package webroot -->
<arg name="cached" default="true"/>
<arg name="basic" default="true"/>
<arg name="basic_yaml" default="$(find roswww)/test/keys.yaml"/>

<include file="$(find roswww)/launch/roswww.launch">
<arg name="name" value="$(arg name)"/>
<arg name="port" value="$(arg port)"/>
<arg name="webpath" value="$(arg webpath)"/>
<arg name="cached" value="$(arg cached)"/>
<arg name="basic" default="$(arg basic)"/>
<arg name="basic_yaml" default="$(arg basic_yaml)"/>
</include>
<include file="$(find rosbridge_server)/launch/rosbridge_websocket.launch">
<arg name="port" value="9092" />
</include>

<param name='url_roswww_testserver' value='http://user:password@localhost:$(arg port)' />

<test type="test_basic.py" pkg="roswww" test-name="test_basic"
time-limit="100" args="$(arg test_option)" />

</launch>
2 changes: 2 additions & 0 deletions test/keys.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# example passwrod file
user: password
105 changes: 105 additions & 0 deletions test/test_basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Software License Agreement (BSD License)
#
# Copyright (c) 2015, Tokyo Opensource Robotics Kyokai Association
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
# * Neither the name of Tokyo Opensource Robotics Kyokai Association. nor the
# names of its contributors may be used to endorse or promote products
# derived from this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
# Author: Isaac I.Y. Saito
# Author: Yuki Furuta <furushchev@jsk.imi.i.u-tokyo.ac.jp>

import argparse
import rospy
import rostest
import sys
import unittest

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC

class TestBasic(unittest.TestCase):

def setUp(self):
parser = argparse.ArgumentParser()
parser.add_argument('--no-headless', action='store_true',
help='start webdriver with headless mode')
args, unknown = parser.parse_known_args()

self.url_base = rospy.get_param("url_roswww_testserver")

opts = webdriver.firefox.options.Options()
if not args.no_headless:
opts.add_argument('-headless')
self.browser = webdriver.Firefox(options=opts)

self.wait = webdriver.support.ui.WebDriverWait(self.browser, 10)
# maximize screen
self.browser.find_element_by_tag_name("html").send_keys(Keys.F11)

def tearDown(self):
try:
self.browser.close()
self.browser.quit()
except:
pass

def _check_index(self, url):
rospy.logwarn("Accessing to %s" % url)

self.browser.get(url)
self.wait.until(EC.presence_of_element_located((By.ID, "title")))

title = self.browser.find_element_by_id("title")
self.assertIsNotNone(title, "Object id=title not found")

# check load other resouces
self.wait.until(EC.presence_of_element_located((By.ID, "relative-link-check")))
check = self.browser.find_element_by_id("relative-link-check")
self.assertIsNotNone(check, "Object id=relative-link-check not found")
self.assertEqual(check.text, "Relative link is loaded",
"Loading 'css/index.css' from 'index.html' failed")

def test_index_served(self):
url = '%s/roswww/' % (self.url_base)
self._check_index(url)

def test_index_redirected(self):
url = '%s/roswww' % (self.url_base)
self._check_index(url)




if __name__ == '__main__':
rospy.init_node("test_basic")
exit(rostest.rosrun("roswww", "test_basic", TestBasic, sys.argv))