@@ -3,27 +3,27 @@ | |||||
from pathlib import Path | 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 flask_mako import MakoTemplates, render_template | ||||
from werkzeug.local import LocalProxy | |||||
import calefaction | import calefaction | ||||
from calefaction.auth import AuthManager | from calefaction.auth import AuthManager | ||||
from calefaction.config import Config | from calefaction.config import Config | ||||
from calefaction.database import Database | from calefaction.database import Database | ||||
from calefaction.eve import EVE | 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__) | app = Flask(__name__) | ||||
basepath = Path(__file__).resolve().parent | basepath = Path(__file__).resolve().parent | ||||
config = Config(basepath / "config") | config = Config(basepath / "config") | ||||
Database.path = str(basepath / "data" / "db.sqlite3") | Database.path = str(basepath / "data" / "db.sqlite3") | ||||
eve = EVE() | |||||
eve = EVE(config) | |||||
auth = AuthManager(config, eve) | auth = AuthManager(config, eve) | ||||
MakoTemplates(app) | MakoTemplates(app) | ||||
set_up_hash_versioning(app) | |||||
set_up_asset_versioning(app) | |||||
config.install(app) | config.install(app) | ||||
@app.before_request | @app.before_request | ||||
@@ -39,12 +39,30 @@ app.teardown_appcontext(Database.post_hook) | |||||
@app.route("/") | @app.route("/") | ||||
@catch_errors(app) | @catch_errors(app) | ||||
def index(): | 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") | return render_template("landing.mako") | ||||
@app.route("/login") | |||||
@app.route("/login", methods=["GET", "POST"]) | |||||
@catch_errors(app) | @catch_errors(app) | ||||
def login(): | 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__": | if __name__ == "__main__": | ||||
app.run(debug=True, port=8080) | app.run(debug=True, port=8080) |
@@ -1 +1,2 @@ | |||||
__version__ = "0.1.dev0" | __version__ = "0.1.dev0" | ||||
__release__ = "0.1" |
@@ -1,36 +1,240 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
from datetime import datetime, timedelta | |||||
from flask import g, session, url_for | from flask import g, session, url_for | ||||
from itsdangerous import URLSafeSerializer | |||||
from itsdangerous import BadSignature, URLSafeSerializer | |||||
from .exceptions import AccessDeniedError | |||||
__all__ = ["AuthManager"] | __all__ = ["AuthManager"] | ||||
_SCOPES = ["publicData", "characterAssetsRead"] # ... | _SCOPES = ["publicData", "characterAssetsRead"] # ... | ||||
class AuthManager: | class AuthManager: | ||||
"""Authentication manager. Handles user access and management.""" | |||||
EXPIRY_THRESHOLD = 30 | |||||
def __init__(self, config, eve): | def __init__(self, config, eve): | ||||
self._config = config | self._config = config | ||||
self._eve = eve | 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: | 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"] | 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") | key = self._config.get("auth.session_key") | ||||
serializer = URLSafeSerializer(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): | 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") | cid = self._config.get("auth.client_id") | ||||
target = url_for("login", _external=True, _scheme=self._config.scheme) | target = url_for("login", _external=True, _scheme=self._config.scheme) | ||||
scopes = _SCOPES | scopes = _SCOPES | ||||
state = self.get_state_hash() | |||||
state = self._get_state_hash() | |||||
return self._eve.sso.get_authorize_url(cid, target, scopes, state) | 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"] | __all__ = ["Config"] | ||||
class Config: | class Config: | ||||
"""Stores application-wide configuration info.""" | |||||
def __init__(self, confdir): | def __init__(self, confdir): | ||||
self._filename = confdir / "config.yml" | self._filename = confdir / "config.yml" | ||||
@@ -12,10 +13,16 @@ class Config: | |||||
self._load() | self._load() | ||||
def _load(self): | def _load(self): | ||||
"""Load config from the config file.""" | |||||
with self._filename.open("rb") as fp: | with self._filename.open("rb") as fp: | ||||
self._data = yaml.load(fp) | self._data = yaml.load(fp) | ||||
def get(self, key="", default=None): | 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 | obj = self._data | ||||
for item in key.split("."): | for item in key.split("."): | ||||
if item not in obj: | if item not in obj: | ||||
@@ -25,9 +32,11 @@ class Config: | |||||
@property | @property | ||||
def scheme(self): | def scheme(self): | ||||
"""Return the site's configured scheme, either "http" or "https".""" | |||||
return "https" if self.get("site.https") else "http" | return "https" if self.get("site.https") else "http" | ||||
def install(self, app): | def install(self, app): | ||||
"""Install relevant config parameters into the application.""" | |||||
app.config["SERVER_NAME"] = self.get("site.canonical") | app.config["SERVER_NAME"] = self.get("site.canonical") | ||||
app.config["PREFERRED_URL_SCHEME"] = self.scheme | app.config["PREFERRED_URL_SCHEME"] = self.scheme | ||||
app.secret_key = self.get("auth.session_key") | app.secret_key = self.get("auth.session_key") |
@@ -1,5 +1,7 @@ | |||||
# -*- coding: utf-8 -*- | # -*- coding: utf-8 -*- | ||||
from datetime import datetime | |||||
import random | |||||
import sqlite3 | import sqlite3 | ||||
from flask import g | from flask import g | ||||
@@ -8,13 +10,16 @@ from werkzeug.local import LocalProxy | |||||
__all__ = ["Database"] | __all__ = ["Database"] | ||||
class 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 | path = None | ||||
def __init__(self): | def __init__(self): | ||||
if self.path is None: | if self.path is None: | ||||
raise RuntimeError("Database.path not set") | raise RuntimeError("Database.path not set") | ||||
self._conn = sqlite3.connect(self.path) | self._conn = sqlite3.connect(self.path) | ||||
import traceback | |||||
def __enter__(self): | def __enter__(self): | ||||
return self._conn.__enter__() | return self._conn.__enter__() | ||||
@@ -24,18 +29,160 @@ class Database: | |||||
@classmethod | @classmethod | ||||
def _get(cls): | def _get(cls): | ||||
"""Return the current database, or allocate a new one if necessary.""" | |||||
if not hasattr(g, "_db"): | if not hasattr(g, "_db"): | ||||
g._db = cls() | g._db = cls() | ||||
return g._db | return g._db | ||||
@classmethod | @classmethod | ||||
def pre_hook(cls): | def pre_hook(cls): | ||||
"""Hook to be called before a request context. | |||||
Sets up the g.db proxy. | |||||
""" | |||||
g.db = LocalProxy(cls._get) | g.db = LocalProxy(cls._get) | ||||
@classmethod | @classmethod | ||||
def post_hook(cls, exc): | def post_hook(cls, exc): | ||||
"""Hook to be called when tearing down an application context. | |||||
Closes the database if necessary. | |||||
""" | |||||
if hasattr(g, "_db"): | if hasattr(g, "_db"): | ||||
g._db.close() | g._db.close() | ||||
def close(self): | def close(self): | ||||
"""Close the database connection.""" | |||||
return self._conn.close() | 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 -*- | # -*- coding: utf-8 -*- | ||||
import platform | |||||
import requests | |||||
from .clock import Clock | from .clock import Clock | ||||
from .esi import EVESwaggerInterface | |||||
from .image import ImageServer | from .image import ImageServer | ||||
from .sso import SSOManager | from .sso import SSOManager | ||||
from .. import __release__ | |||||
__all__ = ["EVE"] | __all__ = ["EVE"] | ||||
class 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._clock = Clock() | ||||
self._esi = EVESwaggerInterface(session) | |||||
self._image = ImageServer() | 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 | @property | ||||
def clock(self): | def clock(self): | ||||
"""The Clock API module.""" | |||||
return self._clock | return self._clock | ||||
@property | @property | ||||
def esi(self): | |||||
"""The EVE Swagger Interface API module.""" | |||||
return self._esi | |||||
@property | |||||
def image(self): | def image(self): | ||||
"""The ImageServer API module.""" | |||||
return self._image | return self._image | ||||
@property | @property | ||||
def sso(self): | def sso(self): | ||||
"""The Single Sign-On API module.""" | |||||
return self._sso | return self._sso |
@@ -7,7 +7,9 @@ __all__ = ["Clock"] | |||||
_YEAR_DELTA = 1898 | _YEAR_DELTA = 1898 | ||||
class Clock: | class Clock: | ||||
"""EVE API module for the in-game clock.""" | |||||
def now(self): | def now(self): | ||||
"""Return the current date and time in the YC calendar as a string.""" | |||||
dt = datetime.utcnow() | dt = datetime.utcnow() | ||||
return str(dt.year - _YEAR_DELTA) + dt.strftime("-%m-%d %H:%M") | 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"] | __all__ = ["ImageServer"] | ||||
class ImageServer: | class ImageServer: | ||||
"""EVE API module for the image server.""" | |||||
def __init__(self): | def __init__(self): | ||||
self._url = "https://imageserver.eveonline.com/" | self._url = "https://imageserver.eveonline.com/" | ||||
@property | @property | ||||
def alliance_widths(self): | def alliance_widths(self): | ||||
"""Return a list of valid widths for alliance logos.""" | |||||
return [32, 64, 128] | return [32, 64, 128] | ||||
@property | @property | ||||
def corp_widths(self): | def corp_widths(self): | ||||
"""Return a list of valid widths for corporation logos.""" | |||||
return [32, 64, 128, 256] | return [32, 64, 128, 256] | ||||
@property | @property | ||||
def character_widths(self): | def character_widths(self): | ||||
"""Return a list of valid widths for character portraits.""" | |||||
return [32, 64, 128, 256, 512, 1024] | return [32, 64, 128, 256, 512, 1024] | ||||
@property | @property | ||||
def faction_widths(self): | def faction_widths(self): | ||||
"""Return a list of valid widths for faction logos.""" | |||||
return [32, 64, 128] | return [32, 64, 128] | ||||
@property | @property | ||||
def inventory_widths(self): | def inventory_widths(self): | ||||
"""Return a list of valid widths for inventory item images.""" | |||||
return [32, 64] | return [32, 64] | ||||
@property | @property | ||||
def render_widths(self): | def render_widths(self): | ||||
"""Return a list of valid widths for ship render images.""" | |||||
return [32, 64, 128, 256, 512] | return [32, 64, 128, 256, 512] | ||||
def alliance(self, id, width): | def alliance(self, id, width): | ||||
"""Return a URL for an alliance logo.""" | |||||
return self._url + "Alliance/{}_{}.png".format(id, width) | return self._url + "Alliance/{}_{}.png".format(id, width) | ||||
def corp(self, id, width): | def corp(self, id, width): | ||||
"""Return a URL for a corporation logo.""" | |||||
return self._url + "Corporation/{}_{}.png".format(id, width) | return self._url + "Corporation/{}_{}.png".format(id, width) | ||||
def character(self, id, width): | def character(self, id, width): | ||||
"""Return a URL for a character portrait.""" | |||||
return self._url + "Character/{}_{}.jpg".format(id, width) | return self._url + "Character/{}_{}.jpg".format(id, width) | ||||
def faction(self, id, width): | def faction(self, id, width): | ||||
"""Return a URL for a faction logo.""" | |||||
return self._url + "Alliance/{}_{}.jpg".format(id, width) | return self._url + "Alliance/{}_{}.jpg".format(id, width) | ||||
def inventory(self, id, width): | def inventory(self, id, width): | ||||
"""Return a URL for an inventory item image.""" | |||||
return self._url + "Type/{}_{}.jpg".format(id, width) | return self._url + "Type/{}_{}.jpg".format(id, width) | ||||
def render(self, id, width): | def render(self, id, width): | ||||
"""Return a URL for ship render image.""" | |||||
return self._url + "Render/{}_{}.jpg".format(id, width) | return self._url + "Render/{}_{}.jpg".format(id, width) |
@@ -2,12 +2,21 @@ | |||||
from urllib.parse import urlencode | from urllib.parse import urlencode | ||||
import requests | |||||
from ..exceptions import EVEAPIError | |||||
__all__ = ["SSOManager"] | __all__ = ["SSOManager"] | ||||
class 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, | def get_authorize_url(self, client_id, redirect_uri, scopes=None, | ||||
state=None): | state=None): | ||||
"""Return a URL that the end user can use to start a login.""" | |||||
baseurl = "https://login.eveonline.com/oauth/authorize?" | baseurl = "https://login.eveonline.com/oauth/authorize?" | ||||
params = { | params = { | ||||
"response_type": "code", | "response_type": "code", | ||||
@@ -19,3 +28,69 @@ class SSOManager: | |||||
if state is not None: | if state is not None: | ||||
params["state"] = state | params["state"] = state | ||||
return baseurl + urlencode(params) | 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 import url_for | ||||
from flask_mako import render_template, TemplateError | 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): | def catch_errors(app): | ||||
"""Wrap a route to display and log any uncaught exceptions.""" | |||||
def callback(func): | def callback(func): | ||||
@wraps(func) | @wraps(func) | ||||
def inner(*args, **kwargs): | def inner(*args, **kwargs): | ||||
@@ -25,7 +26,8 @@ def catch_errors(app): | |||||
return inner | return inner | ||||
return callback | 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): | def callback(app, error, endpoint, values): | ||||
if endpoint == "staticv": | if endpoint == "staticv": | ||||
filename = values["filename"] | filename = values["filename"] | ||||
@@ -45,4 +47,4 @@ def set_up_hash_versioning(app): | |||||
raise error | raise error | ||||
app._hash_cache = {} | 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 | # Assume HTTPS? This affects how URLs are generated, not how the site is | ||||
# served (setting up TLS is your responsibility): | # served (setting up TLS is your responsibility): | ||||
https: yes | https: yes | ||||
# Contact info reported in the User-Agent when making requests to EVE's API: | |||||
contact: webmaster@example.com | |||||
corp: | corp: | ||||
# You need to reset the database if this value is changed in the future. | # 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 ( | CREATE TABLE session ( | ||||
session_id INTEGER PRIMARY KEY AUTOINCREMENT, | 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 | session_touched TIMESTAMP DEFAULT CURRENT_TIMESTAMP | ||||
); | ); | ||||
@@ -13,9 +14,14 @@ DROP TABLE IF EXISTS character; | |||||
CREATE TABLE character ( | CREATE TABLE character ( | ||||
character_id INTEGER PRIMARY KEY, | character_id INTEGER PRIMARY KEY, | ||||
character_name TEXT, | 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> |