auth.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. # mautrix-signal - A Matrix-Signal puppeting bridge
  2. # Copyright (C) 2020 Tulir Asokan
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU Affero General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. from typing import Union
  17. import io
  18. from mausignald.errors import UnexpectedResponse, TimeoutException, AuthorizationFailedException
  19. from mautrix.appservice import IntentAPI
  20. from mautrix.types import MediaMessageEventContent, MessageType, ImageInfo, EventID
  21. from mautrix.bridge.commands import HelpSection, command_handler
  22. from .. import puppet as pu
  23. from .typehint import CommandEvent
  24. try:
  25. import qrcode
  26. import PIL as _
  27. except ImportError:
  28. qrcode = None
  29. SECTION_AUTH = HelpSection("Authentication", 10, "")
  30. remove_extra_chars = str.maketrans("", "", " .,-()")
  31. async def make_qr(
  32. intent: IntentAPI, data: Union[str, bytes], body: str = None
  33. ) -> MediaMessageEventContent:
  34. # TODO always encrypt QR codes?
  35. buffer = io.BytesIO()
  36. image = qrcode.make(data)
  37. size = image.pixel_size
  38. image.save(buffer, "PNG")
  39. qr = buffer.getvalue()
  40. mxc = await intent.upload_media(qr, "image/png", "qr.png", len(qr))
  41. return MediaMessageEventContent(
  42. body=body or data,
  43. url=mxc,
  44. msgtype=MessageType.IMAGE,
  45. info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
  46. )
  47. @command_handler(
  48. needs_auth=False,
  49. management_only=True,
  50. help_section=SECTION_AUTH,
  51. help_text="Link the bridge as a secondary device",
  52. help_args="[device name]",
  53. )
  54. async def link(evt: CommandEvent) -> None:
  55. if qrcode is None:
  56. await evt.reply("Can't generate QR code: qrcode and/or PIL not installed")
  57. return
  58. if await evt.sender.is_logged_in():
  59. await evt.reply(
  60. "You're already logged in. "
  61. "If you want to relink, log out with `$cmdprefix+sp logout` first."
  62. )
  63. return
  64. # TODO make default device name configurable
  65. device_name = " ".join(evt.args) or "Mautrix-Signal bridge"
  66. sess = await evt.bridge.signal.start_link()
  67. content = await make_qr(evt.az.intent, sess.uri)
  68. event_id = await evt.az.intent.send_message(evt.room_id, content)
  69. try:
  70. account = await evt.bridge.signal.finish_link(
  71. session_id=sess.session_id, overwrite=True, device_name=device_name
  72. )
  73. except TimeoutException:
  74. await evt.reply("Linking timed out, please try again.")
  75. except Exception:
  76. evt.log.exception("Fatal error while waiting for linking to finish")
  77. await evt.reply(
  78. "Fatal error while waiting for linking to finish " "(see logs for more details)"
  79. )
  80. else:
  81. await evt.sender.on_signin(account)
  82. await evt.reply(f"Successfully logged in as {pu.Puppet.fmt_phone(evt.sender.username)}")
  83. finally:
  84. await evt.main_intent.redact(evt.room_id, event_id)
  85. @command_handler(
  86. needs_auth=False,
  87. management_only=True,
  88. help_section=SECTION_AUTH,
  89. is_enabled_for=lambda evt: evt.config["signal.registration_enabled"],
  90. help_text="Sign into Signal as the primary device",
  91. help_args="<phone>",
  92. )
  93. async def register(evt: CommandEvent) -> None:
  94. if len(evt.args) == 0:
  95. await evt.reply("**Usage**: $cmdprefix+sp register [--voice] [--captcha <token>] <phone>")
  96. return
  97. if await evt.sender.is_logged_in():
  98. await evt.reply(
  99. "You're already logged in. "
  100. "If you want to re-register, log out with `$cmdprefix+sp logout` first."
  101. )
  102. return
  103. voice = False
  104. captcha = None
  105. while True:
  106. flag = evt.args[0].lower()
  107. if flag == "--voice" or flag == "-v":
  108. voice = True
  109. evt.args = evt.args[1:]
  110. elif flag == "--captcha" or flag == "-c":
  111. if "=" in evt.args[0]:
  112. captcha = evt.args[0].split("=", 1)[1]
  113. evt.args = evt.args[1:]
  114. else:
  115. captcha = evt.args[1]
  116. evt.args = evt.args[2:]
  117. else:
  118. break
  119. phone = evt.args[0].translate(remove_extra_chars)
  120. if not phone.startswith("+") or not phone[1:].isdecimal():
  121. await evt.reply(f"Please enter the phone number in international format (E.164)")
  122. return
  123. username = await evt.bridge.signal.register(phone, voice=voice, captcha=captcha)
  124. evt.sender.command_status = {
  125. "action": "Register",
  126. "room_id": evt.room_id,
  127. "next": enter_register_code,
  128. "username": username,
  129. }
  130. await evt.reply("Register SMS requested, please enter the code here.")
  131. async def enter_register_code(evt: CommandEvent) -> None:
  132. try:
  133. username = evt.sender.command_status["username"]
  134. account = await evt.bridge.signal.verify(username, code=evt.args[0])
  135. except UnexpectedResponse as e:
  136. if e.resp_type == "error":
  137. await evt.reply(e.data)
  138. else:
  139. raise
  140. else:
  141. await evt.sender.on_signin(account)
  142. await evt.reply(
  143. f"Successfully logged in as {pu.Puppet.fmt_phone(evt.sender.username)}."
  144. f"\n\n**N.B.** You must set a Signal profile name with `$cmdprefix+sp "
  145. f"set-profile-name <name>` before you can participate in new groups."
  146. )
  147. @command_handler(
  148. needs_auth=True,
  149. management_only=True,
  150. help_section=SECTION_AUTH,
  151. help_text="Remove all local data about your Signal link",
  152. )
  153. async def logout(evt: CommandEvent) -> None:
  154. if not evt.sender.username:
  155. await evt.reply("You're not logged in")
  156. return
  157. await evt.sender.logout()
  158. await evt.reply("Successfully logged out")
  159. @command_handler(
  160. needs_auth=True,
  161. management_only=True,
  162. help_section=SECTION_AUTH,
  163. help_text="List devices linked to your Signal account",
  164. )
  165. async def list_devices(evt: CommandEvent) -> None:
  166. devices = await evt.bridge.signal.get_linked_devices(evt.sender.username)
  167. await evt.reply(
  168. "\n".join(
  169. f"* #{dev.id}: {dev.name_with_default} (created {dev.created_fmt}, last seen "
  170. f"{dev.last_seen_fmt})"
  171. for dev in devices
  172. )
  173. )
  174. @command_handler(
  175. needs_auth=True,
  176. management_only=True,
  177. help_section=SECTION_AUTH,
  178. help_text="Remove a linked device",
  179. )
  180. async def remove_linked_device(evt: CommandEvent) -> EventID:
  181. if len(evt.args) == 0:
  182. return await evt.reply("**Usage:** `$cmdprefix+sp remove-linked-device <device ID>`")
  183. device_id = int(evt.args[0])
  184. try:
  185. await evt.bridge.signal.remove_linked_device(evt.sender.username, device_id)
  186. except AuthorizationFailedException as e:
  187. return await evt.reply(f"{e} Only the primary device can remove linked devices.")
  188. return await evt.reply("Device removed")