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

323 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +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.metrics import ( 

17 account_deletion_completions_counter, 

18 account_recoveries_counter, 

19 antibot_score_histogram, 

20 antibots_assessed_counter, 

21 logins_counter, 

22 password_reset_completions_counter, 

23 password_reset_initiations_counter, 

24 signup_account_filled_counter, 

25 signup_completions_counter, 

26 signup_email_verified_counter, 

27 signup_guidelines_accepted_counter, 

28 signup_initiations_counter, 

29 signup_motivations_filled_counter, 

30 signup_time_histogram, 

31) 

32from couchers.models import ( 

33 AccountDeletionToken, 

34 AntiBotLog, 

35 ContributorForm, 

36 InviteCode, 

37 NonvisibleUserAccessType, 

38 PasswordResetToken, 

39 PhotoGallery, 

40 SignupFlow, 

41 User, 

42 UserSession, 

43) 

44from couchers.models.notifications import NotificationTopicAction 

45from couchers.models.uploads import get_avatar_upload 

46from couchers.notifications.notify import notify 

47from couchers.notifications.quick_links import decode_quick_link 

48from couchers.proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

49from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

50from couchers.servicers.api import hostingstatus2sql 

51from couchers.servicers.auth_unsubscribe import handle_unsubscribe 

52from couchers.sql import username_or_email 

53from couchers.tasks import ( 

54 enforce_community_memberships_for_user, 

55 maybe_send_contributor_form_email, 

56 send_signup_email, 

57) 

58from couchers.utils import ( 

59 create_coordinate, 

60 create_session_cookies, 

61 is_geom, 

62 is_valid_email, 

63 is_valid_name, 

64 is_valid_username, 

65 minimum_allowed_birthdate, 

66 not_none, 

67 now, 

68 parse_date, 

69 parse_session_cookie, 

70) 

71 

72logger = logging.getLogger(__name__) 

73 

74 

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

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

77 

78 

79def create_session( 

80 context: CouchersContext, 

81 session: Session, 

82 user: User, 

83 long_lived: bool, 

84 is_api_key: bool = False, 

85 duration: timedelta | None = None, 

86 set_cookie: bool = True, 

87) -> tuple[str, datetime]: 

88 """ 

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

90 

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

92 work here due to the active User object. 

93 

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

95 

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

97 

98 ```py3 

99 token, expiry = create_session(...) 

100 ``` 

101 """ 

102 maybe_log_nonvisible_user_access( 

103 context, 

104 user, 

105 access_type=NonvisibleUserAccessType.login_attempt, 

106 actor_user_id=user.id, 

107 ) 

108 

109 if user.banned_at is not None: 

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

111 

112 # just double-check 

113 assert user.deleted_at is None 

114 

115 token = cookiesafe_secure_token() 

116 

117 user_session = UserSession( 

118 token=token, 

119 user_id=user.id, 

120 long_lived=long_lived, 

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

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

123 is_api_key=is_api_key, 

124 ) 

125 if duration: 

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

127 

128 session.add(user_session) 

129 session.commit() 

130 

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

132 

133 if set_cookie: 

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

135 

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

137 

138 return token, user_session.expiry 

139 

140 

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

142 """ 

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

144 

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

146 """ 

147 user_session = session.execute( 

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

149 ).scalar_one_or_none() 

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

151 user_session.deleted = func.now() 

152 session.commit() 

153 return True 

154 else: 

155 return False 

156 

157 

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

159 """ 

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

161 """ 

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

163 if not is_valid_username(username): 

164 return False 

165 for phrase in BANNED_USERNAME_PHRASES: 

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

167 return False 

168 # check for existing user with that username 

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

170 # check for started signup with that username 

171 signup_exists = ( 

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

173 ) 

174 # return False if user exists, True otherwise 

175 return not user_exists and not signup_exists 

176 

177 

178class Auth(auth_pb2_grpc.AuthServicer): 

179 """ 

180 The Auth servicer. 

181 

182 This class services the Auth service/API. 

183 """ 

184 

185 def SignupFlow( 

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

187 ) -> auth_pb2.SignupFlowRes: 

188 if request.email_token: 

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

190 flow = session.execute( 

191 select(SignupFlow) 

192 .where(SignupFlow.email_verified == False) 

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

194 .where(SignupFlow.token_is_valid) 

195 ).scalar_one_or_none() 

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

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

198 flow.email_verified = True 

199 flow.email_token = None 

200 flow.email_token_expiry = None 

201 

202 session.flush() 

203 signup_email_verified_counter.inc() 

204 else: 

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

206 flow = session.execute( 

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

208 ).scalar_one_or_none() 

209 if not flow: 

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

211 else: 

212 if not request.flow_token: 

213 # fresh signup 

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

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

216 # TODO: unique across both tables 

217 existing_user = session.execute( 

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

219 ).scalar_one_or_none() 

220 if existing_user: 

221 if not existing_user.is_visible: 

222 context.abort_with_error_code( 

223 grpc.StatusCode.FAILED_PRECONDITION, "signup_email_cannot_be_used" 

224 ) 

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

226 existing_flow = session.execute( 

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

228 ).scalar_one_or_none() 

229 if existing_flow: 

230 send_signup_email(context, session, existing_flow) 

231 session.commit() 

232 context.abort_with_error_code( 

233 grpc.StatusCode.FAILED_PRECONDITION, "signup_flow_email_started_signup" 

234 ) 

235 

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

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

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

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

240 

241 flow_token = cookiesafe_secure_token() 

242 

243 invite_id = None 

244 if request.basic.invite_code: 

245 invite_id = session.execute( 

246 select(InviteCode.id).where( 

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

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

249 ) 

250 ).scalar_one_or_none() 

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

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

253 

254 flow = SignupFlow( 

255 flow_token=flow_token, 

256 name=request.basic.name, 

257 email=request.basic.email, 

258 invite_code_id=invite_id, 

259 ) 

260 session.add(flow) 

261 session.flush() 

262 signup_initiations_counter.inc() 

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

264 else: 

265 # not fresh signup 

266 flow = session.execute( 

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

268 ).scalar_one_or_none() 

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

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

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

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

273 

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

275 if request.HasField("account"): 

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

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

278 

279 # check username validity 

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

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

282 

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

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

285 

286 abort_on_invalid_password(request.account.password, context) 

287 hashed_password = hash_password(request.account.password) 

288 

289 birthdate = parse_date(request.account.birthdate) 

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

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

292 

293 if not request.account.hosting_status: 

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

295 

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

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

298 

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

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

301 

302 flow.username = request.account.username 

303 flow.hashed_password = hashed_password 

304 flow.birthdate = birthdate 

305 flow.gender = request.account.gender 

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

307 flow.city = request.account.city 

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

309 flow.geom_radius = request.account.radius 

310 flow.accepted_tos = TOS_VERSION 

311 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

312 session.flush() 

313 signup_account_filled_counter.inc() 

314 

315 if request.HasField("feedback"): 

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

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

318 form = request.feedback 

319 

320 flow.filled_feedback = True 

321 flow.ideas = form.ideas 

322 flow.features = form.features 

323 flow.experience = form.experience 

324 flow.contribute = contributeoption2sql[form.contribute] 

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

326 flow.expertise = form.expertise 

327 session.flush() 

328 

329 if request.HasField("motivations"): 

330 if flow.filled_motivations: 

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

332 

333 flow.filled_motivations = True 

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

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

336 session.flush() 

337 signup_motivations_filled_counter.inc() 

338 

339 if request.HasField("accept_community_guidelines"): 

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

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

342 if flow.accepted_community_guidelines < GUIDELINES_VERSION: 

343 signup_guidelines_accepted_counter.inc() 

344 flow.accepted_community_guidelines = GUIDELINES_VERSION 

345 session.flush() 

346 

347 # send verification email if needed 

348 if not flow.email_sent or request.resend_verification_email: 

349 send_signup_email(context, session, flow) 

350 

351 session.flush() 

352 

353 # finish the signup if done 

354 if flow.is_completed: 

355 user = User( 

356 name=flow.name, 

357 email=flow.email, 

358 username=not_none(flow.username), 

359 hashed_password=not_none(flow.hashed_password), 

360 birthdate=not_none(flow.birthdate), 

361 gender=not_none(flow.gender), 

362 hosting_status=not_none(flow.hosting_status), 

363 city=not_none(flow.city), 

364 geom=is_geom(flow.geom), 

365 geom_radius=not_none(flow.geom_radius), 

366 accepted_tos=not_none(flow.accepted_tos), 

367 last_onboarding_email_sent=func.now(), 

368 invite_code_id=flow.invite_code_id, 

369 heard_about_couchers=flow.heard_about_couchers, 

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

371 ) 

372 

373 user.accepted_community_guidelines = flow.accepted_community_guidelines 

374 user.onboarding_emails_sent = 1 

375 user.opt_out_of_newsletter = not_none(flow.opt_out_of_newsletter) 

376 

377 session.add(user) 

378 session.flush() 

379 

380 # Create a profile gallery for the user 

381 profile_gallery = PhotoGallery(owner_user_id=user.id) 

382 session.add(profile_gallery) 

383 session.flush() 

384 user.profile_gallery_id = profile_gallery.id 

385 

386 if flow.filled_feedback: 

387 form_ = ContributorForm( 

388 user_id=user.id, 

389 ideas=flow.ideas or None, 

390 features=flow.features or None, 

391 experience=flow.experience or None, 

392 contribute=flow.contribute or None, 

393 contribute_ways=not_none(flow.contribute_ways), 

394 expertise=flow.expertise or None, 

395 ) 

396 

397 session.add(form_) 

398 

399 user.filled_contributor_form = form_.is_filled 

400 

401 maybe_send_contributor_form_email(session, form_) 

402 

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

404 

405 session.delete(flow) 

406 session.commit() 

407 

408 enforce_community_memberships_for_user(session, user) 

409 

410 # sends onboarding email 

411 notify( 

412 session, 

413 user_id=user.id, 

414 topic_action=NotificationTopicAction.onboarding__reminder, 

415 key="1", 

416 ) 

417 

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

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

420 log_event( 

421 context, 

422 session, 

423 "account.signup_completed", 

424 { 

425 "gender": flow.gender, 

426 "signup_duration_s": signup_duration_s, 

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

428 "city": flow.city, 

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

430 "filled_contributor_form": user.filled_contributor_form, 

431 }, 

432 _override_user_id=user.id, 

433 ) 

434 

435 create_session(context, session, user, False) 

436 return auth_pb2.SignupFlowRes( 

437 auth_res=_auth_res(user), 

438 ) 

439 else: 

440 return auth_pb2.SignupFlowRes( 

441 flow_token=flow.flow_token, 

442 need_account=not flow.account_is_filled, 

443 need_feedback=False, 

444 need_verify_email=not flow.email_verified, 

445 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

446 need_motivations=not flow.filled_motivations, 

447 ) 

448 

449 def UsernameValid( 

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

451 ) -> auth_pb2.UsernameValidRes: 

452 """ 

453 Runs a username availability and validity check. 

454 """ 

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

456 

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

458 """ 

459 Authenticates a classic password-based login request. 

460 

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

462 """ 

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

464 user = session.execute( 

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

466 ).scalar_one_or_none() 

467 if user: 

468 logger.debug("Found user") 

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

470 logger.debug("Right password") 

471 # correct password 

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

473 log_event( 

474 context, 

475 session, 

476 "account.login", 

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

478 _override_user_id=user.id, 

479 ) 

480 return _auth_res(user) 

481 else: 

482 logger.debug("Wrong password") 

483 # wrong password 

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

485 else: # user not found 

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

487 signup_flow = session.execute( 

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

489 ).scalar_one_or_none() 

490 if signup_flow: 

491 send_signup_email(context, session, signup_flow) 

492 session.commit() 

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

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

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

496 

497 def GetAuthState( 

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

499 ) -> auth_pb2.GetAuthStateRes: 

500 if not context.is_logged_in(): 

501 return auth_pb2.GetAuthStateRes(logged_in=False) 

502 else: 

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

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

505 

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

507 """ 

508 Removes an active cookie session. 

509 """ 

510 token = parse_session_cookie(context.headers) 

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

512 

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

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

515 delete_session(session, token) 

516 

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

518 

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

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

521 

522 return empty_pb2.Empty() 

523 

524 def ResetPassword( 

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

526 ) -> empty_pb2.Empty: 

527 """ 

528 If the user does not exist, do nothing. 

529 

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

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

532 

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

534 """ 

535 user = session.execute( 

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

537 ).scalar_one_or_none() 

538 if user: 

539 password_reset_token = PasswordResetToken( 

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

541 ) 

542 session.add(password_reset_token) 

543 session.flush() 

544 

545 notify( 

546 session, 

547 user_id=user.id, 

548 topic_action=NotificationTopicAction.password_reset__start, 

549 key="", 

550 data=notification_data_pb2.PasswordResetStart( 

551 password_reset_token=password_reset_token.token, 

552 ), 

553 ) 

554 

555 password_reset_initiations_counter.inc() 

556 log_event( 

557 context, 

558 session, 

559 "account.password_reset_initiated", 

560 {}, 

561 _override_user_id=user.id, 

562 ) 

563 else: # user not found 

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

565 

566 return empty_pb2.Empty() 

567 

568 def CompletePasswordResetV2( 

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

570 ) -> auth_pb2.AuthRes: 

571 """ 

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

573 """ 

574 res = session.execute( 

575 select(PasswordResetToken, User) 

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

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

578 .where(PasswordResetToken.is_valid) 

579 ).one_or_none() 

580 if res: 

581 password_reset_token, user = res 

582 abort_on_invalid_password(request.new_password, context) 

583 user.hashed_password = hash_password(request.new_password) 

584 session.delete(password_reset_token) 

585 

586 session.flush() 

587 

588 notify( 

589 session, 

590 user_id=user.id, 

591 topic_action=NotificationTopicAction.password_reset__complete, 

592 key="", 

593 ) 

594 

595 create_session(context, session, user, False) 

596 password_reset_completions_counter.inc() 

597 log_event( 

598 context, 

599 session, 

600 "account.password_reset_completed", 

601 {}, 

602 _override_user_id=user.id, 

603 ) 

604 return _auth_res(user) 

605 else: 

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

607 

608 def ConfirmChangeEmailV2( 

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

610 ) -> empty_pb2.Empty: 

611 user = session.execute( 

612 select(User) 

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

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

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

616 ).scalar_one_or_none() 

617 

618 if not user: 

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

620 

621 user.email = not_none(user.new_email) 

622 user.new_email = None 

623 user.new_email_token = None 

624 user.new_email_token_created = None 

625 user.new_email_token_expiry = None 

626 

627 notify( 

628 session, 

629 user_id=user.id, 

630 topic_action=NotificationTopicAction.email_address__verify, 

631 key="", 

632 ) 

633 

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

635 

636 return empty_pb2.Empty() 

637 

638 def ConfirmDeleteAccount( 

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

640 ) -> empty_pb2.Empty: 

641 """ 

642 Confirm account deletion using account delete token 

643 """ 

644 res = session.execute( 

645 select(User, AccountDeletionToken) 

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

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

648 .where(AccountDeletionToken.is_valid) 

649 ).one_or_none() 

650 

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

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

653 

654 user, account_deletion_token = res 

655 

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

657 

658 user.deleted_at = now() 

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

660 user.undelete_token = urlsafe_secure_token() 

661 

662 session.flush() 

663 

664 notify( 

665 session, 

666 user_id=user.id, 

667 topic_action=NotificationTopicAction.account_deletion__complete, 

668 key="", 

669 data=notification_data_pb2.AccountDeletionComplete( 

670 undelete_token=user.undelete_token, 

671 undelete_days=UNDELETE_DAYS, 

672 ), 

673 ) 

674 

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

676 log_event( 

677 context, 

678 session, 

679 "account.deletion_completed", 

680 {"gender": user.gender}, 

681 _override_user_id=user.id, 

682 ) 

683 

684 return empty_pb2.Empty() 

685 

686 def RecoverAccount( 

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

688 ) -> empty_pb2.Empty: 

689 """ 

690 Recovers a recently deleted account 

691 """ 

692 user = session.execute( 

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

694 ).scalar_one_or_none() 

695 

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

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

698 

699 user.deleted_at = None 

700 user.undelete_token = None 

701 user.undelete_until = None 

702 

703 notify( 

704 session, 

705 user_id=user.id, 

706 topic_action=NotificationTopicAction.account_deletion__recovered, 

707 key="", 

708 ) 

709 

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

711 log_event( 

712 context, 

713 session, 

714 "account.recovered", 

715 {"gender": user.gender}, 

716 _override_user_id=user.id, 

717 ) 

718 

719 return empty_pb2.Empty() 

720 

721 def Unsubscribe( 

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

723 ) -> auth_pb2.UnsubscribeRes: 

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

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

726 

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

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

729 return auth_pb2.AntiBotRes() 

730 

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

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

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

734 

735 log = AntiBotLog( 

736 token=request.token, 

737 user_agent=user_agent, 

738 ip_address=ip_address, 

739 action=request.action, 

740 user_id=user_id, 

741 # placeholders: there is currently no provider assessing requests 

742 score=0.0, 

743 provider_data={}, 

744 ) 

745 

746 session.add(log) 

747 session.flush() 

748 

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

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

751 

752 if context.is_logged_in(): 

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

754 user.last_antibot = now() 

755 

756 return auth_pb2.AntiBotRes() 

757 

758 def AntiBotPolicy( 

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

760 ) -> auth_pb2.AntiBotPolicyRes: 

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

762 if context.is_logged_in(): 

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

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

765 return auth_pb2.AntiBotPolicyRes(should_antibot=True) 

766 

767 return auth_pb2.AntiBotPolicyRes(should_antibot=False) 

768 

769 def GetInviteCodeInfo( 

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

771 ) -> auth_pb2.GetInviteCodeInfoRes: 

772 invite = session.execute( 

773 select(InviteCode).where( 

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

775 ) 

776 ).scalar_one_or_none() 

777 

778 if not invite: 

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

780 

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

782 

783 avatar_upload = get_avatar_upload(session, user) 

784 

785 return auth_pb2.GetInviteCodeInfoRes( 

786 name=user.name, 

787 username=user.username, 

788 avatar_url=avatar_upload.thumbnail_url if avatar_upload else None, 

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

790 )