├── setup.cfg ├── .gitignore ├── examples ├── 7_oauth2.config ├── 6_basic_auth.config ├── 2_logging.config ├── 1_simple.py ├── views │ └── upload.html ├── upload.py ├── 4_params.py ├── 3_sessions.py ├── 7_oauth2.py ├── 8_gevent.py ├── cgevent.py ├── 2_logging.py ├── basic_auth.py ├── 5_custom_auth.py ├── 6_basic_auth.py ├── cgevent.config └── upload.config ├── setup.py ├── .github └── workflows │ └── pythonpublish.yml ├── LICENSE ├── security_note.md ├── README.md └── canister.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | __pycache__ 3 | canister.egg-info 4 | examples/logs -------------------------------------------------------------------------------- /examples/7_oauth2.config: -------------------------------------------------------------------------------- 1 | [canister] 2 | 3 | # OAuth2 4 | auth_jwt_client_id = alice 5 | auth_jwt_encoding = clear 6 | auth_jwt_secret = secret -------------------------------------------------------------------------------- /examples/6_basic_auth.config: -------------------------------------------------------------------------------- 1 | [canister] 2 | 3 | # Basic auth 4 | auth_basic_username = alice 5 | auth_basic_encryption = clear 6 | auth_basic_password = my-secret 7 | -------------------------------------------------------------------------------- /examples/2_logging.config: -------------------------------------------------------------------------------- 1 | [canister] 2 | 3 | # The logs directory, if commented, output will be redirected to the console 4 | #log_path = ./logs/ 5 | 6 | # Logging levels: DISABLED, DEBUG, INFO, WARNING, ERROR, CRITICAL 7 | log_level = INFO 8 | 9 | # Log older than that will be deleted 10 | log_days = 30 -------------------------------------------------------------------------------- /examples/1_simple.py: -------------------------------------------------------------------------------- 1 | import sys 2 | # just for the tests, to fetch the development version of canister in the parent directory 3 | sys.path.insert(0, '..') 4 | 5 | import bottle 6 | import canister 7 | from canister import session 8 | 9 | app = bottle.Bottle() 10 | app.install(canister.Canister()) 11 | 12 | 13 | @app.get('/') 14 | def index(): 15 | return 'It works!' 16 | 17 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /examples/views/upload.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # python setup.py sdist upload 2 | from setuptools import setup 3 | 4 | setup( 5 | name='canister', 6 | version='1.5.2', 7 | description='A bottle wrapper to provide logging, sessions and authentication.', 8 | url='https://github.com/dagnelies/canister', 9 | author='Arnaud Dagnelies', 10 | author_email='arnaud.dagnelies@gmail.com', 11 | license='MIT', 12 | keywords='bottle server webserver session logging authentication', 13 | py_modules=['canister'], 14 | scripts=['canister.py'], 15 | install_requires=[ 16 | 'bottle' 17 | ], 18 | extras_require={ 19 | 'jwt': ['PyJWT'] 20 | } 21 | ) -------------------------------------------------------------------------------- /examples/upload.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ab -n 1000 -c 10 http://localhost:8080/hello/world 4 | # ab -n 2 -c 2 http://127.0.0.1:8080/hello/world 5 | 6 | import gevent.monkey 7 | gevent.monkey.patch_all() 8 | 9 | import sys 10 | sys.path.insert(0, '..') 11 | import canister 12 | import time 13 | import bottle 14 | import os.path 15 | 16 | can = canister.build('upload.config') 17 | 18 | @can.get('/') 19 | @bottle.view('upload.html') 20 | def index(): 21 | pass 22 | 23 | @can.route('/upload', method='POST') 24 | def do_upload(): 25 | upload = bottle.request.files.get('upload') 26 | upload.save('uploads/' + upload.filename) 27 | return 'OK' 28 | 29 | can.run() -------------------------------------------------------------------------------- /examples/4_params.py: -------------------------------------------------------------------------------- 1 | import sys 2 | # just for the tests, to fetch the development version of canister in the parent directory 3 | sys.path.insert(0, '..') 4 | 5 | import bottle 6 | import canister 7 | from canister import session 8 | 9 | app = bottle.Bottle() 10 | app.install(canister.Canister()) 11 | 12 | 13 | @app.get('/') 14 | def index(foo=None, bar=None): 15 | return ''' 16 |
17 |             Function parameters are automatically extracted from the request if present.
18 |             foo: %s
19 |             bar: %s
20 |         
21 | /?foo=Foooooooo&bar=Baaaaaaaar 22 | ''' % (foo,bar) 23 | 24 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /examples/3_sessions.py: -------------------------------------------------------------------------------- 1 | import sys 2 | # just for the tests, to fetch the development version of canister in the parent directory 3 | sys.path.insert(0, '..') 4 | 5 | import bottle 6 | import canister 7 | from canister import session 8 | 9 | app = bottle.Bottle() 10 | app.install(canister.Canister()) 11 | 12 | 13 | @app.get('/') 14 | def index(): 15 | if 'counter' in session.data: 16 | session.data['counter'] += 1 17 | else: 18 | session.data['counter'] = 0 19 | 20 | return '''
21 |         Refresh the page to see the counter increase.
22 |         Session sid: %s
23 |         Session data: %s 
24 |     
''' % (session.sid, session.data) 25 | 26 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /examples/7_oauth2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ab -n 1000 -c 10 http://localhost:8080/hello/world 4 | # ab -n 2 -c 2 http://127.0.0.1:8080/hello/world 5 | 6 | import sys 7 | sys.path.insert(0, '..') 8 | import canister 9 | import time 10 | import bottle 11 | from canister import session 12 | 13 | app = bottle.Bottle() 14 | app.config.load_config('7_oauth2.config') 15 | app.install(canister.Canister()) 16 | 17 | @app.get('/') 18 | def index(): 19 | return ''' 20 |
21 |             Session sid: %s
22 |             Session user: %s
23 |         
24 | Now try to send a brearer token in the Authorization header. 25 | ''' % (session.sid, session.user) 26 | 27 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - name: Set up Python 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /examples/8_gevent.py: -------------------------------------------------------------------------------- 1 | import sys 2 | # just for the tests, to fetch the development version of canister in the parent directory 3 | sys.path.insert(0, '..') 4 | 5 | import bottle 6 | import canister 7 | from canister import session 8 | 9 | app = bottle.Bottle() 10 | app.install(canister.Canister()) 11 | 12 | 13 | @app.get('/') 14 | def index(foo=None): 15 | if 'counter' in session.data: 16 | session.data['counter'] += 1 17 | else: 18 | session.data['counter'] = 0 19 | 20 | return ''' 21 |
22 |             Session sid: %s
23 |             Session user: %s
24 |             Session data: %s 
25 |             "?foo=...": %s
26 |         
27 | ''' % (session.sid, session.user, session.data, foo) 28 | 29 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /examples/cgevent.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ab -n 1000 -c 10 http://localhost:8080/hello/world 4 | # ab -n 2 -c 2 http://127.0.0.1:8080/hello/world 5 | 6 | import gevent.monkey 7 | gevent.monkey.patch_all() 8 | 9 | import sys 10 | sys.path.insert(0, '..') 11 | import canister 12 | import time 13 | import bottle 14 | 15 | can = canister.build('cgevent.config') 16 | 17 | @can.get('/') 18 | def index(): 19 | if can.session.user: 20 | return "Hi " + str(can.session.user) + "!"; 21 | else: 22 | err = bottle.HTTPError(401, "Login required") 23 | err.add_header('WWW-Authenticate', 'Basic realm="%s"' % 'private') 24 | return err 25 | 26 | @can.get('/hello/') 27 | def hello(name): 28 | can.log.info('before') 29 | time.sleep(0.1) 30 | can.log.info('after') 31 | return "Hello {0}!".format(name) #template('Hello {{name}}!', name=name) 32 | 33 | can.run() -------------------------------------------------------------------------------- /examples/2_logging.py: -------------------------------------------------------------------------------- 1 | import sys 2 | # just for the tests, to fetch the development version of canister in the parent directory 3 | sys.path.insert(0, '..') 4 | 5 | import bottle 6 | import canister 7 | from canister import session 8 | 9 | app = bottle.Bottle() 10 | app.config.load_config('2_logging.config') 11 | app.install(canister.Canister()) 12 | 13 | 14 | @app.get('/') 15 | def index(): 16 | app.log.debug('Some debug message') 17 | app.log.info('Some info message') 18 | app.log.warn('Some warning message') 19 | app.log.error('Some error message') 20 | app.log.critical('Big problem!') 21 | 22 | return '''
23 |     Without config, all logs go to the console.
24 |     If "log_path=..." is set, they are written to a rotating log file.
25 |     Note that even when the log is written, the underlying WSGI server may still write to stdout, stderr or elsewhere.
26 |     
''' 27 | 28 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /examples/basic_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ab -n 1000 -c 10 http://localhost:8080/hello/world 4 | # ab -n 2 -c 2 http://127.0.0.1:8080/hello/world 5 | 6 | import sys 7 | sys.path.insert(0, '..') 8 | import canister 9 | import time 10 | import bottle 11 | from canister import session 12 | 13 | app = bottle.Bottle() 14 | app.config.load_config('auth.config') 15 | app.install(canister.Canister()) 16 | 17 | @app.get('/') 18 | def index(): 19 | return ''' 20 |
21 |             Session sid: %s
22 |             Session user: %s
23 |         
24 | Basic Auth (username: alice, password: my-secret) 25 | ''' % (session.sid, session.user) 26 | 27 | 28 | @app.get('/secret') 29 | def secret(): 30 | if not session.user: 31 | err = bottle.HTTPError(401, "Login required") 32 | err.add_header('WWW-Authenticate', 'Basic realm="%s"' % 'private') 33 | return err 34 | 35 | return 'You are authenticated as ' + str(session.user) 36 | 37 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /examples/5_custom_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ab -n 1000 -c 10 http://localhost:8080/hello/world 4 | # ab -n 2 -c 2 http://127.0.0.1:8080/hello/world 5 | 6 | import sys 7 | sys.path.insert(0, '..') 8 | import canister 9 | import time 10 | import bottle 11 | from canister import session 12 | 13 | app = bottle.Bottle() 14 | app.install(canister.Canister()) 15 | 16 | @app.get('/') 17 | def index(): 18 | return ''' 19 |
20 |             Session sid: %s
21 |             Session user: %s
22 |         
23 |
24 | My private area (username: alice, password: my-secret) 25 | ''' % (session.sid, session.user) 26 | 27 | 28 | @app.get('/login') 29 | def login(username, password): 30 | session.user = username 31 | return 'Welcome %s! Go back Log out' % session.user 32 | 33 | @app.get('/logout') 34 | def logout(): 35 | session.user = None 36 | return 'Bye! Go back' 37 | 38 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /examples/6_basic_auth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # ab -n 1000 -c 10 http://localhost:8080/hello/world 4 | # ab -n 2 -c 2 http://127.0.0.1:8080/hello/world 5 | 6 | import sys 7 | sys.path.insert(0, '..') 8 | import canister 9 | import time 10 | import bottle 11 | from canister import session 12 | 13 | app = bottle.Bottle() 14 | app.config.load_config('6_basic_auth.config') 15 | app.install(canister.Canister()) 16 | 17 | @app.get('/') 18 | def index(): 19 | return ''' 20 |
21 |             Session sid: %s
22 |             Session user: %s
23 |         
24 | My private area (username: alice, password: my-secret) 25 | ''' % (session.sid, session.user) 26 | 27 | 28 | @app.get('/secret') 29 | def secret(): 30 | if not session.user: 31 | err = bottle.HTTPError(401, "Login required") 32 | err.add_header('WWW-Authenticate', 'Basic realm="%s"' % 'private') 33 | return err 34 | 35 | return 'Welcome %s! Go back (note that there is no reliable way to logout using basic auth)' % session.user 36 | 37 | app.run(host='0.0.0.0') -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /security_note.md: -------------------------------------------------------------------------------- 1 | Security note 2 | ============= 3 | 4 | TODO: add some kind of automated support to avoid CSFR (https://en.wikipedia.org/wiki/Cross-site_request_forgery) 5 | 6 | One of the common security flaws of web apps is Cross Site Request Forgery. 7 | 8 | Either: 9 | - provide auth-creditentials on each request 10 | 11 | Or make a double token check: 12 | - provide a HTTP-Only cookie containing your session ID (to prove your're authenticated and prevent XSS, done by Canister) 13 | - and provide an additional token as parameter or in a "X-Csrf-Token" header (to prove it comes from your Browser and prevent CSFR, must be done client side through javascript) 14 | 15 | ...however, the issue is that this method requires client side stuff in the page (through hidden fields, request parameters or setting specific headers through javascript) 16 | 17 | Alternatively, the Referer Header can be checked. 18 | How does this prevent CSRF? 19 | - if the request doesn't come from the browser, the cookie/session-id is unknown 20 | - if it comes from the browser, we can ensure it comes from the site itself 21 | 22 | ...however, the issue in case is that the header is sometimes removed (because of a privacy proxy or plugin, but I think it's pretty seldom) 23 | 24 | 25 | -------------------------------------------------------------------------------- /examples/cgevent.config: -------------------------------------------------------------------------------- 1 | [bottle] 2 | 3 | # Server adapter to use. Choices: wsgiref, gevent, cherrypy... (see bottle docs for full list) 4 | server = gevent 5 | 6 | # Pass 0.0.0.0 to listens on all interfaces including the external one. 7 | host = 0.0.0.0 8 | 9 | # Server port to bind to. Values below 1024 require root privileges. 10 | port = 8080 11 | 12 | # Traceback on error pages, templates not cached, plugins applied directly 13 | # Also activates reloader, and redirect all logs on DEBUG level to stdout/stderr 14 | debug = true 15 | 16 | # SSL certificate 17 | # certfile = ... 18 | 19 | # SSL key 20 | # keyfile = ... 21 | 22 | [logs] 23 | 24 | # If not in debug mode, 2 logs are produced per day: 25 | # requests.yyyy-mm-dd - contains requests only 26 | # log.yyyy-mm-dd - contains all messages, including requests 27 | # ...and nothing is written to stdout/stderr 28 | 29 | # The logs directory 30 | path = ./logs/ 31 | # Logging levels: DISABLED, DEBUG, INFO, WARNING, ERROR, CRITICAL 32 | level = DEBUG 33 | # Log older than that will be deleted 34 | days = 100 35 | # Requests / server adapter stdout 36 | requests = true 37 | # (not yet implemented) email to notify on error 38 | notify = 39 | 40 | 41 | [sessions] 42 | 43 | # (not yet implemented) how long the session data will still be available after the last access 44 | expiration = 30d 45 | # (not yet implemented) the interval to check for obsolete sessions 46 | check_interval = 1h 47 | 48 | 49 | [views] 50 | # static path: everything inside will be served like normal files 51 | static_path = ./static/ 52 | 53 | # templates path: used in bottle with template('...') 54 | template_path = ./views/ 55 | 56 | 57 | [requests] 58 | 59 | # applies CORS to responses, write * to allow AJAX requests from anywhere 60 | CORS = false 61 | 62 | # Basic auth 63 | [auth_basic] 64 | 65 | username = alice 66 | password_enc = clear 67 | password = my-secret 68 | 69 | # ...or alternatively, if you dislike putting your plain text password in the config: 70 | # password_enc = sha256 71 | # password = 186ef76e9d6a723ecb570d4d9c287487d001e5d35f7ed4a313350a407950318e 72 | 73 | 74 | # Auth using JWT (using auth0 for example) 75 | [auth_jwt] 76 | 77 | client_id = 78 | secret = 79 | # secret = my-secret 80 | # some services, like auth0, provide you a secret as bytes in urlsafe_b64 81 | # if not specified, it will be assumed to be clear text 82 | secret_enc = urlsafe_b64 -------------------------------------------------------------------------------- /examples/upload.config: -------------------------------------------------------------------------------- 1 | [bottle] 2 | 3 | # Server adapter to use. Choices: wsgiref, gevent, cherrypy... (see bottle docs for full list) 4 | server = gevent 5 | 6 | # Pass 0.0.0.0 to listens on all interfaces including the external one. 7 | host = 0.0.0.0 8 | 9 | # Server port to bind to. Values below 1024 require root privileges. 10 | port = 8080 11 | 12 | # Traceback on error pages, templates not cached, plugins applied directly 13 | # Also activates reloader, and redirect all logs on DEBUG level to stdout/stderr 14 | debug = true 15 | 16 | # SSL certificate 17 | # certfile = ... 18 | 19 | # SSL key 20 | # keyfile = ... 21 | 22 | [logs] 23 | 24 | # If not in debug mode, 2 logs are produced per day: 25 | # requests.yyyy-mm-dd - contains requests only 26 | # log.yyyy-mm-dd - contains all messages, including requests 27 | # ...and nothing is written to stdout/stderr 28 | 29 | # The logs directory 30 | path = ./logs/ 31 | # Logging levels: DISABLED, DEBUG, INFO, WARNING, ERROR, CRITICAL 32 | level = DEBUG 33 | # Log older than that will be deleted 34 | days = 100 35 | # Requests / server adapter stdout 36 | requests = true 37 | # (not yet implemented) email to notify on error 38 | notify = 39 | 40 | 41 | [sessions] 42 | 43 | # (not yet implemented) how long the session data will still be available after the last access 44 | expiration = 30d 45 | # (not yet implemented) the interval to check for obsolete sessions 46 | check_interval = 1h 47 | 48 | 49 | [views] 50 | # static path: everything inside will be served like normal files 51 | static_path = ./static/ 52 | 53 | # templates path: used in bottle with template('...') 54 | template_path = ./views/ 55 | 56 | 57 | [requests] 58 | 59 | # applies CORS to responses, write * to allow AJAX requests from anywhere 60 | CORS = false 61 | 62 | # Basic auth 63 | [auth_basic] 64 | 65 | username = alice 66 | password_enc = clear 67 | password = my-secret 68 | 69 | # ...or alternatively, if you dislike putting your plain text password in the config: 70 | # password_enc = sha256 71 | # password = 186ef76e9d6a723ecb570d4d9c287487d001e5d35f7ed4a313350a407950318e 72 | 73 | 74 | # Auth using JWT (using auth0 for example) 75 | [auth_jwt] 76 | 77 | client_id = 78 | secret = 79 | # secret = my-secret 80 | # some services, like auth0, provide you a secret as bytes in urlsafe_b64 81 | # if not specified, it will be assumed to be clear text 82 | secret_enc = urlsafe_b64 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Canister 2 | ======== 3 | 4 | Canister is a simple plugin for bottle, providing: 5 | 6 | - formatted logs 7 | - url and form params unpacking 8 | - sessions (server side) based on a `session_id` cookie 9 | - authentication through basic auth or bearer token (OAuth2) 10 | - CORS for cross-domain REST APIs 11 | 12 | ### Install 13 | 14 | Usually (no dependency except *bottle*): 15 | 16 | `pip install canister` 17 | 18 | ...or download the single file `canister.py`. 19 | 20 | However, if you plan to use JWT/OAuth2: 21 | 22 | `pip install canister[jwt]` 23 | 24 | ### Usage 25 | 26 | ```python 27 | import bottle 28 | import canister 29 | from canister import session 30 | 31 | app = bottle.Bottle() 32 | app.config.load_config('') 33 | app.install(canister.Canister()) 34 | 35 | 36 | @app.get('/') 37 | def index(foo=None): 38 | if 'counter' in session.data: 39 | session.data['counter'] += 1 40 | else: 41 | session.data['counter'] = 0 42 | 43 | return ''' 44 |
 45 |             Session sid: %s
 46 |             Session user: %s
 47 |             Session data: %s 
 48 |             "?foo=...": %s
 49 |         
50 | ''' % (session.sid, session.user, session.data, foo) 51 | 52 | app.run() 53 | ``` 54 | 55 | 56 | ### Logs 57 | 58 | If not otherwise configured, logs will be written in the `logs` directory. They look like this: 59 | 60 | ``` 61 | 2016-07-02 09:38:31,022 INFO [MainThread] ============ 62 | 2016-07-02 09:38:31,022 INFO [MainThread] Initializing 63 | 2016-07-02 09:38:31,022 INFO [MainThread] ============ 64 | 2016-07-02 09:38:31,022 INFO [MainThread] python version: 3.4.3 (default, Oct 14 2015, 20:28:29) 65 | 2016-07-02 09:38:31,022 INFO [MainThread] bottle version: 0.12.9 66 | 2016-07-02 09:38:31,022 INFO [MainThread] ------------------------------------------ 67 | 2016-07-02 09:38:31,022 INFO [MainThread] ...all config params... 68 | 2016-07-02 09:38:31,022 INFO [MainThread] ------------------------------------------ 69 | 2016-07-02 09:38:33,216 INFO [149.172.44.162-...] GET http://localhost:8080/ 70 | 2016-07-02 09:38:33,216 INFO [149.172.44.162-VJ8zq5] Session id: VJ8zq5Gq55cVstAJg4zcC2E1 71 | 2016-07-02 09:38:33,217 INFO [149.172.44.162-VJ8zq5] Response: 200 (1ms) 72 | ``` 73 | 74 | You will get one such log file each day, like `log.2016-07-02`, for the last `30` days. 75 | 76 | You can also log messages in your code like this: 77 | ``` 78 | @app.get('/') 79 | def index(): 80 | app.log.info('Hey!') 81 | return 'Ho!' 82 | ``` 83 | 84 | The logging uses the common `logging` module and is thread safe. 85 | When serving requests, the corresponding thread also gets renamed according to the client IP and the start of its session ID. 86 | This can be seen in the logs `[149.172.44.162-VJ8zq5]` in order to be able to easely follow client-server "discussions" over a longer timespan. 87 | 88 | You can also tweak logging settings in the config: 89 | 90 | ```ini 91 | [canister] 92 | 93 | # The logs directory 94 | log_path = ./logs/ 95 | 96 | # Logging levels: DISABLED, DEBUG, INFO, WARNING, ERROR, CRITICAL 97 | log_level = INFO 98 | 99 | # Log older than that will be deleted 100 | log_days = 30 101 | ``` 102 | 103 | ### URL and form params unpacking 104 | 105 | Using canister, all URL parameters and form POST parameters are automatically unpacked. 106 | 107 | Example: 108 | 109 | ```python 110 | @get('/hi') 111 | def hello(foo, bar=''): 112 | if bar: 113 | return 'Hi %s and %s!' % (foo, bar) 114 | else: 115 | return 'Hi %s!' % foo 116 | ``` 117 | 118 | When requesting `http://.../hi?foo=John&bar=Smith`, the response will be `Hi John Smith!`. 119 | 120 | In this example, the `foo` parameter is mandatory, and the `bar` parameter is optional since it has a default value. 121 | 122 | If a mandatory argument is missing, an Exception will be throw. 123 | 124 | 125 | ### Sessions 126 | 127 | Sessions are kept server side in memory and identified through a HTTP-only cookie with a `session_id`. 128 | 129 | The session data is simply a python dictionary: 130 | 131 | ```python 132 | import bottle 133 | import canister 134 | from canister import session 135 | 136 | @get('/') 137 | def index(): 138 | if 'counter' in session.data: 139 | session.data['counter'] += 1 140 | else: 141 | session.data['counter'] = 0 142 | # ... 143 | ``` 144 | 145 | Since a server never knows when a user quits, sessions simply time out after some time. 146 | By default, they expire after an hour, but this can be fine tuned in the config: 147 | 148 | 149 | ```ini 150 | [canister] 151 | 152 | # how long the session data will still be available after the last access, in seconds 153 | session_timout = 3600 154 | ``` 155 | 156 | One more note about sessions: it's *in-memory*. Therefore sessions are lost when you stop/restart/crash the server. 157 | Also, for large data or long durability, use a DB, not sessions. 158 | 159 | 160 | ### Authentication 161 | 162 | Canister will automatically parse two kind of `Authorization` headers: 163 | - Basic authentication (for basic login/password) 164 | - JWT / Bearer token authentication (for OAuth2) 165 | 166 | See the example configuration above to see how it is configured. 167 | 168 | The user will then be available in `canister.session.user` for the duration of the session. 169 | In case of basic authentication, `user` will be the username. 170 | If it is JWT authentication (OAuth2), `user` will contain the profile with the requested attributes. 171 | 172 | ### CORS 173 | 174 | If you have REST APIs, enabling CORS can be quite useful. 175 | 176 | ```ini 177 | [canister] 178 | 179 | # applies CORS to responses, write * to allow AJAX requests from anywhere 180 | CORS = * 181 | ``` 182 | 183 | If enabled through the config, they will be applied to ***all*** responses! 184 | 185 | 186 | ### Sample config file 187 | 188 | ```ini 189 | [canister] 190 | 191 | # ...due to limitations of bottle's plugin mechanism, 192 | debug=False 193 | 194 | # Logs 195 | log_path = ./logs/ 196 | log_level = INFO 197 | log_days = 30 198 | 199 | # how long the session data will still be available after the last access, in seconds 200 | session_timout = 3600 201 | 202 | # applies CORS to responses, write * to allow AJAX requests from anywhere 203 | #CORS = * 204 | 205 | # Basic auth 206 | auth_basic_username = alice 207 | auth_basic_encryption = clear 208 | auth_basic_password = my-secret 209 | 210 | # ...or alternatively, if you dislike putting your plain text password in the config: 211 | # auth_basic_encryption = sha256 212 | # auth_basic_password = 186ef76e9d6a723ecb570d4d9c287487d001e5d35f7ed4a313350a407950318e 213 | 214 | 215 | # Auth using JWT (for OAuth2) 216 | auth_jwt_client_id = ABC 217 | # accepted encodings are "clear", "base64std" or "base64url" 218 | auth_jwt_encoding = base64url 219 | auth_jwt_secret = my-secret 220 | ``` 221 | 222 | 223 | ### Security ABC 224 | 225 | * use HTTPS 226 | * be aware of CSRF 227 | * don't enable CORS if you don't need to 228 | -------------------------------------------------------------------------------- /canister.py: -------------------------------------------------------------------------------- 1 | """ 2 | Canister is a simple plugin for bottle, providing: 3 | 4 | - formatted logs 5 | - url and form params unpacking 6 | - sessions (server side) based on a `session_id` cookie 7 | - authentication through basic auth or bearer token (OAuth2) 8 | - CORS for cross-domain REST APIs 9 | """ 10 | import sys 11 | import logging 12 | import logging.handlers 13 | import bottle 14 | import threading 15 | import base64 16 | import os 17 | import os.path 18 | import hashlib 19 | import inspect 20 | import time 21 | import math 22 | 23 | # this will contain id, user and data 24 | session = threading.local() 25 | 26 | class TimedDict(dict): 27 | def __init__(self): 28 | self._items = {} 29 | 30 | def __getitem__(self, key): 31 | (t, val) = self._items[key] 32 | t = time.time() 33 | self._items[key] = (t, val) 34 | return val 35 | 36 | def __setitem__(self, key, val): 37 | t = time.time() 38 | self._items[key] = (t, val) 39 | 40 | def __contains__(self, key): 41 | return (key in self._items) 42 | 43 | def __delitem__(self, key): 44 | del self._items[key] 45 | 46 | def keys(self): 47 | return self._items.keys() 48 | 49 | def values(self): 50 | for t,val in self._items.values(): 51 | yield val 52 | 53 | def items(self): 54 | for (k, (t,val)) in self._items.items(): 55 | yield (k, val) 56 | 57 | def prune(self, age): 58 | now = time.time() 59 | survivors = {} 60 | for (k, (t,val)) in self._items.items(): 61 | if now - t < age: 62 | survivors[k] = (t,val) 63 | pruned = len(self._items) - len(survivors) 64 | self._items = survivors 65 | return pruned 66 | 67 | 68 | 69 | 70 | def _buildLogger(config): 71 | level = config.get('canister.log_level', 'INFO') 72 | path = config.get('canister.log_path') 73 | days = int( config.get('canister.log_days', '30') ) 74 | log = logging.getLogger('canister') 75 | 76 | if level == 'DISABLED': 77 | return log 78 | 79 | if not path: 80 | h = logging.StreamHandler() 81 | else: 82 | os.makedirs(path, exist_ok=True) 83 | h = logging.handlers.TimedRotatingFileHandler( os.path.join(path, 'log'), when='midnight', backupCount=int(days)) 84 | 85 | log.setLevel(level) 86 | f = logging.Formatter('%(asctime)s %(levelname)-8s [%(threadName)s] %(message)s') 87 | h.setFormatter( f ) 88 | log.addHandler( h ) 89 | 90 | return log 91 | 92 | 93 | 94 | def _buildAuthBasic(config): 95 | username = config.get('canister.auth_basic_username', None) 96 | password = config.get('canister.auth_basic_password', None) 97 | encryption = config.get('canister.auth_basic_encryption', 'clear').lower() # clear or sha256 98 | 99 | if not username or not password: 100 | return None 101 | 102 | def validate(token): 103 | user, pwd = base64.b64decode(token).decode('utf-8').split(':', 1) 104 | if user != username: 105 | return None 106 | elif encryption == 'clear' and password == pwd: 107 | return user 108 | elif encryption == 'sha256' and password == hashlib.sha256(pwd): 109 | return user 110 | else: 111 | return None 112 | 113 | return validate 114 | 115 | 116 | 117 | def _buildAuthJWT(config): 118 | client_id = config.get('canister.auth_jwt_client_id', None) 119 | secret = config.get('canister.auth_jwt_secret', None) 120 | encoding = config.get('canister.auth_jwt_encoding', 'clear').lower() # clear, base64std, or base64url 121 | 122 | if not client_id or not secret: 123 | return None 124 | 125 | import jwt 126 | 127 | if encoding == 'base64std': # with + and / 128 | secret = base64.standard_b64decode(secret) 129 | elif encoding == 'base64url': # with - and _ 130 | secret = base64.urlsafe_b64decode(secret) 131 | elif encoding == 'clear': 132 | pass 133 | else: 134 | raise Exception('Invalid auth_jwt_encoding in config: "%s" (should be "clear", "base64std" or "base64url")' % encoding) 135 | 136 | def validate(token): 137 | profile = jwt.decode(token, secret, audience=client_id) 138 | return profile 139 | 140 | return validate 141 | 142 | 143 | 144 | class SessionCache: 145 | '''A thread safe session cache with a cleanup thread''' 146 | 147 | def __init__(self, timeout=3600): 148 | self._lock = threading.Lock() 149 | self._cache = TimedDict() 150 | log = logging.getLogger('canister') 151 | 152 | if timeout <= 0: 153 | log.warn('Sessions kept indefinitely! (session timeout is <= 0)') 154 | return 155 | 156 | interval = int(math.sqrt(timeout)) 157 | log.info('Session timeout is %d seconds. Checking for expired sessions every %d seconds. ' % (timeout, interval)) 158 | 159 | def prune(): 160 | while True: 161 | time.sleep(interval) 162 | with self._lock: 163 | n = self._cache.prune(timeout) 164 | log.debug('%d expired sessions pruned' % n) 165 | 166 | # Note Daemon threads are abruptly stopped at shutdown. 167 | # Their resources (such as open files, database transactions, etc.) may not be released properly. 168 | # Since these are "in memory" sessions, we don't care ...just be aware of it if you want to change that. 169 | cleaner = threading.Thread(name="SessionCleaner", target=prune) 170 | cleaner.daemon = True 171 | cleaner.start() 172 | 173 | def __contains__(self, sid): 174 | return sid in self._cache 175 | 176 | 177 | def get(self, sid): 178 | with self._lock: 179 | if sid in self._cache: 180 | return self._cache[sid] 181 | else: 182 | return (None, None) 183 | 184 | def set(self, sid, user, data): 185 | assert sid 186 | with self._lock: 187 | self._cache[sid] = (user, data) 188 | 189 | 190 | def create(self, user=None, data=None): 191 | sid = base64.b64encode(os.urandom(18)).decode('ascii') 192 | with self._lock: 193 | if not data: 194 | data = {} 195 | self._cache[sid] = (user, data) 196 | 197 | return (sid, user, data) 198 | 199 | 200 | def delete(self, sid): 201 | with self._lock: 202 | del self._cache[sid] 203 | 204 | 205 | 206 | class Canister: 207 | name = 'canister' 208 | api = 2 209 | 210 | def __init__(self): 211 | pass 212 | 213 | def setup(self, app): 214 | 215 | #if 'canister' not in app.config: 216 | # raise Exception('Canister requires a configuration file. Please refer to the docs.') 217 | 218 | config = app.config 219 | 220 | log = _buildLogger(config) 221 | 222 | log.info('============') 223 | log.info('Initializing') 224 | log.info('============') 225 | 226 | log.info('python version: ' + sys.version) 227 | log.info('bottle version: ' + bottle.__version__) 228 | 229 | log.info('------------------------------------------') 230 | for k,v in app.config.items(): 231 | log.info('%-30s = %s' % (k,v)) 232 | log.info('------------------------------------------') 233 | 234 | 235 | self.app = app 236 | self.log = log 237 | app.log = log 238 | 239 | timeout = int(config.get('canister.session_timeout', '3600')) 240 | self.sessions = SessionCache(timeout=timeout) 241 | self.session_secret = base64.b64encode(os.urandom(30)).decode('ascii') 242 | 243 | self.auth_basic = _buildAuthBasic(config) 244 | if self.auth_basic: 245 | log.info('Basic authentication enabled.') 246 | 247 | self.auth_jwt = _buildAuthJWT(config) 248 | if self.auth_jwt: 249 | log.info('JWT authentication enabled.') 250 | 251 | self.cors = config.get('canister.CORS', None) 252 | if self.cors and self.cors.lower() == 'false': 253 | self.cors = None 254 | 255 | def apply(self, callback, route): 256 | 257 | log = self.log 258 | 259 | def wrapper(*args, **kwargs): 260 | 261 | global session 262 | 263 | start = time.time() 264 | 265 | req = bottle.request 266 | res = bottle.response 267 | 268 | # thread name = -.... 269 | threading.current_thread().name = req.remote_addr + '-...' 270 | log.info(req.method + ' ' + req.url) 271 | 272 | # session 273 | sid = req.get_cookie('session_id', secret=self.session_secret) 274 | 275 | if sid and sid in self.sessions: 276 | user, data = self.sessions.get(sid) 277 | log.info('Session found: ' + sid) 278 | else: 279 | sid, user, data = self.sessions.create() 280 | res.set_cookie('session_id', sid, secret=self.session_secret) 281 | log.info('Session created: ' + sid) 282 | 283 | session.sid = sid 284 | session.data = data 285 | 286 | # thread name = - 287 | threading.current_thread().name = req.remote_addr + '-' + sid[0:6] 288 | 289 | # user 290 | auth = req.headers.get('Authorization') 291 | if auth: 292 | tokens = auth.split() 293 | if len(tokens) != 2: 294 | self.log.warning('Invalid or unsupported Authorization header: ' + auth) 295 | return None 296 | 297 | if self.auth_basic and tokens[0].lower() == 'basic': 298 | user = self.auth_basic( tokens[1] ) 299 | 300 | elif self.auth_jwt and tokens[0].lower() == 'bearer': 301 | user = self.auth_jwt( tokens[1] ) 302 | 303 | if user: 304 | self.log.info('Logged in as: ' + str(user)) 305 | self.sessions.set(sid, user, data) 306 | 307 | session.user = user 308 | 309 | 310 | # args unpacking 311 | sig = inspect.getargspec(callback) 312 | 313 | for a in sig.args: 314 | if a in req.params: 315 | kwargs[a] = req.params[a] 316 | 317 | result = callback(*args, **kwargs) 318 | 319 | if session.user != user or session.data != data: 320 | self.sessions.set(sid, session.user, session.data) 321 | 322 | if self.cors: 323 | res.headers['Access-Control-Allow-Origin'] = self.cors 324 | 325 | elapsed = time.time() - start 326 | 327 | if elapsed > 1: 328 | log.warn('Response: %d (%dms !!!)' % (res.status_code, 1000*elapsed)) 329 | else: 330 | log.info('Response: %d (%dms)' % (res.status_code, 1000*elapsed)) 331 | return result 332 | 333 | return wrapper 334 | 335 | 336 | def close(self): 337 | pass 338 | --------------------------------------------------------------------------------