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

246 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-06-01 15:07 +0000

1import logging 

2from datetime import timedelta 

3 

4import grpc 

5from google.protobuf import empty_pb2 

6from sqlalchemy.sql import delete, func 

7 

8from couchers import errors 

9from couchers.constants import GUIDELINES_VERSION, TOS_VERSION, UNDELETE_DAYS 

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

11from couchers.metrics import ( 

12 account_deletion_completions_counter, 

13 account_recoveries_counter, 

14 logins_counter, 

15 password_reset_completions_counter, 

16 password_reset_initiations_counter, 

17 signup_completions_counter, 

18 signup_initiations_counter, 

19 signup_time_histogram, 

20) 

21from couchers.models import AccountDeletionToken, ContributorForm, PasswordResetToken, SignupFlow, User, UserSession 

22from couchers.notifications.notify import notify 

23from couchers.notifications.unsubscribe import unsubscribe 

24from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

25from couchers.servicers.api import hostingstatus2sql 

26from couchers.sql import couchers_select as select 

27from couchers.tasks import ( 

28 enforce_community_memberships_for_user, 

29 maybe_send_contributor_form_email, 

30 send_signup_email, 

31) 

32from couchers.utils import ( 

33 create_coordinate, 

34 create_session_cookies, 

35 is_valid_email, 

36 is_valid_name, 

37 is_valid_username, 

38 minimum_allowed_birthdate, 

39 now, 

40 parse_date, 

41 parse_session_cookie, 

42) 

43from proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

44 

45logger = logging.getLogger(__name__) 

46 

47 

48def _auth_res(user): 

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

50 

51 

52def create_session(context, session, user, long_lived, is_api_key=False, duration=None, set_cookie=True): 

53 """ 

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

55 

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

57 work here due to the active User object. 

58 

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

60 

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

62 

63 ```py3 

64 token, expiry = create_session(...) 

65 ``` 

66 """ 

67 if user.is_banned: 

68 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.ACCOUNT_SUSPENDED) 

69 

70 # just double check 

71 assert not user.is_deleted 

72 

73 token = cookiesafe_secure_token() 

74 

75 headers = dict(context.invocation_metadata()) 

76 

77 user_session = UserSession( 

78 token=token, 

79 user=user, 

80 long_lived=long_lived, 

81 ip_address=headers.get("x-couchers-real-ip"), 

82 user_agent=headers.get("user-agent"), 

83 is_api_key=is_api_key, 

84 ) 

85 if duration: 

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

87 

88 session.add(user_session) 

89 session.commit() 

90 

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

92 

93 if set_cookie: 

94 context.send_initial_metadata( 

95 [("set-cookie", cookie) for cookie in create_session_cookies(token, user.id, user_session.expiry)] 

96 ) 

97 

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

99 

100 return token, user_session.expiry 

101 

102 

103def delete_session(session, token): 

104 """ 

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

106 

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

108 """ 

109 user_session = session.execute( 

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

111 ).scalar_one_or_none() 

112 if user_session: 

113 user_session.deleted = func.now() 

114 session.commit() 

115 return True 

116 else: 

117 return False 

118 

119 

120def _username_available(session, username): 

121 """ 

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

123 """ 

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

125 if not is_valid_username(username): 

126 return False 

127 # check for existing user with that username 

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

129 # check for started signup with that username 

130 signup_exists = ( 

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

132 ) 

133 # return False if user exists, True otherwise 

134 return not user_exists and not signup_exists 

135 

136 

137class Auth(auth_pb2_grpc.AuthServicer): 

138 """ 

139 The Auth servicer. 

140 

141 This class services the Auth service/API. 

142 """ 

143 

144 def SignupFlow(self, request, context, session): 

145 if request.email_token: 

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

147 flow = session.execute( 

148 select(SignupFlow) 

149 .where(SignupFlow.email_verified == False) 

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

151 .where(SignupFlow.token_is_valid) 

152 ).scalar_one_or_none() 

153 if flow: 

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

155 flow.email_verified = True 

156 flow.email_token = None 

157 flow.email_token_expiry = None 

158 

159 session.flush() 

160 else: 

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

162 flow = session.execute( 

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

164 ).scalar_one_or_none() 

165 if not flow: 

166 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

167 else: 

168 if not request.flow_token: 

169 # fresh signup 

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

171 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.SIGNUP_FLOW_BASIC_NEEDED) 

172 # TODO: unique across both tables 

173 existing_user = session.execute( 

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

175 ).scalar_one_or_none() 

176 if existing_user: 

177 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_TAKEN) 

178 existing_flow = session.execute( 

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

180 ).scalar_one_or_none() 

181 if existing_flow: 

182 send_signup_email(session, existing_flow) 

183 session.commit() 

184 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP) 

185 

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

187 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_EMAIL) 

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

189 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_NAME) 

190 

191 flow_token = cookiesafe_secure_token() 

192 

193 flow = SignupFlow( 

194 flow_token=flow_token, 

195 name=request.basic.name, 

196 email=request.basic.email, 

197 ) 

198 session.add(flow) 

199 session.flush() 

200 signup_initiations_counter.inc() 

201 else: 

202 # not fresh signup 

203 flow = session.execute( 

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

205 ).scalar_one_or_none() 

206 if not flow: 

207 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

208 if request.HasField("basic"): 

209 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_BASIC_FILLED) 

210 

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

212 if request.HasField("account"): 

213 if flow.account_is_filled: 

214 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_ACCOUNT_FILLED) 

215 

216 # check username validity 

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

218 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_USERNAME) 

219 

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

221 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.USERNAME_NOT_AVAILABLE) 

222 

223 abort_on_invalid_password(request.account.password, context) 

224 hashed_password = hash_password(request.account.password) 

225 

226 birthdate = parse_date(request.account.birthdate) 

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

228 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.INVALID_BIRTHDATE) 

229 

230 if not request.account.hosting_status: 

231 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.HOSTING_STATUS_REQUIRED) 

232 

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

234 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.INVALID_COORDINATE) 

235 

236 if not request.account.accept_tos: 

237 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_TOS) 

238 

239 flow.username = request.account.username 

240 flow.hashed_password = hashed_password 

241 flow.birthdate = birthdate 

242 flow.gender = request.account.gender 

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

244 flow.city = request.account.city 

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

246 flow.geom_radius = request.account.radius 

247 flow.accepted_tos = TOS_VERSION 

248 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

249 session.flush() 

250 

251 if request.HasField("feedback"): 

252 if flow.filled_feedback: 

253 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_FEEDBACK_FILLED) 

254 form = request.feedback 

255 

256 flow.filled_feedback = True 

257 flow.ideas = form.ideas 

258 flow.features = form.features 

259 flow.experience = form.experience 

260 flow.contribute = contributeoption2sql[form.contribute] 

261 flow.contribute_ways = form.contribute_ways 

262 flow.expertise = form.expertise 

263 session.flush() 

264 

265 if request.HasField("accept_community_guidelines"): 

266 if not request.accept_community_guidelines.value: 

267 context.abort(grpc.StatusCode.INVALID_ARGUMENT, errors.MUST_ACCEPT_COMMUNITY_GUIDELINES) 

268 flow.accepted_community_guidelines = GUIDELINES_VERSION 

269 session.flush() 

270 

271 # send verification email if needed 

272 if not flow.email_sent or request.resend_verification_email: 

273 send_signup_email(session, flow) 

274 

275 session.flush() 

276 

277 # finish the signup if done 

278 if flow.is_completed: 

279 user = User( 

280 name=flow.name, 

281 email=flow.email, 

282 username=flow.username, 

283 hashed_password=flow.hashed_password, 

284 birthdate=flow.birthdate, 

285 gender=flow.gender, 

286 hosting_status=flow.hosting_status, 

287 city=flow.city, 

288 geom=flow.geom, 

289 geom_radius=flow.geom_radius, 

290 accepted_tos=flow.accepted_tos, 

291 accepted_community_guidelines=flow.accepted_community_guidelines, 

292 onboarding_emails_sent=1, 

293 last_onboarding_email_sent=func.now(), 

294 opt_out_of_newsletter=flow.opt_out_of_newsletter, 

295 ) 

296 

297 session.add(user) 

298 

299 if flow.filled_feedback: 

300 form = ContributorForm( 

301 user=user, 

302 ideas=flow.ideas or None, 

303 features=flow.features or None, 

304 experience=flow.experience or None, 

305 contribute=flow.contribute or None, 

306 contribute_ways=flow.contribute_ways, 

307 expertise=flow.expertise or None, 

308 ) 

309 

310 session.add(form) 

311 

312 user.filled_contributor_form = form.is_filled 

313 

314 maybe_send_contributor_form_email(session, form) 

315 

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

317 

318 session.delete(flow) 

319 session.commit() 

320 

321 enforce_community_memberships_for_user(session, user) 

322 

323 # sends onboarding email 

324 notify( 

325 session, 

326 user_id=user.id, 

327 topic_action="onboarding:reminder", 

328 key="1", 

329 ) 

330 

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

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

333 

334 create_session(context, session, user, False) 

335 return auth_pb2.SignupFlowRes( 

336 auth_res=_auth_res(user), 

337 ) 

338 else: 

339 return auth_pb2.SignupFlowRes( 

340 flow_token=flow.flow_token, 

341 need_account=not flow.account_is_filled, 

342 need_feedback=False, 

343 need_verify_email=not flow.email_verified, 

344 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

345 ) 

346 

347 def UsernameValid(self, request, context, session): 

348 """ 

349 Runs a username availability and validity check. 

350 """ 

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

352 

353 def Authenticate(self, request, context, session): 

354 """ 

355 Authenticates a classic password based login request. 

356 

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

358 """ 

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

360 user = session.execute( 

361 select(User).where_username_or_email(request.user).where(~User.is_deleted) 

362 ).scalar_one_or_none() 

363 if user: 

364 logger.debug("Found user") 

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

366 logger.debug("Right password") 

367 # correct password 

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

369 return _auth_res(user) 

370 else: 

371 logger.debug("Wrong password") 

372 # wrong password 

373 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_PASSWORD) 

374 else: # user not found 

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

376 signup_flow = session.execute( 

377 select(SignupFlow).where_username_or_email(request.user, table=SignupFlow) 

378 ).scalar_one_or_none() 

379 if signup_flow: 

380 send_signup_email(session, signup_flow) 

381 session.commit() 

382 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.SIGNUP_FLOW_EMAIL_STARTED_SIGNUP) 

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

384 context.abort(grpc.StatusCode.NOT_FOUND, errors.ACCOUNT_NOT_FOUND) 

385 

386 def GetAuthState(self, request, context, session): 

387 if not context.user_id: 

388 return auth_pb2.GetAuthStateRes(logged_in=False) 

389 else: 

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

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

392 

393 def Deauthenticate(self, request, context, session): 

394 """ 

395 Removes an active cookie session. 

396 """ 

397 token = parse_session_cookie(dict(context.invocation_metadata())) 

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

399 

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

401 if token: 

402 delete_session(session, token) 

403 

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

405 context.send_initial_metadata([("set-cookie", cookie) for cookie in create_session_cookies("", "", now())]) 

406 

407 return empty_pb2.Empty() 

408 

409 def ResetPassword(self, request, context, session): 

410 """ 

411 If the user does not exist, do nothing. 

412 

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

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

415 

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

417 """ 

418 user = session.execute( 

419 select(User).where_username_or_email(request.user).where(~User.is_deleted) 

420 ).scalar_one_or_none() 

421 if user: 

422 password_reset_token = PasswordResetToken( 

423 token=urlsafe_secure_token(), user=user, expiry=now() + timedelta(hours=2) 

424 ) 

425 session.add(password_reset_token) 

426 session.flush() 

427 

428 notify( 

429 session, 

430 user_id=user.id, 

431 topic_action="password_reset:start", 

432 data=notification_data_pb2.PasswordResetStart( 

433 password_reset_token=password_reset_token.token, 

434 ), 

435 ) 

436 

437 password_reset_initiations_counter.inc() 

438 else: # user not found 

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

440 

441 return empty_pb2.Empty() 

442 

443 def CompletePasswordResetV2(self, request, context, session): 

444 """ 

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

446 """ 

447 res = session.execute( 

448 select(PasswordResetToken, User) 

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

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

451 .where(PasswordResetToken.is_valid) 

452 ).one_or_none() 

453 if res: 

454 password_reset_token, user = res 

455 abort_on_invalid_password(request.new_password, context) 

456 user.hashed_password = hash_password(request.new_password) 

457 session.delete(password_reset_token) 

458 

459 session.flush() 

460 

461 notify( 

462 session, 

463 user_id=user.id, 

464 topic_action="password_reset:complete", 

465 ) 

466 

467 create_session(context, session, user, False) 

468 password_reset_completions_counter.inc() 

469 return _auth_res(user) 

470 else: 

471 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

472 

473 def ConfirmChangeEmailV2(self, request, context, session): 

474 user = session.execute( 

475 select(User) 

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

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

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

479 ).scalar_one_or_none() 

480 

481 if not user: 

482 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

483 

484 user.email = user.new_email 

485 user.new_email = None 

486 user.new_email_token = None 

487 user.new_email_token_created = None 

488 user.new_email_token_expiry = None 

489 

490 notify( 

491 session, 

492 user_id=user.id, 

493 topic_action="email_address:verify", 

494 ) 

495 

496 return empty_pb2.Empty() 

497 

498 def ConfirmDeleteAccount(self, request, context, session): 

499 """ 

500 Confirm account deletion using account delete token 

501 """ 

502 res = session.execute( 

503 select(User, AccountDeletionToken) 

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

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

506 .where(AccountDeletionToken.is_valid) 

507 ).one_or_none() 

508 

509 if not res: 

510 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

511 

512 user, account_deletion_token = res 

513 

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

515 

516 user.is_deleted = True 

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

518 user.undelete_token = urlsafe_secure_token() 

519 

520 session.flush() 

521 

522 notify( 

523 session, 

524 user_id=user.id, 

525 topic_action="account_deletion:complete", 

526 data=notification_data_pb2.AccountDeletionComplete( 

527 undelete_token=user.undelete_token, 

528 undelete_days=UNDELETE_DAYS, 

529 ), 

530 ) 

531 

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

533 

534 return empty_pb2.Empty() 

535 

536 def RecoverAccount(self, request, context, session): 

537 """ 

538 Recovers a recently deleted account 

539 """ 

540 user = session.execute( 

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

542 ).scalar_one_or_none() 

543 

544 if not user: 

545 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_TOKEN) 

546 

547 user.is_deleted = False 

548 user.undelete_token = None 

549 user.undelete_until = None 

550 

551 notify( 

552 session, 

553 user_id=user.id, 

554 topic_action="account_deletion:recovered", 

555 ) 

556 

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

558 

559 return empty_pb2.Empty() 

560 

561 def Unsubscribe(self, request, context, session): 

562 return auth_pb2.UnsubscribeRes(response=unsubscribe(request, context))