Navigation Menu

Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dagnelies committed Jun 12, 2016
0 parents commit 4643a44
Show file tree
Hide file tree
Showing 8 changed files with 514 additions and 0 deletions.
53 changes: 53 additions & 0 deletions README.md
@@ -0,0 +1,53 @@
Canister
========

Canister is a simple wrapper around bottle, providing:

- file based configuration
- logging
- sessions (server side, based on a `session_id` cookie)
- ssl support (for gevent or cherrypy as server adapter at least)
- can serve all files in a `static_path` directory out of the box
- CORS for cross-domain REST APIs
- authentication through basic auth or bearer token (oauth2)

### Usage

```
import canister
import bottle
can = canister.build('example.config')
@can.get('/')
def index():
if can.session.user:
return "Hi " + str(can.session.user) + "!";
else:
err = bottle.HTTPError(401, "Login required")
err.add_header('WWW-Authenticate', 'Basic realm="%s"' % 'private')
return err
@can.get('/hello/<name>')
def hello(name):
can.log.info('Hey!')
time.sleep(0.1)
can.log.info('Ho!')
return "Hello {0}!".format(name) #template('<b>Hello {{name}}</b>!', name=name)
can.run()
```

### Logs

### Sessions

### Authentication

### SSL

### Serving a directory

### Websockets

### CORS
179 changes: 179 additions & 0 deletions canister.py
@@ -0,0 +1,179 @@
"""
This is a wrapper around bottle, providing:
- file based configuration
- logging
- sessions (server side) based on a `session_id` cookie
- ssl support (for gevent or cherrypy as server adapter at least)
- serves all files in the `static_path` directory out of the box
- CORS for cross-domain REST APIs
- authentication through basic auth or bearer token
"""
import sys
import logging
import logging.handlers
import bottle
import threading
import base64
import os
import os.path
import configparser
import jwt
import hashlib

def getLogger(level, path, days, notify, **kwargs):
log = logging.getLogger('canister')
if level.upper() != 'DISABLED':
os.makedirs(path, exist_ok=True)
log.setLevel(level)
h = logging.handlers.TimedRotatingFileHandler( os.path.join(path, 'log'), when='midnight', backupCount=int(days))
f = logging.Formatter('%(asctime)s %(levelname)-8s [%(threadName)s] %(message)s')
h.setFormatter( f )
log.addHandler( h )
if notify:
pass
# TODO: add email notifier with SMTPHandler + QueueHandler
return log


def getRequestsLogger(level, path, days, requests, **kwargs):
log = logging.getLogger('requests')
if level.upper() != 'DISABLED' and requests.lower() == 'true':
log.setLevel(level)
h = logging.handlers.TimedRotatingFileHandler( os.path.join(path, 'requests'), when='midnight', backupCount=int(days))
log.addHandler( h )
return log

def build(config_path):

config = configparser.ConfigParser()
with open(config_path) as f:
config.read_file(f)

can = bottle.Bottle()


log = getLogger(**config['logs'])
log_req = getRequestsLogger(**config['logs'])

log.info('============')
log.info('Initializing')
log.info('============')

log.info('python version: ' + sys.version)
log.info('bottle version: ' + bottle.__version__)

log.info('------------------------------------------')
for s in config.sections():
for p in config[s]:
log.info('%-23s = %s' % (s + '.' + p, config[s][p]) )
log.info('------------------------------------------')


sessions = {}
session_secret = base64.b64encode(os.urandom(30)).decode('ascii')
can.session = threading.local()

auth_basic_username = config.get('auth_basic', 'username', fallback=None)
auth_basic_password = config.get('auth_basic', 'password', fallback=None)
auth_basic_enc = config.get('auth_basic', 'password_enc', fallback='clear').lower()

if auth_basic_username and auth_basic_password:
log.info('Enabling basic authentication for: ' + auth_basic_username)

auth_jwt_secret = config.get('auth_jwt', 'secret', fallback=None)

if auth_jwt_secret:
log.info('Enabling JWT bearer token authentication')
auth_jwt_secret = base64.urlsafe_b64decode(auth_jwt_secret)

def getUser(auth):
tokens = auth.split()
if len(tokens) != 2:
log.warning('Invalid or unsupported Authorization header: ' + auth)
return None

if auth_basic_username and tokens[0].lower() == 'basic':
user, pwd = base64.b64decode( tokens[1] ).decode('utf-8').split(':', 1)
if user != auth_basic_username:
return None
elif auth_basic_enc == 'clear' and auth_basic_password == pwd:
return user
elif auth_basic_enc == 'sha256' and auth_basic_password == hashlib.sha256(pwd):
return user
else:
return None

elif auth_jwt_secret and tokens[0].lower() == 'bearer':
profile = jwt.decode(tokens[1], auth_jwt_secret)
return profile

@can.hook('before_request')
def before():
req = bottle.request
res = bottle.response

# thread name = <ip>-....
threading.current_thread().name = can.remote_addr + '-....'
log.info(req.method + ' ' + req.url)

can.session.id = None
can.session.user = None
can.session.data = None

can.session.id = req.get_cookie('session_id', secret=session_secret)
if not can.session.id or (can.session.id not in sessions):
# either there is no ID or it's been purged from the sessions
can.session.id = base64.b64encode(os.urandom(18)).decode('ascii')
res.set_cookie('session_id', can.session.id, secret=session_secret, httponly=True)
sessions[can.session.id] = {}

# thread name = <ip>-<session_id[0:4]>
threading.current_thread().name = can.remote_addr + '-' + can.session.id[0-4]
log.info('Session id: ' + can.session.id)

# set data
can.session.data = sessions[can.session.id]

auth = req.headers.get('Authorization')
if auth:
try:
log.debug('Attempting authentication for: ' + auth)
user = getUser(auth)
can.session.user = user
if user:
log.info('Logged in as: ' + user)
except Exception as ex:
log.warning('Authentication failed: ' + str(ex))




@can.hook('after_request')
def after():
res = bottle.response
# TODO: is there a way to log the start of the response?
log.info(res.status_line)


def logout(user):
# TODO: remove cookie
# TODO: clear session
pass

def run():

static_path = config.get('views', 'static_path', fallback=None)
if static_path:
@can.get('/<path:path>')
def serve(path):
return bottle.static_file(path, root=static_path)

log.info('Starting...')
bottle.run(can, log=log_req, error_log=log, **config['bottle'])

can.log = log
can.run = run
can.logout = logout

return can
83 changes: 83 additions & 0 deletions examples/basic.config
@@ -0,0 +1,83 @@
[bottle]

# Server adapter to use. Choices: wsgiref, gevent, cherrypy... (see bottle docs for full list)
server = wsgiref

# Pass 0.0.0.0 to listens on all interfaces including the external one.
host = localhost

# Server port to bind to. Values below 1024 require root privileges.
port = 8080

# Traceback on error pages, templates not cached, plugins applied directly
# Also activates reloader, and redirect all logs on DEBUG level to stdout/stderr
debug = true

[logs]

# If not in debug mode, 2 logs are produced per day:
# requests.yyyy-mm-dd - contains requests only
# log.yyyy-mm-dd - contains all messages, including requests
# ...and nothing is written to stdout/stderr

# The logs directory
path = ./logs/
# Logging levels: DISABLED, DEBUG, INFO, WARNING, ERROR, CRITICAL
level = DEBUG
# Log older than that will be deleted
days = 100
# Requests / server adapter stdout
requests = true
# (not yet implemented) email to notify on error
notify =


[sessions]

# (not yet implemented) how long the session data will still be available after the last access
expiration = 30d
# (not yet implemented) the interval to check for obsolete sessions
check_interval = 1h


[views]
# static path: everything inside will be served like normal files
static_path = ./static/

# templates path: used in bottle with template('...')
template_path = ./views/


[requests]

# applies CORS to responses, write * to allow AJAX requests from anywhere
CORS = false

# Basic auth
[auth_basic]

username = alice
password_enc = clear
password = my-secret

# ...or alternatively, if you dislike putting your plain text password in the config:
# username = alice
# password_enc = sha256
# password = 186ef76e9d6a723ecb570d4d9c287487d001e5d35f7ed4a313350a407950318e



# Auth using JWT (using auth0 for example)
[auth_jwt]

# the secret, encoded as urlsafe_b64decode
secret = 1LlIwTCB7wliFKRVaTcWg7Ask6yRGVHjxcZ-ATVCnGTlKnHLpEuhuUuhv34OdFD0

#[asodb]

# if true, read acces will not require authorization
# public = True
# owner
# owner =
# a list of emails of who can edit data
# admins = *
31 changes: 31 additions & 0 deletions examples/basic.py
@@ -0,0 +1,31 @@
#!/usr/bin/env python3

# ab -n 1000 -c 10 http://localhost:8080/hello/world
# ab -n 2 -c 2 http://127.0.0.1:8080/hello/world

import gevent.monkey
gevent.monkey.patch_all()

import canister
import time
import bottle

can = canister.build('example.config')

@can.get('/')
def index():
# Accessing session data
sdata = can.session.data
if 'counter' in can.session.data:
sdata['counter'] += 1
else:
sdata['counter'] = 0

# Logging something
can.log.info('Hey!')
can.log.warning('Ho!')

return '<pre>Session id: %s\nCounter: %s</pre>' % (can.session.id, can.session.data['counter'])


can.run()

0 comments on commit 4643a44

Please sign in to comment.