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

300 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-13 12:05 +0000

1import logging 

2from datetime import datetime, timedelta 

3from typing import cast 

4 

5import grpc 

6import requests 

7from google.protobuf import empty_pb2 

8from sqlalchemy import select 

9from sqlalchemy.orm import Session 

10from sqlalchemy.sql import delete, func, or_ 

11 

12from couchers import urls 

13from couchers.config import config 

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

15from couchers.context import CouchersContext 

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

17from couchers.metrics import ( 

18 account_deletion_completions_counter, 

19 account_recoveries_counter, 

20 logins_counter, 

21 password_reset_completions_counter, 

22 password_reset_initiations_counter, 

23 recaptcha_score_histogram, 

24 recaptchas_assessed_counter, 

25 signup_completions_counter, 

26 signup_initiations_counter, 

27 signup_time_histogram, 

28) 

29from couchers.models import ( 

30 AccountDeletionToken, 

31 AntiBotLog, 

32 ContributorForm, 

33 InviteCode, 

34 PasswordResetToken, 

35 PhotoGallery, 

36 SignupFlow, 

37 User, 

38 UserSession, 

39) 

40from couchers.models.notifications import NotificationTopicAction 

41from couchers.notifications.notify import notify 

42from couchers.notifications.quick_links import respond_quick_link 

43from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

44from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

45from couchers.servicers.api import hostingstatus2sql 

46from couchers.sql import username_or_email 

47from couchers.tasks import ( 

48 enforce_community_memberships_for_user, 

49 maybe_send_contributor_form_email, 

50 send_signup_email, 

51) 

52from couchers.utils import ( 

53 create_coordinate, 

54 create_session_cookies, 

55 is_geom, 

56 is_valid_email, 

57 is_valid_name, 

58 is_valid_username, 

59 minimum_allowed_birthdate, 

60 not_none, 

61 now, 

62 parse_date, 

63 parse_session_cookie, 

64) 

65 

66logger = logging.getLogger(__name__) 

67 

68 

69def _auth_res(user: User) -> auth_pb2.AuthRes: 

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

71 

72 

73def create_session( 

74 context: CouchersContext, 

75 session: Session, 

76 user: User, 

77 long_lived: bool, 

78 is_api_key: bool = False, 

79 duration: timedelta | None = None, 

80 set_cookie: bool = True, 

81) -> tuple[str, datetime]: 

82 """ 

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

84 

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

86 work here due to the active User object. 

87 

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

89 

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

91 

92 ```py3 

93 token, expiry = create_session(...) 

94 ``` 

95 """ 

96 if user.is_banned: 

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

98 

99 # just double-check 

100 assert not user.is_deleted 

101 

102 token = cookiesafe_secure_token() 

103 

104 user_session = UserSession( 

105 token=token, 

106 user_id=user.id, 

107 long_lived=long_lived, 

108 ip_address=cast(str | None, context.headers.get("x-couchers-real-ip")), 

109 user_agent=cast(str | None, context.headers.get("user-agent")), 

110 is_api_key=is_api_key, 

111 ) 

112 if duration: 

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

114 

115 session.add(user_session) 

116 session.commit() 

117 

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

119 

120 if set_cookie: 

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

122 

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

124 

125 return token, user_session.expiry 

126 

127 

128def delete_session(session: Session, token: str) -> bool: 

129 """ 

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

131 

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

133 """ 

134 user_session = session.execute( 

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

136 ).scalar_one_or_none() 

137 if user_session: 137 ↛ 142line 137 didn't jump to line 142 because the condition on line 137 was always true

138 user_session.deleted = func.now() 

139 session.commit() 

140 return True 

141 else: 

142 return False 

143 

144 

145def _username_available(session: Session, username: str) -> bool: 

146 """ 

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

148 """ 

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

150 if not is_valid_username(username): 

151 return False 

152 for phrase in BANNED_USERNAME_PHRASES: 

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

154 return False 

155 # check for existing user with that username 

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

157 # check for started signup with that username 

158 signup_exists = ( 

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

160 ) 

161 # return False if user exists, True otherwise 

162 return not user_exists and not signup_exists 

163 

164 

165class Auth(auth_pb2_grpc.AuthServicer): 

166 """ 

167 The Auth servicer. 

168 

169 This class services the Auth service/API. 

170 """ 

171 

172 def SignupFlow( 

173 self, request: auth_pb2.SignupFlowReq, context: CouchersContext, session: Session 

174 ) -> auth_pb2.SignupFlowRes: 

175 if request.email_token: 

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

177 flow = session.execute( 

178 select(SignupFlow) 

179 .where(SignupFlow.email_verified == False) 

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

181 .where(SignupFlow.token_is_valid) 

182 ).scalar_one_or_none() 

183 if flow: 183 ↛ 192line 183 didn't jump to line 192 because the condition on line 183 was always true

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

185 flow.email_verified = True 

186 flow.email_token = None 

187 flow.email_token_expiry = None 

188 

189 session.flush() 

190 else: 

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

192 flow = session.execute( 

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

194 ).scalar_one_or_none() 

195 if not flow: 

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

197 else: 

198 if not request.flow_token: 

199 # fresh signup 

200 if not request.HasField("basic"): 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true

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

202 # TODO: unique across both tables 

203 existing_user = session.execute( 

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

205 ).scalar_one_or_none() 

206 if existing_user: 

207 if not existing_user.is_visible: 

208 context.abort_with_error_code( 

209 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used" 

210 ) 

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

212 existing_flow = session.execute( 

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

214 ).scalar_one_or_none() 

215 if existing_flow: 

216 send_signup_email(session, existing_flow) 

217 session.commit() 

218 context.abort_with_error_code( 

219 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup" 

220 ) 

221 

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

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

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

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

226 

227 flow_token = cookiesafe_secure_token() 

228 

229 invite_id = None 

230 if request.basic.invite_code: 

231 invite_id = session.execute( 

232 select(InviteCode.id).where( 

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

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

235 ) 

236 ).scalar_one_or_none() 

237 if not invite_id: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true

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

239 

240 flow = SignupFlow( 

241 flow_token=flow_token, 

242 name=request.basic.name, 

243 email=request.basic.email, 

244 invite_code_id=invite_id, 

245 ) 

246 session.add(flow) 

247 session.flush() 

248 signup_initiations_counter.inc() 

249 else: 

250 # not fresh signup 

251 flow = session.execute( 

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

253 ).scalar_one_or_none() 

254 if not flow: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

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

256 if request.HasField("basic"): 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true

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

258 

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

260 if request.HasField("account"): 

261 if flow.account_is_filled: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

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

263 

264 # check username validity 

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

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

267 

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

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

270 

271 abort_on_invalid_password(request.account.password, context) 

272 hashed_password = hash_password(request.account.password) 

273 

274 birthdate = parse_date(request.account.birthdate) 

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

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

277 

278 if not request.account.hosting_status: 

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

280 

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

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

283 

284 if not request.account.accept_tos: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

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

286 

287 flow.username = request.account.username 

288 flow.hashed_password = hashed_password 

289 flow.birthdate = birthdate 

290 flow.gender = request.account.gender 

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

292 flow.city = request.account.city 

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

294 flow.geom_radius = request.account.radius 

295 flow.accepted_tos = TOS_VERSION 

296 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

297 session.flush() 

298 

299 if request.HasField("feedback"): 

300 if flow.filled_feedback: 300 ↛ 301line 300 didn't jump to line 301 because the condition on line 300 was never true

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

302 form = request.feedback 

303 

304 flow.filled_feedback = True 

305 flow.ideas = form.ideas 

306 flow.features = form.features 

307 flow.experience = form.experience 

308 flow.contribute = contributeoption2sql[form.contribute] 

309 flow.contribute_ways = form.contribute_ways # type: ignore[assignment] 

310 flow.expertise = form.expertise 

311 session.flush() 

312 

313 if request.HasField("accept_community_guidelines"): 

314 if not request.accept_community_guidelines.value: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

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

316 flow.accepted_community_guidelines = GUIDELINES_VERSION 

317 session.flush() 

318 

319 # send verification email if needed 

320 if not flow.email_sent or request.resend_verification_email: 

321 send_signup_email(session, flow) 

322 

323 session.flush() 

324 

325 # finish the signup if done 

326 if flow.is_completed: 

327 user = User( 

328 name=flow.name, 

329 email=flow.email, 

330 username=not_none(flow.username), 

331 hashed_password=not_none(flow.hashed_password), 

332 birthdate=not_none(flow.birthdate), 

333 gender=not_none(flow.gender), 

334 hosting_status=not_none(flow.hosting_status), 

335 city=not_none(flow.city), 

336 geom=is_geom(flow.geom), 

337 geom_radius=not_none(flow.geom_radius), 

338 accepted_tos=not_none(flow.accepted_tos), 

339 last_onboarding_email_sent=func.now(), 

340 invite_code_id=flow.invite_code_id, 

341 ) 

342 

343 user.accepted_community_guidelines = flow.accepted_community_guidelines 

344 user.onboarding_emails_sent = 1 

345 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter) 

346 

347 session.add(user) 

348 session.flush() 

349 

350 # Create a profile gallery for the user 

351 profile_gallery = PhotoGallery(owner_user_id=user.id) 

352 session.add(profile_gallery) 

353 session.flush() 

354 user.profile_gallery_id = profile_gallery.id 

355 

356 if flow.filled_feedback: 

357 form_ = ContributorForm( 

358 user_id=user.id, 

359 ideas=flow.ideas or None, 

360 features=flow.features or None, 

361 experience=flow.experience or None, 

362 contribute=flow.contribute or None, 

363 contribute_ways=not_none(flow.contribute_ways), 

364 expertise=flow.expertise or None, 

365 ) 

366 

367 session.add(form_) 

368 

369 user.filled_contributor_form = form_.is_filled 

370 

371 maybe_send_contributor_form_email(session, form_) 

372 

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

374 

375 session.delete(flow) 

376 session.commit() 

377 

378 enforce_community_memberships_for_user(session, user) 

379 

380 # sends onboarding email 

381 notify( 

382 session, 

383 user_id=user.id, 

384 topic_action=NotificationTopicAction.onboarding__reminder, 

385 key="1", 

386 ) 

387 

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

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

390 

391 create_session(context, session, user, False) 

392 return auth_pb2.SignupFlowRes( 

393 auth_res=_auth_res(user), 

394 ) 

395 else: 

396 return auth_pb2.SignupFlowRes( 

397 flow_token=flow.flow_token, 

398 need_account=not flow.account_is_filled, 

399 need_feedback=False, 

400 need_verify_email=not flow.email_verified, 

401 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

402 ) 

403 

404 def UsernameValid( 

405 self, request: auth_pb2.UsernameValidReq, context: CouchersContext, session: Session 

406 ) -> auth_pb2.UsernameValidRes: 

407 """ 

408 Runs a username availability and validity check. 

409 """ 

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

411 

412 def Authenticate(self, request: auth_pb2.AuthReq, context: CouchersContext, session: Session) -> auth_pb2.AuthRes: 

413 """ 

414 Authenticates a classic password-based login request. 

415 

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

417 """ 

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

419 user = session.execute( 

420 select(User).where(username_or_email(request.user)).where(~User.is_deleted) 

421 ).scalar_one_or_none() 

422 if user: 

423 logger.debug("Found user") 

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

425 logger.debug("Right password") 

426 # correct password 

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

428 return _auth_res(user) 

429 else: 

430 logger.debug("Wrong password") 

431 # wrong password 

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

433 else: # user not found 

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

435 signup_flow = session.execute( 

436 select(SignupFlow).where(username_or_email(request.user, table=SignupFlow)) 

437 ).scalar_one_or_none() 

438 if signup_flow: 

439 send_signup_email(session, signup_flow) 

440 session.commit() 

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

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

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

444 

445 def GetAuthState( 

446 self, request: empty_pb2.Empty, context: CouchersContext, session: Session 

447 ) -> auth_pb2.GetAuthStateRes: 

448 if not context.is_logged_in(): 

449 return auth_pb2.GetAuthStateRes(logged_in=False) 

450 else: 

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

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

453 

454 def Deauthenticate(self, request: empty_pb2.Empty, context: CouchersContext, session: Session) -> empty_pb2.Empty: 

455 """ 

456 Removes an active cookie session. 

457 """ 

458 token = parse_session_cookie(context.headers) 

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

460 

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

462 if token: 462 ↛ 466line 462 didn't jump to line 466 because the condition on line 462 was always true

463 delete_session(session, token) 

464 

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

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

467 

468 return empty_pb2.Empty() 

469 

470 def ResetPassword( 

471 self, request: auth_pb2.ResetPasswordReq, context: CouchersContext, session: Session 

472 ) -> empty_pb2.Empty: 

473 """ 

474 If the user does not exist, do nothing. 

475 

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

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

478 

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

480 """ 

481 user = session.execute( 

482 select(User).where(username_or_email(request.user)).where(~User.is_deleted) 

483 ).scalar_one_or_none() 

484 if user: 

485 password_reset_token = PasswordResetToken( 

486 token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2) 

487 ) 

488 session.add(password_reset_token) 

489 session.flush() 

490 

491 notify( 

492 session, 

493 user_id=user.id, 

494 topic_action=NotificationTopicAction.password_reset__start, 

495 key="", 

496 data=notification_data_pb2.PasswordResetStart( 

497 password_reset_token=password_reset_token.token, 

498 ), 

499 ) 

500 

501 password_reset_initiations_counter.inc() 

502 else: # user not found 

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

504 

505 return empty_pb2.Empty() 

506 

507 def CompletePasswordResetV2( 

508 self, request: auth_pb2.CompletePasswordResetV2Req, context: CouchersContext, session: Session 

509 ) -> auth_pb2.AuthRes: 

510 """ 

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

512 """ 

513 res = session.execute( 

514 select(PasswordResetToken, User) 

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

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

517 .where(PasswordResetToken.is_valid) 

518 ).one_or_none() 

519 if res: 

520 password_reset_token, user = res 

521 abort_on_invalid_password(request.new_password, context) 

522 user.hashed_password = hash_password(request.new_password) 

523 session.delete(password_reset_token) 

524 

525 session.flush() 

526 

527 notify( 

528 session, 

529 user_id=user.id, 

530 topic_action=NotificationTopicAction.password_reset__complete, 

531 key="", 

532 ) 

533 

534 create_session(context, session, user, False) 

535 password_reset_completions_counter.inc() 

536 return _auth_res(user) 

537 else: 

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

539 

540 def ConfirmChangeEmailV2( 

541 self, request: auth_pb2.ConfirmChangeEmailV2Req, context: CouchersContext, session: Session 

542 ) -> empty_pb2.Empty: 

543 user = session.execute( 

544 select(User) 

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

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

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

548 ).scalar_one_or_none() 

549 

550 if not user: 

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

552 

553 user.email = not_none(user.new_email) 

554 user.new_email = None 

555 user.new_email_token = None 

556 user.new_email_token_created = None 

557 user.new_email_token_expiry = None 

558 

559 notify( 

560 session, 

561 user_id=user.id, 

562 topic_action=NotificationTopicAction.email_address__verify, 

563 key="", 

564 ) 

565 

566 return empty_pb2.Empty() 

567 

568 def ConfirmDeleteAccount( 

569 self, request: auth_pb2.ConfirmDeleteAccountReq, context: CouchersContext, session: Session 

570 ) -> empty_pb2.Empty: 

571 """ 

572 Confirm account deletion using account delete token 

573 """ 

574 res = session.execute( 

575 select(User, AccountDeletionToken) 

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

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

578 .where(AccountDeletionToken.is_valid) 

579 ).one_or_none() 

580 

581 if not res: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true

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

583 

584 user, account_deletion_token = res 

585 

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

587 

588 user.is_deleted = True 

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

590 user.undelete_token = urlsafe_secure_token() 

591 

592 session.flush() 

593 

594 notify( 

595 session, 

596 user_id=user.id, 

597 topic_action=NotificationTopicAction.account_deletion__complete, 

598 key="", 

599 data=notification_data_pb2.AccountDeletionComplete( 

600 undelete_token=user.undelete_token, 

601 undelete_days=UNDELETE_DAYS, 

602 ), 

603 ) 

604 

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

606 

607 return empty_pb2.Empty() 

608 

609 def RecoverAccount( 

610 self, request: auth_pb2.RecoverAccountReq, context: CouchersContext, session: Session 

611 ) -> empty_pb2.Empty: 

612 """ 

613 Recovers a recently deleted account 

614 """ 

615 user = session.execute( 

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

617 ).scalar_one_or_none() 

618 

619 if not user: 619 ↛ 620line 619 didn't jump to line 620 because the condition on line 619 was never true

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

621 

622 user.is_deleted = False 

623 user.undelete_token = None 

624 user.undelete_until = None 

625 

626 notify( 

627 session, 

628 user_id=user.id, 

629 topic_action=NotificationTopicAction.account_deletion__recovered, 

630 key="", 

631 ) 

632 

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

634 

635 return empty_pb2.Empty() 

636 

637 def Unsubscribe( 

638 self, request: auth_pb2.UnsubscribeReq, context: CouchersContext, session: Session 

639 ) -> auth_pb2.UnsubscribeRes: 

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

641 

642 def AntiBot(self, request: auth_pb2.AntiBotReq, context: CouchersContext, session: Session) -> auth_pb2.AntiBotRes: 

643 if not config["RECAPTHCA_ENABLED"]: 

644 return auth_pb2.AntiBotRes() 

645 

646 ip_address = cast(str | None, context.headers.get("x-couchers-real-ip")) 

647 user_agent = cast(str | None, context.headers.get("user-agent")) 

648 user_id = context.user_id if context.is_logged_in() else None 

649 

650 resp = requests.post( 

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

652 json={ 

653 "event": { 

654 "token": request.token, 

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

656 "userAgent": user_agent, 

657 "userIpAddress": ip_address, 

658 "expectedAction": request.action, 

659 "userInfo": {"accountId": str(user_id) if user_id else None}, 

660 } 

661 }, 

662 ) 

663 

664 resp.raise_for_status() 

665 

666 log = AntiBotLog( 

667 token=request.token, 

668 user_agent=user_agent, 

669 ip_address=ip_address, 

670 action=request.action, 

671 user_id=user_id, 

672 score=resp.json()["riskAnalysis"]["score"], 

673 provider_data=resp.json(), 

674 ) 

675 

676 session.add(log) 

677 session.flush() 

678 

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

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

681 

682 if context.is_logged_in(): 

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

684 user.last_antibot = now() 

685 

686 return auth_pb2.AntiBotRes() 

687 

688 def AntiBotPolicy( 

689 self, request: auth_pb2.AntiBotPolicyReq, context: CouchersContext, session: Session 

690 ) -> auth_pb2.AntiBotPolicyRes: 

691 if config["RECAPTHCA_ENABLED"]: 

692 if context.is_logged_in(): 

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

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

695 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

696 

697 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

698 

699 def GetInviteCodeInfo( 

700 self, request: auth_pb2.GetInviteCodeInfoReq, context: CouchersContext, session: Session 

701 ) -> auth_pb2.GetInviteCodeInfoRes: 

702 invite = session.execute( 

703 select(InviteCode).where( 

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

705 ) 

706 ).scalar_one_or_none() 

707 

708 if not invite: 

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

710 

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

712 

713 return auth_pb2.GetInviteCodeInfoRes( 

714 name=user.name, 

715 username=user.username, 

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

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

718 )