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

302 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-29 02:10 +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.models.uploads import get_avatar_upload 

42from couchers.notifications.notify import notify 

43from couchers.notifications.quick_links import respond_quick_link 

44from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

45from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

46from couchers.servicers.api import hostingstatus2sql 

47from couchers.sql import username_or_email 

48from couchers.tasks import ( 

49 enforce_community_memberships_for_user, 

50 maybe_send_contributor_form_email, 

51 send_signup_email, 

52) 

53from couchers.utils import ( 

54 create_coordinate, 

55 create_session_cookies, 

56 is_geom, 

57 is_valid_email, 

58 is_valid_name, 

59 is_valid_username, 

60 minimum_allowed_birthdate, 

61 not_none, 

62 now, 

63 parse_date, 

64 parse_session_cookie, 

65) 

66 

67logger = logging.getLogger(__name__) 

68 

69 

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

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

72 

73 

74def create_session( 

75 context: CouchersContext, 

76 session: Session, 

77 user: User, 

78 long_lived: bool, 

79 is_api_key: bool = False, 

80 duration: timedelta | None = None, 

81 set_cookie: bool = True, 

82) -> tuple[str, datetime]: 

83 """ 

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

85 

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

87 work here due to the active User object. 

88 

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

90 

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

92 

93 ```py3 

94 token, expiry = create_session(...) 

95 ``` 

96 """ 

97 if user.is_banned: 

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

99 

100 # just double-check 

101 assert not user.is_deleted 

102 

103 token = cookiesafe_secure_token() 

104 

105 user_session = UserSession( 

106 token=token, 

107 user_id=user.id, 

108 long_lived=long_lived, 

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

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

111 is_api_key=is_api_key, 

112 ) 

113 if duration: 

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

115 

116 session.add(user_session) 

117 session.commit() 

118 

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

120 

121 if set_cookie: 

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

123 

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

125 

126 return token, user_session.expiry 

127 

128 

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

130 """ 

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

132 

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

134 """ 

135 user_session = session.execute( 

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

137 ).scalar_one_or_none() 

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

139 user_session.deleted = func.now() 

140 session.commit() 

141 return True 

142 else: 

143 return False 

144 

145 

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

147 """ 

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

149 """ 

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

151 if not is_valid_username(username): 

152 return False 

153 for phrase in BANNED_USERNAME_PHRASES: 

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

155 return False 

156 # check for existing user with that username 

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

158 # check for started signup with that username 

159 signup_exists = ( 

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

161 ) 

162 # return False if user exists, True otherwise 

163 return not user_exists and not signup_exists 

164 

165 

166class Auth(auth_pb2_grpc.AuthServicer): 

167 """ 

168 The Auth servicer. 

169 

170 This class services the Auth service/API. 

171 """ 

172 

173 def SignupFlow( 

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

175 ) -> auth_pb2.SignupFlowRes: 

176 if request.email_token: 

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

178 flow = session.execute( 

179 select(SignupFlow) 

180 .where(SignupFlow.email_verified == False) 

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

182 .where(SignupFlow.token_is_valid) 

183 ).scalar_one_or_none() 

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

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

186 flow.email_verified = True 

187 flow.email_token = None 

188 flow.email_token_expiry = None 

189 

190 session.flush() 

191 else: 

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

193 flow = session.execute( 

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

195 ).scalar_one_or_none() 

196 if not flow: 

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

198 else: 

199 if not request.flow_token: 

200 # fresh signup 

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

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

203 # TODO: unique across both tables 

204 existing_user = session.execute( 

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

206 ).scalar_one_or_none() 

207 if existing_user: 

208 if not existing_user.is_visible: 

209 context.abort_with_error_code( 

210 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used" 

211 ) 

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

213 existing_flow = session.execute( 

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

215 ).scalar_one_or_none() 

216 if existing_flow: 

217 send_signup_email(session, existing_flow) 

218 session.commit() 

219 context.abort_with_error_code( 

220 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup" 

221 ) 

222 

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

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

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

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

227 

228 flow_token = cookiesafe_secure_token() 

229 

230 invite_id = None 

231 if request.basic.invite_code: 

232 invite_id = session.execute( 

233 select(InviteCode.id).where( 

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

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

236 ) 

237 ).scalar_one_or_none() 

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

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

240 

241 flow = SignupFlow( 

242 flow_token=flow_token, 

243 name=request.basic.name, 

244 email=request.basic.email, 

245 invite_code_id=invite_id, 

246 ) 

247 session.add(flow) 

248 session.flush() 

249 signup_initiations_counter.inc() 

250 else: 

251 # not fresh signup 

252 flow = session.execute( 

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

254 ).scalar_one_or_none() 

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

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

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

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

259 

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

261 if request.HasField("account"): 

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

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

264 

265 # check username validity 

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

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

268 

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

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

271 

272 abort_on_invalid_password(request.account.password, context) 

273 hashed_password = hash_password(request.account.password) 

274 

275 birthdate = parse_date(request.account.birthdate) 

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

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

278 

279 if not request.account.hosting_status: 

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

281 

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

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

284 

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

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

287 

288 flow.username = request.account.username 

289 flow.hashed_password = hashed_password 

290 flow.birthdate = birthdate 

291 flow.gender = request.account.gender 

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

293 flow.city = request.account.city 

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

295 flow.geom_radius = request.account.radius 

296 flow.accepted_tos = TOS_VERSION 

297 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

298 session.flush() 

299 

300 if request.HasField("feedback"): 

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

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

303 form = request.feedback 

304 

305 flow.filled_feedback = True 

306 flow.ideas = form.ideas 

307 flow.features = form.features 

308 flow.experience = form.experience 

309 flow.contribute = contributeoption2sql[form.contribute] 

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

311 flow.expertise = form.expertise 

312 session.flush() 

313 

314 if request.HasField("accept_community_guidelines"): 

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

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

317 flow.accepted_community_guidelines = GUIDELINES_VERSION 

318 session.flush() 

319 

320 # send verification email if needed 

321 if not flow.email_sent or request.resend_verification_email: 

322 send_signup_email(session, flow) 

323 

324 session.flush() 

325 

326 # finish the signup if done 

327 if flow.is_completed: 

328 user = User( 

329 name=flow.name, 

330 email=flow.email, 

331 username=not_none(flow.username), 

332 hashed_password=not_none(flow.hashed_password), 

333 birthdate=not_none(flow.birthdate), 

334 gender=not_none(flow.gender), 

335 hosting_status=not_none(flow.hosting_status), 

336 city=not_none(flow.city), 

337 geom=is_geom(flow.geom), 

338 geom_radius=not_none(flow.geom_radius), 

339 accepted_tos=not_none(flow.accepted_tos), 

340 last_onboarding_email_sent=func.now(), 

341 invite_code_id=flow.invite_code_id, 

342 ) 

343 

344 user.accepted_community_guidelines = flow.accepted_community_guidelines 

345 user.onboarding_emails_sent = 1 

346 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter) 

347 

348 session.add(user) 

349 session.flush() 

350 

351 # Create a profile gallery for the user 

352 profile_gallery = PhotoGallery(owner_user_id=user.id) 

353 session.add(profile_gallery) 

354 session.flush() 

355 user.profile_gallery_id = profile_gallery.id 

356 

357 if flow.filled_feedback: 

358 form_ = ContributorForm( 

359 user_id=user.id, 

360 ideas=flow.ideas or None, 

361 features=flow.features or None, 

362 experience=flow.experience or None, 

363 contribute=flow.contribute or None, 

364 contribute_ways=not_none(flow.contribute_ways), 

365 expertise=flow.expertise or None, 

366 ) 

367 

368 session.add(form_) 

369 

370 user.filled_contributor_form = form_.is_filled 

371 

372 maybe_send_contributor_form_email(session, form_) 

373 

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

375 

376 session.delete(flow) 

377 session.commit() 

378 

379 enforce_community_memberships_for_user(session, user) 

380 

381 # sends onboarding email 

382 notify( 

383 session, 

384 user_id=user.id, 

385 topic_action=NotificationTopicAction.onboarding__reminder, 

386 key="1", 

387 ) 

388 

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

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

391 

392 create_session(context, session, user, False) 

393 return auth_pb2.SignupFlowRes( 

394 auth_res=_auth_res(user), 

395 ) 

396 else: 

397 return auth_pb2.SignupFlowRes( 

398 flow_token=flow.flow_token, 

399 need_account=not flow.account_is_filled, 

400 need_feedback=False, 

401 need_verify_email=not flow.email_verified, 

402 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

403 ) 

404 

405 def UsernameValid( 

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

407 ) -> auth_pb2.UsernameValidRes: 

408 """ 

409 Runs a username availability and validity check. 

410 """ 

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

412 

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

414 """ 

415 Authenticates a classic password-based login request. 

416 

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

418 """ 

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

420 user = session.execute( 

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

422 ).scalar_one_or_none() 

423 if user: 

424 logger.debug("Found user") 

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

426 logger.debug("Right password") 

427 # correct password 

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

429 return _auth_res(user) 

430 else: 

431 logger.debug("Wrong password") 

432 # wrong password 

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

434 else: # user not found 

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

436 signup_flow = session.execute( 

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

438 ).scalar_one_or_none() 

439 if signup_flow: 

440 send_signup_email(session, signup_flow) 

441 session.commit() 

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

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

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

445 

446 def GetAuthState( 

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

448 ) -> auth_pb2.GetAuthStateRes: 

449 if not context.is_logged_in(): 

450 return auth_pb2.GetAuthStateRes(logged_in=False) 

451 else: 

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

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

454 

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

456 """ 

457 Removes an active cookie session. 

458 """ 

459 token = parse_session_cookie(context.headers) 

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

461 

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

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

464 delete_session(session, token) 

465 

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

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

468 

469 return empty_pb2.Empty() 

470 

471 def ResetPassword( 

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

473 ) -> empty_pb2.Empty: 

474 """ 

475 If the user does not exist, do nothing. 

476 

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

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

479 

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

481 """ 

482 user = session.execute( 

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

484 ).scalar_one_or_none() 

485 if user: 

486 password_reset_token = PasswordResetToken( 

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

488 ) 

489 session.add(password_reset_token) 

490 session.flush() 

491 

492 notify( 

493 session, 

494 user_id=user.id, 

495 topic_action=NotificationTopicAction.password_reset__start, 

496 key="", 

497 data=notification_data_pb2.PasswordResetStart( 

498 password_reset_token=password_reset_token.token, 

499 ), 

500 ) 

501 

502 password_reset_initiations_counter.inc() 

503 else: # user not found 

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

505 

506 return empty_pb2.Empty() 

507 

508 def CompletePasswordResetV2( 

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

510 ) -> auth_pb2.AuthRes: 

511 """ 

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

513 """ 

514 res = session.execute( 

515 select(PasswordResetToken, User) 

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

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

518 .where(PasswordResetToken.is_valid) 

519 ).one_or_none() 

520 if res: 

521 password_reset_token, user = res 

522 abort_on_invalid_password(request.new_password, context) 

523 user.hashed_password = hash_password(request.new_password) 

524 session.delete(password_reset_token) 

525 

526 session.flush() 

527 

528 notify( 

529 session, 

530 user_id=user.id, 

531 topic_action=NotificationTopicAction.password_reset__complete, 

532 key="", 

533 ) 

534 

535 create_session(context, session, user, False) 

536 password_reset_completions_counter.inc() 

537 return _auth_res(user) 

538 else: 

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

540 

541 def ConfirmChangeEmailV2( 

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

543 ) -> empty_pb2.Empty: 

544 user = session.execute( 

545 select(User) 

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

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

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

549 ).scalar_one_or_none() 

550 

551 if not user: 

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

553 

554 user.email = not_none(user.new_email) 

555 user.new_email = None 

556 user.new_email_token = None 

557 user.new_email_token_created = None 

558 user.new_email_token_expiry = None 

559 

560 notify( 

561 session, 

562 user_id=user.id, 

563 topic_action=NotificationTopicAction.email_address__verify, 

564 key="", 

565 ) 

566 

567 return empty_pb2.Empty() 

568 

569 def ConfirmDeleteAccount( 

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

571 ) -> empty_pb2.Empty: 

572 """ 

573 Confirm account deletion using account delete token 

574 """ 

575 res = session.execute( 

576 select(User, AccountDeletionToken) 

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

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

579 .where(AccountDeletionToken.is_valid) 

580 ).one_or_none() 

581 

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

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

584 

585 user, account_deletion_token = res 

586 

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

588 

589 user.is_deleted = True 

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

591 user.undelete_token = urlsafe_secure_token() 

592 

593 session.flush() 

594 

595 notify( 

596 session, 

597 user_id=user.id, 

598 topic_action=NotificationTopicAction.account_deletion__complete, 

599 key="", 

600 data=notification_data_pb2.AccountDeletionComplete( 

601 undelete_token=user.undelete_token, 

602 undelete_days=UNDELETE_DAYS, 

603 ), 

604 ) 

605 

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

607 

608 return empty_pb2.Empty() 

609 

610 def RecoverAccount( 

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

612 ) -> empty_pb2.Empty: 

613 """ 

614 Recovers a recently deleted account 

615 """ 

616 user = session.execute( 

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

618 ).scalar_one_or_none() 

619 

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

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

622 

623 user.is_deleted = False 

624 user.undelete_token = None 

625 user.undelete_until = None 

626 

627 notify( 

628 session, 

629 user_id=user.id, 

630 topic_action=NotificationTopicAction.account_deletion__recovered, 

631 key="", 

632 ) 

633 

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

635 

636 return empty_pb2.Empty() 

637 

638 def Unsubscribe( 

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

640 ) -> auth_pb2.UnsubscribeRes: 

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

642 

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

644 if not config["RECAPTHCA_ENABLED"]: 

645 return auth_pb2.AntiBotRes() 

646 

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

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

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

650 

651 resp = requests.post( 

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

653 json={ 

654 "event": { 

655 "token": request.token, 

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

657 "userAgent": user_agent, 

658 "userIpAddress": ip_address, 

659 "expectedAction": request.action, 

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

661 } 

662 }, 

663 ) 

664 

665 resp.raise_for_status() 

666 

667 log = AntiBotLog( 

668 token=request.token, 

669 user_agent=user_agent, 

670 ip_address=ip_address, 

671 action=request.action, 

672 user_id=user_id, 

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

674 provider_data=resp.json(), 

675 ) 

676 

677 session.add(log) 

678 session.flush() 

679 

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

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

682 

683 if context.is_logged_in(): 

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

685 user.last_antibot = now() 

686 

687 return auth_pb2.AntiBotRes() 

688 

689 def AntiBotPolicy( 

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

691 ) -> auth_pb2.AntiBotPolicyRes: 

692 if config["RECAPTHCA_ENABLED"]: 

693 if context.is_logged_in(): 

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

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

696 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

697 

698 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

699 

700 def GetInviteCodeInfo( 

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

702 ) -> auth_pb2.GetInviteCodeInfoRes: 

703 invite = session.execute( 

704 select(InviteCode).where( 

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

706 ) 

707 ).scalar_one_or_none() 

708 

709 if not invite: 

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

711 

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

713 

714 avatar_upload = get_avatar_upload(session, user) 

715 

716 return auth_pb2.GetInviteCodeInfoRes( 

717 name=user.name, 

718 username=user.username, 

719 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None, 

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

721 )