| @@ -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> | |||||