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

287 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-08-28 14:55 +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 errors, 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.servicers.account import abort_on_invalid_password, contributeoption2sql 

39from couchers.servicers.api import hostingstatus2sql 

40from couchers.sql import couchers_select as select 

41from couchers.tasks import ( 

42 enforce_community_memberships_for_user, 

43 maybe_send_contributor_form_email, 

44 send_signup_email, 

45) 

46from couchers.utils import ( 

47 create_coordinate, 

48 create_session_cookies, 

49 is_valid_email, 

50 is_valid_name, 

51 is_valid_username, 

52 minimum_allowed_birthdate, 

53 now, 

54 parse_date, 

55 parse_session_cookie, 

56) 

57from proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

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(grpc.StatusCode.FAILED_PRECONDITION, errors.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(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

182 else: 

183 if not request.flow_token: 

184 # fresh signup 

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

186 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.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 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_TAKEN) 

193 existing_flow = session.execute( 

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

195 ).scalar_one_or_none() 

196 if existing_flow: 

197 send_signup_email(session, existing_flow) 

198 session.commit() 

199 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP) 

200 

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

202 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL) 

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

204 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_NAME) 

205 

206 flow_token = cookiesafe_secure_token() 

207 

208 invite_id = None 

209 if request.basic.invite_code: 

210 invite_id = session.execute( 

211 select(InviteCode.id).where( 

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

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

214 ) 

215 ).scalar_one_or_none() 

216 if not invite_id: 

217 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_INVITE_CODE) 

218 

219 flow = SignupFlow( 

220 flow_token=flow_token, 

221 name=request.basic.name, 

222 email=request.basic.email, 

223 invite_code_id=invite_id, 

224 ) 

225 session.add(flow) 

226 session.flush() 

227 signup_initiations_counter.inc() 

228 else: 

229 # not fresh signup 

230 flow = session.execute( 

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

232 ).scalar_one_or_none() 

233 if not flow: 

234 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

235 if request.HasField("basic"): 

236 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_BASIC_FILLED) 

237 

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

239 if request.HasField("account"): 

240 if flow.account_is_filled: 

241 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_ACCOUNT_FILLED) 

242 

243 # check username validity 

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

245 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_USERNAME) 

246 

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

248 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.USERNAME_NOT_AVAILABLE) 

249 

250 abort_on_invalid_password(request.account.password, context) 

251 hashed_password = hash_password(request.account.password) 

252 

253 birthdate = parse_date(request.account.birthdate) 

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

255 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.INVALID_BIRTHDATE) 

256 

257 if not request.account.hosting_status: 

258 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.HOSTING_STATUS_REQUIRED) 

259 

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

261 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_COORDINATE) 

262 

263 if not request.account.accept_tos: 

264 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_TOS) 

265 

266 flow.username = request.account.username 

267 flow.hashed_password = hashed_password 

268 flow.birthdate = birthdate 

269 flow.gender = request.account.gender 

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

271 flow.city = request.account.city 

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

273 flow.geom_radius = request.account.radius 

274 flow.accepted_tos = TOS_VERSION 

275 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

276 session.flush() 

277 

278 if request.HasField("feedback"): 

279 if flow.filled_feedback: 

280 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_FEEDBACK_FILLED) 

281 form = request.feedback 

282 

283 flow.filled_feedback = True 

284 flow.ideas = form.ideas 

285 flow.features = form.features 

286 flow.experience = form.experience 

287 flow.contribute = contributeoption2sql[form.contribute] 

288 flow.contribute_ways = form.contribute_ways 

289 flow.expertise = form.expertise 

290 session.flush() 

291 

292 if request.HasField("accept_community_guidelines"): 

293 if not request.accept_community_guidelines.value: 

294 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_COMMUNITY_GUIDELINES) 

295 flow.accepted_community_guidelines = GUIDELINES_VERSION 

296 session.flush() 

297 

298 # send verification email if needed 

299 if not flow.email_sent or request.resend_verification_email: 

300 send_signup_email(session, flow) 

301 

302 session.flush() 

303 

304 # finish the signup if done 

305 if flow.is_completed: 

306 user = User( 

307 name=flow.name, 

308 email=flow.email, 

309 username=flow.username, 

310 hashed_password=flow.hashed_password, 

311 birthdate=flow.birthdate, 

312 gender=flow.gender, 

313 hosting_status=flow.hosting_status, 

314 city=flow.city, 

315 geom=flow.geom, 

316 geom_radius=flow.geom_radius, 

317 accepted_tos=flow.accepted_tos, 

318 accepted_community_guidelines=flow.accepted_community_guidelines, 

319 onboarding_emails_sent=1, 

320 last_onboarding_email_sent=func.now(), 

321 opt_out_of_newsletter=flow.opt_out_of_newsletter, 

322 invite_code_id=flow.invite_code_id, 

323 ) 

324 

325 session.add(user) 

326 

327 if flow.filled_feedback: 

328 form = ContributorForm( 

329 user=user, 

330 ideas=flow.ideas or None, 

331 features=flow.features or None, 

332 experience=flow.experience or None, 

333 contribute=flow.contribute or None, 

334 contribute_ways=flow.contribute_ways, 

335 expertise=flow.expertise or None, 

336 ) 

337 

338 session.add(form) 

339 

340 user.filled_contributor_form = form.is_filled 

341 

342 maybe_send_contributor_form_email(session, form) 

343 

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

345 

346 session.delete(flow) 

347 session.commit() 

348 

349 enforce_community_memberships_for_user(session, user) 

350 

351 # sends onboarding email 

352 notify( 

353 session, 

354 user_id=user.id, 

355 topic_action="onboarding:reminder", 

356 key="1", 

357 ) 

358 

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

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

361 

362 create_session(context, session, user, False) 

363 return auth_pb2.SignupFlowRes( 

364 auth_res=_auth_res(user), 

365 ) 

366 else: 

367 return auth_pb2.SignupFlowRes( 

368 flow_token=flow.flow_token, 

369 need_account=not flow.account_is_filled, 

370 need_feedback=False, 

371 need_verify_email=not flow.email_verified, 

372 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

373 ) 

374 

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

376 """ 

377 Runs a username availability and validity check. 

378 """ 

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

380 

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

382 """ 

383 Authenticates a classic password based login request. 

384 

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

386 """ 

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

388 user = session.execute( 

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

390 ).scalar_one_or_none() 

391 if user: 

392 logger.debug("Found user") 

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

394 logger.debug("Right password") 

395 # correct password 

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

397 return _auth_res(user) 

398 else: 

399 logger.debug("Wrong password") 

400 # wrong password 

401 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_PASSWORD) 

402 else: # user not found 

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

404 signup_flow = session.execute( 

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

406 ).scalar_one_or_none() 

407 if signup_flow: 

408 send_signup_email(session, signup_flow) 

409 session.commit() 

410 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP) 

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

412 context.abort(grpc.StatusCode.NOT_FOUND, errors.ACCOUNT_NOT_FOUND) 

413 

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

415 if not context.is_logged_in(): 

416 return auth_pb2.GetAuthStateRes(logged_in=False) 

417 else: 

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

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

420 

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

422 """ 

423 Removes an active cookie session. 

424 """ 

425 token = parse_session_cookie(context.headers) 

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

427 

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

429 if token: 

430 delete_session(session, token) 

431 

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

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

434 

435 return empty_pb2.Empty() 

436 

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

438 """ 

439 If the user does not exist, do nothing. 

440 

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

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

443 

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

445 """ 

446 user = session.execute( 

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

448 ).scalar_one_or_none() 

449 if user: 

450 password_reset_token = PasswordResetToken( 

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

452 ) 

453 session.add(password_reset_token) 

454 session.flush() 

455 

456 notify( 

457 session, 

458 user_id=user.id, 

459 topic_action="password_reset:start", 

460 data=notification_data_pb2.PasswordResetStart( 

461 password_reset_token=password_reset_token.token, 

462 ), 

463 ) 

464 

465 password_reset_initiations_counter.inc() 

466 else: # user not found 

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

468 

469 return empty_pb2.Empty() 

470 

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

472 """ 

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

474 """ 

475 res = session.execute( 

476 select(PasswordResetToken, User) 

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

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

479 .where(PasswordResetToken.is_valid) 

480 ).one_or_none() 

481 if res: 

482 password_reset_token, user = res 

483 abort_on_invalid_password(request.new_password, context) 

484 user.hashed_password = hash_password(request.new_password) 

485 session.delete(password_reset_token) 

486 

487 session.flush() 

488 

489 notify( 

490 session, 

491 user_id=user.id, 

492 topic_action="password_reset:complete", 

493 ) 

494 

495 create_session(context, session, user, False) 

496 password_reset_completions_counter.inc() 

497 return _auth_res(user) 

498 else: 

499 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

500 

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

502 user = session.execute( 

503 select(User) 

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

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

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

507 ).scalar_one_or_none() 

508 

509 if not user: 

510 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

511 

512 user.email = user.new_email 

513 user.new_email = None 

514 user.new_email_token = None 

515 user.new_email_token_created = None 

516 user.new_email_token_expiry = None 

517 

518 notify( 

519 session, 

520 user_id=user.id, 

521 topic_action="email_address:verify", 

522 ) 

523 

524 return empty_pb2.Empty() 

525 

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

527 """ 

528 Confirm account deletion using account delete token 

529 """ 

530 res = session.execute( 

531 select(User, AccountDeletionToken) 

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

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

534 .where(AccountDeletionToken.is_valid) 

535 ).one_or_none() 

536 

537 if not res: 

538 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

539 

540 user, account_deletion_token = res 

541 

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

543 

544 user.is_deleted = True 

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

546 user.undelete_token = urlsafe_secure_token() 

547 

548 session.flush() 

549 

550 notify( 

551 session, 

552 user_id=user.id, 

553 topic_action="account_deletion:complete", 

554 data=notification_data_pb2.AccountDeletionComplete( 

555 undelete_token=user.undelete_token, 

556 undelete_days=UNDELETE_DAYS, 

557 ), 

558 ) 

559 

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

561 

562 return empty_pb2.Empty() 

563 

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

565 """ 

566 Recovers a recently deleted account 

567 """ 

568 user = session.execute( 

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

570 ).scalar_one_or_none() 

571 

572 if not user: 

573 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

574 

575 user.is_deleted = False 

576 user.undelete_token = None 

577 user.undelete_until = None 

578 

579 notify( 

580 session, 

581 user_id=user.id, 

582 topic_action="account_deletion:recovered", 

583 ) 

584 

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

586 

587 return empty_pb2.Empty() 

588 

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

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

591 

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

593 if not config["RECAPTHCA_ENABLED"]: 

594 return auth_pb2.AntiBotRes() 

595 

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

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

598 

599 log = AntiBotLog( 

600 token=request.token, 

601 user_agent=user_agent, 

602 ip_address=ip_address, 

603 action=request.action, 

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

605 ) 

606 

607 resp = requests.post( 

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

609 json={ 

610 "event": { 

611 "token": log.token, 

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

613 "userAgent": log.user_agent, 

614 "userIpAddress": log.ip_address, 

615 "expectedAction": log.action, 

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

617 } 

618 }, 

619 ) 

620 

621 resp.raise_for_status() 

622 

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

624 log.provider_data = resp.json() 

625 

626 session.add(log) 

627 

628 session.flush() 

629 

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

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

632 

633 if context.is_logged_in(): 

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

635 user.last_antibot = now() 

636 

637 return auth_pb2.AntiBotRes() 

638 

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

640 if config["RECAPTHCA_ENABLED"]: 

641 if context.is_logged_in(): 

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

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

644 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

645 

646 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

647 

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

649 invite = session.execute( 

650 select(InviteCode).where( 

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

652 ) 

653 ).scalar_one_or_none() 

654 

655 if not invite: 

656 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVITE_CODE_NOT_FOUND) 

657 

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

659 

660 return auth_pb2.GetInviteCodeInfoRes( 

661 name=user.name, 

662 username=user.username, 

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

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

665 )