diff --git a/matrix_puppeteer_line/commands/auth.py b/matrix_puppeteer_line/commands/auth.py index 324f452..b013d48 100644 --- a/matrix_puppeteer_line/commands/auth.py +++ b/matrix_puppeteer_line/commands/auth.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional, AsyncGenerator, Tuple +from typing import Optional, AsyncGenerator, Tuple, TYPE_CHECKING import io import qrcode @@ -26,8 +26,13 @@ from .typehint import CommandEvent SECTION_AUTH = HelpSection("Authentication", 10, "") +from ..db import LoginCredential -async def login_prep(evt: CommandEvent, login_type: str) -> bool: +if TYPE_CHECKING: + from ..user import User + + +async def _login_prep(evt: CommandEvent, login_type: str) -> bool: status = await evt.sender.client.start() if status.is_logged_in: await evt.reply("You're already logged in") @@ -49,16 +54,36 @@ async def login_prep(evt: CommandEvent, login_type: str) -> bool: } return True -async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]) -> None: +async def _login_do( + gen: AsyncGenerator[Tuple[str, str], None], + *, + evt: Optional[CommandEvent] = None, + sender: Optional["User"] = None, +) -> bool: qr_event_id: Optional[EventID] = None pin_event_id: Optional[EventID] = None failure = False + + if not evt and not sender: + raise ValueError("Must set either a CommandEvent or a User") + if evt: + sender = evt.sender + az = evt.az + room_id = evt.room_id + else: + az = sender.az + room_id = sender.notice_room + if not room_id: + sender.log.warning("Cannot auto-loggin: must have a notice room to do so") + return False + async for item in gen: if item[0] == "qr": message = "Open LINE on your smartphone and scan this QR code:" content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE) - content.set_reply(evt.event_id) - await evt.az.intent.send_message(evt.room_id, content) + if evt: + content.set_reply(evt.event_id) + await az.intent.send_message(room_id, content) url = item[1] buffer = io.BytesIO() @@ -66,61 +91,131 @@ async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None] size = image.pixel_size image.save(buffer, "PNG") qr = buffer.getvalue() - mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr)) + mxc = await az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr)) content = MediaMessageEventContent(body=url, url=mxc, msgtype=MessageType.IMAGE, info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size)) if qr_event_id: content.set_edit(qr_event_id) - await evt.az.intent.send_message(evt.room_id, content) + await az.intent.send_message(room_id, content) else: - qr_event_id = await evt.az.intent.send_message(evt.room_id, content) + qr_event_id = await az.intent.send_message(room_id, content) elif item[0] == "pin": pin = item[1] message = f"Enter this PIN in LINE on your smartphone:\n{pin}" content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE) if pin_event_id: content.set_edit(pin_event_id) - await evt.az.intent.send_message(evt.room_id, content) + await az.intent.send_message(room_id, content) else: - pin_event_id = await evt.az.intent.send_message(evt.room_id, content) + pin_event_id = await az.intent.send_message(room_id, content) elif item[0] == "login_success": - await evt.reply("Successfully logged in, waiting for LINE to load...") + await az.intent.send_notice(room_id, "Successfully logged in, waiting for LINE to load...") elif item[0] in ("login_failure", "error"): # TODO Handle errors differently? failure = True reason = item[1] if reason: - content = TextMessageEventContent(body=reason, msgtype=MessageType.NOTICE) - await evt.az.intent.send_message(evt.room_id, content) + await az.intent.send_notice(room_id, reason) # else: pass - if not failure and evt.sender.command_status: - await evt.reply("LINE loading complete") - await evt.sender.sync() + login_success = not failure and sender.command_status + if login_success: + await az.intent.send_notice(room_id, "LINE loading complete") + await sender.sync() # else command was cancelled or failed. Don't post message about it, "cancel" command or failure did already - evt.sender.command_status = None + sender.command_status = None + return login_success @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, help_text="Log into LINE via QR code") async def login_qr(evt: CommandEvent) -> None: - if not await login_prep(evt, "qr"): + if not await _login_prep(evt, "qr"): return gen = evt.sender.client.login(evt.sender) - await login_do(evt, gen) + await _login_do(gen, evt=evt) @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, - help_text="Log into LINE via email/password", - help_args="<_email_> <_password_>") + help_text="Log into LINE via email/password, and optionally save credentials for auto-login", + help_args="[--save] <_email_> <_password_>") async def login_email(evt: CommandEvent) -> None: await evt.az.intent.redact(evt.room_id, evt.event_id) + if evt.args and evt.args[0] == "--save": + save = True + evt.args.pop(0) + else: + save = False if len(evt.args) != 2: - await evt.reply("Usage: `$cmdprefix+sp login-email `") + await evt.reply("Usage: `$cmdprefix+sp login-email [--save] `") return - if not await login_prep(evt, "email"): + if not await _login_prep(evt, "email"): return await evt.reply("Logging in...") + login_data = { + "email": evt.args[0], + "password": evt.args[1] + } gen = evt.sender.client.login( - evt.sender, - login_data=dict(email=evt.args[0], password=evt.args[1])) - await login_do(evt, gen) + evt.sender, + login_data=login_data + ) + login_success = await _login_do(gen, evt=evt) + if login_success and save: + if not evt.sender.notice_room: + await evt.reply("WARNING: You do not have a notice room, but auto-login requires one.") + await _save_password_helper(evt) + +async def auto_login(sender: "User") -> bool: + status = await sender.client.start() + if status.is_logged_in: + return True + if sender.command_status is not None: + return False + creds = await LoginCredential.get_by_mxid(sender.mxid) + if not creds: + return False + sender.command_status = { + "action": "Login", + "login_type": "email", + } + gen = sender.client.login( + sender, + login_data={ + "email": creds.email, + "password": creds.password, + } + ) + return await _login_do(gen, sender=sender) + +@command_handler(needs_auth=True, management_only=True, help_section=SECTION_AUTH, + help_text="Remember email/password credentials for auto-login", + help_args="<_email_> <_password_>") +async def save_password(evt: CommandEvent) -> None: + await evt.az.intent.redact(evt.room_id, evt.event_id) + if len(evt.args) != 2: + await evt.reply("Usage: `$cmdprefix+sp save_password `") + return + await _save_password_helper(evt) + +async def _save_password_helper(evt: CommandEvent) -> None: + creds = await LoginCredential.get_by_mxid(evt.sender.mxid) + if creds: + creds.email = evt.args[0] + creds.password = evt.args[1] + await creds.update() + else: + await LoginCredential(evt.sender.mxid, email=evt.args[0], password=evt.args[1]).insert() + await evt.reply("Login email/password saved, and will be used to log you back in if your LINE connection ends.") + +@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH, + help_text="Delete saved email/password credentials") +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 email/password, so there was nothing to forget.") + else: + await creds.delete() + await evt.reply( + "This bridge is no longer storing your email/password. \n" + "You will have to log in manually the next time your LINE connection ends." + ) diff --git a/matrix_puppeteer_line/db/__init__.py b/matrix_puppeteer_line/db/__init__.py index dff5ef7..c7517bf 100644 --- a/matrix_puppeteer_line/db/__init__.py +++ b/matrix_puppeteer_line/db/__init__.py @@ -9,11 +9,23 @@ from .message import Message from .media import Media from .receipt import Receipt from .receipt_reaction import ReceiptReaction +from .login_credential import LoginCredential def init(db: Database) -> None: - for table in (User, Puppet, Stranger, Portal, Message, Media, Receipt, ReceiptReaction): + for table in (User, Puppet, Stranger, Portal, Message, Media, Receipt, ReceiptReaction, LoginCredential): table.db = db -__all__ = ["upgrade_table", "User", "Puppet", "Stranger", "Portal", "Message", "Media", "Receipt", "ReceiptReaction"] +__all__ = [ + "upgrade_table", + "User", + "Puppet", + "Stranger", + "Portal", + "Message", + "Media", + "Receipt", + "ReceiptReaction", + "LoginCredential" +] diff --git a/matrix_puppeteer_line/db/login_credential.py b/matrix_puppeteer_line/db/login_credential.py new file mode 100644 index 0000000..091f068 --- /dev/null +++ b/matrix_puppeteer_line/db/login_credential.py @@ -0,0 +1,54 @@ +# matrix-puppeteer-line - A very hacky Matrix-LINE bridge based on running LINE's Chrome extension in Puppeteer +# Copyright (C) 2020-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 typing import Optional, ClassVar, TYPE_CHECKING + +from attr import dataclass + +from mautrix.types import UserID +from mautrix.util.async_db import Database + +fake_db = Database("") if TYPE_CHECKING else None + + +@dataclass +class LoginCredential: + db: ClassVar[Database] = fake_db + + mxid: UserID + email: str + password: str + + 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) + + async def update(self) -> None: + await self.db.execute("UPDATE login_credential SET email=$2, password=$3 WHERE mxid=$1", + self.mxid, self.email, self.password) + + @classmethod + async def get_by_mxid(cls, mxid: UserID) -> Optional["LoginCredential"]: + q = ("SELECT mxid, email, password " + "FROM login_credential WHERE mxid=$1") + row = await cls.db.fetchrow(q, mxid) + if not row: + return None + return cls(**row) + + async def delete(self) -> None: + await self.db.execute("DELETE FROM login_credential WHERE mxid=$1", + self.mxid) diff --git a/matrix_puppeteer_line/db/upgrade.py b/matrix_puppeteer_line/db/upgrade.py index 7a6617c..0b322cb 100644 --- a/matrix_puppeteer_line/db/upgrade.py +++ b/matrix_puppeteer_line/db/upgrade.py @@ -169,4 +169,17 @@ async def upgrade_latest_read_receipts(conn: Connection) -> None: @upgrade_table.register(description="Allow messages with no mxid") async def upgrade_nomxid_msgs(conn: Connection) -> None: - await conn.execute("ALTER TABLE message ALTER COLUMN mxid DROP NOT NULL") \ No newline at end of file + await conn.execute("ALTER TABLE message ALTER COLUMN mxid DROP NOT NULL") + + +@upgrade_table.register(description="Allow storing email/password login credentials") +async def upgrade_login_credentials(conn: Connection) -> None: + await conn.execute("""CREATE TABLE IF NOT EXISTS login_credential ( + mxid TEXT PRIMARY KEY, + email TEXT NOT NULL, + password TEXT NOT NULL, + + FOREIGN KEY (mxid) + REFERENCES "user" (mxid) + ON DELETE CASCADE + )""") \ No newline at end of file diff --git a/matrix_puppeteer_line/user.py b/matrix_puppeteer_line/user.py index f1fa3c8..e7efa15 100644 --- a/matrix_puppeteer_line/user.py +++ b/matrix_puppeteer_line/user.py @@ -22,6 +22,8 @@ from mautrix.types import UserID, RoomID from mautrix.appservice import AppService, IntentAPI from mautrix.util.opt_prometheus import Gauge +from .commands.auth import auto_login + from .db import User as DBUser, Portal as DBPortal, Message as DBMessage, Receipt as DBReceipt from .config import Config from .rpc import Client, Message, Receipt @@ -117,6 +119,8 @@ class User(DBUser, BaseUser): if state.is_logged_in: await self.send_bridge_notice("Already logged in to LINE") self.loop.create_task(self._try_sync()) + elif await auto_login(self): + await self.send_bridge_notice("Auto-logged in to LINE") else: await self.send_bridge_notice("Ready to log in to LINE")