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