Coverage for slidge/command/register.py: 52%

106 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-10 12:26 +0000

1""" 

2This module handles the registration :term:`Command`, which is a necessary 

3step for a JID to become a slidge :term:`User`. 

4""" 

5 

6from __future__ import annotations 

7 

8import asyncio 

9import functools 

10from collections.abc import Mapping, Sequence 

11from enum import IntEnum 

12from typing import TYPE_CHECKING, Any, NamedTuple 

13 

14from slixmpp import JID, Iq 

15from slixmpp.exceptions import XMPPError 

16 

17from ..core import config 

18from ..db import GatewayUser 

19from ..util.types import AnySession, RegistrationValidationCoroutine, UserPreferences 

20from .base import Command, CommandAccess, Form, FormField, FormSession, FormValues 

21from .user import Preferences 

22 

23if TYPE_CHECKING: 

24 from slidge import BaseGateway 

25 

26 

27class AltRegistrationFlow[ 

28 RegistrationFormT, 

29 ModuleDataT: Mapping[str, object], 

30](NamedTuple): 

31 """ 

32 Named tuple defining an alternative registration flow. 

33 """ 

34 

35 type: RegistrationType 

36 """ 

37 Type of the alternative registration flow. 

38 """ 

39 title: str 

40 """ 

41 Name of that registration flow (presented to the candidate user). 

42 """ 

43 instructions: str 

44 """ 

45 Instructions of that registration flow. 

46 """ 

47 fields: tuple[FormField, ...] = () 

48 """ 

49 Fields defining the form of this registration flow. 

50 """ 

51 validate: RegistrationValidationCoroutine[RegistrationFormT, ModuleDataT] | None = ( 

52 None 

53 ) 

54 """ 

55 A coroutine awaited with the content of the registration form, similar to 

56 :meth:`BaseGatway.validate`. If `None`, then :meth:`BaseGatway.validate` is 

57 used and you have to handle different form contents in there. 

58 """ 

59 

60 

61class RegistrationType(IntEnum): 

62 """ 

63 An :class:`Enum` to define the registration flow. 

64 """ 

65 

66 SINGLE_STEP_FORM = 0 

67 """ 

68 1 step, 1 form, the only flow compatible with :xep:`0077`. 

69 Using this, the whole flow is defined 

70 by :attr:`slidge.BaseGateway.REGISTRATION_FIELDS` and 

71 :attr:`.REGISTRATION_INSTRUCTIONS`. 

72 """ 

73 

74 QRCODE = 10 

75 """ 

76 The registration requires flashing a QR code in an official client. 

77 See :meth:`slidge.BaseGateway.send_qr`, :meth:`.get_qr_text` 

78 and :meth:`.confirm_qr`. 

79 """ 

80 

81 TWO_FACTOR_CODE = 20 

82 """ 

83 The registration requires confirming login with a 2FA code, 

84 eg something received by email or SMS to finalize the authentication. 

85 See :meth:`.validate_two_factor_code`. 

86 """ 

87 

88 

89class TwoFactorNotRequired(Exception): 

90 """ 

91 Should be raised in :meth:`slidge.BaseGateway.validate` if the code is not 

92 required after all. This can happen for a :term:`Legacy Network` where 2FA 

93 is optional. 

94 """ 

95 

96 

97class Register[RegistrationFormT, ModuleDataT: Mapping[str, object]]( 

98 Command[AnySession] 

99): 

100 NAME = "📝 Register to the gateway" 

101 HELP = "Link your JID to this gateway" 

102 NODE = "jabber:iq:register" 

103 CHAT_COMMAND = "register" 

104 ACCESS = CommandAccess.NON_USER 

105 

106 SUCCESS_MESSAGE = "Success, welcome!" 

107 

108 _fields: tuple[FormField, ...] | None = None 

109 _type: RegistrationType | None = None 

110 _validate: RegistrationValidationCoroutine | None = None 

111 _instructions: str | None = None 

112 

113 xmpp: BaseGateway[Any, RegistrationFormT, ModuleDataT] 

114 

115 @property 

116 def type(self) -> RegistrationType: 

117 return self._type or self.xmpp.REGISTRATION_TYPE 

118 

119 @property 

120 def fields(self) -> Sequence[FormField]: 

121 if self._fields is None: 

122 return self.xmpp.REGISTRATION_FIELDS 

123 return self._fields 

124 

125 def _finalize( 

126 self, 

127 form_values: UserPreferences, 

128 _session: None, 

129 ifrom: JID, 

130 user: GatewayUser, 

131 *_: Any, # noqa:ANN401 

132 ) -> str: 

133 user.preferences = form_values # type: ignore 

134 self.xmpp.store.users.update(user) 

135 self.xmpp.event("user_register", Iq(sfrom=ifrom.bare)) 

136 return self.SUCCESS_MESSAGE 

137 

138 async def run( 

139 self, 

140 _session: AnySession | None, 

141 ifrom: JID, 

142 *_: str, 

143 ) -> FormSession[AnySession]: 

144 self.xmpp.raise_if_not_allowed_jid(ifrom) 

145 return FormSession( 

146 title=f"Registration to '{self.xmpp.COMPONENT_NAME}'", 

147 instructions=self._instructions or self.xmpp.REGISTRATION_INSTRUCTIONS, 

148 fields=self.fields, 

149 handler=self.register, 

150 ) 

151 

152 async def register( 

153 self, 

154 form_values: RegistrationFormT, 

155 _session: None, 

156 ifrom: JID, 

157 ) -> Form | None: 

158 two_fa_needed = True 

159 try: 

160 data = await self.xmpp.user_prevalidate( 

161 ifrom, form_values, self.fields, self._validate 

162 ) 

163 except ValueError as e: 

164 raise XMPPError("bad-request", str(e)) 

165 except TwoFactorNotRequired: 

166 data = None 

167 if self.type == RegistrationType.TWO_FACTOR_CODE: 

168 two_fa_needed = False 

169 else: 

170 raise 

171 except Exception as e: # noqa: BLE001 

172 raise XMPPError("internal-server-error", str(e)) 

173 

174 user = GatewayUser( 

175 jid=JID(ifrom.bare), 

176 legacy_module_data=form_values if data is None else data, 

177 ) 

178 

179 if self.type == RegistrationType.SINGLE_STEP_FORM or ( 

180 self.type == RegistrationType.TWO_FACTOR_CODE and not two_fa_needed 

181 ): 

182 return await self.preferences(user) 

183 

184 if self.type == RegistrationType.TWO_FACTOR_CODE: 

185 return Form( 

186 title=self.xmpp.REGISTRATION_2FA_TITLE, 

187 instructions=self.xmpp.REGISTRATION_2FA_INSTRUCTIONS, 

188 fields=[FormField("code", label="Code", required=True)], 

189 handler=functools.partial(self.two_fa, user=user), 

190 ) 

191 

192 elif self.type == RegistrationType.QRCODE: 

193 self.xmpp.qr_pending_registrations[user.jid.bare] = ( 

194 self.xmpp.loop.create_future() 

195 ) 

196 qr_text = await self.xmpp.get_qr_text(user) 

197 img_url = await self.xmpp.send_qr(qr_text, mto=ifrom) 

198 if img_url is None: 

199 raise XMPPError( 

200 "internal-server-error", "Slidge cannot send attachments" 

201 ) 

202 self.xmpp.send_text(qr_text, mto=ifrom) 

203 return Form( 

204 title="Flash this", 

205 instructions="Flash this QR in the appropriate place", 

206 fields=[ 

207 FormField( 

208 "qr_img", 

209 type="fixed", 

210 value=qr_text, 

211 image_url=img_url, 

212 ), 

213 FormField( 

214 "qr_text", 

215 type="fixed", 

216 value=qr_text, 

217 label="Text encoded in the QR code", 

218 ), 

219 FormField( 

220 "qr_img_url", 

221 type="fixed", 

222 value=img_url, 

223 label="URL of the QR code image", 

224 ), 

225 ], 

226 handler=functools.partial(self.qr, user=user), 

227 ) 

228 

229 async def two_fa( 

230 self, 

231 form_values: FormValues, 

232 _session: None, 

233 _ifrom: JID, 

234 user: GatewayUser, 

235 ) -> Form: 

236 assert isinstance(form_values["code"], str) 

237 data = await self.xmpp.validate_two_factor_code(user, form_values["code"]) 

238 if data is not None: 

239 user.legacy_module_data.update(data) # type:ignore[arg-type] 

240 return await self.preferences(user) 

241 

242 async def qr( 

243 self, 

244 _form_values: FormValues, 

245 _session: None, 

246 _ifrom: JID, 

247 user: GatewayUser, 

248 ) -> Form: 

249 try: 

250 data = await asyncio.wait_for( 

251 self.xmpp.qr_pending_registrations[user.jid.bare], 

252 config.QR_TIMEOUT, 

253 ) 

254 except TimeoutError: 

255 raise XMPPError( 

256 "remote-server-timeout", 

257 ( 

258 "It does not seem that the QR code was correctly used, " 

259 "or you took too much time" 

260 ), 

261 ) 

262 if data is not None: 

263 user.legacy_module_data.update(data) # type:ignore[arg-type] 

264 return await self.preferences(user) 

265 

266 async def preferences(self, user: GatewayUser) -> Form: 

267 return Form( 

268 title="Preferences", 

269 instructions=Preferences.HELP, 

270 fields=self.xmpp.PREFERENCES, 

271 handler=functools.partial(self._finalize, user=user), 

272 timeout_handler=functools.partial(self._preferences_timeout, user=user), 

273 ) 

274 

275 def _preferences_timeout(self, user: GatewayUser) -> None: 

276 self.xmpp.event("user_register", Iq(sfrom=user.jid.bare)) 

277 self.xmpp.store.users.update(user) 

278 self.xmpp.send_message( 

279 mfrom=self.xmpp.boundjid.bare, 

280 mto=user.jid, 

281 mbody="You did not choose your preferences in time, falling back to defaults. " 

282 "You can change preferences later with the 'preferences' command.\n" 

283 + self.SUCCESS_MESSAGE, 

284 )