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

355 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-19 15:47 +0000

1import logging 

2from datetime import datetime, timedelta 

3 

4import grpc 

5from google.protobuf import empty_pb2 

6from sqlalchemy import 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 UserSession, 

47) 

48from couchers.models.notifications import NotificationTopicAction 

49from couchers.models.uploads import get_avatar_upload 

50from couchers.moderation.utils import create_moderation 

51from couchers.notifications.notify import notify 

52from couchers.notifications.quick_links import decode_quick_link 

53from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

54from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

55from couchers.servicers.api import hostingstatus2sql 

56from couchers.servicers.auth_unsubscribe import handle_unsubscribe 

57from couchers.sql import username_or_email 

58from couchers.tasks import ( 

59 enforce_community_memberships_for_user, 

60 maybe_send_contributor_form_email, 

61 send_signup_email, 

62) 

63from couchers.utils import ( 

64 create_coordinate, 

65 create_session_cookies, 

66 is_geom, 

67 is_valid_email, 

68 is_valid_name, 

69 is_valid_username, 

70 minimum_allowed_birthdate, 

71 not_none, 

72 now, 

73 parse_date, 

74 parse_session_cookie, 

75) 

76 

77logger = logging.getLogger(__name__) 

78 

79 

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

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

82 

83 

84def create_session( 

85 context: CouchersContext, 

86 session: Session, 

87 user: User, 

88 long_lived: bool, 

89 is_api_key: bool = False, 

90 duration: timedelta | None = None, 

91 set_cookie: bool = True, 

92) -> tuple[str, datetime]: 

93 """ 

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

95 

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

97 work here due to the active User object. 

98 

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

100 

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

102 

103 ```py3 

104 token, expiry = create_session(...) 

105 ``` 

106 """ 

107 maybe_log_nonvisible_user_access( 

108 context, 

109 user, 

110 access_type=NonvisibleUserAccessType.login_attempt, 

111 actor_user_id=user.id, 

112 ) 

113 

114 if user.banned_at is not None: 

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

116 

117 # just double-check 

118 assert user.deleted_at is None 

119 

120 token = cookiesafe_secure_token() 

121 

122 user_session = UserSession( 

123 token=token, 

124 user_id=user.id, 

125 long_lived=long_lived, 

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

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

128 is_api_key=is_api_key, 

129 ) 

130 if duration: 

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

132 

133 session.add(user_session) 

134 

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

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

137 user_id = user.id 

138 user_gender = user.gender 

139 

140 session.commit() 

141 

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

143 

144 if set_cookie: 

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

146 

147 logins_counter.labels(user_gender).inc() 

148 

149 return token, user_session.expiry 

150 

151 

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

153 """ 

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

155 

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

157 """ 

158 user_session = session.execute( 

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

160 ).scalar_one_or_none() 

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

162 user_session.deleted = func.now() 

163 session.commit() 

164 return True 

165 else: 

166 return False 

167 

168 

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

170 """ 

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

172 """ 

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

174 if not is_valid_username(username): 

175 return False 

176 for phrase in BANNED_USERNAME_PHRASES: 

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

178 return False 

179 # check for existing user with that username 

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

181 # check for started signup with that username 

182 signup_exists = ( 

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

184 ) 

185 # return False if user exists, True otherwise 

186 return not user_exists and not signup_exists 

187 

188 

189class Auth(auth_pb2_grpc.AuthServicer): 

190 """ 

191 The Auth servicer. 

192 

193 This class services the Auth service/API. 

194 """ 

195 

196 def SignupFlow( 

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

198 ) -> auth_pb2.SignupFlowRes: 

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

200 has_signup_step = ( 

201 request.HasField("basic") 

202 or request.HasField("account") 

203 or request.HasField("feedback") 

204 or request.HasField("motivations") 

205 or request.HasField("accept_community_guidelines") 

206 ) 

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

208 

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

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

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

212 

213 # just for safety 

214 if has_signup_step and has_recovery_step: 

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

216 

217 if request.email_token: 

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

219 flow = session.execute( 

220 select(SignupFlow) 

221 .where(SignupFlow.email_verified == False) 

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

223 .where(SignupFlow.token_is_valid) 

224 ).scalar_one_or_none() 

225 if flow: 

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

227 flow.email_verified = True 

228 flow.email_token = None 

229 flow.email_token_expiry = None 

230 

231 session.flush() 

232 signup_email_verified_counter.inc() 

233 else: 

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

235 flow = session.execute( 

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

237 ).scalar_one_or_none() 

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

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

240 else: 

241 

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

243 # TODO: unique across both tables 

244 existing_user = session.execute(select(User).where(User.email == new_email)).scalar_one_or_none() 

245 if existing_user: 

246 if not existing_user.is_visible: 

247 context.abort_with_error_code( 

248 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used" 

249 ) 

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

251 existing_flow = session.execute( 

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

253 ).scalar_one_or_none() 

254 if existing_flow: 

255 send_signup_email(context, session, existing_flow) 

256 session.commit() 

257 context.abort_with_error_code( 

258 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup" 

259 ) 

260 

261 if not is_valid_email(new_email): 

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

263 

264 if not request.flow_token: 

265 # fresh signup 

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

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

268 _check_email_is_available_and_valid(request.basic.email) 

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

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

271 

272 flow_token = cookiesafe_secure_token() 

273 

274 invite_id = None 

275 if request.basic.invite_code: 

276 invite_id = session.execute( 

277 select(InviteCode.id).where( 

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

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

280 ) 

281 ).scalar_one_or_none() 

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

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

284 

285 flow = SignupFlow( 

286 flow_token=flow_token, 

287 name=request.basic.name, 

288 email=request.basic.email, 

289 invite_code_id=invite_id, 

290 ) 

291 session.add(flow) 

292 session.flush() 

293 signup_initiations_counter.inc() 

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

295 else: 

296 # not fresh signup 

297 flow = session.execute( 

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

299 ).scalar_one_or_none() 

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

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

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

304 

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

306 

307 if request.HasField("account"): 

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

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

310 

311 # check username validity 

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

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

314 

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

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

317 

318 abort_on_invalid_password(request.account.password, context) 

319 hashed_password = hash_password(request.account.password) 

320 

321 birthdate = parse_date(request.account.birthdate) 

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

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

324 

325 if not request.account.hosting_status: 

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

327 

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

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

330 

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

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

333 

334 flow.username = request.account.username 

335 flow.hashed_password = hashed_password 

336 flow.birthdate = birthdate 

337 flow.gender = request.account.gender 

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

339 flow.city = request.account.city 

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

341 flow.geom_radius = request.account.radius 

342 flow.accepted_tos = TOS_VERSION 

343 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

344 session.flush() 

345 signup_account_filled_counter.inc() 

346 

347 if request.HasField("feedback"): 

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

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

350 form = request.feedback 

351 

352 flow.filled_feedback = True 

353 flow.ideas = form.ideas 

354 flow.features = form.features 

355 flow.experience = form.experience 

356 flow.contribute = contributeoption2sql[form.contribute] 

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

358 flow.expertise = form.expertise 

359 session.flush() 

360 

361 if request.HasField("motivations"): 

362 if flow.filled_motivations: 

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

364 

365 flow.filled_motivations = True 

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

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

368 session.flush() 

369 signup_motivations_filled_counter.inc() 

370 

371 if request.HasField("accept_community_guidelines"): 

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

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

374 if flow.accepted_community_guidelines < GUIDELINES_VERSION: 

375 signup_guidelines_accepted_counter.inc() 

376 flow.accepted_community_guidelines = GUIDELINES_VERSION 

377 session.flush() 

378 

379 if request.HasField("change_email"): 

380 # can't change after verifying 

381 if flow.email_verified: 

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

383 # can't resend verification at the same time 

384 if request.resend_verification_email: 

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

386 

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

388 _check_email_is_available_and_valid(request.change_email.new_email) 

389 

390 flow.email = request.change_email.new_email 

391 flow.email_verified = False 

392 flow.email_sent = False 

393 flow.email_token = None 

394 flow.email_token_expiry = None 

395 flow.email_changed_count += 1 

396 signup_email_changes_counter.inc() 

397 

398 session.flush() 

399 

400 # send verification email if needed 

401 if not flow.email_sent or request.resend_verification_email: 

402 send_signup_email(context, session, flow) 

403 

404 session.flush() 

405 

406 # finish the signup if done 

407 if flow.is_completed: 

408 user: User | None = None 

409 

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

411 nonlocal user 

412 user = User( 

413 name=flow.name, 

414 email=flow.email, 

415 username=not_none(flow.username), 

416 hashed_password=not_none(flow.hashed_password), 

417 birthdate=not_none(flow.birthdate), 

418 gender=not_none(flow.gender), 

419 hosting_status=not_none(flow.hosting_status), 

420 city=not_none(flow.city), 

421 geom=is_geom(flow.geom), 

422 geom_radius=not_none(flow.geom_radius), 

423 accepted_tos=not_none(flow.accepted_tos), 

424 last_onboarding_email_sent=func.now(), 

425 invite_code_id=flow.invite_code_id, 

426 heard_about_couchers=flow.heard_about_couchers, 

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

428 moderation_state_id=moderation_state_id, 

429 ) 

430 

431 user.accepted_community_guidelines = flow.accepted_community_guidelines 

432 user.onboarding_emails_sent = 1 

433 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter) 

434 

435 session.add(user) 

436 session.flush() 

437 return user.id 

438 

439 create_moderation( 

440 session=session, 

441 object_type=ModerationObjectType.user, 

442 object_id=create_user, 

443 ) 

444 assert user is not None 

445 

446 record_hosting_meetup_status(session, user, HostingMeetupStatusSource.signup) 

447 

448 # Create a profile gallery for the user 

449 profile_gallery = PhotoGallery(owner_user_id=user.id) 

450 session.add(profile_gallery) 

451 session.flush() 

452 user.profile_gallery_id = profile_gallery.id 

453 

454 if flow.filled_feedback: 

455 form_ = ContributorForm( 

456 user_id=user.id, 

457 ideas=flow.ideas or None, 

458 features=flow.features or None, 

459 experience=flow.experience or None, 

460 contribute=flow.contribute or None, 

461 contribute_ways=not_none(flow.contribute_ways), 

462 expertise=flow.expertise or None, 

463 ) 

464 

465 session.add(form_) 

466 

467 user.filled_contributor_form = form_.is_filled 

468 

469 maybe_send_contributor_form_email(session, form_) 

470 

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

472 

473 session.delete(flow) 

474 session.commit() 

475 

476 enforce_community_memberships_for_user(session, user) 

477 

478 # sends onboarding email 

479 notify( 

480 session, 

481 user_id=user.id, 

482 topic_action=NotificationTopicAction.onboarding__reminder, 

483 key="1", 

484 ) 

485 

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

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

488 log_event( 

489 context, 

490 session, 

491 "account.signup_completed", 

492 { 

493 "gender": flow.gender, 

494 "signup_duration_s": signup_duration_s, 

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

496 "city": flow.city, 

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

498 "filled_contributor_form": user.filled_contributor_form, 

499 }, 

500 _override_user_id=user.id, 

501 ) 

502 

503 create_session(context, session, user, False) 

504 return auth_pb2.SignupFlowRes( 

505 auth_res=_auth_res(user), 

506 ) 

507 else: 

508 return auth_pb2.SignupFlowRes( 

509 flow_token=flow.flow_token, 

510 email=flow.email, 

511 need_account=not flow.account_is_filled, 

512 need_feedback=False, 

513 need_verify_email=not flow.email_verified, 

514 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

515 need_motivations=not flow.filled_motivations, 

516 ) 

517 

518 def UsernameValid( 

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

520 ) -> auth_pb2.UsernameValidRes: 

521 """ 

522 Runs a username availability and validity check. 

523 """ 

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

525 

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

527 """ 

528 Authenticates a classic password-based login request. 

529 

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

531 """ 

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

533 user = session.execute( 

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

535 ).scalar_one_or_none() 

536 if user: 

537 logger.debug("Found user") 

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

539 logger.debug("Right password") 

540 # correct password 

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

542 log_event( 

543 context, 

544 session, 

545 "account.login", 

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

547 _override_user_id=user.id, 

548 ) 

549 return _auth_res(user) 

550 else: 

551 logger.debug("Wrong password") 

552 # wrong password 

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

554 else: # user not found 

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

556 signup_flow = session.execute( 

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

558 ).scalar_one_or_none() 

559 if signup_flow: 

560 send_signup_email(context, session, signup_flow) 

561 session.commit() 

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

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

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

565 

566 def GetAuthState( 

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

568 ) -> auth_pb2.GetAuthStateRes: 

569 if not context.is_logged_in(): 

570 return auth_pb2.GetAuthStateRes(logged_in=False) 

571 else: 

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

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

574 

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

576 """ 

577 Removes an active cookie session. 

578 """ 

579 token = parse_session_cookie(context.headers) 

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

581 

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

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

584 delete_session(session, token) 

585 

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

587 

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

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

590 

591 return empty_pb2.Empty() 

592 

593 def ResetPassword( 

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

595 ) -> empty_pb2.Empty: 

596 """ 

597 If the user does not exist, do nothing. 

598 

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

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

601 

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

603 """ 

604 user = session.execute( 

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

606 ).scalar_one_or_none() 

607 if user: 

608 password_reset_token = PasswordResetToken( 

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

610 ) 

611 session.add(password_reset_token) 

612 session.flush() 

613 

614 notify( 

615 session, 

616 user_id=user.id, 

617 topic_action=NotificationTopicAction.password_reset__start, 

618 key="", 

619 data=notification_data_pb2.PasswordResetStart( 

620 password_reset_token=password_reset_token.token, 

621 ), 

622 ) 

623 

624 password_reset_initiations_counter.inc() 

625 log_event( 

626 context, 

627 session, 

628 "account.password_reset_initiated", 

629 {}, 

630 _override_user_id=user.id, 

631 ) 

632 else: # user not found 

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

634 

635 return empty_pb2.Empty() 

636 

637 def CompletePasswordResetV2( 

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

639 ) -> auth_pb2.AuthRes: 

640 """ 

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

642 """ 

643 res = session.execute( 

644 select(PasswordResetToken, User) 

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

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

647 .where(PasswordResetToken.is_valid) 

648 ).one_or_none() 

649 if res: 

650 password_reset_token, user = res 

651 abort_on_invalid_password(request.new_password, context) 

652 user.hashed_password = hash_password(request.new_password) 

653 session.delete(password_reset_token) 

654 

655 session.flush() 

656 

657 notify( 

658 session, 

659 user_id=user.id, 

660 topic_action=NotificationTopicAction.password_reset__complete, 

661 key="", 

662 ) 

663 

664 create_session(context, session, user, False) 

665 password_reset_completions_counter.inc() 

666 log_event( 

667 context, 

668 session, 

669 "account.password_reset_completed", 

670 {}, 

671 _override_user_id=user.id, 

672 ) 

673 return _auth_res(user) 

674 else: 

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

676 

677 def ConfirmChangeEmailV2( 

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

679 ) -> empty_pb2.Empty: 

680 user = session.execute( 

681 select(User) 

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

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

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

685 ).scalar_one_or_none() 

686 

687 if not user: 

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

689 

690 user.email = not_none(user.new_email) 

691 user.new_email = None 

692 user.new_email_token = None 

693 user.new_email_token_created = None 

694 user.new_email_token_expiry = None 

695 

696 notify( 

697 session, 

698 user_id=user.id, 

699 topic_action=NotificationTopicAction.email_address__verify, 

700 key="", 

701 ) 

702 

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

704 

705 return empty_pb2.Empty() 

706 

707 def ConfirmDeleteAccount( 

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

709 ) -> empty_pb2.Empty: 

710 """ 

711 Confirm account deletion using account delete token 

712 """ 

713 res = session.execute( 

714 select(User, AccountDeletionToken) 

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

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

717 .where(AccountDeletionToken.is_valid) 

718 ).one_or_none() 

719 

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

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

722 

723 user, account_deletion_token = res 

724 

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

726 

727 user.deleted_at = now() 

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

729 user.undelete_token = urlsafe_secure_token() 

730 

731 session.flush() 

732 

733 notify( 

734 session, 

735 user_id=user.id, 

736 topic_action=NotificationTopicAction.account_deletion__complete, 

737 key="", 

738 data=notification_data_pb2.AccountDeletionComplete( 

739 undelete_token=user.undelete_token, 

740 undelete_days=UNDELETE_DAYS, 

741 ), 

742 ) 

743 

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

745 log_event( 

746 context, 

747 session, 

748 "account.deletion_completed", 

749 {"gender": user.gender}, 

750 _override_user_id=user.id, 

751 ) 

752 

753 return empty_pb2.Empty() 

754 

755 def RecoverAccount( 

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

757 ) -> empty_pb2.Empty: 

758 """ 

759 Recovers a recently deleted account 

760 """ 

761 user = session.execute( 

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

763 ).scalar_one_or_none() 

764 

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

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

767 

768 user.deleted_at = None 

769 user.undelete_token = None 

770 user.undelete_until = None 

771 

772 notify( 

773 session, 

774 user_id=user.id, 

775 topic_action=NotificationTopicAction.account_deletion__recovered, 

776 key="", 

777 ) 

778 

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

780 log_event( 

781 context, 

782 session, 

783 "account.recovered", 

784 {"gender": user.gender}, 

785 _override_user_id=user.id, 

786 ) 

787 

788 return empty_pb2.Empty() 

789 

790 def Unsubscribe( 

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

792 ) -> auth_pb2.UnsubscribeRes: 

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

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

795 

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

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

798 return auth_pb2.AntiBotRes() 

799 

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

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

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

803 

804 log = AntiBotLog( 

805 token=request.token, 

806 user_agent=user_agent, 

807 ip_address=ip_address, 

808 action=request.action, 

809 user_id=user_id, 

810 # placeholders: there is currently no provider assessing requests 

811 score=0.0, 

812 provider_data={}, 

813 ) 

814 

815 session.add(log) 

816 session.flush() 

817 

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

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

820 

821 if context.is_logged_in(): 

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

823 user.last_antibot = now() 

824 

825 return auth_pb2.AntiBotRes() 

826 

827 def AntiBotPolicy( 

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

829 ) -> auth_pb2.AntiBotPolicyRes: 

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

831 if context.is_logged_in(): 

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

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

834 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

835 

836 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

837 

838 def GetInviteCodeInfo( 

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

840 ) -> auth_pb2.GetInviteCodeInfoRes: 

841 invite = session.execute( 

842 select(InviteCode).where( 

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

844 ) 

845 ).scalar_one_or_none() 

846 

847 if not invite: 

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

849 

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

851 

852 avatar_upload = get_avatar_upload(session, user) 

853 

854 return auth_pb2.GetInviteCodeInfoRes( 

855 name=user.name, 

856 username=user.username, 

857 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None, 

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

859 )