Allow login flow to be more like the official PC client
This commit is contained in:
parent
6623dd46c0
commit
e44536f9f2
|
@ -22,9 +22,17 @@ from mautrix.errors import MForbidden
|
|||
#from mautrix.util.signed_token import sign_token
|
||||
|
||||
from ..kt.client import Client as KakaoTalkClient
|
||||
from ..kt.client.errors import DeviceVerificationRequired, IncorrectPasscode, IncorrectPassword, CommandException
|
||||
from ..kt.client.errors import (
|
||||
AnotherLogonDetected,
|
||||
CommandException,
|
||||
DeviceVerificationRequired,
|
||||
IncorrectPasscode,
|
||||
IncorrectPassword,
|
||||
)
|
||||
|
||||
from .. import puppet as pu
|
||||
from ..db import LoginCredential
|
||||
|
||||
from .typehint import CommandEvent
|
||||
|
||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
||||
|
@ -46,28 +54,47 @@ send_password = "Please send your password here to log in"
|
|||
missing_email = "Please use `$cmdprefix+sp login <email>` to log in here"
|
||||
try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
|
||||
|
||||
_CMD_CONTINUE_LOGIN = "continue-login"
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
management_only=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Log in to KakaoTalk",
|
||||
help_args="[_email_]",
|
||||
help_text="Log in to KakaoTalk. Optionally save login credentials & have the bridge use them when needed to automatically restore your login session.",
|
||||
help_args="[--save] [_email_]",
|
||||
)
|
||||
async def login(evt: CommandEvent) -> None:
|
||||
if await evt.sender.is_logged_in():
|
||||
await evt.reply("You're already logged in")
|
||||
return
|
||||
|
||||
email = evt.args[0] if len(evt.args) > 0 else None
|
||||
save = len(evt.args) > 0 and evt.args[0] == "--save"
|
||||
email = evt.args[0 if not save else 1] if len(evt.args) > 0 else None
|
||||
|
||||
if email:
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": enter_password,
|
||||
"next": _enter_password,
|
||||
"email": email,
|
||||
"save": save,
|
||||
"forced": evt.sender.force_login,
|
||||
}
|
||||
try:
|
||||
creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
|
||||
except:
|
||||
evt.log.exception("Exception while looking for saved password")
|
||||
creds = None
|
||||
if creds and creds.email == email:
|
||||
await evt.reply("Logging in with saved password")
|
||||
await _login_with_password(
|
||||
evt,
|
||||
evt.sender.command_status.pop("email"),
|
||||
creds.password,
|
||||
evt.sender.command_status.pop("forced"),
|
||||
)
|
||||
return
|
||||
|
||||
""" TODO Implement web login
|
||||
if evt.bridge.public_website:
|
||||
|
@ -94,65 +121,146 @@ async def login(evt: CommandEvent) -> None:
|
|||
if not email:
|
||||
await evt.reply(f"{missing_email}.")
|
||||
else:
|
||||
await evt.reply(f"{send_password}.")
|
||||
save_warning = (
|
||||
" NOTE: When saving your credentials, the bridge will store your KakaoTalk password **unencrypted** until you discard it with `$cmdprefix+sp forget-password`."
|
||||
" If you would rather not have the bridge save your password, type `$cmdprefix+sp cancel`, then `$cmdprefix+sp login [_email_]` without `--save`."
|
||||
) if save else ""
|
||||
await evt.reply(f"{send_password}.{save_warning}")
|
||||
|
||||
|
||||
async def enter_password(evt: CommandEvent) -> None:
|
||||
async def _enter_password(evt: CommandEvent) -> None:
|
||||
try:
|
||||
await evt.az.intent.redact(evt.room_id, evt.event_id)
|
||||
except MForbidden:
|
||||
pass
|
||||
|
||||
await evt.mark_read()
|
||||
assert evt.sender.command_status
|
||||
await _login_with_password(
|
||||
evt,
|
||||
evt.sender.command_status.pop("email"),
|
||||
evt.content.body,
|
||||
evt.sender.command_status.pop("forced"),
|
||||
)
|
||||
|
||||
async def _login_with_password(evt: CommandEvent, email: str, password: str, forced: bool) -> None:
|
||||
req = {
|
||||
"uuid": await evt.sender.get_uuid(),
|
||||
"form": {
|
||||
"email": evt.sender.command_status["email"],
|
||||
"password": evt.content.body,
|
||||
}
|
||||
"email": email,
|
||||
"password": password,
|
||||
},
|
||||
"forced": forced,
|
||||
}
|
||||
await _try_login(evt, req)
|
||||
|
||||
async def _try_login(evt: CommandEvent, req: dict) -> None:
|
||||
save = (
|
||||
evt.sender.command_status.get("save", False)
|
||||
if evt.sender.command_status
|
||||
else False
|
||||
)
|
||||
try:
|
||||
await _do_login(evt, req)
|
||||
except DeviceVerificationRequired:
|
||||
await evt.reply(
|
||||
"Open KakaoTalk on your smartphone. It should show a device registration passcode. "
|
||||
"Enter that passcode here."
|
||||
)
|
||||
await _do_login(evt, req, save)
|
||||
except DeviceVerificationRequired as e:
|
||||
if evt.sender.command_status and evt.sender.command_status.get("next") != _enter_dv_code:
|
||||
await evt.reply(
|
||||
"Open KakaoTalk on your smartphone. It should show a device registration passcode. "
|
||||
"Enter that passcode here."
|
||||
)
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": _enter_dv_code,
|
||||
"save": save,
|
||||
"req": req,
|
||||
}
|
||||
else:
|
||||
evt.log.error("Device registration still required after having just registered")
|
||||
await _handle_login_failure(evt, e)
|
||||
except AnotherLogonDetected as e:
|
||||
if not req["forced"]:
|
||||
await evt.reply(
|
||||
"You are currently logged in to KakaoTalk on a PC or another bridge. "
|
||||
"In order to log in to this bridge, you will be forced to log out from your other session. "
|
||||
f"To proceed, type `$cmdprefix+sp {_CMD_CONTINUE_LOGIN}`. Otherwise, type `$cmdprefix+sp cancel`."
|
||||
)
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": _force_login,
|
||||
"save": save,
|
||||
"req": req,
|
||||
}
|
||||
else:
|
||||
evt.log.error("Failed to force-login while logged in elsewhere")
|
||||
await _handle_login_failure(evt, e)
|
||||
except IncorrectPassword:
|
||||
await evt.reply(f"Incorrect password. {try_again_or_cancel}")
|
||||
evt.sender.command_status = {
|
||||
"action": "Login",
|
||||
"room_id": evt.room_id,
|
||||
"next": enter_dv_code,
|
||||
"req": req,
|
||||
"next": _enter_password,
|
||||
"email": req["form"]["email"],
|
||||
"save": save,
|
||||
"forced": req["forced"],
|
||||
}
|
||||
except IncorrectPassword:
|
||||
await evt.reply(f"Incorrect password. {try_again_or_cancel}")
|
||||
#except OAuthException as e:
|
||||
# await evt.reply(f"Error from KakaoTalk:\n\n> {e}")
|
||||
except Exception as e:
|
||||
await _handle_login_failure(evt, e)
|
||||
|
||||
|
||||
async def enter_dv_code(evt: CommandEvent) -> None:
|
||||
async def _enter_dv_code(evt: CommandEvent) -> None:
|
||||
assert evt.sender.command_status
|
||||
req: dict = evt.sender.command_status["req"]
|
||||
req: dict = evt.sender.command_status.pop("req")
|
||||
passcode = evt.content.body
|
||||
await evt.mark_read()
|
||||
try:
|
||||
await KakaoTalkClient.register_device(passcode, **req)
|
||||
await _do_login(evt, req)
|
||||
except IncorrectPasscode:
|
||||
await evt.reply(f"Incorrect device registration passcode. {try_again_or_cancel}")
|
||||
#except OAuthException as e:
|
||||
# await evt.reply(f"Error from KakaoTalk:\n\n> {e}")
|
||||
except Exception as e:
|
||||
await _handle_login_failure(evt, e)
|
||||
else:
|
||||
await _try_login(evt, req)
|
||||
|
||||
|
||||
async def _do_login(evt: CommandEvent, req: dict) -> None:
|
||||
async def _force_login(evt: CommandEvent) -> None:
|
||||
command = evt.content.body
|
||||
if command != _CMD_CONTINUE_LOGIN:
|
||||
await evt.reply(
|
||||
f"Unknown action `{command}`. "
|
||||
f"Use `$cmdprefix+sp {_CMD_CONTINUE_LOGIN}` or `$cmdprefix+sp cancel`."
|
||||
)
|
||||
return
|
||||
assert evt.sender.command_status
|
||||
evt.sender.command_status["forced"] = True
|
||||
await evt.mark_read()
|
||||
await _try_login(evt, evt.sender.command_status.pop("req"))
|
||||
|
||||
|
||||
async def _do_login(evt: CommandEvent, req: dict, save: bool) -> None:
|
||||
oauth_credential = await KakaoTalkClient.login(**req)
|
||||
await evt.sender.on_logged_in(oauth_credential)
|
||||
evt.sender.command_status = None
|
||||
await evt.reply("Successfully logged in")
|
||||
try:
|
||||
if save:
|
||||
email = req["form"]["email"]
|
||||
password = req["form"]["password"]
|
||||
creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
|
||||
if creds:
|
||||
evt.log.warning("Found pre-existing login credentials, so updating them now")
|
||||
creds.email = email
|
||||
creds.password = password
|
||||
await creds.save()
|
||||
else:
|
||||
await LoginCredential(evt.sender.mxid, email, password).insert()
|
||||
else:
|
||||
await LoginCredential.delete_by_mxid(evt.sender.mxid)
|
||||
except:
|
||||
evt.log.exception("Error updating saved credentials after successful login")
|
||||
|
||||
async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None:
|
||||
evt.sender.command_status = None
|
||||
|
@ -165,6 +273,24 @@ async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None:
|
|||
await evt.reply(f"{message}: {e}")
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=True,
|
||||
management_only=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Delete saved login credentials, if they were saved"
|
||||
)
|
||||
async def forget_password(evt: CommandEvent) -> None:
|
||||
creds = await LoginCredential.get_by_mxid(evt.sender.mxid)
|
||||
if not creds:
|
||||
await evt.reply("The bridge wasn't storing your login credentials, so there was nothing to forget.")
|
||||
else:
|
||||
await creds.delete()
|
||||
await evt.reply(
|
||||
"This bridge is no longer storing your login credentials. "
|
||||
"If you get logged out unexpectedly, you will have to manually log back in."
|
||||
)
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=True,
|
||||
help_section=SECTION_AUTH,
|
||||
|
@ -206,3 +332,30 @@ async def reset_device(evt: CommandEvent) -> None:
|
|||
"Your next login will use a different device ID.\n\n"
|
||||
"The old device must be manually de-registered from the KakaoTalk app."
|
||||
)
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="When logging in, automatically log out of any other existing KakaoTalk session"
|
||||
)
|
||||
async def enable_forced_login(evt: CommandEvent) -> None:
|
||||
if evt.sender.force_login:
|
||||
await evt.reply("Forced login is already enabled.")
|
||||
return
|
||||
evt.sender.force_login = True
|
||||
await evt.sender.save()
|
||||
await evt.reply("Forced login is now enabled.")
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="When logging in, ask before logging out of another existing KakaoTalk session, if one exists"
|
||||
)
|
||||
async def disable_forced_login(evt: CommandEvent) -> None:
|
||||
if not evt.sender.force_login:
|
||||
await evt.reply("Forced login is already disabled.")
|
||||
return
|
||||
evt.sender.force_login = False
|
||||
await evt.sender.save()
|
||||
await evt.reply("Forced login is now disabled.")
|
||||
|
|
|
@ -45,6 +45,12 @@ async def disconnect(evt: CommandEvent) -> None:
|
|||
if not evt.sender.is_connected:
|
||||
await evt.reply("You are already disconnected from KakaoTalk chats")
|
||||
return
|
||||
if not evt.sender.config["bridge.remain_logged_in_on_disconnect"]:
|
||||
await evt.reply(
|
||||
"This instance of the KakaoTalk bridge does not allow being disconnected from KakaoTalk chats while logged in. "
|
||||
"So, to disconnect, you must log out entirely with the `logout` command."
|
||||
)
|
||||
return
|
||||
await evt.mark_read()
|
||||
await evt.sender.client.disconnect()
|
||||
await evt.reply("Successfully disconnected from KakaoTalk chats. To reconnect, use the `sync` command.")
|
||||
|
|
|
@ -104,6 +104,9 @@ class Config(BaseBridgeConfig):
|
|||
copy("bridge.periodic_reconnect.min_connected_time")
|
||||
"""
|
||||
copy("bridge.resync_max_disconnected_time")
|
||||
copy("bridge.remain_logged_in_on_disconnect")
|
||||
copy("bridge.allow_token_relogin")
|
||||
copy("bridge.reconnect_on_token_relogin")
|
||||
copy("bridge.sync_on_startup")
|
||||
copy("bridge.temporary_disconnect_notices")
|
||||
copy("bridge.disable_bridge_notices")
|
||||
|
|
|
@ -20,10 +20,11 @@ from .portal import Portal
|
|||
from .puppet import Puppet
|
||||
from .upgrade import upgrade_table
|
||||
from .user import User
|
||||
from .login_credential import LoginCredential
|
||||
|
||||
|
||||
def init(db: Database) -> None:
|
||||
for table in (Portal, Message, User, Puppet):
|
||||
for table in (Portal, Message, User, Puppet, LoginCredential):
|
||||
table.db = db
|
||||
|
||||
|
||||
|
@ -34,4 +35,5 @@ __all__ = [
|
|||
"Portal",
|
||||
"Puppet",
|
||||
"User",
|
||||
"LoginCredential",
|
||||
]
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from asyncpg import Record
|
||||
from attr import dataclass
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util.async_db import Database
|
||||
|
||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginCredential:
|
||||
db: ClassVar[Database] = fake_db
|
||||
|
||||
mxid: UserID
|
||||
email: str
|
||||
password: str
|
||||
|
||||
@classmethod
|
||||
async def get_by_mxid(cls, mxid: UserID) -> LoginCredential | None:
|
||||
q = "SELECT mxid, email, password FROM login_credential WHERE mxid=$1"
|
||||
row = await cls.db.fetchrow(q, mxid)
|
||||
return cls(**row) if row else None
|
||||
|
||||
@classmethod
|
||||
async def delete_by_mxid(cls, mxid: UserID) -> None:
|
||||
await cls.db.execute("DELETE FROM login_credential WHERE mxid=$1", mxid)
|
||||
|
||||
async def insert(self) -> None:
|
||||
q = """
|
||||
INSERT INTO login_credential (mxid, email, password)
|
||||
VALUES ($1, $2, $3)
|
||||
"""
|
||||
await self.db.execute(q, self.mxid, self.email, self.password)
|
||||
|
||||
def get_form(self) -> dict[str, str]:
|
||||
return {
|
||||
"email": self.email,
|
||||
"password": self.password,
|
||||
}
|
||||
|
||||
async def delete(self) -> None:
|
||||
await self.delete_by_mxid(self.mxid)
|
||||
|
||||
async def save(self) -> None:
|
||||
q = """
|
||||
UPDATE login_credential SET email=$2, password=$3
|
||||
WHERE mxid=$1
|
||||
"""
|
||||
await self.db.execute(q, self.mxid, self.email, self.password)
|
|
@ -19,3 +19,4 @@ upgrade_table = UpgradeTable()
|
|||
|
||||
from . import v01_initial_revision
|
||||
from . import v02_channel_meta
|
||||
from . import v03_user_connection
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
# matrix-appservice-kakaotalk - A Matrix-KakaoTalk puppeting bridge.
|
||||
# Copyright (C) 2022 Tulir Asokan, Andrew Ferrazzutti
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from mautrix.util.async_db import Connection
|
||||
|
||||
from . import upgrade_table
|
||||
|
||||
|
||||
@upgrade_table.register(description="Track login credentials, connections, and force-login preferences")
|
||||
async def upgrade_v3(conn: Connection) -> None:
|
||||
await conn.execute('ALTER TABLE "user" ADD COLUMN force_login BOOLEAN NOT NULL DEFAULT false')
|
||||
await conn.execute('ALTER TABLE "user" ADD COLUMN was_connected BOOLEAN NOT NULL DEFAULT false')
|
||||
# Just for now, assume that logged in = connected
|
||||
await conn.execute('UPDATE "user" SET was_connected=true WHERE access_token IS NOT NULL')
|
||||
|
||||
await conn.execute(
|
||||
"""CREATE TABLE login_credential (
|
||||
mxid TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (mxid)
|
||||
REFERENCES "user" (mxid)
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
)
|
|
@ -33,6 +33,8 @@ class User:
|
|||
db: ClassVar[Database] = fake_db
|
||||
|
||||
mxid: UserID
|
||||
force_login: bool
|
||||
was_connected: bool
|
||||
ktid: Long | None = field(converter=to_optional_long)
|
||||
uuid: str | None
|
||||
access_token: str | None
|
||||
|
@ -47,7 +49,7 @@ class User:
|
|||
def _from_optional_row(cls, row: Record | None) -> User | None:
|
||||
return cls._from_row(row) if row is not None else None
|
||||
|
||||
_columns = "mxid, ktid, uuid, access_token, refresh_token, notice_room"
|
||||
_columns = "mxid, force_login, was_connected, ktid, uuid, access_token, refresh_token, notice_room"
|
||||
|
||||
@classmethod
|
||||
async def all_logged_in(cls) -> List[User]:
|
||||
|
@ -76,6 +78,8 @@ class User:
|
|||
def _values(self):
|
||||
return (
|
||||
self.mxid,
|
||||
self.force_login,
|
||||
self.was_connected,
|
||||
self.ktid,
|
||||
self.uuid,
|
||||
self.access_token,
|
||||
|
@ -85,8 +89,8 @@ class User:
|
|||
|
||||
async def insert(self) -> None:
|
||||
q = """
|
||||
INSERT INTO "user" (mxid, ktid, uuid, access_token, refresh_token, notice_room)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO "user" (mxid, force_login, was_connected, ktid, uuid, access_token, refresh_token, notice_room)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
|
@ -95,7 +99,7 @@ class User:
|
|||
|
||||
async def save(self) -> None:
|
||||
q = """
|
||||
UPDATE "user" SET ktid=$2, uuid=$3, access_token=$4, refresh_token=$5, notice_room=$6
|
||||
UPDATE "user" SET force_login=$2, was_connected=$3, ktid=$4, uuid=$5, access_token=$6, refresh_token=$7, notice_room=$8
|
||||
WHERE mxid=$1
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
|
|
@ -191,7 +191,18 @@ bridge:
|
|||
# Set to 0 to always re-sync, or -1 to never re-sync automatically.
|
||||
# TODO Actually use this setting
|
||||
resync_max_disconnected_time: 5
|
||||
# Should the bridge do a resync on startup?
|
||||
# Should users remain logged in after being disconnected from chatroom updates?
|
||||
# This is a convenience feature, but might make the bridge look more suspicious to KakaoTalk.
|
||||
remain_logged_in_on_disconnect: true
|
||||
# May the bridge restore user logins with session tokens instead of requiring a password?
|
||||
# This is a convenience feature, but might make the bridge look more suspicious to KakaoTalk.
|
||||
# Note that password-based login will be tried first for users who have saved their password.
|
||||
allow_token_relogin: true
|
||||
# Should the bridge connect users to chatroom updates after a token-based login?
|
||||
# This will disconnect any KakaoTalk PC/bridge sessions that were started since the last connection.
|
||||
# This is a convenience feature, but might make the bridge look more suspicious to KakaoTalk.
|
||||
reconnect_on_token_relogin: true
|
||||
# Should the bridge do a resync for connected users on startup?
|
||||
sync_on_startup: true
|
||||
# Whether or not temporary disconnections should send notices to the notice room.
|
||||
# If this is false, disconnections will never send messages and connections will only send
|
||||
|
|
|
@ -137,13 +137,19 @@ class Client:
|
|||
return cls._api_request_void("register_device", passcode=passcode, **req)
|
||||
|
||||
@classmethod
|
||||
def login(cls, **req: JSON) -> Awaitable[OAuthCredential]:
|
||||
def login(cls, uuid: str, form: JSON, forced: bool) -> Awaitable[OAuthCredential]:
|
||||
"""
|
||||
Obtain a session token by logging in with user-provided credentials.
|
||||
Must have first called register_device with these credentials.
|
||||
"""
|
||||
# NOTE Actually returns an auth LoginData, but this only needs an OAuthCredential
|
||||
return cls._api_request_result(OAuthCredential, "login", **req)
|
||||
return cls._api_request_result(
|
||||
OAuthCredential,
|
||||
"login",
|
||||
uuid=uuid,
|
||||
form=form,
|
||||
forced=forced,
|
||||
)
|
||||
|
||||
# endregion
|
||||
|
||||
|
|
|
@ -14,13 +14,13 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Internal helpers for error handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn, Type
|
||||
|
||||
from .errors import (
|
||||
CommandException,
|
||||
AnotherLogonDetected,
|
||||
AuthenticationRequired,
|
||||
DeviceVerificationRequired,
|
||||
IncorrectPasscode,
|
||||
|
@ -61,7 +61,7 @@ _error_code_class_map: dict[KnownAuthStatusCode | KnownDataStatusCode | int, Typ
|
|||
#KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_VOICECALL: "Daily voicecall limit exceeded",
|
||||
#KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_WITHOUT_TOKEN: "Daily tokenless request limit exceeded",
|
||||
KnownAuthStatusCode.DEVICE_NOT_REGISTERED: DeviceVerificationRequired,
|
||||
#KnownAuthStatusCode.ANOTHER_LOGON: "Another logon detected",
|
||||
KnownAuthStatusCode.ANOTHER_LOGON: AnotherLogonDetected,
|
||||
#KnownAuthStatusCode.DEVICE_REGISTER_FAILED: "Device registration failed",
|
||||
#KnownAuthStatusCode.INVALID_DEVICE_REGISTER: "Invalid device",
|
||||
KnownAuthStatusCode.INVALID_PASSCODE: IncorrectPasscode,
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""Helper functions & types for status codes for the KakaoTalk API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..types.api.auth_api_client import KnownAuthStatusCode
|
||||
|
@ -48,6 +47,9 @@ class OAuthException(CommandException):
|
|||
class DeviceVerificationRequired(OAuthException):
|
||||
pass
|
||||
|
||||
class AnotherLogonDetected(OAuthException):
|
||||
pass
|
||||
|
||||
class IncorrectPassword(OAuthException):
|
||||
pass
|
||||
|
||||
|
|
|
@ -35,10 +35,16 @@ from mautrix.util.simple_lock import SimpleLock
|
|||
|
||||
from . import portal as po, puppet as pu
|
||||
from .config import Config
|
||||
from .db import User as DBUser
|
||||
from .db import User as DBUser, LoginCredential
|
||||
|
||||
from .kt.client import Client
|
||||
from .kt.client.errors import AuthenticationRequired, ResponseError
|
||||
from .kt.client.errors import (
|
||||
AuthenticationRequired,
|
||||
AnotherLogonDetected,
|
||||
IncorrectPassword,
|
||||
OAuthException,
|
||||
ResponseError,
|
||||
)
|
||||
from .kt.client.types import PortalChannelInfo, SettingsStruct
|
||||
from .kt.types.bson import Long
|
||||
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
|
||||
|
@ -107,6 +113,8 @@ class User(DBUser, BaseUser):
|
|||
def __init__(
|
||||
self,
|
||||
mxid: UserID,
|
||||
force_login: bool,
|
||||
was_connected: bool,
|
||||
ktid: Long | None = None,
|
||||
uuid: str | None = None,
|
||||
access_token: str | None = None,
|
||||
|
@ -115,6 +123,8 @@ class User(DBUser, BaseUser):
|
|||
) -> None:
|
||||
super().__init__(
|
||||
mxid=mxid,
|
||||
force_login=force_login,
|
||||
was_connected=was_connected,
|
||||
ktid=ktid,
|
||||
uuid=uuid,
|
||||
access_token=access_token,
|
||||
|
@ -277,7 +287,51 @@ class User(DBUser, BaseUser):
|
|||
async def _load_session(self, is_startup: bool) -> bool:
|
||||
if self._is_logged_in and is_startup:
|
||||
return True
|
||||
elif not self.has_state:
|
||||
if not self.was_connected and not self.config["bridge.remain_logged_in_on_disconnect"]:
|
||||
self.log.warning("Not logging in because last session was disconnected, and login+disconnection is forbidden by config")
|
||||
await self.push_bridge_state(
|
||||
BridgeStateEvent.LOGGED_OUT,
|
||||
error="logged-out",
|
||||
)
|
||||
return False
|
||||
latest_exc: Exception | None = None
|
||||
password_ok = False
|
||||
try:
|
||||
creds = await LoginCredential.get_by_mxid(self.mxid)
|
||||
except Exception as e:
|
||||
self.log.exception("Exception while looking for saved password")
|
||||
creds = None
|
||||
if creds:
|
||||
uuid = await self.get_uuid()
|
||||
form = creds.get_form()
|
||||
oauth_credential = None
|
||||
try:
|
||||
oauth_credential = await Client.login(uuid=uuid, form=form, forced=False)
|
||||
except IncorrectPassword as e:
|
||||
latest_exc = e
|
||||
except AnotherLogonDetected as e:
|
||||
if self.force_login:
|
||||
try:
|
||||
# Wait a moment to make it look like a user-initiated response
|
||||
await asyncio.sleep(2)
|
||||
oauth_credential = await Client.login(uuid=uuid, form=form, forced=True)
|
||||
except OAuthException as e:
|
||||
latest_exc = e
|
||||
if oauth_credential:
|
||||
self.oauth_credential = oauth_credential
|
||||
await self.save()
|
||||
password_ok = True
|
||||
else:
|
||||
try:
|
||||
await creds.delete()
|
||||
except:
|
||||
self.log.exception("Exception while deleting incorrect password")
|
||||
if password_ok or self.config["bridge.allow_token_relogin"] and self.has_state:
|
||||
if not password_ok:
|
||||
self.log.warning("Using token-based relogin after password-based relogin failed")
|
||||
elif latest_exc:
|
||||
raise latest_exc
|
||||
else:
|
||||
# If we have a user in the DB with no state, we can assume
|
||||
# KT logged us out and the bridge has restarted
|
||||
await self.push_bridge_state(
|
||||
|
@ -295,10 +349,10 @@ class User(DBUser, BaseUser):
|
|||
self._track_metric(METRIC_LOGGED_IN, True)
|
||||
self._is_logged_in = True
|
||||
self.is_connected = None
|
||||
asyncio.create_task(self.post_login(is_startup=is_startup))
|
||||
asyncio.create_task(self.post_login(is_startup=is_startup, is_token_login=not password_ok))
|
||||
return True
|
||||
|
||||
async def _send_reset_notice(self, e: AuthenticationRequired, edit: EventID | None = None) -> None:
|
||||
async def _send_reset_notice(self, e: OAuthException, edit: EventID | None = None) -> None:
|
||||
await self.send_bridge_notice(
|
||||
"Got authentication error from KakaoTalk:\n\n"
|
||||
f"> {e.message}\n\n"
|
||||
|
@ -333,8 +387,13 @@ class User(DBUser, BaseUser):
|
|||
self, event_id: EventID | None = None, retries: int = 3, is_startup: bool = False
|
||||
) -> None:
|
||||
try:
|
||||
await self._load_session(is_startup=is_startup)
|
||||
except AuthenticationRequired as e:
|
||||
if not await self._load_session(is_startup=is_startup) and self.has_state:
|
||||
await self.send_bridge_notice(
|
||||
"Logged out of KakaoTalk. Must use the `login` command to log back in.",
|
||||
important=True,
|
||||
)
|
||||
await self.logout(remove_ktid=False)
|
||||
except OAuthException as e:
|
||||
await self._send_reset_notice(e, edit=event_id)
|
||||
# TODO Throw a ResponseError on network failures
|
||||
except ResponseError as e:
|
||||
|
@ -383,6 +442,7 @@ class User(DBUser, BaseUser):
|
|||
|
||||
self._is_logged_in = False
|
||||
self.is_connected = None
|
||||
self.was_connected = False
|
||||
self._client = None
|
||||
|
||||
if self.ktid and remove_ktid:
|
||||
|
@ -392,7 +452,7 @@ class User(DBUser, BaseUser):
|
|||
|
||||
await self.save()
|
||||
|
||||
async def post_login(self, is_startup: bool) -> None:
|
||||
async def post_login(self, is_startup: bool, is_token_login: bool) -> None:
|
||||
self.log.info("Running post-login actions")
|
||||
assert self.ktid
|
||||
self._add_to_cache()
|
||||
|
@ -406,13 +466,19 @@ class User(DBUser, BaseUser):
|
|||
except Exception:
|
||||
self.log.exception("Failed to automatically enable custom puppet")
|
||||
|
||||
# TODO Check if things break when a live message comes in during syncing
|
||||
if self.config["bridge.sync_on_startup"] or not is_startup:
|
||||
sync_count = self.config["bridge.initial_chat_sync"]
|
||||
if not is_token_login or self.was_connected and self.config["bridge.reconnect_on_token_relogin"]:
|
||||
# TODO Check if things break when a live message comes in during syncing
|
||||
if self.config["bridge.sync_on_startup"] or not is_startup:
|
||||
sync_count = self.config["bridge.initial_chat_sync"]
|
||||
else:
|
||||
sync_count = None
|
||||
await self.connect_and_sync(sync_count)
|
||||
else:
|
||||
sync_count = None
|
||||
# TODO Don't auto-connect on startup if user's last state was disconnected
|
||||
await self.connect_and_sync(sync_count)
|
||||
await self.send_bridge_notice(
|
||||
f"Logged into KakaoTalk. To connect to chatroom updates, use the `sync` command."
|
||||
)
|
||||
self.was_connected = False
|
||||
await self.save()
|
||||
|
||||
async def get_direct_chats(self) -> dict[UserID, list[RoomID]]:
|
||||
return {
|
||||
|
@ -645,14 +711,16 @@ class User(DBUser, BaseUser):
|
|||
if self.temp_disconnect_notices:
|
||||
await self.send_bridge_notice("Connected to KakaoTalk chats")
|
||||
await self.push_bridge_state(BridgeStateEvent.CONNECTED)
|
||||
self.was_connected = True
|
||||
await self.save()
|
||||
|
||||
async def on_disconnect(self, res: KickoutRes | None) -> None:
|
||||
self.is_connected = False
|
||||
self._track_metric(METRIC_CONNECTED, False)
|
||||
logout = not self.config["bridge.remain_logged_in_on_disconnect"]
|
||||
if res:
|
||||
logout = False
|
||||
if res.reason == KnownKickoutType.LOGIN_ANOTHER:
|
||||
reason_str = "Logged in from another desktop client."
|
||||
reason_str = "Logged in on a PC or another bridge."
|
||||
elif res.reason == KnownKickoutType.CHANGE_SERVER:
|
||||
# TODO Reconnect automatically instead
|
||||
reason_str = "KakaoTalk backend server changed."
|
||||
|
@ -661,12 +729,19 @@ class User(DBUser, BaseUser):
|
|||
logout = True
|
||||
else:
|
||||
reason_str = f"Unknown reason ({res.reason})."
|
||||
else:
|
||||
reason_str = None
|
||||
if not logout:
|
||||
# TODO What bridge state to push?
|
||||
self.was_connected = False
|
||||
await self.save()
|
||||
else:
|
||||
await self.logout()
|
||||
if reason_str:
|
||||
if not logout:
|
||||
reason_suffix = " To reconnect, use the `sync` command."
|
||||
# TODO What bridge state to push?
|
||||
reason_suffix = "To reconnect, use the `sync` command."
|
||||
else:
|
||||
reason_suffix = " You are now logged out."
|
||||
await self.logout()
|
||||
reason_suffix = "You are now logged out. To log back in, use the `login` command."
|
||||
await self.send_bridge_notice(f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}")
|
||||
|
||||
async def on_error(self, error: JSON) -> None:
|
||||
|
@ -681,7 +756,7 @@ class User(DBUser, BaseUser):
|
|||
self.is_connected = False
|
||||
self._track_metric(METRIC_CONNECTED, False)
|
||||
self._client = None
|
||||
if self._is_logged_in:
|
||||
if self._is_logged_in and self.was_connected:
|
||||
if self.temp_disconnect_notices:
|
||||
await self.send_bridge_notice(
|
||||
"Disconnected from KakaoTalk: backend helper module exited. "
|
||||
|
@ -702,7 +777,7 @@ class User(DBUser, BaseUser):
|
|||
# Should also catch unlikely authentication errors
|
||||
self._logged_in_info = await self._client.start()
|
||||
self._logged_in_info_time = time.monotonic()
|
||||
asyncio.create_task(self.post_login(is_startup=True))
|
||||
asyncio.create_task(self.post_login(is_startup=True, is_token_login=False))
|
||||
|
||||
@async_time(METRIC_CHAT)
|
||||
async def on_chat(self, chat: Chatlog, channel_id: Long, channel_type: ChannelType) -> None:
|
||||
|
|
|
@ -520,6 +520,7 @@ export default class PeerClient {
|
|||
* @param {Object} req
|
||||
* @param {string} req.uuid
|
||||
* @param {Object} req.form
|
||||
* @param {boolean} req.forced
|
||||
* @returns The response of the login attempt, including obtained
|
||||
* credentials for subsequent token-based login. If a required device passcode
|
||||
* request failed, its status is stored here.
|
||||
|
@ -527,7 +528,7 @@ export default class PeerClient {
|
|||
handleLogin = async (req) => {
|
||||
// TODO Look for a logout API call
|
||||
const authClient = await this.#createAuthClient(req.uuid)
|
||||
const loginRes = await authClient.login(req.form, true)
|
||||
const loginRes = await authClient.login(req.form, req.forced)
|
||||
if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) {
|
||||
const passcodeRes = await authClient.requestPasscode(req.form)
|
||||
if (!passcodeRes.success) {
|
||||
|
|
Loading…
Reference in New Issue