Support auto-login

This commit is contained in:
Andrew Ferrazzutti 2022-03-28 18:40:55 -04:00
parent 0202dd182a
commit e02c91c093
5 changed files with 207 additions and 29 deletions

View File

@ -13,7 +13,7 @@
# #
# 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/>.
from typing import Optional, AsyncGenerator, Tuple from typing import Optional, AsyncGenerator, Tuple, TYPE_CHECKING
import io import io
import qrcode import qrcode
@ -26,8 +26,13 @@ from .typehint import CommandEvent
SECTION_AUTH = HelpSection("Authentication", 10, "") 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() status = await evt.sender.client.start()
if status.is_logged_in: if status.is_logged_in:
await evt.reply("You're already 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 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 qr_event_id: Optional[EventID] = None
pin_event_id: Optional[EventID] = None pin_event_id: Optional[EventID] = None
failure = False 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: async for item in gen:
if item[0] == "qr": if item[0] == "qr":
message = "Open LINE on your smartphone and scan this QR code:" message = "Open LINE on your smartphone and scan this QR code:"
content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE) content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE)
content.set_reply(evt.event_id) if evt:
await evt.az.intent.send_message(evt.room_id, content) content.set_reply(evt.event_id)
await az.intent.send_message(room_id, content)
url = item[1] url = item[1]
buffer = io.BytesIO() buffer = io.BytesIO()
@ -66,61 +91,131 @@ async def login_do(evt: CommandEvent, gen: AsyncGenerator[Tuple[str, str], None]
size = image.pixel_size size = image.pixel_size
image.save(buffer, "PNG") image.save(buffer, "PNG")
qr = buffer.getvalue() 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, content = MediaMessageEventContent(body=url, url=mxc, msgtype=MessageType.IMAGE,
info=ImageInfo(mimetype="image/png", size=len(qr), info=ImageInfo(mimetype="image/png", size=len(qr),
width=size, height=size)) width=size, height=size))
if qr_event_id: if qr_event_id:
content.set_edit(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: 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": elif item[0] == "pin":
pin = item[1] pin = item[1]
message = f"Enter this PIN in LINE on your smartphone:\n{pin}" message = f"Enter this PIN in LINE on your smartphone:\n{pin}"
content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE) content = TextMessageEventContent(body=message, msgtype=MessageType.NOTICE)
if pin_event_id: if pin_event_id:
content.set_edit(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: 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": 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"): elif item[0] in ("login_failure", "error"):
# TODO Handle errors differently? # TODO Handle errors differently?
failure = True failure = True
reason = item[1] reason = item[1]
if reason: if reason:
content = TextMessageEventContent(body=reason, msgtype=MessageType.NOTICE) await az.intent.send_notice(room_id, reason)
await evt.az.intent.send_message(evt.room_id, content)
# else: pass # else: pass
if not failure and evt.sender.command_status: login_success = not failure and sender.command_status
await evt.reply("LINE loading complete") if login_success:
await evt.sender.sync() 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 # 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, @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
help_text="Log into LINE via QR code") help_text="Log into LINE via QR code")
async def login_qr(evt: CommandEvent) -> None: async def login_qr(evt: CommandEvent) -> None:
if not await login_prep(evt, "qr"): if not await _login_prep(evt, "qr"):
return return
gen = evt.sender.client.login(evt.sender) 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, @command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
help_text="Log into LINE via email/password", help_text="Log into LINE via email/password, and optionally save credentials for auto-login",
help_args="<_email_> <_password_>") help_args="[--save] <_email_> <_password_>")
async def login_email(evt: CommandEvent) -> None: async def login_email(evt: CommandEvent) -> None:
await evt.az.intent.redact(evt.room_id, evt.event_id) 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: if len(evt.args) != 2:
await evt.reply("Usage: `$cmdprefix+sp login-email <email> <password>`") await evt.reply("Usage: `$cmdprefix+sp login-email [--save] <email> <password>`")
return return
if not await login_prep(evt, "email"): if not await _login_prep(evt, "email"):
return return
await evt.reply("Logging in...") await evt.reply("Logging in...")
login_data = {
"email": evt.args[0],
"password": evt.args[1]
}
gen = evt.sender.client.login( gen = evt.sender.client.login(
evt.sender, evt.sender,
login_data=dict(email=evt.args[0], password=evt.args[1])) login_data=login_data
await login_do(evt, gen) )
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 <email> <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."
)

View File

@ -9,11 +9,23 @@ from .message import Message
from .media import Media from .media import Media
from .receipt import Receipt from .receipt import Receipt
from .receipt_reaction import ReceiptReaction from .receipt_reaction import ReceiptReaction
from .login_credential import LoginCredential
def init(db: Database) -> None: 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 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"
]

View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -170,3 +170,16 @@ async def upgrade_latest_read_receipts(conn: Connection) -> None:
@upgrade_table.register(description="Allow messages with no mxid") @upgrade_table.register(description="Allow messages with no mxid")
async def upgrade_nomxid_msgs(conn: Connection) -> None: async def upgrade_nomxid_msgs(conn: Connection) -> None:
await conn.execute("ALTER TABLE message ALTER COLUMN mxid DROP NOT NULL") 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
)""")

View File

@ -22,6 +22,8 @@ from mautrix.types import UserID, RoomID
from mautrix.appservice import AppService, IntentAPI from mautrix.appservice import AppService, IntentAPI
from mautrix.util.opt_prometheus import Gauge 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 .db import User as DBUser, Portal as DBPortal, Message as DBMessage, Receipt as DBReceipt
from .config import Config from .config import Config
from .rpc import Client, Message, Receipt from .rpc import Client, Message, Receipt
@ -117,6 +119,8 @@ class User(DBUser, BaseUser):
if state.is_logged_in: if state.is_logged_in:
await self.send_bridge_notice("Already logged in to LINE") await self.send_bridge_notice("Already logged in to LINE")
self.loop.create_task(self._try_sync()) self.loop.create_task(self._try_sync())
elif await auto_login(self):
await self.send_bridge_notice("Auto-logged in to LINE")
else: else:
await self.send_bridge_notice("Ready to log in to LINE") await self.send_bridge_notice("Ready to log in to LINE")