Coverage for app / backend / src / couchers / servicers / auth.py: 86%

319 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +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.event_log import log_event 

18from couchers.metrics import ( 

19 account_deletion_completions_counter, 

20 account_recoveries_counter, 

21 logins_counter, 

22 password_reset_completions_counter, 

23 password_reset_initiations_counter, 

24 recaptcha_score_histogram, 

25 recaptchas_assessed_counter, 

26 signup_completions_counter, 

27 signup_initiations_counter, 

28 signup_time_histogram, 

29) 

30from couchers.models import ( 

31 AccountDeletionToken, 

32 AntiBotLog, 

33 ContributorForm, 

34 InviteCode, 

35 PasswordResetToken, 

36 PhotoGallery, 

37 SignupFlow, 

38 User, 

39 UserSession, 

40) 

41from couchers.models.notifications import NotificationTopicAction 

42from couchers.models.uploads import get_avatar_upload 

43from couchers.notifications.notify import notify 

44from couchers.notifications.quick_links import respond_quick_link 

45from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

46from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

47from couchers.servicers.api import hostingstatus2sql 

48from couchers.sql import username_or_email 

49from couchers.tasks import ( 

50 enforce_community_memberships_for_user, 

51 maybe_send_contributor_form_email, 

52 send_signup_email, 

53) 

54from couchers.utils import ( 

55 create_coordinate, 

56 create_session_cookies, 

57 is_geom, 

58 is_valid_email, 

59 is_valid_name, 

60 is_valid_username, 

61 minimum_allowed_birthdate, 

62 not_none, 

63 now, 

64 parse_date, 

65 parse_session_cookie, 

66) 

67 

68logger = logging.getLogger(__name__) 

69 

70 

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

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

73 

74 

75def create_session( 

76 context: CouchersContext, 

77 session: Session, 

78 user: User, 

79 long_lived: bool, 

80 is_api_key: bool = False, 

81 duration: timedelta | None = None, 

82 set_cookie: bool = True, 

83) -> tuple[str, datetime]: 

84 """ 

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

86 

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

88 work here due to the active User object. 

89 

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

91 

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

93 

94 ```py3 

95 token, expiry = create_session(...) 

96 ``` 

97 """ 

98 if user.banned_at is not None: 

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

100 

101 # just double-check 

102 assert user.deleted_at is None 

103 

104 token = cookiesafe_secure_token() 

105 

106 user_session = UserSession( 

107 token=token, 

108 user_id=user.id, 

109 long_lived=long_lived, 

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

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

112 is_api_key=is_api_key, 

113 ) 

114 if duration: 

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

116 

117 session.add(user_session) 

118 session.commit() 

119 

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

121 

122 if set_cookie: 

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

124 

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

126 

127 return token, user_session.expiry 

128 

129 

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

131 """ 

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

133 

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

135 """ 

136 user_session = session.execute( 

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

138 ).scalar_one_or_none() 

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

140 user_session.deleted = func.now() 

141 session.commit() 

142 return True 

143 else: 

144 return False 

145 

146 

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

148 """ 

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

150 """ 

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

152 if not is_valid_username(username): 

153 return False 

154 for phrase in BANNED_USERNAME_PHRASES: 

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

156 return False 

157 # check for existing user with that username 

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

159 # check for started signup with that username 

160 signup_exists = ( 

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

162 ) 

163 # return False if user exists, True otherwise 

164 return not user_exists and not signup_exists 

165 

166 

167class Auth(auth_pb2_grpc.AuthServicer): 

168 """ 

169 The Auth servicer. 

170 

171 This class services the Auth service/API. 

172 """ 

173 

174 def SignupFlow( 

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

176 ) -> auth_pb2.SignupFlowRes: 

177 if request.email_token: 

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

179 flow = session.execute( 

180 select(SignupFlow) 

181 .where(SignupFlow.email_verified == False) 

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

183 .where(SignupFlow.token_is_valid) 

184 ).scalar_one_or_none() 

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

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

187 flow.email_verified = True 

188 flow.email_token = None 

189 flow.email_token_expiry = None 

190 

191 session.flush() 

192 else: 

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

194 flow = session.execute( 

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

196 ).scalar_one_or_none() 

197 if not flow: 

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

199 else: 

200 if not request.flow_token: 

201 # fresh signup 

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

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

204 # TODO: unique across both tables 

205 existing_user = session.execute( 

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

207 ).scalar_one_or_none() 

208 if existing_user: 

209 if not existing_user.is_visible: 

210 context.abort_with_error_code( 

211 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used" 

212 ) 

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

214 existing_flow = session.execute( 

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

216 ).scalar_one_or_none() 

217 if existing_flow: 

218 send_signup_email(session, existing_flow) 

219 session.commit() 

220 context.abort_with_error_code( 

221 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup" 

222 ) 

223 

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

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

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

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

228 

229 flow_token = cookiesafe_secure_token() 

230 

231 invite_id = None 

232 if request.basic.invite_code: 

233 invite_id = session.execute( 

234 select(InviteCode.id).where( 

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

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

237 ) 

238 ).scalar_one_or_none() 

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

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

241 

242 flow = SignupFlow( 

243 flow_token=flow_token, 

244 name=request.basic.name, 

245 email=request.basic.email, 

246 invite_code_id=invite_id, 

247 ) 

248 session.add(flow) 

249 session.flush() 

250 signup_initiations_counter.inc() 

251 log_event(context, session, "account.signup_initiated", {"has_invite_code": invite_id is not None}) 

252 else: 

253 # not fresh signup 

254 flow = session.execute( 

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

256 ).scalar_one_or_none() 

257 if not flow: 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.NOT_FOUND, "invalid_token") 

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

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

261 

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

263 if request.HasField("account"): 

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

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

266 

267 # check username validity 

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

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

270 

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

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

273 

274 abort_on_invalid_password(request.account.password, context) 

275 hashed_password = hash_password(request.account.password) 

276 

277 birthdate = parse_date(request.account.birthdate) 

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

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

280 

281 if not request.account.hosting_status: 

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

283 

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

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

286 

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

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

289 

290 flow.username = request.account.username 

291 flow.hashed_password = hashed_password 

292 flow.birthdate = birthdate 

293 flow.gender = request.account.gender 

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

295 flow.city = request.account.city 

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

297 flow.geom_radius = request.account.radius 

298 flow.accepted_tos = TOS_VERSION 

299 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

300 session.flush() 

301 

302 if request.HasField("feedback"): 

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

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

305 form = request.feedback 

306 

307 flow.filled_feedback = True 

308 flow.ideas = form.ideas 

309 flow.features = form.features 

310 flow.experience = form.experience 

311 flow.contribute = contributeoption2sql[form.contribute] 

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

313 flow.expertise = form.expertise 

314 session.flush() 

315 

316 if request.HasField("motivations"): 

317 if flow.filled_motivations: 

318 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_motivations_filled") 

319 

320 flow.filled_motivations = True 

321 flow.heard_about_couchers = request.motivations.heard_about_couchers or None 

322 flow.signup_motivations = list(request.motivations.motivations) 

323 session.flush() 

324 

325 if request.HasField("accept_community_guidelines"): 

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

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

328 flow.accepted_community_guidelines = GUIDELINES_VERSION 

329 session.flush() 

330 

331 # send verification email if needed 

332 if not flow.email_sent or request.resend_verification_email: 

333 send_signup_email(session, flow) 

334 

335 session.flush() 

336 

337 # finish the signup if done 

338 if flow.is_completed: 

339 user = User( 

340 name=flow.name, 

341 email=flow.email, 

342 username=not_none(flow.username), 

343 hashed_password=not_none(flow.hashed_password), 

344 birthdate=not_none(flow.birthdate), 

345 gender=not_none(flow.gender), 

346 hosting_status=not_none(flow.hosting_status), 

347 city=not_none(flow.city), 

348 geom=is_geom(flow.geom), 

349 geom_radius=not_none(flow.geom_radius), 

350 accepted_tos=not_none(flow.accepted_tos), 

351 last_onboarding_email_sent=func.now(), 

352 invite_code_id=flow.invite_code_id, 

353 heard_about_couchers=flow.heard_about_couchers, 

354 signup_motivations=flow.signup_motivations if flow.filled_motivations else None, 

355 ) 

356 

357 user.accepted_community_guidelines = flow.accepted_community_guidelines 

358 user.onboarding_emails_sent = 1 

359 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter) 

360 

361 session.add(user) 

362 session.flush() 

363 

364 # Create a profile gallery for the user 

365 profile_gallery = PhotoGallery(owner_user_id=user.id) 

366 session.add(profile_gallery) 

367 session.flush() 

368 user.profile_gallery_id = profile_gallery.id 

369 

370 if flow.filled_feedback: 

371 form_ = ContributorForm( 

372 user_id=user.id, 

373 ideas=flow.ideas or None, 

374 features=flow.features or None, 

375 experience=flow.experience or None, 

376 contribute=flow.contribute or None, 

377 contribute_ways=not_none(flow.contribute_ways), 

378 expertise=flow.expertise or None, 

379 ) 

380 

381 session.add(form_) 

382 

383 user.filled_contributor_form = form_.is_filled 

384 

385 maybe_send_contributor_form_email(session, form_) 

386 

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

388 

389 session.delete(flow) 

390 session.commit() 

391 

392 enforce_community_memberships_for_user(session, user) 

393 

394 # sends onboarding email 

395 notify( 

396 session, 

397 user_id=user.id, 

398 topic_action=NotificationTopicAction.onboarding__reminder, 

399 key="1", 

400 ) 

401 

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

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

404 log_event( 

405 context, 

406 session, 

407 "account.signup_completed", 

408 { 

409 "gender": flow.gender, 

410 "signup_duration_s": signup_duration_s, 

411 "hosting_status": str(flow.hosting_status), 

412 "city": flow.city, 

413 "has_invite_code": flow.invite_code_id is not None, 

414 "filled_contributor_form": user.filled_contributor_form, 

415 }, 

416 _override_user_id=user.id, 

417 ) 

418 

419 create_session(context, session, user, False) 

420 return auth_pb2.SignupFlowRes( 

421 auth_res=_auth_res(user), 

422 ) 

423 else: 

424 return auth_pb2.SignupFlowRes( 

425 flow_token=flow.flow_token, 

426 need_account=not flow.account_is_filled, 

427 need_feedback=False, 

428 need_verify_email=not flow.email_verified, 

429 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

430 need_motivations=not flow.filled_motivations, 

431 ) 

432 

433 def UsernameValid( 

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

435 ) -> auth_pb2.UsernameValidRes: 

436 """ 

437 Runs a username availability and validity check. 

438 """ 

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

440 

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

442 """ 

443 Authenticates a classic password-based login request. 

444 

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

446 """ 

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

448 user = session.execute( 

449 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None)) 

450 ).scalar_one_or_none() 

451 if user: 

452 logger.debug("Found user") 

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

454 logger.debug("Right password") 

455 # correct password 

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

457 log_event( 

458 context, 

459 session, 

460 "account.login", 

461 {"gender": user.gender, "remember_device": request.remember_device}, 

462 _override_user_id=user.id, 

463 ) 

464 return _auth_res(user) 

465 else: 

466 logger.debug("Wrong password") 

467 # wrong password 

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

469 else: # user not found 

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

471 signup_flow = session.execute( 

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

473 ).scalar_one_or_none() 

474 if signup_flow: 

475 send_signup_email(session, signup_flow) 

476 session.commit() 

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

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

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

480 

481 def GetAuthState( 

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

483 ) -> auth_pb2.GetAuthStateRes: 

484 if not context.is_logged_in(): 

485 return auth_pb2.GetAuthStateRes(logged_in=False) 

486 else: 

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

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

489 

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

491 """ 

492 Removes an active cookie session. 

493 """ 

494 token = parse_session_cookie(context.headers) 

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

496 

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

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

499 delete_session(session, token) 

500 

501 log_event(context, session, "account.logout", {}) 

502 

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

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

505 

506 return empty_pb2.Empty() 

507 

508 def ResetPassword( 

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

510 ) -> empty_pb2.Empty: 

511 """ 

512 If the user does not exist, do nothing. 

513 

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

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

516 

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

518 """ 

519 user = session.execute( 

520 select(User).where(username_or_email(request.user)).where(User.deleted_at.is_(None)) 

521 ).scalar_one_or_none() 

522 if user: 

523 password_reset_token = PasswordResetToken( 

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

525 ) 

526 session.add(password_reset_token) 

527 session.flush() 

528 

529 notify( 

530 session, 

531 user_id=user.id, 

532 topic_action=NotificationTopicAction.password_reset__start, 

533 key="", 

534 data=notification_data_pb2.PasswordResetStart( 

535 password_reset_token=password_reset_token.token, 

536 ), 

537 ) 

538 

539 password_reset_initiations_counter.inc() 

540 log_event( 

541 context, 

542 session, 

543 "account.password_reset_initiated", 

544 {}, 

545 _override_user_id=user.id, 

546 ) 

547 else: # user not found 

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

549 

550 return empty_pb2.Empty() 

551 

552 def CompletePasswordResetV2( 

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

554 ) -> auth_pb2.AuthRes: 

555 """ 

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

557 """ 

558 res = session.execute( 

559 select(PasswordResetToken, User) 

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

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

562 .where(PasswordResetToken.is_valid) 

563 ).one_or_none() 

564 if res: 

565 password_reset_token, user = res 

566 abort_on_invalid_password(request.new_password, context) 

567 user.hashed_password = hash_password(request.new_password) 

568 session.delete(password_reset_token) 

569 

570 session.flush() 

571 

572 notify( 

573 session, 

574 user_id=user.id, 

575 topic_action=NotificationTopicAction.password_reset__complete, 

576 key="", 

577 ) 

578 

579 create_session(context, session, user, False) 

580 password_reset_completions_counter.inc() 

581 log_event( 

582 context, 

583 session, 

584 "account.password_reset_completed", 

585 {}, 

586 _override_user_id=user.id, 

587 ) 

588 return _auth_res(user) 

589 else: 

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

591 

592 def ConfirmChangeEmailV2( 

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

594 ) -> empty_pb2.Empty: 

595 user = session.execute( 

596 select(User) 

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

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

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

600 ).scalar_one_or_none() 

601 

602 if not user: 

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

604 

605 user.email = not_none(user.new_email) 

606 user.new_email = None 

607 user.new_email_token = None 

608 user.new_email_token_created = None 

609 user.new_email_token_expiry = None 

610 

611 notify( 

612 session, 

613 user_id=user.id, 

614 topic_action=NotificationTopicAction.email_address__verify, 

615 key="", 

616 ) 

617 

618 log_event(context, session, "account.email_confirmed", {}, _override_user_id=user.id) 

619 

620 return empty_pb2.Empty() 

621 

622 def ConfirmDeleteAccount( 

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

624 ) -> empty_pb2.Empty: 

625 """ 

626 Confirm account deletion using account delete token 

627 """ 

628 res = session.execute( 

629 select(User, AccountDeletionToken) 

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

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

632 .where(AccountDeletionToken.is_valid) 

633 ).one_or_none() 

634 

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

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

637 

638 user, account_deletion_token = res 

639 

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

641 

642 user.deleted_at = now() 

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

644 user.undelete_token = urlsafe_secure_token() 

645 

646 session.flush() 

647 

648 notify( 

649 session, 

650 user_id=user.id, 

651 topic_action=NotificationTopicAction.account_deletion__complete, 

652 key="", 

653 data=notification_data_pb2.AccountDeletionComplete( 

654 undelete_token=user.undelete_token, 

655 undelete_days=UNDELETE_DAYS, 

656 ), 

657 ) 

658 

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

660 log_event( 

661 context, 

662 session, 

663 "account.deletion_completed", 

664 {"gender": user.gender}, 

665 _override_user_id=user.id, 

666 ) 

667 

668 return empty_pb2.Empty() 

669 

670 def RecoverAccount( 

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

672 ) -> empty_pb2.Empty: 

673 """ 

674 Recovers a recently deleted account 

675 """ 

676 user = session.execute( 

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

678 ).scalar_one_or_none() 

679 

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

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

682 

683 user.deleted_at = None 

684 user.undelete_token = None 

685 user.undelete_until = None 

686 

687 notify( 

688 session, 

689 user_id=user.id, 

690 topic_action=NotificationTopicAction.account_deletion__recovered, 

691 key="", 

692 ) 

693 

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

695 log_event( 

696 context, 

697 session, 

698 "account.recovered", 

699 {"gender": user.gender}, 

700 _override_user_id=user.id, 

701 ) 

702 

703 return empty_pb2.Empty() 

704 

705 def Unsubscribe( 

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

707 ) -> auth_pb2.UnsubscribeRes: 

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

709 

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

711 if not config["RECAPTHCA_ENABLED"]: 

712 return auth_pb2.AntiBotRes() 

713 

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

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

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

717 

718 resp = requests.post( 

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

720 json={ 

721 "event": { 

722 "token": request.token, 

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

724 "userAgent": user_agent, 

725 "userIpAddress": ip_address, 

726 "expectedAction": request.action, 

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

728 } 

729 }, 

730 ) 

731 

732 resp.raise_for_status() 

733 

734 log = AntiBotLog( 

735 token=request.token, 

736 user_agent=user_agent, 

737 ip_address=ip_address, 

738 action=request.action, 

739 user_id=user_id, 

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

741 provider_data=resp.json(), 

742 ) 

743 

744 session.add(log) 

745 session.flush() 

746 

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

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

749 

750 if context.is_logged_in(): 

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

752 user.last_antibot = now() 

753 

754 return auth_pb2.AntiBotRes() 

755 

756 def AntiBotPolicy( 

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

758 ) -> auth_pb2.AntiBotPolicyRes: 

759 if config["RECAPTHCA_ENABLED"]: 

760 if context.is_logged_in(): 

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

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

763 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

764 

765 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

766 

767 def GetInviteCodeInfo( 

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

769 ) -> auth_pb2.GetInviteCodeInfoRes: 

770 invite = session.execute( 

771 select(InviteCode).where( 

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

773 ) 

774 ).scalar_one_or_none() 

775 

776 if not invite: 

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

778 

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

780 

781 avatar_upload = get_avatar_upload(session, user) 

782 

783 return auth_pb2.GetInviteCodeInfoRes( 

784 name=user.name, 

785 username=user.username, 

786 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None, 

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

788 )