@@ -3,27 +3,27 @@ | |||
from pathlib import Path | |||
from flask import Flask, g | |||
from flask import Flask, g, redirect, request, url_for | |||
from flask_mako import MakoTemplates, render_template | |||
from werkzeug.local import LocalProxy | |||
import calefaction | |||
from calefaction.auth import AuthManager | |||
from calefaction.config import Config | |||
from calefaction.database import Database | |||
from calefaction.eve import EVE | |||
from calefaction.util import catch_errors, set_up_hash_versioning | |||
from calefaction.exceptions import AccessDeniedError, EVEAPIError | |||
from calefaction.util import catch_errors, set_up_asset_versioning | |||
app = Flask(__name__) | |||
basepath = Path(__file__).resolve().parent | |||
config = Config(basepath / "config") | |||
Database.path = str(basepath / "data" / "db.sqlite3") | |||
eve = EVE() | |||
eve = EVE(config) | |||
auth = AuthManager(config, eve) | |||
MakoTemplates(app) | |||
set_up_hash_versioning(app) | |||
set_up_asset_versioning(app) | |||
config.install(app) | |||
@app.before_request | |||
@@ -39,12 +39,30 @@ app.teardown_appcontext(Database.post_hook) | |||
@app.route("/") | |||
@catch_errors(app) | |||
def index(): | |||
... # handle flashed error messages in _base.mako | |||
if auth.is_authenticated(): # ... need to check for exceptions | |||
return render_template("home.mako") | |||
return render_template("landing.mako") | |||
@app.route("/login") | |||
@app.route("/login", methods=["GET", "POST"]) | |||
@catch_errors(app) | |||
def login(): | |||
return "login" # ... | |||
code = request.args.get("code") | |||
state = request.args.get("state") | |||
try: | |||
auth.handle_login(code, state) | |||
except EVEAPIError: | |||
... # flash error message | |||
except AccessDeniedError: | |||
... # flash error message | |||
if getattr(g, "_session_expired"): | |||
... # flash error message | |||
return redirect(url_for("index"), 303) | |||
# @app.route("/logout") ... | |||
# @auth.route_restricted ... | |||
# check for same exceptions as login() and use same flashes | |||
if __name__ == "__main__": | |||
app.run(debug=True, port=8080) |
@@ -1 +1,2 @@ | |||
__version__ = "0.1.dev0" | |||
__release__ = "0.1" |
@@ -1,36 +1,240 @@ | |||
# -*- coding: utf-8 -*- | |||
from datetime import datetime, timedelta | |||
from flask import g, session, url_for | |||
from itsdangerous import URLSafeSerializer | |||
from itsdangerous import BadSignature, URLSafeSerializer | |||
from .exceptions import AccessDeniedError | |||
__all__ = ["AuthManager"] | |||
_SCOPES = ["publicData", "characterAssetsRead"] # ... | |||
class AuthManager: | |||
"""Authentication manager. Handles user access and management.""" | |||
EXPIRY_THRESHOLD = 30 | |||
def __init__(self, config, eve): | |||
self._config = config | |||
self._eve = eve | |||
def _new_session_id(self): | |||
with g.db as conn: | |||
cur = conn.execute("INSERT INTO session DEFAULT VALUES") | |||
return cur.lastrowid | |||
def get_session_id(self): | |||
def _get_session_id(self): | |||
"""Return the current session ID, allocating a new one if necessary.""" | |||
if "id" not in session: | |||
session["id"] = self._new_session_id() | |||
session["id"] = g.db.new_session() | |||
g._session_checked = True | |||
g._session_expired = False | |||
return session["id"] | |||
def get_state_hash(self): | |||
def _invalidate_session(self): | |||
"""Mark the current session as invalid. | |||
Remove it from the database and from the user's cookies. | |||
""" | |||
if "id" in session: | |||
g.db.drop_session(session["id"]) | |||
del session["id"] | |||
def _check_session(self): | |||
"""Return whether the user has a valid, non-expired session. | |||
This checks for the session existing in the database, but does not | |||
check that the user is logged in or has any particular access roles. | |||
""" | |||
if "id" not in session: | |||
return False | |||
if hasattr(g, "_session_checked"): | |||
return g._session_checked | |||
g._session_checked = check = g.db.has_session(session["id"]) | |||
if not check: | |||
g._session_expired = True | |||
self._invalidate_session() | |||
return check | |||
def _get_state_hash(self): | |||
"""Return a hash of the user's session ID suitable for OAuth2 state. | |||
Allocates a new session ID if necessary. | |||
""" | |||
key = self._config.get("auth.session_key") | |||
serializer = URLSafeSerializer(key) | |||
return serializer.dumps(self._get_session_id()) | |||
def _verify_state_hash(self, state): | |||
"""Confirm that a state hash is correct for the user's session. | |||
Assumes we've already checked the session ID. If the state is invalid, | |||
the session will be invalidated. | |||
""" | |||
key = self._config.get("auth.session_key") | |||
serializer = URLSafeSerializer(key) | |||
return serializer.dumps(self.get_session_id()) | |||
try: | |||
value = serializer.loads(state) | |||
except BadSignature: | |||
self._invalidate_session() | |||
return False | |||
if value != session["id"]: | |||
self._invalidate_session() | |||
return False | |||
return True | |||
def _fetch_new_token(self, code, refresh=False): | |||
"""Given an auth code or refresh token, get a new token and other data. | |||
If refresh is True, code should be a refresh token, otherwise an auth | |||
code. If successful, we'll return a 5-tuple of (access_token, | |||
token_expiry, refresh_token, char_id, char_name). If the token was | |||
invalid, we'll return None. We may also raise EVEAPIError if there was | |||
an internal API error. | |||
""" | |||
cid = self._config.get("auth.client_id") | |||
secret = self._config.get("auth.client_secret") | |||
result = self._eve.sso.get_access_token(cid, secret, code, refresh) | |||
if not result: | |||
return None | |||
token, expiry, refresh = result | |||
expires = datetime.utcnow() + timedelta(seconds=expiry) | |||
result = self._eve.sso.get_character_info(token) | |||
if not result: | |||
return None | |||
char_id, char_name = result | |||
return token, expires, refresh, char_id, char_name | |||
def _get_token(self, cid): | |||
"""Return a valid access token for the given character, or None. | |||
If the database doesn't have an auth entry for this character, return | |||
None. If the database's token is expired but the refresh token is | |||
valid, then refresh it, update the database, and return the new token. | |||
If the token has become invalid and couldn't be refreshed, drop the | |||
auth information from the database and return None. | |||
""" | |||
result = g.db.get_auth(cid) | |||
if not result: | |||
return None | |||
token, expires, refresh = result | |||
seconds_til_expiry = (expires - datetime.utcnow()).total_seconds() | |||
if seconds_til_expiry >= self.EXPIRY_THRESHOLD: | |||
return token | |||
result = self._fetch_new_token(refresh, refresh=True) | |||
if not result: | |||
g.db.drop_auth(cid) | |||
return None | |||
token, expires, refresh, char_id, char_name = result | |||
if char_id != cid: | |||
g.db.drop_auth(cid) | |||
return None | |||
g.db.put_character(cid, char_name) | |||
g.db.update_auth(cid, token, expires, refresh) | |||
return token | |||
def _check_access(self, token, char_id): | |||
""""Check whether the given character is allowed to access this site. | |||
If allowed, do nothing. If not, raise AccessDeniedError. | |||
""" | |||
resp = self._eve.esi(token).v3.characters(char_id).get() | |||
if resp.get("corporation_id") != self._config.get("corp.id"): | |||
g.db.drop_auth(char_id) | |||
self._invalidate_session() | |||
raise AccessDeniedError() | |||
def get_character_id(self): | |||
"""Return the character ID associated with the current session. | |||
Returns None if the session is invalid or is not associated with a | |||
character. | |||
""" | |||
if not self._check_session(): | |||
return None | |||
if not hasattr(g, "_character_id"): | |||
g._character_id = g.db.read_session(session["id"]) | |||
return g._character_id | |||
def get_character_prop(self, prop): | |||
"""Look up a property for the current session's character. | |||
Returns None if the session is invalid, is not associated with a | |||
character, or the property has no non-default value. | |||
""" | |||
cid = self.get_character_id() | |||
if not cid: | |||
return None | |||
if not hasattr(g, "_character_props"): | |||
g._character_props = g.db.read_character(cid) | |||
return g._character_props.get(prop) | |||
def is_authenticated(self): | |||
"""Return whether the user has permission to access this site. | |||
We confirm that they have a valid, non-expired session that is | |||
associated with a character that is permitted to be here. | |||
EVEAPIError or AccessDeniedError may be raised. | |||
""" | |||
cid = self.get_character_id() | |||
if not cid: | |||
return False | |||
token = self._get_token(cid) | |||
if not token: | |||
self._invalidate_session() | |||
return False | |||
self._check_access(token, cid) | |||
g.db.touch_session(session["id"]) | |||
return True | |||
def make_login_link(self): | |||
"""Return a complete EVE SSO link that the user can use to log in.""" | |||
cid = self._config.get("auth.client_id") | |||
target = url_for("login", _external=True, _scheme=self._config.scheme) | |||
scopes = _SCOPES | |||
state = self.get_state_hash() | |||
state = self._get_state_hash() | |||
return self._eve.sso.get_authorize_url(cid, target, scopes, state) | |||
def handle_login(self, code, state): | |||
"""Given an OAuth2 code and state, try to authenticate the user. | |||
If the user has a legitimate session and the state is valid, we'll | |||
check the code with EVE SSO to fetch an authentication token. If the | |||
token corresponds to a character that is allowed to access the site, | |||
we'll update their session to indicate so. | |||
Return whether authentication was successful. EVEAPIError or | |||
AccessDeniedError may be raised. | |||
""" | |||
if not code or not state: | |||
return False | |||
if not self._check_session(): | |||
return False | |||
if not self._verify_state_hash(state): | |||
return False | |||
result = self._fetch_new_token(code) | |||
if not result: | |||
self._invalidate_session() | |||
return False | |||
token, expires, refresh, char_id, char_name = result | |||
self._check_access(token, char_id) | |||
sid = session["id"] | |||
g.db.put_character(char_id, char_name) | |||
g.db.set_auth(char_id, token, expires, refresh) | |||
g.db.attach_session(sid, char_id) | |||
g.db.touch_session(sid) | |||
return True |
@@ -5,6 +5,7 @@ import yaml | |||
__all__ = ["Config"] | |||
class Config: | |||
"""Stores application-wide configuration info.""" | |||
def __init__(self, confdir): | |||
self._filename = confdir / "config.yml" | |||
@@ -12,10 +13,16 @@ class Config: | |||
self._load() | |||
def _load(self): | |||
"""Load config from the config file.""" | |||
with self._filename.open("rb") as fp: | |||
self._data = yaml.load(fp) | |||
def get(self, key="", default=None): | |||
"""Acts like a dict lookup in the config file. | |||
Dots can be used to separate keys. For example, | |||
config["foo.bar"] == config["foo"]["bar"]. | |||
""" | |||
obj = self._data | |||
for item in key.split("."): | |||
if item not in obj: | |||
@@ -25,9 +32,11 @@ class Config: | |||
@property | |||
def scheme(self): | |||
"""Return the site's configured scheme, either "http" or "https".""" | |||
return "https" if self.get("site.https") else "http" | |||
def install(self, app): | |||
"""Install relevant config parameters into the application.""" | |||
app.config["SERVER_NAME"] = self.get("site.canonical") | |||
app.config["PREFERRED_URL_SCHEME"] = self.scheme | |||
app.secret_key = self.get("auth.session_key") |
@@ -1,5 +1,7 @@ | |||
# -*- coding: utf-8 -*- | |||
from datetime import datetime | |||
import random | |||
import sqlite3 | |||
from flask import g | |||
@@ -8,13 +10,16 @@ from werkzeug.local import LocalProxy | |||
__all__ = ["Database"] | |||
class Database: | |||
"""Database manager for low-level authentication actions.""" | |||
MAX_SESSION_STALENESS = 30 * 60 # 30 minutes | |||
MAX_SESSION_AGE = 6 * 60 * 60 # 6 hours | |||
SESSION_GRACE = 60 * 60 # 1 hour | |||
path = None | |||
def __init__(self): | |||
if self.path is None: | |||
raise RuntimeError("Database.path not set") | |||
self._conn = sqlite3.connect(self.path) | |||
import traceback | |||
def __enter__(self): | |||
return self._conn.__enter__() | |||
@@ -24,18 +29,160 @@ class Database: | |||
@classmethod | |||
def _get(cls): | |||
"""Return the current database, or allocate a new one if necessary.""" | |||
if not hasattr(g, "_db"): | |||
g._db = cls() | |||
return g._db | |||
@classmethod | |||
def pre_hook(cls): | |||
"""Hook to be called before a request context. | |||
Sets up the g.db proxy. | |||
""" | |||
g.db = LocalProxy(cls._get) | |||
@classmethod | |||
def post_hook(cls, exc): | |||
"""Hook to be called when tearing down an application context. | |||
Closes the database if necessary. | |||
""" | |||
if hasattr(g, "_db"): | |||
g._db.close() | |||
def close(self): | |||
"""Close the database connection.""" | |||
return self._conn.close() | |||
def _clear_old_sessions(self): | |||
"""Remove old sessions from the database. | |||
Sessions can expire if they are not touched (accessed) in a certain | |||
period of time, or if their absolute age exceeds some number. We don't | |||
actually remove them until a bit after this time. | |||
""" | |||
query = """DELETE FROM session WHERE | |||
strftime("%s", "now") - strftime("%s", session_created) >= {} OR | |||
strftime("%s", "now") - strftime("%s", session_touched) >= {}""" | |||
create_thresh = self.MAX_SESSION_AGE + self.SESSION_GRACE | |||
touch_thresh = self.MAX_SESSION_STALENESS + self.SESSION_GRACE | |||
with self._conn as conn: | |||
conn.execute(query.format(create_thresh, touch_thresh)) | |||
def new_session(self): | |||
"""Allocate a new session in the database and return its ID.""" | |||
with self._conn as conn: | |||
cur = conn.execute("INSERT INTO session DEFAULT VALUES") | |||
return cur.lastrowid | |||
def has_session(self, sid): | |||
"""Return whether the given session ID exists in the database. | |||
Will only be True for non-expired sessions. This function randomly does | |||
database maintenance; very old expired sessions may be cleared. | |||
""" | |||
if random.random() <= 0.2: | |||
self._clear_old_sessions() | |||
query = """SELECT 1 FROM session | |||
WHERE session_id = ? AND | |||
strftime("%s", "now") - strftime("%s", session_created) < {} AND | |||
strftime("%s", "now") - strftime("%s", session_touched) < {}""" | |||
query = query.format(self.MAX_SESSION_AGE, self.MAX_SESSION_STALENESS) | |||
cur = self._conn.execute(query, (sid,)) | |||
return bool(cur.fetchall()) | |||
def read_session(self, sid): | |||
"""Return the character associated with the given session, or None.""" | |||
query = """SELECT session_character FROM session | |||
WHERE session_id = ? AND | |||
strftime("%s", "now") - strftime("%s", session_created) < {} AND | |||
strftime("%s", "now") - strftime("%s", session_touched) < {}""" | |||
query = query.format(self.MAX_SESSION_AGE, self.MAX_SESSION_STALENESS) | |||
res = self._conn.execute(query, (sid,)).fetchall() | |||
return res[0][0] if res else None | |||
def touch_session(self, sid): | |||
"""Update the given session's last access timestamp.""" | |||
query = """UPDATE session | |||
SET session_touched = CURRENT_TIMESTAMP | |||
WHERE session_id = ?""" | |||
with self._conn as conn: | |||
conn.execute(query, (sid,)) | |||
def attach_session(self, sid, cid): | |||
"""Attach the given session to a character. Does not touch it.""" | |||
query = """UPDATE session | |||
SET session_character = ? | |||
WHERE session_id = ?""" | |||
with self._conn as conn: | |||
conn.execute(query, (cid, sid)) | |||
def drop_session(self, sid): | |||
"""Remove the given session from the database.""" | |||
with self._conn as conn: | |||
conn.execute("DELETE FROM session WHERE session_id = ?", (sid,)) | |||
def put_character(self, cid, name): | |||
"""Put a character into the database if they don't already exist.""" | |||
with self._conn as conn: | |||
cur = conn.execute("BEGIN TRANSACTION") | |||
cur.execute( | |||
"""UPDATE character SET character_name = ? | |||
WHERE character_id = ?""", (name, cid)) | |||
if cur.rowcount == 0: | |||
cur.execute( | |||
"""INSERT INTO character (character_id, character_name) | |||
VALUES (?, ?)""", (cid, name)) | |||
def read_character(self, cid): | |||
"""Return a dictionary of properties for the given character.""" | |||
query = """SELECT character_name, character_style | |||
FROM character WHERE character_id = ?""" | |||
res = self._conn.execute(query, (cid,)).fetchall() | |||
return {"name": res[0][0], "style": res[0][1]} if res else {} | |||
def set_auth(self, cid, token, expires, refresh): | |||
"""Set the authentication info for the given character.""" | |||
with self._conn as conn: | |||
conn.execute("""INSERT OR REPLACE INTO auth | |||
(auth_character, auth_token, auth_token_expiry, auth_refresh) | |||
VALUES (?, ?, ?, ?)""", (cid, token, expires, refresh)) | |||
def update_auth(self, cid, token, expires, refresh): | |||
"""Update the authentication info for the given character. | |||
Functionally equivalent to set_auth provided that the character has an | |||
existing auth entry, but is more efficient. | |||
""" | |||
with self._conn as conn: | |||
conn.execute("""UPDATE auth | |||
SET auth_token = ?, auth_token_expiry = ?, auth_refresh = ? | |||
WHERE auth_character = ?""", (token, expires, refresh, cid)) | |||
def get_auth(self, cid): | |||
"""Return authentication info for the given character. | |||
Return a 3-tuple of (access_token, token_expiry, refresh_token), or | |||
None if there is no auth info. | |||
""" | |||
query = """SELECT auth_token, auth_token_expiry, auth_refresh | |||
FROM auth WHERE auth_character = ?""" | |||
res = self._conn.execute(query, (cid,)).fetchall() | |||
if not res: | |||
return None | |||
token, expiry, refresh = res[0] | |||
expires = datetime.strptime(expiry, "%Y-%m-%d %H:%M:%S.%f") | |||
return token, expires, refresh | |||
def drop_auth(self, cid): | |||
"""Drop any authentication info for the given character.""" | |||
with self._conn as conn: | |||
conn.execute("DELETE FROM auth WHERE auth_character = ?", (cid,)) |
@@ -1,26 +1,55 @@ | |||
# -*- coding: utf-8 -*- | |||
import platform | |||
import requests | |||
from .clock import Clock | |||
from .esi import EVESwaggerInterface | |||
from .image import ImageServer | |||
from .sso import SSOManager | |||
from .. import __release__ | |||
__all__ = ["EVE"] | |||
class EVE: | |||
"""Interface to EVE's various APIs.""" | |||
def __init__(self, config): | |||
session = requests.Session() | |||
agent = self._get_user_agent(config.get("site.contact")) | |||
session.headers["User-Agent"] = agent | |||
def __init__(self): | |||
self._clock = Clock() | |||
self._esi = EVESwaggerInterface(session) | |||
self._image = ImageServer() | |||
self._sso = SSOManager() | |||
self._sso = SSOManager(session) | |||
@staticmethod | |||
def _get_user_agent(contact): | |||
"""Return the user agent when accessing APIs.""" | |||
template = ("Calefaction/{} ({}; Python/{}; {}; " | |||
"https://github.com/earwig/calefaction)") | |||
return template.format( | |||
__release__, requests.utils.default_user_agent(), | |||
platform.python_version(), contact) | |||
@property | |||
def clock(self): | |||
"""The Clock API module.""" | |||
return self._clock | |||
@property | |||
def esi(self): | |||
"""The EVE Swagger Interface API module.""" | |||
return self._esi | |||
@property | |||
def image(self): | |||
"""The ImageServer API module.""" | |||
return self._image | |||
@property | |||
def sso(self): | |||
"""The Single Sign-On API module.""" | |||
return self._sso |
@@ -7,7 +7,9 @@ __all__ = ["Clock"] | |||
_YEAR_DELTA = 1898 | |||
class Clock: | |||
"""EVE API module for the in-game clock.""" | |||
def now(self): | |||
"""Return the current date and time in the YC calendar as a string.""" | |||
dt = datetime.utcnow() | |||
return str(dt.year - _YEAR_DELTA) + dt.strftime("-%m-%d %H:%M") |
@@ -0,0 +1,13 @@ | |||
# -*- coding: utf-8 -*- | |||
__all__ = ["EVESwaggerInterface"] | |||
class EVESwaggerInterface: | |||
"""EVE API module for the EVE Swagger Interface (ESI).""" | |||
def __init__(self, session): | |||
self._session = session | |||
def __call__(self, *args): | |||
... | |||
raise NotImplementedError() |
@@ -3,48 +3,61 @@ | |||
__all__ = ["ImageServer"] | |||
class ImageServer: | |||
"""EVE API module for the image server.""" | |||
def __init__(self): | |||
self._url = "https://imageserver.eveonline.com/" | |||
@property | |||
def alliance_widths(self): | |||
"""Return a list of valid widths for alliance logos.""" | |||
return [32, 64, 128] | |||
@property | |||
def corp_widths(self): | |||
"""Return a list of valid widths for corporation logos.""" | |||
return [32, 64, 128, 256] | |||
@property | |||
def character_widths(self): | |||
"""Return a list of valid widths for character portraits.""" | |||
return [32, 64, 128, 256, 512, 1024] | |||
@property | |||
def faction_widths(self): | |||
"""Return a list of valid widths for faction logos.""" | |||
return [32, 64, 128] | |||
@property | |||
def inventory_widths(self): | |||
"""Return a list of valid widths for inventory item images.""" | |||
return [32, 64] | |||
@property | |||
def render_widths(self): | |||
"""Return a list of valid widths for ship render images.""" | |||
return [32, 64, 128, 256, 512] | |||
def alliance(self, id, width): | |||
"""Return a URL for an alliance logo.""" | |||
return self._url + "Alliance/{}_{}.png".format(id, width) | |||
def corp(self, id, width): | |||
"""Return a URL for a corporation logo.""" | |||
return self._url + "Corporation/{}_{}.png".format(id, width) | |||
def character(self, id, width): | |||
"""Return a URL for a character portrait.""" | |||
return self._url + "Character/{}_{}.jpg".format(id, width) | |||
def faction(self, id, width): | |||
"""Return a URL for a faction logo.""" | |||
return self._url + "Alliance/{}_{}.jpg".format(id, width) | |||
def inventory(self, id, width): | |||
"""Return a URL for an inventory item image.""" | |||
return self._url + "Type/{}_{}.jpg".format(id, width) | |||
def render(self, id, width): | |||
"""Return a URL for ship render image.""" | |||
return self._url + "Render/{}_{}.jpg".format(id, width) |
@@ -2,12 +2,21 @@ | |||
from urllib.parse import urlencode | |||
import requests | |||
from ..exceptions import EVEAPIError | |||
__all__ = ["SSOManager"] | |||
class SSOManager: | |||
"""EVE API module for Single Sign-On (SSO).""" | |||
def __init__(self, session): | |||
self._session = session | |||
def get_authorize_url(self, client_id, redirect_uri, scopes=None, | |||
state=None): | |||
"""Return a URL that the end user can use to start a login.""" | |||
baseurl = "https://login.eveonline.com/oauth/authorize?" | |||
params = { | |||
"response_type": "code", | |||
@@ -19,3 +28,69 @@ class SSOManager: | |||
if state is not None: | |||
params["state"] = state | |||
return baseurl + urlencode(params) | |||
def get_access_token(self, client_id, client_secret, code, refresh=False): | |||
"""Given an auth code or refresh token, return an access token. | |||
If refresh is True, code should be a refresh token. Otherwise, it | |||
should be an authorization code. | |||
Does a step of OAuth2 and returns a 3-tuple of (access_token, | |||
token_expiry, refresh_token) if successful. Returns None if one of the | |||
arguments is not valid. Raises EVEAPIError if the API did not respond | |||
in a sensible way or looks to be down. | |||
""" | |||
url = "https://login.eveonline.com/oauth/token" | |||
params = {"code": code} | |||
if refresh: | |||
params["grant_type"] = "refresh_token" | |||
else: | |||
params["grant_type"] = "authorization_code" | |||
try: | |||
resp = self._session.post(url, data=params, timeout=10, | |||
auth=(client_id, client_secret)) | |||
json = resp.json() | |||
except (requests.RequestException, ValueError): | |||
raise EVEAPIError() | |||
if not resp.ok or "error" in json: | |||
return None | |||
if json.get("token_type") != "Bearer": | |||
raise EVEAPIError() | |||
token = json.get("access_token") | |||
expiry = json.get("expires_in") | |||
refresh = json.get("refresh_token") | |||
if not token or not expiry or not refresh: | |||
raise EVEAPIError() | |||
return token, expiry, refresh | |||
def get_character_info(self, token): | |||
"""Given an access token, return character info. | |||
If successful, returns a 2-tuple of (character_id, character_name). | |||
Returns None if the token isn't valid. Raises EVEAPIError if the API | |||
did not respond in a sensible way or looks to be down. | |||
""" | |||
url = "https://login.eveonline.com/oauth/verify" | |||
headers = {"Authorization": "Bearer " + token} | |||
try: | |||
resp = self._session.get(url, timeout=10, headers=headers) | |||
json = resp.json() | |||
except (requests.RequestException, ValueError): | |||
raise EVEAPIError() | |||
if not resp.ok or "error" in json: | |||
return None | |||
char_id = json.get("CharacterID") | |||
char_name = json.get("CharacterName") | |||
if not char_id or not char_name: | |||
raise EVEAPIError() | |||
return char_id, char_name |
@@ -0,0 +1,13 @@ | |||
# -*- coding: utf-8 -*- | |||
class CalefactionError(RuntimeError): | |||
"""Base exception class for errors within Calefaction.""" | |||
pass | |||
class EVEAPIError(CalefactionError): | |||
"""Represents (generally external) errors while using the EVE APIs.""" | |||
pass | |||
class AccessDeniedError(CalefactionError): | |||
"""The user tried to do something they don't have permission for.""" | |||
pass |
@@ -8,9 +8,10 @@ from traceback import format_exc | |||
from flask import url_for | |||
from flask_mako import render_template, TemplateError | |||
__all__ = ["catch_errors", "set_up_hash_versioning"] | |||
__all__ = ["catch_errors", "set_up_asset_versioning"] | |||
def catch_errors(app): | |||
"""Wrap a route to display and log any uncaught exceptions.""" | |||
def callback(func): | |||
@wraps(func) | |||
def inner(*args, **kwargs): | |||
@@ -25,7 +26,8 @@ def catch_errors(app): | |||
return inner | |||
return callback | |||
def set_up_hash_versioning(app): | |||
def set_up_asset_versioning(app): | |||
"""Add a staticv endpoint that adds hash versioning to static assets.""" | |||
def callback(app, error, endpoint, values): | |||
if endpoint == "staticv": | |||
filename = values["filename"] | |||
@@ -45,4 +47,4 @@ def set_up_hash_versioning(app): | |||
raise error | |||
app._hash_cache = {} | |||
app.url_build_error_handlers.append(lambda *args: callback(app, *args)) | |||
app.url_build_error_handlers.append(lambda a, b, c: callback(app, a, b, c)) |
@@ -8,6 +8,8 @@ site: | |||
# Assume HTTPS? This affects how URLs are generated, not how the site is | |||
# served (setting up TLS is your responsibility): | |||
https: yes | |||
# Contact info reported in the User-Agent when making requests to EVE's API: | |||
contact: webmaster@example.com | |||
corp: | |||
# You need to reset the database if this value is changed in the future. | |||
@@ -4,7 +4,8 @@ DROP TABLE IF EXISTS session; | |||
CREATE TABLE session ( | |||
session_id INTEGER PRIMARY KEY AUTOINCREMENT, | |||
session_character INTEGER DEFAULT 0, | |||
session_character INTEGER DEFAULT NULL, | |||
session_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | |||
session_touched TIMESTAMP DEFAULT CURRENT_TIMESTAMP | |||
); | |||
@@ -13,9 +14,14 @@ DROP TABLE IF EXISTS character; | |||
CREATE TABLE character ( | |||
character_id INTEGER PRIMARY KEY, | |||
character_name TEXT, | |||
character_token BLOB, | |||
character_refresh BLOB, | |||
character_token_expiry TIMESTAMP, | |||
character_last_verify TIMESTAMP, | |||
character_style TEXT | |||
character_style TEXT DEFAULT NULL | |||
); | |||
DROP TABLE IF EXISTS auth; | |||
CREATE TABLE auth ( | |||
auth_character INTEGER PRIMARY KEY, | |||
auth_token BLOB, | |||
auth_refresh BLOB, | |||
auth_token_expiry TIMESTAMP | |||
); |
@@ -0,0 +1,7 @@ | |||
<%inherit file="_default.mako"/> | |||
<div id="welcome"> | |||
<p><em>Hi, ${g.auth.get_character_prop("name")}!</em></p> | |||
% for paragraph in g.config.get("welcome").split("\n\n"): | |||
<p>${paragraph.replace("\n", " ")}</p> | |||
% endfor | |||
</div> |