Coverage for src/couchers/servicers/auth.py: 87%

289 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-04 01:57 +0000

1import logging 

2from datetime import timedelta 

3 

4import grpc 

5import requests 

6from google.protobuf import empty_pb2 

7from sqlalchemy.sql import delete, func, or_ 

8 

9from couchers import urls 

10from couchers.config import config 

11from couchers.constants import ANTIBOT_FREQ, BANNED_USERNAME_PHRASES, GUIDELINES_VERSION, TOS_VERSION, UNDELETE_DAYS 

12from couchers.context import CouchersContext 

13from couchers.crypto import cookiesafe_secure_token, hash_password, urlsafe_secure_token, verify_password 

14from couchers.metrics import ( 

15 account_deletion_completions_counter, 

16 account_recoveries_counter, 

17 logins_counter, 

18 password_reset_completions_counter, 

19 password_reset_initiations_counter, 

20 recaptcha_score_histogram, 

21 recaptchas_assessed_counter, 

22 signup_completions_counter, 

23 signup_initiations_counter, 

24 signup_time_histogram, 

25) 

26from couchers.models import ( 

27 AccountDeletionToken, 

28 AntiBotLog, 

29 ContributorForm, 

30 InviteCode, 

31 PasswordResetToken, 

32 SignupFlow, 

33 User, 

34 UserSession, 

35) 

36from couchers.notifications.notify import notify 

37from couchers.notifications.quick_links import respond_quick_link 

38from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

39from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

40from couchers.servicers.api import hostingstatus2sql 

41from couchers.sql import couchers_select as select 

42from couchers.tasks import ( 

43 enforce_community_memberships_for_user, 

44 maybe_send_contributor_form_email, 

45 send_signup_email, 

46) 

47from couchers.utils import ( 

48 create_coordinate, 

49 create_session_cookies, 

50 is_valid_email, 

51 is_valid_name, 

52 is_valid_username, 

53 minimum_allowed_birthdate, 

54 now, 

55 parse_date, 

56 parse_session_cookie, 

57) 

58 

59logger = logging.getLogger(__name__) 

60 

61 

62def _auth_res(user): 

63 return auth_pb2.AuthRes(jailed=user.is_jailed, user_id=user.id) 

64 

65 

66def create_session( 

67 context: CouchersContext, session, user, long_lived, is_api_key=False, duration=None, set_cookie=True 

68): 

69 """ 

70 Creates a session for the given user and returns the token and expiry. 

71 

72 You need to give an active DB session as nested sessions don't really 

73 work here due to the active User object. 

74 

75 Will abort the API calling context if the user is banned from logging in. 

76 

77 You can set the cookie on the client (if `is_api_key=False`) with 

78 

79 ```py3 

80 token, expiry = create_session(...) 

81 ``` 

82 """ 

83 if user.is_banned: 

84 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "account_suspended") 

85 

86 # just double-check 

87 assert not user.is_deleted 

88 

89 token = cookiesafe_secure_token() 

90 

91 user_session = UserSession( 

92 token=token, 

93 user=user, 

94 long_lived=long_lived, 

95 ip_address=context.headers.get("x-couchers-real-ip"), 

96 user_agent=context.headers.get("user-agent"), 

97 is_api_key=is_api_key, 

98 ) 

99 if duration: 

100 user_session.expiry = func.now() + duration 

101 

102 session.add(user_session) 

103 session.commit() 

104 

105 logger.debug(f"Handing out {token=} to {user=}") 

106 

107 if set_cookie: 

108 context.set_cookies(create_session_cookies(token, user.id, user_session.expiry)) 

109 

110 logins_counter.labels(user.gender).inc() 

111 

112 return token, user_session.expiry 

113 

114 

115def delete_session(session, token): 

116 """ 

117 Deletes the given session (practically logging the user out) 

118 

119 Returns True if the session was found, False otherwise. 

120 """ 

121 user_session = session.execute( 

122 select(UserSession).where(UserSession.token == token).where(UserSession.is_valid) 

123 ).scalar_one_or_none() 

124 if user_session: 

125 user_session.deleted = func.now() 

126 session.commit() 

127 return True 

128 else: 

129 return False 

130 

131 

132def _username_available(session, username): 

133 """ 

134 Checks if the given username adheres to our rules and isn't taken already. 

135 """ 

136 logger.debug(f"Checking if {username=} is valid") 

137 if not is_valid_username(username): 

138 return False 

139 for phrase in BANNED_USERNAME_PHRASES: 

140 if phrase.lower() in username.lower(): 

141 return False 

142 # check for existing user with that username 

143 user_exists = session.execute(select(User).where(User.username == username)).scalar_one_or_none() is not None 

144 # check for started signup with that username 

145 signup_exists = ( 

146 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() is not None 

147 ) 

148 # return False if user exists, True otherwise 

149 return not user_exists and not signup_exists 

150 

151 

152class Auth(auth_pb2_grpc.AuthServicer): 

153 """ 

154 The Auth servicer. 

155 

156 This class services the Auth service/API. 

157 """ 

158 

159 def SignupFlow(self, request, context, session): 

160 if request.email_token: 

161 # the email token can either be for verification or just to find an existing signup 

162 flow = session.execute( 

163 select(SignupFlow) 

164 .where(SignupFlow.email_verified == False) 

165 .where(SignupFlow.email_token == request.email_token) 

166 .where(SignupFlow.token_is_valid) 

167 ).scalar_one_or_none() 

168 if flow: 

169 # find flow by email verification token and mark it as verified 

170 flow.email_verified = True 

171 flow.email_token = None 

172 flow.email_token_expiry = None 

173 

174 session.flush() 

175 else: 

176 # just try to find the flow by flow token, no verification is done 

177 flow = session.execute( 

178 select(SignupFlow).where(SignupFlow.flow_token == request.email_token) 

179 ).scalar_one_or_none() 

180 if not flow: 

181 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token") 

182 else: 

183 if not request.flow_token: 

184 # fresh signup 

185 if not request.HasField("basic"): 

186 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_basic_needed") 

187 # TODO: unique across both tables 

188 existing_user = session.execute( 

189 select(User).where(User.email == request.basic.email) 

190 ).scalar_one_or_none() 

191 if existing_user: 

192 if not existing_user.is_visible: 

193 context.abort_with_error_code( 

194 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used" 

195 ) 

196 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_taken") 

197 existing_flow = session.execute( 

198 select(SignupFlow).where(SignupFlow.email == request.basic.email) 

199 ).scalar_one_or_none() 

200 if existing_flow: 

201 send_signup_email(session, existing_flow) 

202 session.commit() 

203 context.abort_with_error_code( 

204 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup" 

205 ) 

206 

207 if not is_valid_email(request.basic.email): 

208 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_email") 

209 if not is_valid_name(request.basic.name): 

210 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_name") 

211 

212 flow_token = cookiesafe_secure_token() 

213 

214 invite_id = None 

215 if request.basic.invite_code: 

216 invite_id = session.execute( 

217 select(InviteCode.id).where( 

218 InviteCode.id == request.basic.invite_code, 

219 or_(InviteCode.disabled == None, InviteCode.disabled > func.now()), 

220 ) 

221 ).scalar_one_or_none() 

222 if not invite_id: 

223 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_invite_code") 

224 

225 flow = SignupFlow( 

226 flow_token=flow_token, 

227 name=request.basic.name, 

228 email=request.basic.email, 

229 invite_code_id=invite_id, 

230 ) 

231 session.add(flow) 

232 session.flush() 

233 signup_initiations_counter.inc() 

234 else: 

235 # not fresh signup 

236 flow = session.execute( 

237 select(SignupFlow).where(SignupFlow.flow_token == request.flow_token) 

238 ).scalar_one_or_none() 

239 if not flow: 

240 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token") 

241 if request.HasField("basic"): 

242 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_basic_filled") 

243 

244 # we've found and/or created a new flow, now sort out other parts 

245 if request.HasField("account"): 

246 if flow.account_is_filled: 

247 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_account_filled") 

248 

249 # check username validity 

250 if not is_valid_username(request.account.username): 

251 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_username") 

252 

253 if not _username_available(session, request.account.username): 

254 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "username_not_available") 

255 

256 abort_on_invalid_password(request.account.password, context) 

257 hashed_password = hash_password(request.account.password) 

258 

259 birthdate = parse_date(request.account.birthdate) 

260 if not birthdate or birthdate >= minimum_allowed_birthdate(): 

261 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "invalid_birthdate") 

262 

263 if not request.account.hosting_status: 

264 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "hosting_status_required") 

265 

266 if request.account.lat == 0 and request.account.lng == 0: 

267 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_coordinate") 

268 

269 if not request.account.accept_tos: 

270 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_tos") 

271 

272 flow.username = request.account.username 

273 flow.hashed_password = hashed_password 

274 flow.birthdate = birthdate 

275 flow.gender = request.account.gender 

276 flow.hosting_status = hostingstatus2sql[request.account.hosting_status] 

277 flow.city = request.account.city 

278 flow.geom = create_coordinate(request.account.lat, request.account.lng) 

279 flow.geom_radius = request.account.radius 

280 flow.accepted_tos = TOS_VERSION 

281 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

282 session.flush() 

283 

284 if request.HasField("feedback"): 

285 if flow.filled_feedback: 

286 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_feedback_filled") 

287 form = request.feedback 

288 

289 flow.filled_feedback = True 

290 flow.ideas = form.ideas 

291 flow.features = form.features 

292 flow.experience = form.experience 

293 flow.contribute = contributeoption2sql[form.contribute] 

294 flow.contribute_ways = form.contribute_ways 

295 flow.expertise = form.expertise 

296 session.flush() 

297 

298 if request.HasField("accept_community_guidelines"): 

299 if not request.accept_community_guidelines.value: 

300 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "must_accept_community_guidelines") 

301 flow.accepted_community_guidelines = GUIDELINES_VERSION 

302 session.flush() 

303 

304 # send verification email if needed 

305 if not flow.email_sent or request.resend_verification_email: 

306 send_signup_email(session, flow) 

307 

308 session.flush() 

309 

310 # finish the signup if done 

311 if flow.is_completed: 

312 user = User( 

313 name=flow.name, 

314 email=flow.email, 

315 username=flow.username, 

316 hashed_password=flow.hashed_password, 

317 birthdate=flow.birthdate, 

318 gender=flow.gender, 

319 hosting_status=flow.hosting_status, 

320 city=flow.city, 

321 geom=flow.geom, 

322 geom_radius=flow.geom_radius, 

323 accepted_tos=flow.accepted_tos, 

324 accepted_community_guidelines=flow.accepted_community_guidelines, 

325 onboarding_emails_sent=1, 

326 last_onboarding_email_sent=func.now(), 

327 opt_out_of_newsletter=flow.opt_out_of_newsletter, 

328 invite_code_id=flow.invite_code_id, 

329 ) 

330 

331 session.add(user) 

332 

333 if flow.filled_feedback: 

334 form = ContributorForm( 

335 user=user, 

336 ideas=flow.ideas or None, 

337 features=flow.features or None, 

338 experience=flow.experience or None, 

339 contribute=flow.contribute or None, 

340 contribute_ways=flow.contribute_ways, 

341 expertise=flow.expertise or None, 

342 ) 

343 

344 session.add(form) 

345 

346 user.filled_contributor_form = form.is_filled 

347 

348 maybe_send_contributor_form_email(session, form) 

349 

350 signup_duration_s = (now() - flow.created).total_seconds() 

351 

352 session.delete(flow) 

353 session.commit() 

354 

355 enforce_community_memberships_for_user(session, user) 

356 

357 # sends onboarding email 

358 notify( 

359 session, 

360 user_id=user.id, 

361 topic_action="onboarding:reminder", 

362 key="1", 

363 ) 

364 

365 signup_completions_counter.labels(flow.gender).inc() 

366 signup_time_histogram.labels(flow.gender).observe(signup_duration_s) 

367 

368 create_session(context, session, user, False) 

369 return auth_pb2.SignupFlowRes( 

370 auth_res=_auth_res(user), 

371 ) 

372 else: 

373 return auth_pb2.SignupFlowRes( 

374 flow_token=flow.flow_token, 

375 need_account=not flow.account_is_filled, 

376 need_feedback=False, 

377 need_verify_email=not flow.email_verified, 

378 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

379 ) 

380 

381 def UsernameValid(self, request, context, session): 

382 """ 

383 Runs a username availability and validity check. 

384 """ 

385 return auth_pb2.UsernameValidRes(valid=_username_available(session, request.username.lower())) 

386 

387 def Authenticate(self, request, context, session): 

388 """ 

389 Authenticates a classic password-based login request. 

390 

391 request.user can be any of id/username/email 

392 """ 

393 logger.debug(f"Logging in with {request.user=}, password=*******") 

394 user = session.execute( 

395 select(User).where_username_or_email(request.user).where(~User.is_deleted) 

396 ).scalar_one_or_none() 

397 if user: 

398 logger.debug("Found user") 

399 if verify_password(user.hashed_password, request.password): 

400 logger.debug("Right password") 

401 # correct password 

402 create_session(context, session, user, request.remember_device) 

403 return _auth_res(user) 

404 else: 

405 logger.debug("Wrong password") 

406 # wrong password 

407 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_password") 

408 else: # user not found 

409 # check if this is an email and they tried to sign up but didn't complete 

410 signup_flow = session.execute( 

411 select(SignupFlow).where_username_or_email(request.user, table=SignupFlow) 

412 ).scalar_one_or_none() 

413 if signup_flow: 

414 send_signup_email(session, signup_flow) 

415 session.commit() 

416 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup") 

417 logger.debug("Didn't find user") 

418 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "account_not_found") 

419 

420 def GetAuthState(self, request, context, session): 

421 if not context.is_logged_in(): 

422 return auth_pb2.GetAuthStateRes(logged_in=False) 

423 else: 

424 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

425 return auth_pb2.GetAuthStateRes(logged_in=True, auth_res=_auth_res(user)) 

426 

427 def Deauthenticate(self, request, context, session): 

428 """ 

429 Removes an active cookie session. 

430 """ 

431 token = parse_session_cookie(context.headers) 

432 logger.info(f"Deauthenticate(token={token})") 

433 

434 # if we had a token, try to remove the session 

435 if token: 

436 delete_session(session, token) 

437 

438 # set the cookie to an empty string and expire immediately, should remove it from the browser 

439 context.set_cookies(create_session_cookies("", "", now())) 

440 

441 return empty_pb2.Empty() 

442 

443 def ResetPassword(self, request, context, session): 

444 """ 

445 If the user does not exist, do nothing. 

446 

447 If the user exists, we send them an email. If they have a password, clicking that email will remove the password. 

448 If they don't have a password, it sends them an email saying someone tried to reset the password but there was none. 

449 

450 Note that as long as emails are send synchronously, this is far from constant time regardless of output. 

451 """ 

452 user = session.execute( 

453 select(User).where_username_or_email(request.user).where(~User.is_deleted) 

454 ).scalar_one_or_none() 

455 if user: 

456 password_reset_token = PasswordResetToken( 

457 token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2) 

458 ) 

459 session.add(password_reset_token) 

460 session.flush() 

461 

462 notify( 

463 session, 

464 user_id=user.id, 

465 topic_action="password_reset:start", 

466 data=notification_data_pb2.PasswordResetStart( 

467 password_reset_token=password_reset_token.token, 

468 ), 

469 ) 

470 

471 password_reset_initiations_counter.inc() 

472 else: # user not found 

473 logger.debug("Didn't find user") 

474 

475 return empty_pb2.Empty() 

476 

477 def CompletePasswordResetV2(self, request, context, session): 

478 """ 

479 Completes the password reset: just clears the user's password 

480 """ 

481 res = session.execute( 

482 select(PasswordResetToken, User) 

483 .join(User, User.id == PasswordResetToken.user_id) 

484 .where(PasswordResetToken.token == request.password_reset_token) 

485 .where(PasswordResetToken.is_valid) 

486 ).one_or_none() 

487 if res: 

488 password_reset_token, user = res 

489 abort_on_invalid_password(request.new_password, context) 

490 user.hashed_password = hash_password(request.new_password) 

491 session.delete(password_reset_token) 

492 

493 session.flush() 

494 

495 notify( 

496 session, 

497 user_id=user.id, 

498 topic_action="password_reset:complete", 

499 ) 

500 

501 create_session(context, session, user, False) 

502 password_reset_completions_counter.inc() 

503 return _auth_res(user) 

504 else: 

505 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token") 

506 

507 def ConfirmChangeEmailV2(self, request, context, session): 

508 user = session.execute( 

509 select(User) 

510 .where(User.new_email_token == request.change_email_token) 

511 .where(User.new_email_token_created <= now()) 

512 .where(User.new_email_token_expiry >= now()) 

513 ).scalar_one_or_none() 

514 

515 if not user: 

516 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token") 

517 

518 user.email = user.new_email 

519 user.new_email = None 

520 user.new_email_token = None 

521 user.new_email_token_created = None 

522 user.new_email_token_expiry = None 

523 

524 notify( 

525 session, 

526 user_id=user.id, 

527 topic_action="email_address:verify", 

528 ) 

529 

530 return empty_pb2.Empty() 

531 

532 def ConfirmDeleteAccount(self, request, context, session): 

533 """ 

534 Confirm account deletion using account delete token 

535 """ 

536 res = session.execute( 

537 select(User, AccountDeletionToken) 

538 .join(AccountDeletionToken, AccountDeletionToken.user_id == User.id) 

539 .where(AccountDeletionToken.token == request.token) 

540 .where(AccountDeletionToken.is_valid) 

541 ).one_or_none() 

542 

543 if not res: 

544 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token") 

545 

546 user, account_deletion_token = res 

547 

548 session.execute(delete(AccountDeletionToken).where(AccountDeletionToken.user_id == user.id)) 

549 

550 user.is_deleted = True 

551 user.undelete_until = now() + timedelta(days=UNDELETE_DAYS) 

552 user.undelete_token = urlsafe_secure_token() 

553 

554 session.flush() 

555 

556 notify( 

557 session, 

558 user_id=user.id, 

559 topic_action="account_deletion:complete", 

560 data=notification_data_pb2.AccountDeletionComplete( 

561 undelete_token=user.undelete_token, 

562 undelete_days=UNDELETE_DAYS, 

563 ), 

564 ) 

565 

566 account_deletion_completions_counter.labels(user.gender).inc() 

567 

568 return empty_pb2.Empty() 

569 

570 def RecoverAccount(self, request, context, session): 

571 """ 

572 Recovers a recently deleted account 

573 """ 

574 user = session.execute( 

575 select(User).where(User.undelete_token == request.token).where(User.undelete_until > now()) 

576 ).scalar_one_or_none() 

577 

578 if not user: 

579 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_token") 

580 

581 user.is_deleted = False 

582 user.undelete_token = None 

583 user.undelete_until = None 

584 

585 notify( 

586 session, 

587 user_id=user.id, 

588 topic_action="account_deletion:recovered", 

589 ) 

590 

591 account_recoveries_counter.labels(user.gender).inc() 

592 

593 return empty_pb2.Empty() 

594 

595 def Unsubscribe(self, request, context, session): 

596 return auth_pb2.UnsubscribeRes(response=respond_quick_link(request, context, session)) 

597 

598 def AntiBot(self, request, context, session): 

599 if not config["RECAPTHCA_ENABLED"]: 

600 return auth_pb2.AntiBotRes() 

601 

602 ip_address = context.headers.get("x-couchers-real-ip") 

603 user_agent = context.headers.get("user-agent") 

604 

605 log = AntiBotLog( 

606 token=request.token, 

607 user_agent=user_agent, 

608 ip_address=ip_address, 

609 action=request.action, 

610 user_id=context.user_id if context.is_logged_in() else None, 

611 ) 

612 

613 resp = requests.post( 

614 f"https://recaptchaenterprise.googleapis.com/v1/projects/{config['RECAPTHCA_PROJECT_ID']}/assessments?key={config['RECAPTHCA_API_KEY']}", 

615 json={ 

616 "event": { 

617 "token": log.token, 

618 "siteKey": config["RECAPTHCA_SITE_KEY"], 

619 "userAgent": log.user_agent, 

620 "userIpAddress": log.ip_address, 

621 "expectedAction": log.action, 

622 "userInfo": {"accountId": str(log.user_id) if log.user_id else None}, 

623 } 

624 }, 

625 ) 

626 

627 resp.raise_for_status() 

628 

629 log.score = resp.json()["riskAnalysis"]["score"] 

630 log.provider_data = resp.json() 

631 

632 session.add(log) 

633 

634 session.flush() 

635 

636 recaptchas_assessed_counter.labels(log.action).inc() 

637 recaptcha_score_histogram.labels(log.action).observe(log.score) 

638 

639 if context.is_logged_in(): 

640 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

641 user.last_antibot = now() 

642 

643 return auth_pb2.AntiBotRes() 

644 

645 def AntiBotPolicy(self, request, context, session): 

646 if config["RECAPTHCA_ENABLED"]: 

647 if context.is_logged_in(): 

648 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

649 if now() - user.last_antibot > ANTIBOT_FREQ: 

650 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

651 

652 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

653 

654 def GetInviteCodeInfo(self, request, context, session): 

655 invite = session.execute( 

656 select(InviteCode).where( 

657 InviteCode.id == request.code, or_(InviteCode.disabled == None, InviteCode.disabled > func.now()) 

658 ) 

659 ).scalar_one_or_none() 

660 

661 if not invite: 

662 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invite_code_not_found") 

663 

664 user = session.execute(select(User).where(User.id == invite.creator_user_id)).scalar_one() 

665 

666 return auth_pb2.GetInviteCodeInfoRes( 

667 name=user.name, 

668 username=user.username, 

669 avatar_url=user.avatar.thumbnail_url if user.avatar else None, 

670 url=urls.invite_code_link(code=request.code), 

671 )