1#!/usr/bin/env python3 2# -*- coding: utf-8 -*- 3""" 4QGIS Server HTTP wrapper for testing purposes 5================================================================================ 6 7This script launches a QGIS Server listening on port 8081 or on the port 8specified on the environment variable QGIS_SERVER_PORT. 9Hostname is set by environment variable QGIS_SERVER_HOST (defaults to 127.0.0.1) 10 11The server can be configured to support any of the following auth systems 12(mutually exclusive): 13 14 * PKI 15 * HTTP Basic 16 * OAuth2 (requires python package oauthlib, installable with: 17 with "pip install oauthlib") 18 19 20!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 21SECURITY WARNING: 22!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 23 24This script was developed for testing purposes and was not meant to be secure, 25please do not use in a production server any of the authentication systems 26implemented here. 27 28 29HTTPS 30-------------------------------------------------------------------------------- 31 32HTTPS is automatically enabled for PKI and OAuth2 33 34 35HTTP Basic 36-------------------------------------------------------------------------------- 37 38A XYZ map service is also available for multithreading testing: 39 40 ?MAP=/path/to/projects.qgs&SERVICE=XYZ&X=1&Y=0&Z=1&LAYERS=world 41 42Note that multithreading in QGIS server is not officially supported and 43it is not supposed to work in any case 44 45Set MULTITHREADING environment variable to 1 to activate. 46 47 48For testing purposes, HTTP Basic can be enabled by setting the following 49environment variables: 50 51 * QGIS_SERVER_HTTP_BASIC_AUTH (default not set, set to anything to enable) 52 * QGIS_SERVER_USERNAME (default ="username") 53 * QGIS_SERVER_PASSWORD (default ="password") 54 55 56PKI 57-------------------------------------------------------------------------------- 58 59PKI authentication with HTTPS can be enabled with: 60 61 * QGIS_SERVER_PKI_CERTIFICATE (server certificate) 62 * QGIS_SERVER_PKI_KEY (server private key) 63 * QGIS_SERVER_PKI_AUTHORITY (root CA) 64 * QGIS_SERVER_PKI_USERNAME (valid username) 65 66 67OAuth2 Resource Owner Grant Flow 68-------------------------------------------------------------------------------- 69 70OAuth2 Resource Owner Grant Flow with HTTPS can be enabled with: 71 72 * QGIS_SERVER_OAUTH2_AUTHORITY (no default) 73 * QGIS_SERVER_OAUTH2_KEY (server private key) 74 * QGIS_SERVER_OAUTH2_CERTIFICATE (server certificate) 75 * QGIS_SERVER_OAUTH2_USERNAME (default ="username") 76 * QGIS_SERVER_OAUTH2_PASSWORD (default ="password") 77 * QGIS_SERVER_OAUTH2_TOKEN_EXPIRES_IN (default = 3600) 78 79Available endpoints: 80 81 - /token (returns a new access_token), 82 optionally specify an expiration time in seconds with ?ttl=<int> 83 - /refresh (returns a new access_token from a refresh token), 84 optionally specify an expiration time in seconds with ?ttl=<int> 85 - /result (check the Bearer token and returns a short sentence if it validates) 86 87 88Sample runs 89-------------------------------------------------------------------------------- 90 91PKI: 92 93QGIS_SERVER_PKI_USERNAME=Gerardus QGIS_SERVER_PORT=47547 QGIS_SERVER_HOST=localhost \ 94 QGIS_SERVER_PKI_KEY=/home/$USER/dev/QGIS/tests/testdata/auth_system/certs_keys/localhost_ssl_key.pem \ 95 QGIS_SERVER_PKI_CERTIFICATE=/home/$USER/dev/QGIS/tests/testdata/auth_system/certs_keys/localhost_ssl_cert.pem \ 96 QGIS_SERVER_PKI_AUTHORITY=/home/$USER/dev/QGIS/tests/testdata/auth_system/certs_keys/chains_subissuer-issuer-root_issuer2-root2.pem \ 97 python3 /home/$USER/dev/QGIS/tests/src/python/qgis_wrapped_server.py 98 99 100OAuth2: 101 102QGIS_SERVER_PORT=8443 \ 103 QGIS_SERVER_HOST=127.0.0.1 \ 104 QGIS_SERVER_OAUTH2_AUTHORITY=/home/$USER/dev/QGIS/tests/testdata/auth_system/certs_keys/chains_subissuer-issuer-root_issuer2-root2.pem \ 105 QGIS_SERVER_OAUTH2_CERTIFICATE=/home/$USER/dev/QGIS/tests/testdata/auth_system/certs_keys/127_0_0_1_ssl_cert.pem \ 106 QGIS_SERVER_OAUTH2_KEY=/home/$USER/dev/QGIS/tests/testdata/auth_system/certs_keys/127_0_0_1_ssl_key.pem \ 107 python3 \ 108 /home/$USER/dev/QGIS/tests/src/python/qgis_wrapped_server.py 109 110 111 112.. note:: This program is free software; you can redistribute it and/or modify 113it under the terms of the GNU General Public License as published by 114the Free Software Foundation; either version 2 of the License, or 115(at your option) any later version. 116""" 117 118import copy 119import os 120import signal 121import ssl 122import sys 123import urllib.parse 124 125from http.server import BaseHTTPRequestHandler, HTTPServer 126from qgis.core import QgsApplication 127from qgis.server import (QgsBufferServerRequest, QgsBufferServerResponse, 128 QgsServer, QgsServerRequest) 129 130__author__ = 'Alessandro Pasotti' 131__date__ = '05/15/2016' 132__copyright__ = 'Copyright 2016, The QGIS Project' 133 134# Needed on Qt 5 so that the serialization of XML is consistent among all 135# executions 136os.environ['QT_HASH_SEED'] = '1' 137 138import sys 139import signal 140import ssl 141import math 142import copy 143import urllib.parse 144from http.server import BaseHTTPRequestHandler, HTTPServer 145from socketserver import ThreadingMixIn 146import threading 147 148from qgis.core import QgsApplication, QgsCoordinateTransform, QgsCoordinateReferenceSystem 149from qgis.server import QgsServer, QgsServerRequest, QgsBufferServerRequest, QgsBufferServerResponse, QgsServerFilter 150 151QGIS_SERVER_PORT = int(os.environ.get('QGIS_SERVER_PORT', '8081')) 152QGIS_SERVER_HOST = os.environ.get('QGIS_SERVER_HOST', '127.0.0.1') 153 154# HTTP Basic 155QGIS_SERVER_HTTP_BASIC_AUTH = os.environ.get( 156 'QGIS_SERVER_HTTP_BASIC_AUTH', False) 157QGIS_SERVER_USERNAME = os.environ.get('QGIS_SERVER_USERNAME', 'username') 158QGIS_SERVER_PASSWORD = os.environ.get('QGIS_SERVER_PASSWORD', 'password') 159 160# PKI authentication 161QGIS_SERVER_PKI_CERTIFICATE = os.environ.get('QGIS_SERVER_PKI_CERTIFICATE') 162QGIS_SERVER_PKI_KEY = os.environ.get('QGIS_SERVER_PKI_KEY') 163QGIS_SERVER_PKI_AUTHORITY = os.environ.get('QGIS_SERVER_PKI_AUTHORITY') 164QGIS_SERVER_PKI_USERNAME = os.environ.get('QGIS_SERVER_PKI_USERNAME') 165 166# OAuth2 authentication 167QGIS_SERVER_OAUTH2_CERTIFICATE = os.environ.get( 168 'QGIS_SERVER_OAUTH2_CERTIFICATE') 169QGIS_SERVER_OAUTH2_KEY = os.environ.get('QGIS_SERVER_OAUTH2_KEY') 170QGIS_SERVER_OAUTH2_AUTHORITY = os.environ.get('QGIS_SERVER_OAUTH2_AUTHORITY') 171QGIS_SERVER_OAUTH2_USERNAME = os.environ.get( 172 'QGIS_SERVER_OAUTH2_USERNAME', 'username') 173QGIS_SERVER_OAUTH2_PASSWORD = os.environ.get( 174 'QGIS_SERVER_OAUTH2_PASSWORD', 'password') 175QGIS_SERVER_OAUTH2_TOKEN_EXPIRES_IN = os.environ.get( 176 'QGIS_SERVER_OAUTH2_TOKEN_EXPIRES_IN', 3600) 177 178# Check if PKI is enabled 179QGIS_SERVER_PKI_AUTH = ( 180 QGIS_SERVER_PKI_CERTIFICATE is not None and 181 os.path.isfile(QGIS_SERVER_PKI_CERTIFICATE) and 182 QGIS_SERVER_PKI_KEY is not None and 183 os.path.isfile(QGIS_SERVER_PKI_KEY) and 184 QGIS_SERVER_PKI_AUTHORITY is not None and 185 os.path.isfile(QGIS_SERVER_PKI_AUTHORITY) and 186 QGIS_SERVER_PKI_USERNAME) 187 188# Check if OAuth2 is enabled 189QGIS_SERVER_OAUTH2_AUTH = ( 190 QGIS_SERVER_OAUTH2_CERTIFICATE is not None and 191 os.path.isfile(QGIS_SERVER_OAUTH2_CERTIFICATE) and 192 QGIS_SERVER_OAUTH2_KEY is not None and 193 os.path.isfile(QGIS_SERVER_OAUTH2_KEY) and 194 QGIS_SERVER_OAUTH2_AUTHORITY is not None and 195 os.path.isfile(QGIS_SERVER_OAUTH2_AUTHORITY) and 196 QGIS_SERVER_OAUTH2_USERNAME and QGIS_SERVER_OAUTH2_PASSWORD) 197 198HTTPS_ENABLED = QGIS_SERVER_PKI_AUTH or QGIS_SERVER_OAUTH2_AUTH 199 200qgs_app = QgsApplication([], False) 201qgs_server = QgsServer() 202 203if QGIS_SERVER_HTTP_BASIC_AUTH: 204 from qgis.server import QgsServerFilter 205 import base64 206 207 class HTTPBasicFilter(QgsServerFilter): 208 209 def requestReady(self): 210 handler = self.serverInterface().requestHandler() 211 auth = self.serverInterface().requestHandler().requestHeader('HTTP_AUTHORIZATION') 212 if auth: 213 username, password = base64.b64decode(auth[6:]).split(b':') 214 if (username.decode('utf-8') == os.environ.get('QGIS_SERVER_USERNAME', 'username') and 215 password.decode('utf-8') == os.environ.get('QGIS_SERVER_PASSWORD', 'password')): 216 return 217 handler.setParameter('SERVICE', 'ACCESS_DENIED') 218 219 def responseComplete(self): 220 handler = self.serverInterface().requestHandler() 221 auth = handler.requestHeader('HTTP_AUTHORIZATION') 222 if auth: 223 username, password = base64.b64decode(auth[6:]).split(b':') 224 if (username.decode('utf-8') == os.environ.get('QGIS_SERVER_USERNAME', 'username') and 225 password.decode('utf-8') == os.environ.get('QGIS_SERVER_PASSWORD', 'password')): 226 return 227 # No auth ... 228 handler.clear() 229 handler.setResponseHeader('Status', '401 Authorization required') 230 handler.setResponseHeader( 231 'WWW-Authenticate', 'Basic realm="QGIS Server"') 232 handler.appendBody(b'<h1>Authorization required</h1>') 233 234 filter = HTTPBasicFilter(qgs_server.serverInterface()) 235 qgs_server.serverInterface().registerFilter(filter) 236 237 238def num2deg(xtile, ytile, zoom): 239 """This returns the NW-corner of the square. Use the function with xtile+1 and/or ytile+1 240 to get the other corners. With xtile+0.5 & ytile+0.5 it will return the center of the tile.""" 241 n = 2.0 ** zoom 242 lon_deg = xtile / n * 360.0 - 180.0 243 lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n))) 244 lat_deg = math.degrees(lat_rad) 245 return (lat_deg, lon_deg) 246 247 248class XYZFilter(QgsServerFilter): 249 """XYZ server, example: ?MAP=/path/to/projects.qgs&SERVICE=XYZ&X=1&Y=0&Z=1&LAYERS=world""" 250 251 def requestReady(self): 252 handler = self.serverInterface().requestHandler() 253 if handler.parameter('SERVICE') == 'XYZ': 254 x = int(handler.parameter('X')) 255 y = int(handler.parameter('Y')) 256 z = int(handler.parameter('Z')) 257 # NW corner 258 lat_deg, lon_deg = num2deg(x, y, z) 259 # SE corner 260 lat_deg2, lon_deg2 = num2deg(x + 1, y + 1, z) 261 handler.setParameter('SERVICE', 'WMS') 262 handler.setParameter('REQUEST', 'GetMap') 263 handler.setParameter('VERSION', '1.3.0') 264 handler.setParameter('SRS', 'EPSG:4326') 265 handler.setParameter('HEIGHT', '256') 266 handler.setParameter('WIDTH', '256') 267 handler.setParameter('BBOX', "{},{},{},{}".format(lat_deg2, lon_deg, lat_deg, lon_deg2)) 268 269 270xyzfilter = XYZFilter(qgs_server.serverInterface()) 271qgs_server.serverInterface().registerFilter(xyzfilter) 272 273if QGIS_SERVER_OAUTH2_AUTH: 274 from qgis.server import QgsServerFilter 275 from oauthlib.oauth2 import RequestValidator, LegacyApplicationServer 276 import base64 277 from datetime import datetime 278 279 # Naive token storage implementation 280 _tokens = {} 281 282 class SimpleValidator(RequestValidator): 283 """Validate username and password 284 Note: does not support scopes or client_id""" 285 286 def validate_client_id(self, client_id, request): 287 return True 288 289 def authenticate_client(self, request, *args, **kwargs): 290 """Wide open""" 291 request.client = type("Client", (), {'client_id': 'my_id'}) 292 return True 293 294 def validate_user(self, username, password, client, request, *args, **kwargs): 295 if username == QGIS_SERVER_OAUTH2_USERNAME and password == QGIS_SERVER_OAUTH2_PASSWORD: 296 return True 297 return False 298 299 def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): 300 # Clients should only be allowed to use one type of grant. 301 return grant_type in ('password', 'refresh_token') 302 303 def get_default_scopes(self, client_id, request, *args, **kwargs): 304 # Scopes a client will authorize for if none are supplied in the 305 # authorization request. 306 return ('my_scope',) 307 308 def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): 309 """Wide open""" 310 return True 311 312 def save_bearer_token(self, token, request, *args, **kwargs): 313 # Remember to associate it with request.scopes, request.user and 314 # request.client. The two former will be set when you validate 315 # the authorization code. Don't forget to save both the 316 # access_token and the refresh_token and set expiration for the 317 # access_token to now + expires_in seconds. 318 _tokens[token['access_token']] = copy.copy(token) 319 _tokens[token['access_token']]['expiration'] = datetime.now( 320 ).timestamp() + int(token['expires_in']) 321 322 def validate_bearer_token(self, token, scopes, request): 323 """Check the token""" 324 return token in _tokens and _tokens[token]['expiration'] > datetime.now().timestamp() 325 326 def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): 327 """Ensure the Bearer token is valid and authorized access to scopes.""" 328 for t in _tokens.values(): 329 if t['refresh_token'] == refresh_token: 330 return True 331 return False 332 333 def get_original_scopes(self, refresh_token, request, *args, **kwargs): 334 """Get the list of scopes associated with the refresh token.""" 335 return [] 336 337 validator = SimpleValidator() 338 oauth_server = LegacyApplicationServer( 339 validator, token_expires_in=QGIS_SERVER_OAUTH2_TOKEN_EXPIRES_IN) 340 341 class OAuth2Filter(QgsServerFilter): 342 """This filter provides testing endpoint for OAuth2 Resource Owner Grant Flow 343 344 Available endpoints: 345 - /token (returns a new access_token), 346 optionally specify an expiration time in seconds with ?ttl=<int> 347 - /refresh (returns a new access_token from a refresh token), 348 optionally specify an expiration time in seconds with ?ttl=<int> 349 - /result (check the Bearer token and returns a short sentence if it validates) 350 """ 351 352 def responseComplete(self): 353 354 handler = self.serverInterface().requestHandler() 355 356 def _token(ttl): 357 """Common code for new and refresh token""" 358 handler.clear() 359 body = bytes(handler.data()).decode('utf8') 360 old_expires_in = oauth_server.default_token_type.expires_in 361 # Hacky way to dynamically set token expiration time 362 oauth_server.default_token_type.expires_in = ttl 363 headers, payload, code = oauth_server.create_token_response( 364 '/token', 'post', body, {}) 365 oauth_server.default_token_type.expires_in = old_expires_in 366 for k, v in headers.items(): 367 handler.setResponseHeader(k, v) 368 handler.setStatusCode(code) 369 handler.appendBody(payload.encode('utf-8')) 370 371 # Token expiration 372 ttl = handler.parameterMap().get('TTL', QGIS_SERVER_OAUTH2_TOKEN_EXPIRES_IN) 373 # Issue a new token 374 if handler.url().find('/token') != -1: 375 _token(ttl) 376 return 377 378 # Refresh token 379 if handler.url().find('/refresh') != -1: 380 _token(ttl) 381 return 382 383 # Check for valid token 384 auth = handler.requestHeader('HTTP_AUTHORIZATION') 385 if auth: 386 result, response = oauth_server.verify_request( 387 urllib.parse.quote_plus(handler.url(), safe='/:?=&'), 'post', '', {'Authorization': auth}) 388 if result: 389 # This is a test endpoint for OAuth2, it requires a valid 390 # token 391 if handler.url().find('/result') != -1: 392 handler.clear() 393 handler.appendBody(b'Valid Token: enjoy OAuth2') 394 # Standard flow 395 return 396 else: 397 # Wrong token, default response 401 398 pass 399 400 # No auth ... 401 handler.clear() 402 handler.setStatusCode(401) 403 handler.setResponseHeader('Status', '401 Unauthorized') 404 handler.setResponseHeader( 405 'WWW-Authenticate', 'Bearer realm="QGIS Server"') 406 handler.appendBody(b'Invalid Token: Authorization required.') 407 408 filter = OAuth2Filter(qgs_server.serverInterface()) 409 qgs_server.serverInterface().registerFilter(filter) 410 411 412class Handler(BaseHTTPRequestHandler): 413 414 def do_GET(self, post_body=None): 415 # CGI vars: 416 headers = {} 417 for k, v in self.headers.items(): 418 headers['HTTP_%s' % k.replace(' ', '-').replace('-', '_').replace(' ', '-').upper()] = v 419 if not self.path.startswith('http'): 420 self.path = "%s://%s:%s%s" % ('https' if HTTPS_ENABLED else 'http', QGIS_SERVER_HOST, self.server.server_port, self.path) 421 request = QgsBufferServerRequest( 422 self.path, (QgsServerRequest.PostMethod if post_body is not None else QgsServerRequest.GetMethod), headers, post_body) 423 response = QgsBufferServerResponse() 424 qgs_server.handleRequest(request, response) 425 426 headers_dict = response.headers() 427 try: 428 self.send_response(int(headers_dict['Status'].split(' ')[0])) 429 except: 430 self.send_response(200) 431 for k, v in headers_dict.items(): 432 self.send_header(k, v) 433 self.end_headers() 434 self.wfile.write(response.body()) 435 return 436 437 def do_POST(self): 438 content_len = int(self.headers.get('content-length', 0)) 439 post_body = self.rfile.read(content_len) 440 return self.do_GET(post_body) 441 442 443class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): 444 """Handle requests in a separate thread.""" 445 pass 446 447 448if __name__ == '__main__': 449 if os.environ.get('MULTITHREADING') == '1': 450 server = ThreadedHTTPServer((QGIS_SERVER_HOST, QGIS_SERVER_PORT), Handler) 451 else: 452 server = HTTPServer((QGIS_SERVER_HOST, QGIS_SERVER_PORT), Handler) 453 # HTTPS is enabled if any of PKI or OAuth2 are enabled too 454 if HTTPS_ENABLED: 455 if QGIS_SERVER_OAUTH2_AUTH: 456 server.socket = ssl.wrap_socket( 457 server.socket, 458 certfile=QGIS_SERVER_OAUTH2_CERTIFICATE, 459 ca_certs=QGIS_SERVER_OAUTH2_AUTHORITY, 460 keyfile=QGIS_SERVER_OAUTH2_KEY, 461 server_side=True, 462 # cert_reqs=ssl.CERT_REQUIRED, # No certs for OAuth2 463 ssl_version=ssl.PROTOCOL_TLSv1_2) 464 else: 465 server.socket = ssl.wrap_socket( 466 server.socket, 467 certfile=QGIS_SERVER_PKI_CERTIFICATE, 468 keyfile=QGIS_SERVER_PKI_KEY, 469 ca_certs=QGIS_SERVER_PKI_AUTHORITY, 470 cert_reqs=ssl.CERT_REQUIRED, 471 server_side=True, 472 ssl_version=ssl.PROTOCOL_TLSv1_2) 473 474 print('Starting server on %s://%s:%s, use <Ctrl-C> to stop' % 475 ('https' if HTTPS_ENABLED else 'http', QGIS_SERVER_HOST, server.server_port), flush=True) 476 477 def signal_handler(signal, frame): 478 global qgs_app 479 print("\nExiting QGIS...") 480 qgs_app.exitQgis() 481 sys.exit(0) 482 483 signal.signal(signal.SIGINT, signal_handler) 484 server.serve_forever() 485