mirror of
https://github.com/norohind/FDEV-CAPI-Handler.git
synced 2026-10-27 01:16:03 +03:00
WIP: full refactor
This commit is contained in:
11 files changed
+904
-2
No files matched your search
@@ -0,0 +1,167 @@
|
||||
from . import model
|
||||
from . import utils
|
||||
from . import exceptions
|
||||
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
|
||||
import config
|
||||
from EDMCLogging import get_main_logger
|
||||
|
||||
logger = get_main_logger()
|
||||
|
||||
|
||||
class CAPIAuthorizer:
|
||||
def __init__(self, _model: model.Model):
|
||||
self.model: model.Model = _model
|
||||
|
||||
def auth_init(self) -> str:
|
||||
"""
|
||||
Generates initial url for fdev auth
|
||||
|
||||
:return:
|
||||
"""
|
||||
|
||||
code_verifier = base64.urlsafe_b64encode(os.urandom(32))
|
||||
code_challenge = utils.generate_challenge(code_verifier)
|
||||
state_string = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8")
|
||||
|
||||
self.model.auth_init(code_verifier.decode('utf-8'), state_string)
|
||||
|
||||
redirect_user_to_fdev = f"{config.AUTH_URL}?" \
|
||||
f"audience=all&" \
|
||||
f"scope=capi&" \
|
||||
f"response_type=code&" \
|
||||
f"client_id={config.CLIENT_ID}&" \
|
||||
f"code_challenge={code_challenge}&" \
|
||||
f"code_challenge_method=S256&" \
|
||||
f"state={state_string}&" \
|
||||
f"redirect_uri={config.REDIRECT_URL}"
|
||||
|
||||
return redirect_user_to_fdev
|
||||
|
||||
def fdev_callback(self, code: str, state: str) -> dict[str, str]:
|
||||
if type(code) is not str or type(state) != str:
|
||||
raise TypeError('code and state must be strings')
|
||||
|
||||
code_verifier = self.model.get_verifier(state)
|
||||
|
||||
self.model.set_code(code, state)
|
||||
|
||||
token_request = utils.get_tokens_request(code, code_verifier)
|
||||
|
||||
try:
|
||||
token_request.raise_for_status()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f'token_request failed. Status: {token_request.status_code!r}, text: {token_request.text!r}',
|
||||
exc_info=e
|
||||
)
|
||||
|
||||
# self.model.delete_row(state)
|
||||
|
||||
raise e
|
||||
|
||||
tokens = token_request.json()
|
||||
|
||||
access_token = tokens["access_token"]
|
||||
refresh_token = tokens["refresh_token"]
|
||||
expires_in = tokens["expires_in"]
|
||||
timestamp_got_expires_in = int(time.time())
|
||||
|
||||
self.model.set_tokens(access_token, refresh_token, expires_in, timestamp_got_expires_in, state)
|
||||
|
||||
try:
|
||||
nickname = utils.get_nickname(access_token)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Couldn't get nickname for state: {state!r}", exc_info=e)
|
||||
raise KeyError(f"Couldn't get nickname for state: {state!r}")
|
||||
|
||||
msg = {'status': 'ok', 'description': '', 'state': ''}
|
||||
|
||||
if not self.model.set_nickname(nickname, state):
|
||||
msg['description'] = 'Tokens updated'
|
||||
|
||||
else:
|
||||
msg['description'] = 'Tokens saved'
|
||||
|
||||
msg['state'] = self.model.get_state_by_nickname(nickname)
|
||||
|
||||
return msg
|
||||
|
||||
def get_token_by_state(self, state: str) -> dict:
|
||||
self.refresh_by_state(state)
|
||||
row = self.model.get_token_for_user(state)
|
||||
row['expires_over'] = int(row['expires_on']) - int(time.time())
|
||||
return row
|
||||
|
||||
def refresh_by_state(self, state: str, force_refresh=False, failure_tolerance=True) -> dict:
|
||||
"""
|
||||
|
||||
:param state:
|
||||
:param force_refresh: if we should update token when its time hasn't come
|
||||
:param failure_tolerance: if we shouldn't remove row in case of update's failure
|
||||
:return:
|
||||
"""
|
||||
|
||||
msg = {'status': '', 'description': '', 'state': ''}
|
||||
row = self.model.get_row(state)
|
||||
|
||||
if row is None:
|
||||
# No such state in DB
|
||||
msg['status'] = 'error'
|
||||
msg['message'] = 'No such state in DB'
|
||||
raise exceptions.RefreshFail(msg['description'], msg['status'], state)
|
||||
|
||||
msg['state'] = state
|
||||
|
||||
if int(time.time()) < int(row['timestamp_got_expires_in']) + int(row['expires_in']) and not force_refresh:
|
||||
msg['status'] = 'ok'
|
||||
msg['description'] = "Didn't refresh since it isn't required"
|
||||
|
||||
return msg # token isn't expired and we don't force updating
|
||||
|
||||
try:
|
||||
refresh_request = utils.refresh_request(row['refresh_token'])
|
||||
if refresh_request.status_code == 418: # Server's maintenance
|
||||
logger.warning(f'FDEV maintenance 418, text: {refresh_request.text!r}')
|
||||
msg['status'] = 'error'
|
||||
msg['description'] = 'FDEV on maintenance'
|
||||
raise exceptions.RefreshFail(msg['message'], msg['status'], state)
|
||||
|
||||
refresh_request.raise_for_status()
|
||||
|
||||
tokens = refresh_request.json()
|
||||
tokens['timestamp_got_expires_in'] = int(time.time())
|
||||
self.model.set_tokens(*tokens, state)
|
||||
msg['status'] = 'ok'
|
||||
msg['description'] = 'Token were successfully updated'
|
||||
return msg
|
||||
|
||||
except Exception as e:
|
||||
# probably here something don't work
|
||||
logger.warning(f'Fail on refreshing token for {state}, row:{row}')
|
||||
msg['status'] = 'error'
|
||||
|
||||
self.model.increment_refresh_tries(state)
|
||||
|
||||
if not failure_tolerance:
|
||||
if row['refresh_tries'] >= 5: # hardcoded limit
|
||||
msg['description'] = "Refresh failed. You were removed from DB due to refresh rate limitings"
|
||||
self.model.delete_row(state)
|
||||
raise exceptions.RefreshFail(msg['description'], msg['status'], state)
|
||||
|
||||
msg['description'] = "Refresh failed. Try later"
|
||||
raise exceptions.RefreshFail(msg['description'], msg['status'], state)
|
||||
|
||||
def delete_by_state(self, state: str) -> None:
|
||||
self.model.delete_row(state)
|
||||
|
||||
def list_all_users(self) -> list[dict]:
|
||||
return self.model.list_all_records()
|
||||
|
||||
|
||||
capi_authorizer = CAPIAuthorizer(model.Model())
|
||||
@@ -0,0 +1,6 @@
|
||||
class RefreshFail(Exception):
|
||||
def __init__(self, message: str, status: str, state: str):
|
||||
self.message = message
|
||||
self.status = status
|
||||
self.state = state
|
||||
super().__init__(self.message + ' for ' + self.state)
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import sqlite3
|
||||
from typing import Union
|
||||
|
||||
from . import sqlite_requests
|
||||
from EDMCLogging import get_main_logger
|
||||
|
||||
logger = get_main_logger()
|
||||
|
||||
|
||||
class Model:
|
||||
def __init__(self):
|
||||
self.db: sqlite3.Connection = sqlite3.connect('companion-api.sqlite', check_same_thread=False)
|
||||
self.db.row_factory = lambda c, r: dict(zip([col[0] for col in c.description], r))
|
||||
with self.db:
|
||||
self.db.execute(sqlite_requests.schema)
|
||||
|
||||
def auth_init(self, verifier: str, state: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
sqlite_requests.insert_auth_init,
|
||||
{
|
||||
'code_verifier': verifier,
|
||||
'state': state
|
||||
})
|
||||
|
||||
def get_verifier(self, state: str) -> str:
|
||||
code_verifier_req = self.db.execute(sqlite_requests.select_all_by_state, {'state': state}).fetchone()
|
||||
|
||||
if code_verifier_req is None:
|
||||
# Somebody got here not by frontier redirect
|
||||
raise KeyError('No state in DB found')
|
||||
|
||||
code_verifier: str = code_verifier_req['code_verifier']
|
||||
|
||||
return code_verifier
|
||||
|
||||
def set_code(self, code: str, state: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute(sqlite_requests.set_code_state, {'code': code, 'state': state})
|
||||
|
||||
def delete_row(self, state: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute(sqlite_requests.delete_by_state, {'state': state})
|
||||
|
||||
def set_tokens(self, access_token: str, refresh_token: str, expires_in: int, timestamp_got_expires_in: int,
|
||||
state: str) -> None:
|
||||
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
sqlite_requests.set_tokens_by_state,
|
||||
{
|
||||
'access_token': access_token,
|
||||
'refresh_token': refresh_token,
|
||||
'expires_in': expires_in,
|
||||
'timestamp_got_expires_in': timestamp_got_expires_in,
|
||||
'state': state
|
||||
})
|
||||
|
||||
def set_nickname(self, nickname: str, state: str) -> bool:
|
||||
"""
|
||||
Return True if inserted successfully, False if catch sqlite3.IntegrityError
|
||||
|
||||
:param nickname:
|
||||
:param state:
|
||||
:return:
|
||||
"""
|
||||
|
||||
try:
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
sqlite_requests.set_nickname_by_state,
|
||||
{'nickname': nickname, 'state': state}
|
||||
)
|
||||
return True
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
"""
|
||||
let's migrate new received data to old state
|
||||
1. Get old state by nickname
|
||||
2. Remove row with old state
|
||||
3. Set old state where new state
|
||||
"""
|
||||
state_to_set: str = self.get_state_by_nickname(nickname)
|
||||
self.delete_row(state_to_set)
|
||||
self.set_new_state(state_to_set, state)
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
sqlite_requests.set_nickname_by_state,
|
||||
{'nickname': nickname, 'state': state_to_set}
|
||||
)
|
||||
return False
|
||||
|
||||
def get_state_by_nickname(self, nickname: str) -> Union[None, str]:
|
||||
with self.db:
|
||||
nickname_f1 = self.db.execute(sqlite_requests.get_state_by_nickname, {'nickname': nickname}).fetchone()
|
||||
|
||||
if nickname_f1 is None:
|
||||
return None
|
||||
|
||||
return nickname_f1['state']
|
||||
|
||||
def set_new_state(self, new_state: str, old_state: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute(sqlite_requests.update_state_by_state, {'new_state': new_state, 'state': old_state})
|
||||
|
||||
def get_row(self, state: str) -> dict:
|
||||
return self.db.execute(sqlite_requests.select_all_by_state, {'state': state}).fetchone()
|
||||
|
||||
def increment_refresh_tries(self, state: str) -> None:
|
||||
with self.db:
|
||||
self.db.execute(sqlite_requests.refresh_times_increment, {'state': state})
|
||||
|
||||
def get_token_for_user(self, state: str) -> dict:
|
||||
return self.db.execute(sqlite_requests.get_token_for_user, {'state': state}).fetchone()
|
||||
|
||||
def list_all_records(self) -> list:
|
||||
return self.db.execute(sqlite_requests.select_nickname_state_all).fetchall()
|
||||
@@ -0,0 +1,48 @@
|
||||
schema = """create table if not exists authorizations (
|
||||
code_verifier text ,
|
||||
state text,
|
||||
timestamp_init datetime default current_timestamp ,
|
||||
code text,
|
||||
access_token text,
|
||||
refresh_token text,
|
||||
expires_in text,
|
||||
timestamp_got_expires_in text,
|
||||
nickname text unique,
|
||||
refresh_tries int default 0
|
||||
);"""
|
||||
|
||||
insert_auth_init = """insert into authorizations
|
||||
(code_verifier, state)
|
||||
values
|
||||
(:code_verifier, :state);"""
|
||||
|
||||
select_all_by_state = """select * from authorizations where state = :state;"""
|
||||
|
||||
set_code_state = """update authorizations set code = :code where state = :state;"""
|
||||
|
||||
delete_by_state = """delete from authorizations where state = :state;"""
|
||||
|
||||
set_tokens_by_state = """update authorizations
|
||||
set
|
||||
access_token = :access_token,
|
||||
refresh_token = :refresh_token,
|
||||
expires_in = :expires_in,
|
||||
timestamp_got_expires_in = :timestamp_got_expires_in,
|
||||
refresh_tries = 0
|
||||
where state = :state;"""
|
||||
|
||||
set_nickname_by_state = """update authorizations set nickname = :nickname where state = :state;"""
|
||||
|
||||
get_state_by_nickname = """select state from authorizations where nickname = :nickname;"""
|
||||
|
||||
update_state_by_state = """update authorizations set state = :new_state where state = :state;"""
|
||||
|
||||
refresh_times_increment = """update authorizations set refresh_tries = refresh_tries + 1 where state = :state;"""
|
||||
|
||||
get_token_for_user = """select
|
||||
access_token,
|
||||
timestamp_got_expires_in + expires_in as expires_on,
|
||||
nickname
|
||||
from authorizations where state = :state;"""
|
||||
|
||||
select_nickname_state_all = """select nickname, state from authorizations where nickname is not null;"""
|
||||
@@ -0,0 +1,50 @@
|
||||
import hashlib
|
||||
import base64
|
||||
import requests
|
||||
import config
|
||||
|
||||
|
||||
def get_tokens_request(code, code_verifier) -> requests.Response:
|
||||
"""
|
||||
Performs initial requesting access and refresh tokens
|
||||
|
||||
:param code:
|
||||
:param code_verifier:
|
||||
:return:
|
||||
"""
|
||||
token_request: requests.Response = requests.post(
|
||||
url=config.TOKEN_URL,
|
||||
headers={
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': config.PROPER_USER_AGENT
|
||||
},
|
||||
data=f'redirect_uri={config.REDIRECT_URL}&'
|
||||
f'code={code}&'
|
||||
f'grant_type=authorization_code&'
|
||||
f'code_verifier={code_verifier}&'
|
||||
f'client_id={config.CLIENT_ID}')
|
||||
|
||||
return token_request
|
||||
|
||||
|
||||
def get_nickname(access_token: str) -> str:
|
||||
return requests.get(
|
||||
url='https://companion.orerve.net/profile',
|
||||
headers={'Authorization': f'Bearer {access_token}',
|
||||
'User-Agent': config.PROPER_USER_AGENT}).json()["commander"]["name"]
|
||||
|
||||
|
||||
def refresh_request(refresh_token: str) -> requests.Response:
|
||||
return requests.post(
|
||||
url=config.TOKEN_URL,
|
||||
headers={'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
data=f'grant_type=refresh_token&client_id={config.CLIENT_ID}&refresh_token={refresh_token}')
|
||||
|
||||
|
||||
def generate_challenge(_verifier: bytes) -> str:
|
||||
"""
|
||||
It takes code verifier and return code challenge
|
||||
:param _verifier:
|
||||
:return:
|
||||
"""
|
||||
return base64.urlsafe_b64encode(hashlib.sha256(_verifier).digest())[:-1].decode('utf-8')
|
||||
Reference in new issue
Block a user