1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-23 03:24:20 +00:00

#1 Add /login

This commit is contained in:
Steffo 2021-04-19 16:11:08 +02:00
parent 80cf8fc83d
commit 8961b58078
Signed by: steffo
GPG key ID: 6965406171929D01
3 changed files with 235 additions and 101 deletions

2
poetry.lock generated
View file

@ -512,7 +512,7 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.8" python-versions = "^3.8"
content-hash = "c30fdf09f35a2430d64998b966b2648d1bed68a9a1e58e45339f5a1a53895263" content-hash = "331ed6c6b9a070807ccc984fcda1e4d8c08da999f49a96195a7f3c25356fbd73"
[metadata.files] [metadata.files]
aiohttp = [ aiohttp = [

View file

@ -28,6 +28,7 @@ SQLAlchemy-Utils = "^0.37.0"
arrow = "^1.0.3" arrow = "^1.0.3"
colour = "^0.1.5" colour = "^0.1.5"
royalspells = "^3.2" royalspells = "^3.2"
async-timeout = "^3.0.1"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]

View file

@ -1,20 +1,216 @@
import royalnet.royaltyping as t
import royalnet.engineer as engi import royalnet.engineer as engi
import sqlalchemy.sql as ss import sqlalchemy.sql as ss
import sqlalchemy.orm as so import sqlalchemy.orm as so
import royalpack.database as db import royalpack.database as db
import royalpack.config as cfg import royalpack.config as cfg
import royalnet_telethon
import royalnet_telethon.bullet.contents import royalnet_telethon.bullet.contents
import aiohttp import aiohttp
import asyncio import asyncio
import logging import logging
import arrow import arrow
import datetime import async_timeout
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# FIXME: Properly handle errors in this function! # FIXME: Properly handle errors in this function!
async def enforce_private_message(msg: engi.Message) -> engi.Channel:
"""
Get the private chat for an user and notify them of the switch.
:param msg: The :class:`~.engi.Message` to reply to.
:return: The private :class:`~.engi.Channel`.
"""
log.debug("Sliding into DMs...")
sender: engi.User = await msg.sender
current: engi.Channel = await msg.channel
private: engi.Channel = await sender.slide()
if hash(current) != hash(private):
await msg.reply(text="👤 Ti sto inviando un messaggio in chat privata contenente le istruzioni per il login!")
return private
async def device_code_request(
http_session: aiohttp.ClientSession,
client_id: str,
device_url: str,
scopes: list[str],
) -> t.JSON:
"""
Request a OAuth2 device code (which can be exchanged for an access token once the user has given us the
authorization to do so).
:param http_session: The :class:`aiohttp.ClientSession` to use.
:param client_id: The OAuth2 Client ID.
:param device_url: The URL where device codes can be obtained.
:param scopes: A :class:`list` of scopes to require from the user.
:return: The JSON response received from the Identity Provider.
"""
log.debug("Requesting device code...")
async with http_session.post(device_url, data={
"client_id": client_id,
"scope": " ".join(scopes),
}) as request:
return await request.json()
async def prompt_login(channel: engi.Channel, verification_url: str, user_code: str) -> None:
"""
Ask the user to login.
:param channel: The :class:`~.engi.Channel` to send the message in.
:param verification_url: The URL where the user can approve / reject the token.
:param user_code: Human-friendly view of the device code.
"""
log.debug("Asking user to login...")
await channel.send_message(
text=f"🌍 Effettua il RYGlogin al seguente URL, poi premi Confirm:\n"
f"{verification_url}\n"
f"\n"
f"(Codice: {user_code})"
)
async def device_code_exchange(
http_session: aiohttp.ClientSession,
client_id: str,
token_url: str,
device_code: str,
sleep_time: float,
):
"""
Check if the user has authorized the device code, and try to exchange it for an access token.
:return: The JSON response received from the Identity Provider.
"""
log.debug("Starting validation process...")
while True:
log.debug(f"Sleeping for {sleep_time}s...")
await asyncio.sleep(sleep_time)
async with http_session.post(token_url, data={
"client_id": client_id,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code,
}) as request:
response = await request.json()
if "error" in response:
log.debug(f"Response returned error {response['error']!r}, retrying...")
continue
elif "access_token" in response:
log.debug(f"Obtained access token!")
return response
else:
log.error(f"Didn't get an access token, but didn't get an error either?!")
continue
async def get_user_info(
http_session: aiohttp.ClientSession,
userinfo_url: str,
token_type: str,
access_token: str,
):
"""
Get the userinfo of an user.
:param http_session: The :class:`aiohttp.ClientSession` to use.
:param userinfo_url: The URL where the user info is obtained.
:param token_type: The type of the token returned by the Identity Provider (usually ``"Bearer"``)
:param access_token: The access token to use.
:return:
"""
log.debug("Getting user info...")
async with http_session.post(userinfo_url, headers={
"Authorization": f"{token_type} {access_token}"
}) as request:
return await request.json()
async def notify_expiration(channel: engi.Channel) -> None:
"""
Notify the user of the device code expiration.
:param channel: The :class:`~.engi.Channel` to send the message in.
"""
log.debug("Notifying the user of the expiration...")
await channel.send_message(
text=f"🕒 Il codice dispositivo è scaduto e il login è stato annullato. "
f"Fai il login più in fretta la prossima volta! :)",
)
async def register_user_generic(
session: so.Session,
user_info: dict[str, t.Any],
) -> db.User:
"""
Sync the user info with the data inside the database.
:param session: The :class:`~.so.Session` to use.
:param user_info: The user_info obtained by the Identity Provider.
:return: The created/updated :class:`.db.User`.
"""
log.debug("Syncing generic user...")
user = db.User(
sub=user_info['sub'],
last_update=arrow.now(),
name=user_info['name'],
nickname=user_info['nickname'],
avatar=user_info['picture'],
email=user_info['email'],
)
session.merge(user)
return user
async def register_user_telethon(
session: so.Session,
user_info: dict[str, t.Any],
telethon_user,
) -> db.TelegramAccount:
"""
Sync an user's Telegram account via a Telethon message.
:param session: The :class:`~.so.Session` to use.
:param user_info: The user_info obtained by the Identity Provider.
:param telethon_user: The telethon user to base the user data on.
:return: The created/updated :class:`~.db.TelegramAccount`
"""
log.debug("Syncing telethon user...")
tg = db.TelegramAccount(
user_fk=user_info["sub"],
id=telethon_user.id,
first_name=telethon_user.first_name,
last_name=telethon_user.last_name,
username=telethon_user.username,
avatar_url=None, # TODO: avatars
)
session.merge(tg)
return tg
@engi.use_database(db.lazy_session_class) @engi.use_database(db.lazy_session_class)
@engi.TeleportingConversation @engi.TeleportingConversation
async def login(*, _msg: engi.Message, _session: so.Session, _imp, **__): async def login(*, _msg: engi.Message, _session: so.Session, _imp, **__):
@ -24,119 +220,56 @@ async def login(*, _msg: engi.Message, _session: so.Session, _imp, **__):
log.debug("Evaluating config...") log.debug("Evaluating config...")
config = cfg.lazy_config.evaluate() config = cfg.lazy_config.evaluate()
log.debug("Sliding into DMs...") private = await enforce_private_message(msg=_msg)
sender: engi.User = await _msg.sender
current: engi.Channel = await _msg.channel
private: engi.Channel = await sender.slide()
if hash(current) != hash(private):
await _msg.reply(text="👤 Ti ho inviato un messaggio in chat privata contenente le istruzioni per il login!")
async with aiohttp.ClientSession() as http_session: async with aiohttp.ClientSession() as http_session:
log.debug("Generating device code...") dc = await device_code_request(
async with http_session.post(config["auth.url.device"], data={ http_session=http_session,
"client_id": config["auth.client.id"], client_id=config["auth.client.id"],
"scope": "profile email openid", device_url=config["auth.url.device"],
"prompt": "consent", scopes=["profile", "email", "openid"],
}) as request: )
response = await request.json()
start = arrow.now()
log.debug("Asking user to login...") await prompt_login(
await private.send_message( channel=private,
text=f"🌍 Effettua il RYGlogin al seguente URL, poi premi Confirm:\n" verification_url=dc['verification_uri_complete'],
f"{response['verification_uri_complete']}\n" user_code=dc['user_code']
f"\n" )
f"(Codice: {response['user_code']})"
try:
async with async_timeout.timeout(dc["expires_in"]):
at = await device_code_exchange(
http_session=http_session,
client_id=config["auth.client.id"],
token_url=config["auth.url.token"],
device_code=dc["device_code"],
sleep_time=9
)
except asyncio.TimeoutError:
await notify_expiration(
channel=private
) )
expiration = start + datetime.timedelta(seconds=response["expires_in"])
while arrow.now() < expiration:
log.debug("Sleeping for 10 seconds...")
await asyncio.sleep(10)
async with http_session.post(config["auth.url.token"], data={
"client_id": config["auth.client.id"],
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": response["device_code"],
}) as request:
response = await request.json()
if "error" in response:
log.debug(f"Response returned error {response['error']!r}, retrying...")
continue
elif "access_token" in response:
log.debug(f"Obtained access token...")
break
else:
log.error(f"Didn't get an access token, but didn't get an error either?!")
continue
else:
log.debug("Login request expired.")
await private.send_message(text="🕒 La tua richiesta di login è scaduta. "
"Riinvia il comando per ricominciare!")
return return
async with http_session.post(config["auth.url.userinfo"], headers={ ui = await get_user_info(
"Authorization": f"{response['token_type']} {response['access_token']}" http_session=http_session,
}) as request: userinfo_url=config["auth.url.userinfo"],
response = await request.json() token_type=at["token_type"],
access_token=at["access_token"],
)
log.debug("Checking if the user already exists...") user = await register_user_generic(session=_session, user_info=ui)
user: db.User = _session.execute(
ss.select(db.User).where(db.User.sub == response["sub"])
).scalar()
log.debug("Creating user dict...")
user_dict = {
"sub": response['sub'],
"last_update": arrow.now(),
"name": response['name'],
"nickname": response['nickname'],
"avatar": response['picture'],
"email": response['email'],
}
if user is None:
log.info(f"Creating new user: {response['sub']}")
user = db.User(**user_dict)
_session.add(user)
else:
log.debug(f"Updating existing user: {response['sub']}")
user.update(**user_dict)
if isinstance(_imp, royalnet_telethon.TelethonPDAImplementation): if isinstance(_imp, royalnet_telethon.TelethonPDAImplementation):
log.debug("Found out I'm running on Telethon...") sender = await _msg.sender
tg = await register_user_telethon(session=_session, user_info=ui, telethon_user=sender._user)
sender: royalnet_telethon.bullet.contents.TelegramUser
log.debug("Checking if the TelegramAccount already exists...")
tg: db.TelegramAccount = _session.execute(
ss.select(db.TelegramAccount).where(db.TelegramAccount.id == sender._user.id)
).scalar()
log.debug("Creating tg_dict...")
tg_dict = {
"user_fk": response["sub"],
"id": sender._user.id,
"first_name": sender._user.first_name,
"last_name": sender._user.last_name,
"username": sender._user.username,
"avatar_url": None, # TODO: avatars
}
if tg is None:
log.info(f"Creating new TelegramAccount: {sender._user.id}")
tg = db.TelegramAccount(**tg_dict)
_session.add(tg)
else:
log.debug(f"Updating existing TelegramAccount: {sender._user.id}")
tg.update(**tg_dict)
log.debug(f"Committing session...") log.debug(f"Committing session...")
_session.commit() _session.commit()
log.debug(f"Done, notifying the user...") log.debug(f"Done, notifying the user...")
await private.send_message(text=f"✅ Login riuscito! Sei loggato come {response['name']}!") await private.send_message(text=f"✅ Login riuscito! Sei loggato come {user.name}!")
__all__ = ("login",) __all__ = ("login",)