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