Allow login flow to be more like the official PC client

This commit is contained in:
Andrew Ferrazzutti 2022-05-04 02:50:59 -04:00
parent 6623dd46c0
commit e44536f9f2
14 changed files with 430 additions and 60 deletions

View File

@ -22,9 +22,17 @@ from mautrix.errors import MForbidden
#from mautrix.util.signed_token import sign_token #from mautrix.util.signed_token import sign_token
from ..kt.client import Client as KakaoTalkClient 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 .. import puppet as pu
from ..db import LoginCredential
from .typehint import CommandEvent from .typehint import CommandEvent
SECTION_AUTH = HelpSection("Authentication", 10, "") 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" 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." try_again_or_cancel = "Try again, or say `$cmdprefix+sp cancel` to give up."
_CMD_CONTINUE_LOGIN = "continue-login"
@command_handler( @command_handler(
needs_auth=False, needs_auth=False,
management_only=True, management_only=True,
help_section=SECTION_AUTH, help_section=SECTION_AUTH,
help_text="Log in to KakaoTalk", 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="[_email_]", help_args="[--save] [_email_]",
) )
async def login(evt: CommandEvent) -> None: async def login(evt: CommandEvent) -> None:
if await evt.sender.is_logged_in(): if await evt.sender.is_logged_in():
await evt.reply("You're already logged in") await evt.reply("You're already logged in")
return 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: if email:
evt.sender.command_status = { evt.sender.command_status = {
"action": "Login", "action": "Login",
"room_id": evt.room_id, "room_id": evt.room_id,
"next": enter_password, "next": _enter_password,
"email": email, "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 """ TODO Implement web login
if evt.bridge.public_website: if evt.bridge.public_website:
@ -94,65 +121,146 @@ async def login(evt: CommandEvent) -> None:
if not email: if not email:
await evt.reply(f"{missing_email}.") await evt.reply(f"{missing_email}.")
else: 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: try:
await evt.az.intent.redact(evt.room_id, evt.event_id) await evt.az.intent.redact(evt.room_id, evt.event_id)
except MForbidden: except MForbidden:
pass await evt.mark_read()
assert evt.sender.command_status 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 = { req = {
"uuid": await evt.sender.get_uuid(), "uuid": await evt.sender.get_uuid(),
"form": { "form": {
"email": evt.sender.command_status["email"], "email": email,
"password": evt.content.body, "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: try:
await _do_login(evt, req) await _do_login(evt, req, save)
except DeviceVerificationRequired: except DeviceVerificationRequired as e:
await evt.reply( if evt.sender.command_status and evt.sender.command_status.get("next") != _enter_dv_code:
"Open KakaoTalk on your smartphone. It should show a device registration passcode. " await evt.reply(
"Enter that passcode here." "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 = { evt.sender.command_status = {
"action": "Login", "action": "Login",
"room_id": evt.room_id, "room_id": evt.room_id,
"next": enter_dv_code, "next": _enter_password,
"req": req, "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: #except OAuthException as e:
# await evt.reply(f"Error from KakaoTalk:\n\n> {e}") # await evt.reply(f"Error from KakaoTalk:\n\n> {e}")
except Exception as e: except Exception as e:
await _handle_login_failure(evt, 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 assert evt.sender.command_status
req: dict = evt.sender.command_status["req"] req: dict = evt.sender.command_status.pop("req")
passcode = evt.content.body passcode = evt.content.body
await evt.mark_read() await evt.mark_read()
try: try:
await KakaoTalkClient.register_device(passcode, **req) await KakaoTalkClient.register_device(passcode, **req)
await _do_login(evt, req)
except IncorrectPasscode: except IncorrectPasscode:
await evt.reply(f"Incorrect device registration passcode. {try_again_or_cancel}") await evt.reply(f"Incorrect device registration passcode. {try_again_or_cancel}")
#except OAuthException as e: #except OAuthException as e:
# await evt.reply(f"Error from KakaoTalk:\n\n> {e}") # await evt.reply(f"Error from KakaoTalk:\n\n> {e}")
except Exception as e: except Exception as e:
await _handle_login_failure(evt, 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) oauth_credential = await KakaoTalkClient.login(**req)
await evt.sender.on_logged_in(oauth_credential) await evt.sender.on_logged_in(oauth_credential)
evt.sender.command_status = None evt.sender.command_status = None
await evt.reply("Successfully logged in") 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: async def _handle_login_failure(evt: CommandEvent, e: Exception) -> None:
evt.sender.command_status = 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}") 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( @command_handler(
needs_auth=True, needs_auth=True,
help_section=SECTION_AUTH, 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" "Your next login will use a different device ID.\n\n"
"The old device must be manually de-registered from the KakaoTalk app." "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.")

View File

@ -45,6 +45,12 @@ async def disconnect(evt: CommandEvent) -> None:
if not evt.sender.is_connected: if not evt.sender.is_connected:
await evt.reply("You are already disconnected from KakaoTalk chats") await evt.reply("You are already disconnected from KakaoTalk chats")
return 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.mark_read()
await evt.sender.client.disconnect() await evt.sender.client.disconnect()
await evt.reply("Successfully disconnected from KakaoTalk chats. To reconnect, use the `sync` command.") await evt.reply("Successfully disconnected from KakaoTalk chats. To reconnect, use the `sync` command.")

View File

@ -104,6 +104,9 @@ class Config(BaseBridgeConfig):
copy("bridge.periodic_reconnect.min_connected_time") copy("bridge.periodic_reconnect.min_connected_time")
""" """
copy("bridge.resync_max_disconnected_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.sync_on_startup")
copy("bridge.temporary_disconnect_notices") copy("bridge.temporary_disconnect_notices")
copy("bridge.disable_bridge_notices") copy("bridge.disable_bridge_notices")

View File

@ -20,10 +20,11 @@ from .portal import Portal
from .puppet import Puppet from .puppet import Puppet
from .upgrade import upgrade_table from .upgrade import upgrade_table
from .user import User from .user import User
from .login_credential import LoginCredential
def init(db: Database) -> None: def init(db: Database) -> None:
for table in (Portal, Message, User, Puppet): for table in (Portal, Message, User, Puppet, LoginCredential):
table.db = db table.db = db
@ -34,4 +35,5 @@ __all__ = [
"Portal", "Portal",
"Puppet", "Puppet",
"User", "User",
"LoginCredential",
] ]

View File

@ -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)

View File

@ -19,3 +19,4 @@ upgrade_table = UpgradeTable()
from . import v01_initial_revision from . import v01_initial_revision
from . import v02_channel_meta from . import v02_channel_meta
from . import v03_user_connection

View File

@ -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
)"""
)

View File

@ -33,6 +33,8 @@ class User:
db: ClassVar[Database] = fake_db db: ClassVar[Database] = fake_db
mxid: UserID mxid: UserID
force_login: bool
was_connected: bool
ktid: Long | None = field(converter=to_optional_long) ktid: Long | None = field(converter=to_optional_long)
uuid: str | None uuid: str | None
access_token: str | None access_token: str | None
@ -47,7 +49,7 @@ class User:
def _from_optional_row(cls, row: Record | None) -> User | None: def _from_optional_row(cls, row: Record | None) -> User | None:
return cls._from_row(row) if row is not None else 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 @classmethod
async def all_logged_in(cls) -> List[User]: async def all_logged_in(cls) -> List[User]:
@ -76,6 +78,8 @@ class User:
def _values(self): def _values(self):
return ( return (
self.mxid, self.mxid,
self.force_login,
self.was_connected,
self.ktid, self.ktid,
self.uuid, self.uuid,
self.access_token, self.access_token,
@ -85,8 +89,8 @@ class User:
async def insert(self) -> None: async def insert(self) -> None:
q = """ q = """
INSERT INTO "user" (mxid, ktid, uuid, access_token, refresh_token, notice_room) INSERT INTO "user" (mxid, force_login, was_connected, ktid, uuid, access_token, refresh_token, notice_room)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
@ -95,7 +99,7 @@ class User:
async def save(self) -> None: async def save(self) -> None:
q = """ 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 WHERE mxid=$1
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)

View File

@ -191,7 +191,18 @@ bridge:
# Set to 0 to always re-sync, or -1 to never re-sync automatically. # Set to 0 to always re-sync, or -1 to never re-sync automatically.
# TODO Actually use this setting # TODO Actually use this setting
resync_max_disconnected_time: 5 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 sync_on_startup: true
# Whether or not temporary disconnections should send notices to the notice room. # 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 # If this is false, disconnections will never send messages and connections will only send

View File

@ -137,13 +137,19 @@ class Client:
return cls._api_request_void("register_device", passcode=passcode, **req) return cls._api_request_void("register_device", passcode=passcode, **req)
@classmethod @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. Obtain a session token by logging in with user-provided credentials.
Must have first called register_device with these credentials. Must have first called register_device with these credentials.
""" """
# NOTE Actually returns an auth LoginData, but this only needs an OAuthCredential # 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 # endregion

View File

@ -14,13 +14,13 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Internal helpers for error handling.""" """Internal helpers for error handling."""
from __future__ import annotations from __future__ import annotations
from typing import NoReturn, Type from typing import NoReturn, Type
from .errors import ( from .errors import (
CommandException, CommandException,
AnotherLogonDetected,
AuthenticationRequired, AuthenticationRequired,
DeviceVerificationRequired, DeviceVerificationRequired,
IncorrectPasscode, 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_VOICECALL: "Daily voicecall limit exceeded",
#KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_WITHOUT_TOKEN: "Daily tokenless request limit exceeded", #KnownAuthStatusCode.EXCEED_DAILY_REQUEST_LIMIT_WITHOUT_TOKEN: "Daily tokenless request limit exceeded",
KnownAuthStatusCode.DEVICE_NOT_REGISTERED: DeviceVerificationRequired, KnownAuthStatusCode.DEVICE_NOT_REGISTERED: DeviceVerificationRequired,
#KnownAuthStatusCode.ANOTHER_LOGON: "Another logon detected", KnownAuthStatusCode.ANOTHER_LOGON: AnotherLogonDetected,
#KnownAuthStatusCode.DEVICE_REGISTER_FAILED: "Device registration failed", #KnownAuthStatusCode.DEVICE_REGISTER_FAILED: "Device registration failed",
#KnownAuthStatusCode.INVALID_DEVICE_REGISTER: "Invalid device", #KnownAuthStatusCode.INVALID_DEVICE_REGISTER: "Invalid device",
KnownAuthStatusCode.INVALID_PASSCODE: IncorrectPasscode, KnownAuthStatusCode.INVALID_PASSCODE: IncorrectPasscode,

View File

@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Helper functions & types for status codes for the KakaoTalk API.""" """Helper functions & types for status codes for the KakaoTalk API."""
from __future__ import annotations from __future__ import annotations
from ..types.api.auth_api_client import KnownAuthStatusCode from ..types.api.auth_api_client import KnownAuthStatusCode
@ -48,6 +47,9 @@ class OAuthException(CommandException):
class DeviceVerificationRequired(OAuthException): class DeviceVerificationRequired(OAuthException):
pass pass
class AnotherLogonDetected(OAuthException):
pass
class IncorrectPassword(OAuthException): class IncorrectPassword(OAuthException):
pass pass

View File

@ -35,10 +35,16 @@ from mautrix.util.simple_lock import SimpleLock
from . import portal as po, puppet as pu from . import portal as po, puppet as pu
from .config import Config 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 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.client.types import PortalChannelInfo, SettingsStruct
from .kt.types.bson import Long from .kt.types.bson import Long
from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData from .kt.types.channel.channel_info import ChannelInfo, NormalChannelInfo, NormalChannelData
@ -107,6 +113,8 @@ class User(DBUser, BaseUser):
def __init__( def __init__(
self, self,
mxid: UserID, mxid: UserID,
force_login: bool,
was_connected: bool,
ktid: Long | None = None, ktid: Long | None = None,
uuid: str | None = None, uuid: str | None = None,
access_token: str | None = None, access_token: str | None = None,
@ -115,6 +123,8 @@ class User(DBUser, BaseUser):
) -> None: ) -> None:
super().__init__( super().__init__(
mxid=mxid, mxid=mxid,
force_login=force_login,
was_connected=was_connected,
ktid=ktid, ktid=ktid,
uuid=uuid, uuid=uuid,
access_token=access_token, access_token=access_token,
@ -277,7 +287,51 @@ class User(DBUser, BaseUser):
async def _load_session(self, is_startup: bool) -> bool: async def _load_session(self, is_startup: bool) -> bool:
if self._is_logged_in and is_startup: if self._is_logged_in and is_startup:
return True 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 # If we have a user in the DB with no state, we can assume
# KT logged us out and the bridge has restarted # KT logged us out and the bridge has restarted
await self.push_bridge_state( await self.push_bridge_state(
@ -295,10 +349,10 @@ class User(DBUser, BaseUser):
self._track_metric(METRIC_LOGGED_IN, True) self._track_metric(METRIC_LOGGED_IN, True)
self._is_logged_in = True self._is_logged_in = True
self.is_connected = None 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 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( await self.send_bridge_notice(
"Got authentication error from KakaoTalk:\n\n" "Got authentication error from KakaoTalk:\n\n"
f"> {e.message}\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 self, event_id: EventID | None = None, retries: int = 3, is_startup: bool = False
) -> None: ) -> None:
try: try:
await self._load_session(is_startup=is_startup) if not await self._load_session(is_startup=is_startup) and self.has_state:
except AuthenticationRequired as e: 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) await self._send_reset_notice(e, edit=event_id)
# TODO Throw a ResponseError on network failures # TODO Throw a ResponseError on network failures
except ResponseError as e: except ResponseError as e:
@ -383,6 +442,7 @@ class User(DBUser, BaseUser):
self._is_logged_in = False self._is_logged_in = False
self.is_connected = None self.is_connected = None
self.was_connected = False
self._client = None self._client = None
if self.ktid and remove_ktid: if self.ktid and remove_ktid:
@ -392,7 +452,7 @@ class User(DBUser, BaseUser):
await self.save() 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") self.log.info("Running post-login actions")
assert self.ktid assert self.ktid
self._add_to_cache() self._add_to_cache()
@ -406,13 +466,19 @@ class User(DBUser, BaseUser):
except Exception: except Exception:
self.log.exception("Failed to automatically enable custom puppet") self.log.exception("Failed to automatically enable custom puppet")
# TODO Check if things break when a live message comes in during syncing if not is_token_login or self.was_connected and self.config["bridge.reconnect_on_token_relogin"]:
if self.config["bridge.sync_on_startup"] or not is_startup: # TODO Check if things break when a live message comes in during syncing
sync_count = self.config["bridge.initial_chat_sync"] 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: else:
sync_count = None await self.send_bridge_notice(
# TODO Don't auto-connect on startup if user's last state was disconnected f"Logged into KakaoTalk. To connect to chatroom updates, use the `sync` command."
await self.connect_and_sync(sync_count) )
self.was_connected = False
await self.save()
async def get_direct_chats(self) -> dict[UserID, list[RoomID]]: async def get_direct_chats(self) -> dict[UserID, list[RoomID]]:
return { return {
@ -645,14 +711,16 @@ class User(DBUser, BaseUser):
if self.temp_disconnect_notices: if self.temp_disconnect_notices:
await self.send_bridge_notice("Connected to KakaoTalk chats") await self.send_bridge_notice("Connected to KakaoTalk chats")
await self.push_bridge_state(BridgeStateEvent.CONNECTED) await self.push_bridge_state(BridgeStateEvent.CONNECTED)
self.was_connected = True
await self.save()
async def on_disconnect(self, res: KickoutRes | None) -> None: async def on_disconnect(self, res: KickoutRes | None) -> None:
self.is_connected = False self.is_connected = False
self._track_metric(METRIC_CONNECTED, False) self._track_metric(METRIC_CONNECTED, False)
logout = not self.config["bridge.remain_logged_in_on_disconnect"]
if res: if res:
logout = False
if res.reason == KnownKickoutType.LOGIN_ANOTHER: 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: elif res.reason == KnownKickoutType.CHANGE_SERVER:
# TODO Reconnect automatically instead # TODO Reconnect automatically instead
reason_str = "KakaoTalk backend server changed." reason_str = "KakaoTalk backend server changed."
@ -661,12 +729,19 @@ class User(DBUser, BaseUser):
logout = True logout = True
else: else:
reason_str = f"Unknown reason ({res.reason})." 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: if not logout:
reason_suffix = " To reconnect, use the `sync` command." reason_suffix = "To reconnect, use the `sync` command."
# TODO What bridge state to push?
else: else:
reason_suffix = " You are now logged out." reason_suffix = "You are now logged out. To log back in, use the `login` command."
await self.logout()
await self.send_bridge_notice(f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}") await self.send_bridge_notice(f"Disconnected from KakaoTalk: {reason_str} {reason_suffix}")
async def on_error(self, error: JSON) -> None: async def on_error(self, error: JSON) -> None:
@ -681,7 +756,7 @@ class User(DBUser, BaseUser):
self.is_connected = False self.is_connected = False
self._track_metric(METRIC_CONNECTED, False) self._track_metric(METRIC_CONNECTED, False)
self._client = None self._client = None
if self._is_logged_in: if self._is_logged_in and self.was_connected:
if self.temp_disconnect_notices: if self.temp_disconnect_notices:
await self.send_bridge_notice( await self.send_bridge_notice(
"Disconnected from KakaoTalk: backend helper module exited. " "Disconnected from KakaoTalk: backend helper module exited. "
@ -702,7 +777,7 @@ class User(DBUser, BaseUser):
# Should also catch unlikely authentication errors # Should also catch unlikely authentication errors
self._logged_in_info = await self._client.start() self._logged_in_info = await self._client.start()
self._logged_in_info_time = time.monotonic() 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_time(METRIC_CHAT)
async def on_chat(self, chat: Chatlog, channel_id: Long, channel_type: ChannelType) -> None: async def on_chat(self, chat: Chatlog, channel_id: Long, channel_type: ChannelType) -> None:

View File

@ -520,6 +520,7 @@ export default class PeerClient {
* @param {Object} req * @param {Object} req
* @param {string} req.uuid * @param {string} req.uuid
* @param {Object} req.form * @param {Object} req.form
* @param {boolean} req.forced
* @returns The response of the login attempt, including obtained * @returns The response of the login attempt, including obtained
* credentials for subsequent token-based login. If a required device passcode * credentials for subsequent token-based login. If a required device passcode
* request failed, its status is stored here. * request failed, its status is stored here.
@ -527,7 +528,7 @@ export default class PeerClient {
handleLogin = async (req) => { handleLogin = async (req) => {
// TODO Look for a logout API call // TODO Look for a logout API call
const authClient = await this.#createAuthClient(req.uuid) 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) { if (loginRes.status === KnownAuthStatusCode.DEVICE_NOT_REGISTERED) {
const passcodeRes = await authClient.requestPasscode(req.form) const passcodeRes = await authClient.requestPasscode(req.form)
if (!passcodeRes.success) { if (!passcodeRes.success) {