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

359 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1import logging 

2from datetime import datetime, timedelta 

3 

4import grpc 

5from google.protobuf import empty_pb2 

6from sqlalchemy import exists, select 

7from sqlalchemy.orm import Session 

8from sqlalchemy.sql import delete, func, or_ 

9 

10from couchers import urls 

11from couchers.abuse import maybe_log_nonvisible_user_access 

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

13from couchers.context import CouchersContext 

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

15from couchers.event_log import log_event 

16from couchers.helpers.hosting_meetup_status import record_hosting_meetup_status 

17from couchers.metrics import ( 

18 account_deletion_completions_counter, 

19 account_recoveries_counter, 

20 antibot_score_histogram, 

21 antibots_assessed_counter, 

22 logins_counter, 

23 password_reset_completions_counter, 

24 password_reset_initiations_counter, 

25 signup_account_filled_counter, 

26 signup_completions_counter, 

27 signup_email_changes_counter, 

28 signup_email_verified_counter, 

29 signup_guidelines_accepted_counter, 

30 signup_initiations_counter, 

31 signup_motivations_filled_counter, 

32 signup_time_histogram, 

33) 

34from couchers.models import ( 

35 AccountDeletionToken, 

36 AntiBotLog, 

37 ContributorForm, 

38 HostingMeetupStatusSource, 

39 InviteCode, 

40 ModerationObjectType, 

41 NonvisibleUserAccessType, 

42 PasswordResetToken, 

43 PhotoGallery, 

44 SignupFlow, 

45 User, 

46 UserEmailHistory, 

47 UserSession, 

48) 

49from couchers.models.notifications import NotificationTopicAction 

50from couchers.models.uploads import get_avatar_upload 

51from couchers.moderation.utils import create_moderation 

52from couchers.notifications.notify import notify 

53from couchers.notifications.quick_links import decode_quick_link 

54from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

55from couchers.servicers.account import abort_if_email_reused, abort_on_invalid_password, contributeoption2sql 

56from couchers.servicers.api import hostingstatus2sql 

57from couchers.servicers.auth_unsubscribe import handle_unsubscribe 

58from couchers.sql import username_or_email 

59from couchers.tasks import ( 

60 enforce_community_memberships_for_user, 

61 maybe_send_contributor_form_email, 

62 send_signup_email, 

63) 

64from couchers.utils import ( 

65 create_coordinate, 

66 create_session_cookies, 

67 is_geom, 

68 is_valid_email, 

69 is_valid_name, 

70 is_valid_username, 

71 minimum_allowed_birthdate, 

72 not_none, 

73 now, 

74 parse_date, 

75 parse_session_cookie, 

76) 

77 

78logger = logging.getLogger(__name__) 

79 

80 

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

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

83 

84 

85def create_session( 

86 context: CouchersContext, 

87 session: Session, 

88 user: User, 

89 long_lived: bool, 

90 is_api_key: bool = False, 

91 duration: timedelta | None = None, 

92 set_cookie: bool = True, 

93) -> tuple[str, datetime]: 

94 """ 

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

96 

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

98 work here due to the active User object. 

99 

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

101 

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

103 

104 ```py3 

105 token, expiry = create_session(...) 

106 ``` 

107 """ 

108 maybe_log_nonvisible_user_access( 

109 context, 

110 user, 

111 access_type=NonvisibleUserAccessType.login_attempt, 

112 actor_user_id=user.id, 

113 ) 

114 

115 if user.banned_at is not None: 

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

117 

118 # just double-check 

119 assert user.deleted_at is None 

120 

121 token = cookiesafe_secure_token() 

122 

123 user_session = UserSession( 

124 token=token, 

125 user_id=user.id, 

126 long_lived=long_lived, 

127 ip_address=context.get_header("x-couchers-real-ip"), 

128 user_agent=context.get_header("user-agent"), 

129 is_api_key=is_api_key, 

130 ) 

131 if duration: 

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

133 

134 session.add(user_session) 

135 

136 # read off the user before the commit expires it: every attribute touched after the commit re-selects the 

137 # whole row, and this is on the path of every single login 

138 user_id = user.id 

139 user_gender = user.gender 

140 

141 session.commit() 

142 

143 logger.debug("Handing out %s to user %s", token, user_id) 

144 

145 if set_cookie: 

146 context.set_cookies(create_session_cookies(token, user_id, user_session.expiry)) 

147 

148 logins_counter.labels(user_gender).inc() 

149 

150 return token, user_session.expiry 

151 

152 

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

154 """ 

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

156 

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

158 """ 

159 user_session = session.execute( 

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

161 ).scalar_one_or_none() 

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

163 user_session.deleted = func.now() 

164 session.commit() 

165 return True 

166 else: 

167 return False 

168 

169 

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

171 """ 

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

173 """ 

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

175 if not is_valid_username(username): 

176 return False 

177 for phrase in BANNED_USERNAME_PHRASES: 

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

179 return False 

180 # check for existing user with that username 

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

182 # check for started signup with that username 

183 signup_exists = ( 

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

185 ) 

186 # return False if user exists, True otherwise 

187 return not user_exists and not signup_exists 

188 

189 

190class Auth(auth_pb2_grpc.AuthServicer): 

191 """ 

192 The Auth servicer. 

193 

194 This class services the Auth service/API. 

195 """ 

196 

197 def SignupFlow( 

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

199 ) -> auth_pb2.SignupFlowRes: 

200 # this is a bit ugly, probably one RPC for this mega-mutation of signup flows is not ideal 

201 has_signup_step = ( 

202 request.HasField("basic") 

203 or request.HasField("account") 

204 or request.HasField("feedback") 

205 or request.HasField("motivations") 

206 or request.HasField("accept_community_guidelines") 

207 ) 

208 has_recovery_step = request.HasField("change_email") or request.resend_verification_email 

209 

210 # if we have the token then we ignore all the other stuff, so better just error to the client 

211 if request.email_token and (has_signup_step or has_recovery_step): 

212 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_invalid_request") 

213 

214 # just for safety 

215 if has_signup_step and has_recovery_step: 

216 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_invalid_request") 

217 

218 if request.email_token: 

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

220 flow = session.execute( 

221 select(SignupFlow) 

222 .where(SignupFlow.email_verified == False) 

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

224 .where(SignupFlow.token_is_valid) 

225 ).scalar_one_or_none() 

226 if flow: 

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

228 flow.email_verified = True 

229 flow.email_token = None 

230 flow.email_token_expiry = None 

231 

232 session.flush() 

233 signup_email_verified_counter.inc() 

234 log_event(context, session, "account.signup_email_verified", {"signup_flow_id": flow.id}) 

235 else: 

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

237 flow = session.execute( 

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

239 ).scalar_one_or_none() 

240 if not flow: 240 ↛ 417line 240 didn't jump to line 417 because the condition on line 240 was always true

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

242 else: 

243 

244 def _check_email_is_available_and_valid(new_email: str) -> None: 

245 # TODO: unique across both tables 

246 is_banned, is_taken = session.execute( 

247 select( 

248 exists().where(User.email == new_email).where(User.is_banned), 

249 exists().where(User.email == new_email).where(User.reserves_email), 

250 ) 

251 ).one() 

252 if is_banned: 

253 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used") 

254 if is_taken: 

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

256 existing_flow = session.execute( 

257 select(SignupFlow).where(SignupFlow.email == new_email) 

258 ).scalar_one_or_none() 

259 if existing_flow: 

260 send_signup_email(context, session, existing_flow) 

261 session.commit() 

262 context.abort_with_error_code( 

263 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup" 

264 ) 

265 

266 if not is_valid_email(new_email): 

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

268 

269 if not request.flow_token: 

270 # fresh signup 

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

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

273 _check_email_is_available_and_valid(request.basic.email) 

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

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

276 

277 flow_token = cookiesafe_secure_token() 

278 

279 invite_id = None 

280 if request.basic.invite_code: 

281 invite_id = session.execute( 

282 select(InviteCode.id).where( 

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

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

285 ) 

286 ).scalar_one_or_none() 

287 if not invite_id: 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, "invalid_invite_code") 

289 

290 flow = SignupFlow( 

291 flow_token=flow_token, 

292 name=request.basic.name, 

293 email=request.basic.email, 

294 invite_code_id=invite_id, 

295 ) 

296 session.add(flow) 

297 session.flush() 

298 signup_initiations_counter.inc() 

299 log_event( 

300 context, 

301 session, 

302 "account.signup_initiated", 

303 {"has_invite_code": invite_id is not None, "signup_flow_id": flow.id}, 

304 ) 

305 else: 

306 # not fresh signup 

307 flow = session.execute( 

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

309 ).scalar_one_or_none() 

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

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

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

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

314 

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

316 

317 if request.HasField("account"): 

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

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

320 

321 # check username validity 

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

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

324 

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

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

327 

328 abort_on_invalid_password(request.account.password, context) 

329 hashed_password = hash_password(request.account.password) 

330 

331 birthdate = parse_date(request.account.birthdate) 

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

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

334 

335 if not request.account.hosting_status: 

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

337 

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

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

340 

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

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

343 

344 flow.username = request.account.username 

345 flow.hashed_password = hashed_password 

346 flow.birthdate = birthdate 

347 flow.gender = request.account.gender 

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

349 flow.city = request.account.city 

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

351 flow.geom_radius = request.account.radius 

352 flow.accepted_tos = TOS_VERSION 

353 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

354 session.flush() 

355 signup_account_filled_counter.inc() 

356 

357 if request.HasField("feedback"): 

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

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

360 form = request.feedback 

361 

362 flow.filled_feedback = True 

363 flow.ideas = form.ideas 

364 flow.features = form.features 

365 flow.experience = form.experience 

366 flow.contribute = contributeoption2sql[form.contribute] 

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

368 flow.expertise = form.expertise 

369 session.flush() 

370 

371 if request.HasField("motivations"): 

372 if flow.filled_motivations: 

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

374 

375 flow.filled_motivations = True 

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

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

378 session.flush() 

379 signup_motivations_filled_counter.inc() 

380 

381 if request.HasField("accept_community_guidelines"): 

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

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

384 if flow.accepted_community_guidelines < GUIDELINES_VERSION: 

385 signup_guidelines_accepted_counter.inc() 

386 flow.accepted_community_guidelines = GUIDELINES_VERSION 

387 session.flush() 

388 

389 if request.HasField("change_email"): 

390 # can't change after verifying 

391 if flow.email_verified: 

392 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_verified") 

393 # can't resend verification at the same time 

394 if request.resend_verification_email: 

395 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "signup_flow_invalid_request") 

396 

397 # this will error if the email is not different from the current one 

398 _check_email_is_available_and_valid(request.change_email.new_email) 

399 

400 flow.email = request.change_email.new_email 

401 flow.email_verified = False 

402 flow.email_sent = False 

403 flow.email_token = None 

404 flow.email_token_expiry = None 

405 flow.email_changed_count += 1 

406 signup_email_changes_counter.inc() 

407 

408 session.flush() 

409 

410 # send verification email if needed 

411 if not flow.email_sent or request.resend_verification_email: 

412 send_signup_email(context, session, flow) 

413 

414 session.flush() 

415 

416 # finish the signup if done 

417 if flow.is_completed: 

418 user: User | None = None 

419 

420 def create_user(moderation_state_id: int) -> int: 

421 nonlocal user 

422 user = User( 

423 name=flow.name, 

424 email=flow.email, 

425 username=not_none(flow.username), 

426 hashed_password=not_none(flow.hashed_password), 

427 birthdate=not_none(flow.birthdate), 

428 gender=not_none(flow.gender), 

429 hosting_status=not_none(flow.hosting_status), 

430 city=not_none(flow.city), 

431 geom=is_geom(flow.geom), 

432 geom_radius=not_none(flow.geom_radius), 

433 accepted_tos=not_none(flow.accepted_tos), 

434 last_onboarding_email_sent=func.now(), 

435 invite_code_id=flow.invite_code_id, 

436 heard_about_couchers=flow.heard_about_couchers, 

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

438 moderation_state_id=moderation_state_id, 

439 ) 

440 

441 user.accepted_community_guidelines = flow.accepted_community_guidelines 

442 user.onboarding_emails_sent = 1 

443 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter) 

444 

445 session.add(user) 

446 session.flush() 

447 return user.id 

448 

449 create_moderation( 

450 session=session, 

451 object_type=ModerationObjectType.user, 

452 object_id=create_user, 

453 ) 

454 assert user is not None 

455 

456 record_hosting_meetup_status(session, user, HostingMeetupStatusSource.signup) 

457 session.add(UserEmailHistory(user_id=user.id, email=user.email)) 

458 

459 # Create a profile gallery for the user 

460 profile_gallery = PhotoGallery(owner_user_id=user.id) 

461 session.add(profile_gallery) 

462 session.flush() 

463 user.profile_gallery_id = profile_gallery.id 

464 

465 if flow.filled_feedback: 

466 form_ = ContributorForm( 

467 user_id=user.id, 

468 ideas=flow.ideas or None, 

469 features=flow.features or None, 

470 experience=flow.experience or None, 

471 contribute=flow.contribute or None, 

472 contribute_ways=not_none(flow.contribute_ways), 

473 expertise=flow.expertise or None, 

474 ) 

475 

476 session.add(form_) 

477 

478 user.filled_contributor_form = form_.is_filled 

479 

480 maybe_send_contributor_form_email(session, form_) 

481 

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

483 

484 session.delete(flow) 

485 session.commit() 

486 

487 enforce_community_memberships_for_user(session, user) 

488 

489 # sends onboarding email 

490 notify( 

491 session, 

492 user_id=user.id, 

493 topic_action=NotificationTopicAction.onboarding__reminder, 

494 key="1", 

495 ) 

496 

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

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

499 log_event( 

500 context, 

501 session, 

502 "account.signup_completed", 

503 { 

504 "gender": flow.gender, 

505 "signup_duration_s": signup_duration_s, 

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

507 "city": flow.city, 

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

509 "filled_contributor_form": user.filled_contributor_form, 

510 "signup_flow_id": flow.id, 

511 }, 

512 _override_user_id=user.id, 

513 ) 

514 

515 create_session(context, session, user, False) 

516 return auth_pb2.SignupFlowRes( 

517 auth_res=_auth_res(user), 

518 ) 

519 else: 

520 return auth_pb2.SignupFlowRes( 

521 flow_token=flow.flow_token, 

522 email=flow.email, 

523 need_account=not flow.account_is_filled, 

524 need_feedback=False, 

525 need_verify_email=not flow.email_verified, 

526 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

527 need_motivations=not flow.filled_motivations, 

528 ) 

529 

530 def UsernameValid( 

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

532 ) -> auth_pb2.UsernameValidRes: 

533 """ 

534 Runs a username availability and validity check. 

535 """ 

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

537 

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

539 """ 

540 Authenticates a classic password-based login request. 

541 

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

543 """ 

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

545 user = session.execute( 

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

547 ).scalar_one_or_none() 

548 if user: 

549 logger.debug("Found user") 

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

551 logger.debug("Right password") 

552 # correct password 

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

554 log_event( 

555 context, 

556 session, 

557 "account.login", 

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

559 _override_user_id=user.id, 

560 ) 

561 return _auth_res(user) 

562 else: 

563 logger.debug("Wrong password") 

564 # wrong password 

565 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "invalid_password_login") 

566 else: # user not found 

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

568 signup_flow = session.execute( 

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

570 ).scalar_one_or_none() 

571 if signup_flow: 

572 send_signup_email(context, session, signup_flow) 

573 session.commit() 

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

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

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

577 

578 def GetAuthState( 

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

580 ) -> auth_pb2.GetAuthStateRes: 

581 if not context.is_logged_in(): 

582 return auth_pb2.GetAuthStateRes(logged_in=False) 

583 else: 

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

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

586 

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

588 """ 

589 Removes an active cookie session. 

590 """ 

591 token = parse_session_cookie(context.headers) 

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

593 

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

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

596 delete_session(session, token) 

597 

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

599 

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

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

602 

603 return empty_pb2.Empty() 

604 

605 def ResetPassword( 

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

607 ) -> empty_pb2.Empty: 

608 """ 

609 If the user does not exist, do nothing. 

610 

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

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

613 

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

615 """ 

616 user = session.execute( 

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

618 ).scalar_one_or_none() 

619 if user: 

620 password_reset_token = PasswordResetToken( 

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

622 ) 

623 session.add(password_reset_token) 

624 session.flush() 

625 

626 notify( 

627 session, 

628 user_id=user.id, 

629 topic_action=NotificationTopicAction.password_reset__start, 

630 key="", 

631 data=notification_data_pb2.PasswordResetStart( 

632 password_reset_token=password_reset_token.token, 

633 ), 

634 ) 

635 

636 password_reset_initiations_counter.inc() 

637 log_event( 

638 context, 

639 session, 

640 "account.password_reset_initiated", 

641 {}, 

642 _override_user_id=user.id, 

643 ) 

644 else: # user not found 

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

646 

647 return empty_pb2.Empty() 

648 

649 def CompletePasswordResetV2( 

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

651 ) -> auth_pb2.AuthRes: 

652 """ 

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

654 """ 

655 res = session.execute( 

656 select(PasswordResetToken, User) 

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

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

659 .where(PasswordResetToken.is_valid) 

660 ).one_or_none() 

661 if res: 

662 password_reset_token, user = res 

663 abort_on_invalid_password(request.new_password, context) 

664 user.hashed_password = hash_password(request.new_password) 

665 session.delete(password_reset_token) 

666 

667 session.flush() 

668 

669 notify( 

670 session, 

671 user_id=user.id, 

672 topic_action=NotificationTopicAction.password_reset__complete, 

673 key="", 

674 ) 

675 

676 create_session(context, session, user, False) 

677 password_reset_completions_counter.inc() 

678 log_event( 

679 context, 

680 session, 

681 "account.password_reset_completed", 

682 {}, 

683 _override_user_id=user.id, 

684 ) 

685 return _auth_res(user) 

686 else: 

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

688 

689 def ConfirmChangeEmailV2( 

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

691 ) -> empty_pb2.Empty: 

692 user = session.execute( 

693 select(User) 

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

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

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

697 ).scalar_one_or_none() 

698 

699 if not user: 

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

701 

702 user.email = not_none(user.new_email) 

703 user.new_email = None 

704 user.new_email_token = None 

705 user.new_email_token_created = None 

706 user.new_email_token_expiry = None 

707 session.add(UserEmailHistory(user_id=user.id, email=user.email)) 

708 

709 notify( 

710 session, 

711 user_id=user.id, 

712 topic_action=NotificationTopicAction.email_address__verify, 

713 key="", 

714 ) 

715 

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

717 

718 return empty_pb2.Empty() 

719 

720 def ConfirmDeleteAccount( 

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

722 ) -> empty_pb2.Empty: 

723 """ 

724 Confirm account deletion using account delete token 

725 """ 

726 res = session.execute( 

727 select(User, AccountDeletionToken) 

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

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

730 .where(AccountDeletionToken.is_valid) 

731 ).one_or_none() 

732 

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

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

735 

736 user, account_deletion_token = res 

737 

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

739 

740 user.deleted_at = now() 

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

742 user.undelete_token = urlsafe_secure_token() 

743 

744 session.flush() 

745 

746 notify( 

747 session, 

748 user_id=user.id, 

749 topic_action=NotificationTopicAction.account_deletion__complete, 

750 key="", 

751 data=notification_data_pb2.AccountDeletionComplete( 

752 undelete_token=user.undelete_token, 

753 undelete_days=UNDELETE_DAYS, 

754 ), 

755 ) 

756 

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

758 log_event( 

759 context, 

760 session, 

761 "account.deletion_completed", 

762 {"gender": user.gender}, 

763 _override_user_id=user.id, 

764 ) 

765 

766 return empty_pb2.Empty() 

767 

768 def RecoverAccount( 

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

770 ) -> empty_pb2.Empty: 

771 """ 

772 Recovers a recently deleted account 

773 """ 

774 user = session.execute( 

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

776 ).scalar_one_or_none() 

777 

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

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

780 

781 abort_if_email_reused(context, session, user) 

782 

783 user.deleted_at = None 

784 user.undelete_token = None 

785 user.undelete_until = None 

786 

787 notify( 

788 session, 

789 user_id=user.id, 

790 topic_action=NotificationTopicAction.account_deletion__recovered, 

791 key="", 

792 ) 

793 

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

795 log_event( 

796 context, 

797 session, 

798 "account.recovered", 

799 {"gender": user.gender}, 

800 _override_user_id=user.id, 

801 ) 

802 

803 return empty_pb2.Empty() 

804 

805 def Unsubscribe( 

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

807 ) -> auth_pb2.UnsubscribeRes: 

808 payload = decode_quick_link(request.payload, request.sig, context) 

809 return auth_pb2.UnsubscribeRes(response=handle_unsubscribe(payload, context, session)) 

810 

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

812 if not context.get_boolean_value("antibot_enabled", default=False): 

813 return auth_pb2.AntiBotRes() 

814 

815 ip_address = context.get_header("x-couchers-real-ip") 

816 user_agent = context.get_header("user-agent") 

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

818 

819 log = AntiBotLog( 

820 token=request.token, 

821 user_agent=user_agent, 

822 ip_address=ip_address, 

823 action=request.action, 

824 user_id=user_id, 

825 # placeholders: there is currently no provider assessing requests 

826 score=0.0, 

827 provider_data={}, 

828 ) 

829 

830 session.add(log) 

831 session.flush() 

832 

833 antibots_assessed_counter.labels(log.action).inc() 

834 antibot_score_histogram.labels(log.action).observe(log.score) 

835 

836 if context.is_logged_in(): 

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

838 user.last_antibot = now() 

839 

840 return auth_pb2.AntiBotRes() 

841 

842 def AntiBotPolicy( 

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

844 ) -> auth_pb2.AntiBotPolicyRes: 

845 if context.get_boolean_value("antibot_enabled", default=False): 

846 if context.is_logged_in(): 

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

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

849 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

850 

851 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

852 

853 def GetInviteCodeInfo( 

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

855 ) -> auth_pb2.GetInviteCodeInfoRes: 

856 invite = session.execute( 

857 select(InviteCode).where( 

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

859 ) 

860 ).scalar_one_or_none() 

861 

862 if not invite: 

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

864 

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

866 

867 avatar_upload = get_avatar_upload(session, user) 

868 

869 return auth_pb2.GetInviteCodeInfoRes( 

870 name=user.name, 

871 username=user.username, 

872 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None, 

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

874 )