From e44536f9f280d34dc93ea259f9b44fd6bfff407d Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 4 May 2022 02:50:59 -0400 Subject: [PATCH] Allow login flow to be more like the official PC client --- matrix_appservice_kakaotalk/commands/auth.py | 205 +++++++++++++++--- matrix_appservice_kakaotalk/commands/conn.py | 6 + matrix_appservice_kakaotalk/config.py | 3 + matrix_appservice_kakaotalk/db/__init__.py | 4 +- .../db/login_credential.py | 68 ++++++ .../db/upgrade/__init__.py | 1 + .../db/upgrade/v03_user_connection.py | 38 ++++ matrix_appservice_kakaotalk/db/user.py | 12 +- .../example-config.yaml | 13 +- .../kt/client/client.py | 10 +- .../kt/client/error_helper.py | 4 +- .../kt/client/errors.py | 4 +- matrix_appservice_kakaotalk/user.py | 119 ++++++++-- node/src/client.js | 3 +- 14 files changed, 430 insertions(+), 60 deletions(-) create mode 100644 matrix_appservice_kakaotalk/db/login_credential.py create mode 100644 matrix_appservice_kakaotalk/db/upgrade/v03_user_connection.py diff --git a/matrix_appservice_kakaotalk/commands/auth.py b/matrix_appservice_kakaotalk/commands/auth.py index 9ed7759..e3f1318 100644 --- a/matrix_appservice_kakaotalk/commands/auth.py +++ b/matrix_appservice_kakaotalk/commands/auth.py @@ -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 ` 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.") diff --git a/matrix_appservice_kakaotalk/commands/conn.py b/matrix_appservice_kakaotalk/commands/conn.py index c1fb034..339032a 100644 --- a/matrix_appservice_kakaotalk/commands/conn.py +++ b/matrix_appservice_kakaotalk/commands/conn.py @@ -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.") diff --git a/matrix_appservice_kakaotalk/config.py b/matrix_appservice_kakaotalk/config.py index 0c47882..ebb5762 100644 --- a/matrix_appservice_kakaotalk/config.py +++ b/matrix_appservice_kakaotalk/config.py @@ -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") diff --git a/matrix_appservice_kakaotalk/db/__init__.py b/matrix_appservice_kakaotalk/db/__init__.py index eabf900..d50f420 100644 --- a/matrix_appservice_kakaotalk/db/__init__.py +++ b/matrix_appservice_kakaotalk/db/__init__.py @@ -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", ] diff --git a/matrix_appservice_kakaotalk/db/login_credential.py b/matrix_appservice_kakaotalk/db/login_credential.py new file mode 100644 index 0000000..e2292c5 --- /dev/null +++ b/matrix_appservice_kakaotalk/db/login_credential.py @@ -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 . +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) diff --git a/matrix_appservice_kakaotalk/db/upgrade/__init__.py b/matrix_appservice_kakaotalk/db/upgrade/__init__.py index af7dd17..af8ed7f 100644 --- a/matrix_appservice_kakaotalk/db/upgrade/__init__.py +++ b/matrix_appservice_kakaotalk/db/upgrade/__init__.py @@ -19,3 +19,4 @@ upgrade_table = UpgradeTable() from . import v01_initial_revision from . import v02_channel_meta +from . import v03_user_connection diff --git a/matrix_appservice_kakaotalk/db/upgrade/v03_user_connection.py b/matrix_appservice_kakaotalk/db/upgrade/v03_user_connection.py new file mode 100644 index 0000000..a1fd656 --- /dev/null +++ b/matrix_appservice_kakaotalk/db/upgrade/v03_user_connection.py @@ -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 . +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 + )""" + ) diff --git a/matrix_appservice_kakaotalk/db/user.py b/matrix_appservice_kakaotalk/db/user.py index 7dee70e..b996353 100644 --- a/matrix_appservice_kakaotalk/db/user.py +++ b/matrix_appservice_kakaotalk/db/user.py @@ -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) diff --git a/matrix_appservice_kakaotalk/example-config.yaml b/matrix_appservice_kakaotalk/example-config.yaml index 75d86dc..7fda682 100644 --- a/matrix_appservice_kakaotalk/example-config.yaml +++ b/matrix_appservice_kakaotalk/example-config.yaml @@ -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 diff --git a/matrix_appservice_kakaotalk/kt/client/client.py b/matrix_appservice_kakaotalk/kt/client/client.py index ce2e820..7a09486 100644 --- a/matrix_appservice_kakaotalk/kt/client/client.py +++ b/matrix_appservice_kakaotalk/kt/client/client.py @@ -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 diff --git a/matrix_appservice_kakaotalk/kt/client/error_helper.py b/matrix_appservice_kakaotalk/kt/client/error_helper.py index b178803..f0f516c 100644 --- a/matrix_appservice_kakaotalk/kt/client/error_helper.py +++ b/matrix_appservice_kakaotalk/kt/client/error_helper.py @@ -14,13 +14,13 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """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, diff --git a/matrix_appservice_kakaotalk/kt/client/errors.py b/matrix_appservice_kakaotalk/kt/client/errors.py index 426f7fd..3dc77e3 100644 --- a/matrix_appservice_kakaotalk/kt/client/errors.py +++ b/matrix_appservice_kakaotalk/kt/client/errors.py @@ -14,7 +14,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """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 diff --git a/matrix_appservice_kakaotalk/user.py b/matrix_appservice_kakaotalk/user.py index ed5e092..3058eda 100644 --- a/matrix_appservice_kakaotalk/user.py +++ b/matrix_appservice_kakaotalk/user.py @@ -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: diff --git a/node/src/client.js b/node/src/client.js index 71b12e3..123aab2 100644 --- a/node/src/client.js +++ b/node/src/client.js @@ -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) {