#!/usr/bin/env python
# -*- coding: utf-8 -*-
# actions_apikey.py - Waqas Bhatti (wbhatti@astro.princeton.edu) - Aug 2018
# License: MIT - see the LICENSE file for the full text.
'''This contains functions to drive API key related auth actions.
'''
#############
## LOGGING ##
#############
import logging
import json
# get a logger
LOGGER = logging.getLogger(__name__)
#############
## IMPORTS ##
#############
try:
from datetime import datetime, timezone, timedelta
utc = timezone.utc
except Exception:
from datetime import datetime, timedelta, tzinfo
ZERO = timedelta(0)
class UTC(tzinfo):
"""UTC"""
def utcoffset(self, dt):
return ZERO
def tzname(self, dt):
return "UTC"
def dst(self, dt):
return ZERO
utc = UTC()
import secrets
import multiprocessing as mp
from sqlalchemy import select
from .. import authdb
from .session import auth_session_exists
from ..permissions import pii_hash
######################
## API KEY HANDLING ##
######################
[docs]def issue_new_apikey(payload,
raiseonfail=False,
override_authdb_path=None):
'''Issues a new API key.
Parameters
----------
payload : dict
The payload dict must have the following keys:
- audience: str, the service this API key is being issued for
- subject: str, the specific API endpoint API key is being issued for
- apiversion: int or str, the API version that the API key is valid for
- expires_days: int, the number of days after which the API key will
expire
- not_valid_before: float or int, the amount of seconds after utcnow()
when the API key becomes valid
- user_id: int, the user ID of the user requesting the API key
- user_role: str, the user role of the user requesting the API key
- ip_address: str, the IP address to tie the API key to
- user_agent: str, the browser user agent requesting the API key
- session_token: str, the session token of the user requesting the API
key
raiseonfail : bool
If True, will raise an Exception if something goes wrong.
override_authdb_path : str or None
If given as a str, is the alternative path to the auth DB.
Returns
-------
dict
The dict returned is of the form::
{'success': True or False,
'apikey': apikey dict,
'expires': expiry datetime in ISO format,
'messages': list of str messages if any}
Notes
-----
API keys are tied to an IP address and client header combination.
This function will return a dict with all the API key information. This
entire dict should be serialized to JSON, encrypted and time-stamp signed by
the frontend as the final "API key", and finally sent back to the client.
'''
for key in ('reqid','pii_salt'):
if key not in payload:
LOGGER.error(
"Missing %s in payload dict. Can't process this request." % key
)
return {
'success':False,
'apikey':None,
'expires':None,
'messages':["Invalid API key request."],
}
for key in ('user_id',
'user_role',
'expires_days',
'not_valid_before',
'audience',
'subject',
'ip_address',
'user_agent',
'session_token',
'apiversion'):
if key not in payload:
LOGGER.error(
'[%s] Invalid API key request, missing %s.' %
(payload['reqid'], key)
)
if key not in payload:
return {
'success':False,
'apikey':None,
'expires':None,
'messages':["Some required keys are missing from payload."]
}
# this checks if the database connection is live
currproc = mp.current_process()
engine = getattr(currproc, 'authdb_engine', None)
if override_authdb_path:
currproc.auth_db_path = override_authdb_path
if not engine:
currproc.authdb_engine, currproc.authdb_conn, currproc.authdb_meta = (
authdb.get_auth_db(
currproc.auth_db_path,
echo=raiseonfail
)
)
# check the session
session_info = auth_session_exists(
{'session_token':payload['session_token'],
'pii_salt':payload['pii_salt'],
'reqid':payload['reqid']},
raiseonfail=raiseonfail,
override_authdb_path=override_authdb_path
)
if not session_info['success']:
LOGGER.error(
"[%s] Invalid API key request. "
"user_id: %s, session_token: %s, role: %s, "
"ip_address: %s, user_agent: %s requested an API key for "
"audience: %s, subject: %s, apiversion: %s."
"Session token of requestor was not found in the DB." %
(payload['reqid'],
pii_hash(payload['user_id'],
payload['pii_salt']),
pii_hash(payload['session_token'],
payload['pii_salt']),
payload['user_role'],
pii_hash(payload['ip_address'],
payload['pii_salt']),
pii_hash(payload['user_agent'],
payload['pii_salt']),
payload['audience'],
payload['subject'],
payload['apiversion'])
)
return {
'success':False,
'apikey':None,
'expires':None,
'messages':([
"Invalid session token for password reset request."
])
}
session = session_info['session_info']
# check if the session info matches what we have in the payload
session_ok = (
(session['user_id'] == payload['user_id']) and
(session['ip_address'] == payload['ip_address']) and
(session['user_agent'] == payload['user_agent']) and
(session['user_role'] == payload['user_role'])
)
if not session_ok:
LOGGER.error(
"[%s] Invalid API key request. "
"user_id: %s, session_token: %s, role: %s, "
"ip_address: %s, user_agent: %s requested an API key for "
"audience: %s, subject: %s, apiversion: %s."
"Session token info of requestor does not match payload info." %
(payload['reqid'],
pii_hash(payload['user_id'],
payload['pii_salt']),
pii_hash(payload['session_token'],
payload['pii_salt']),
payload['user_role'],
pii_hash(payload['ip_address'],
payload['pii_salt']),
pii_hash(payload['user_agent'],
payload['pii_salt']),
payload['audience'],
payload['subject'],
payload['apiversion'])
)
return {
'success':False,
'apikey':None,
'expires':None,
'messages':([
"DB session user_id, ip_address, user_agent, "
"user_role does not match provided session info."
])
}
#
# finally, generate the API key
#
random_token = secrets.token_urlsafe(32)
# we'll return this API key dict to the frontend so it can JSON dump it,
# encode to bytes, then encrypt, then sign it, and finally send back to the
# client
issued = datetime.utcnow()
expires = datetime.utcnow() + timedelta(days=payload['expires_days'])
notvalidbefore = (
datetime.utcnow() +
timedelta(seconds=payload['not_valid_before'])
)
apikey_dict = {
'ver':payload['apiversion'],
'uid':payload['user_id'],
'rol':payload['user_role'],
'clt':payload['user_agent'],
'aud':payload['audience'],
'sub':payload['subject'],
'ipa':payload['ip_address'],
'tkn':random_token,
'iat':issued.isoformat(),
'nbf':notvalidbefore.isoformat(),
'exp':expires.isoformat()
}
apikey_json = json.dumps(apikey_dict)
# we'll also store this dict in the apikeys table
apikeys = currproc.authdb_meta.tables['apikeys']
# NOTE: we store only the random token. this will later be checked for
# equality against the value stored in the API key dict['tkn'] when we send
# in this API key for verification later
ins = apikeys.insert({
'apikey':random_token,
'issued':issued,
'expires':expires,
'not_valid_before':notvalidbefore,
'user_id':payload['user_id'],
'user_role':payload['user_role'],
'session_token':payload['session_token'],
})
result = currproc.authdb_conn.execute(ins)
result.close()
#
# return the API key to the frontend
#
LOGGER.info(
"[%s] API key request successful. "
"user_id: %s, session_token: %s, role: %s, "
"ip_address: %s, user_agent: %s requested an API key for "
"audience: %s, subject: %s, apiversion: %s."
"API key not valid before: %s, expires on: %s." %
(payload['reqid'],
pii_hash(payload['user_id'],
payload['pii_salt']),
pii_hash(payload['session_token'],
payload['pii_salt']),
payload['user_role'],
pii_hash(payload['ip_address'],
payload['pii_salt']),
pii_hash(payload['user_agent'],
payload['pii_salt']),
payload['audience'],
payload['subject'],
payload['apiversion'],
notvalidbefore.isoformat(),
expires.isoformat())
)
messages = (
"API key generated successfully for user_id = %s, expires: %s." %
(payload['user_id'],
expires.isoformat())
)
return {
'success':True,
'apikey':apikey_json,
'expires':expires.isoformat(),
'messages':([
messages
])
}
[docs]def verify_apikey(payload,
raiseonfail=False,
override_authdb_path=None):
'''Checks if an API key is valid.
Parameters
----------
payload : dict
This dict contains a single key:
- apikey_dict: the decrypted and verified API key info dict from the
frontend.
raiseonfail : bool
If True, will raise an Exception if something goes wrong.
override_authdb_path : str or None
If given as a str, is the alternative path to the auth DB.
Returns
-------
dict
The dict returned is of the form::
{'success': True if API key is OK and False otherwise,
'messages': list of str messages if any}
'''
for key in ('reqid','pii_salt'):
if key not in payload:
LOGGER.error(
"Missing %s in payload dict. Can't process this request." % key
)
return {
'success':False,
'apikey':None,
'expires':None,
'messages':["Invalid API key request."],
}
if 'apikey_dict' not in payload:
LOGGER.error(
'[%s] Invalid API key request, missing %s.' %
(payload['reqid'], 'apikey_dict')
)
return {
'success':False,
'messages':["Some required keys are missing from payload."]
}
apikey_dict = payload['apikey_dict']
# this checks if the database connection is live
currproc = mp.current_process()
engine = getattr(currproc, 'authdb_engine', None)
if override_authdb_path:
currproc.auth_db_path = override_authdb_path
if not engine:
currproc.authdb_engine, currproc.authdb_conn, currproc.authdb_meta = (
authdb.get_auth_db(
currproc.auth_db_path,
echo=raiseonfail
)
)
apikeys = currproc.authdb_meta.tables['apikeys']
# the apikey sent to us must match the stored apikey's properties:
# - token
# - userid
# - expired must be in the future
# - issued must be in the past
# - not_valid_before must be in the past
sel = select([
apikeys.c.apikey,
apikeys.c.expires,
]).select_from(apikeys).where(
apikeys.c.apikey == apikey_dict['tkn']
).where(
apikeys.c.user_id == apikey_dict['uid']
).where(
apikeys.c.user_role == apikey_dict['rol']
).where(
apikeys.c.expires > datetime.utcnow()
).where(
apikeys.c.issued < datetime.utcnow()
).where(
apikeys.c.not_valid_before < datetime.utcnow()
)
result = currproc.authdb_conn.execute(sel)
row = result.fetchone()
result.close()
if row is not None and len(row) != 0:
LOGGER.info(
'[%s] API key verified successfully. '
'user_id: %s, role: %s, audience: %s, subject: %s, '
'apiversion: %s, expires on: %s' %
(payload['reqid'],
pii_hash(apikey_dict['uid'],
payload['pii_salt']),
apikey_dict['rol'],
apikey_dict['aud'],
apikey_dict['sub'],
apikey_dict['ver'],
apikey_dict['exp'])
)
return {
'success':True,
'messages':[(
"API key verified successfully. Expires: %s." %
row['expires'].isoformat()
)]
}
else:
LOGGER.error(
'[%s] API key verification failed. Failed key '
'user_id: %s, role: %s, audience: %s, subject: %s, '
'apiversion: %s, expires on: %s' %
(payload['reqid'],
pii_hash(apikey_dict['uid'],
payload['pii_salt']),
apikey_dict['rol'],
apikey_dict['aud'],
apikey_dict['sub'],
apikey_dict['ver'],
apikey_dict['exp'])
)
return {
'success':False,
'messages':[(
"API key could not be verified."
)]
}