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

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

275 statements  

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, urls 

9from couchers.constants import GUIDELINES_VERSION, TOS_VERSION 

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

11from couchers.db import session_scope 

12from couchers.models import ( 

13 AccountDeletionToken, 

14 ContributorForm, 

15 LoginToken, 

16 PasswordResetToken, 

17 SignupFlow, 

18 User, 

19 UserSession, 

20) 

21from couchers.notifications.notify import notify 

22from couchers.notifications.unsubscribe import unsubscribe 

23from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

24from couchers.servicers.api import hostingstatus2sql 

25from couchers.sql import couchers_select as select 

26from couchers.tasks import ( 

27 enforce_community_memberships_for_user, 

28 maybe_send_contributor_form_email, 

29 send_account_deletion_successful_email, 

30 send_account_recovered_email, 

31 send_login_email, 

32 send_onboarding_email, 

33 send_password_reset_email, 

34 send_signup_email, 

35) 

36from couchers.utils import ( 

37 create_coordinate, 

38 create_session_cookie, 

39 is_valid_email, 

40 is_valid_name, 

41 is_valid_username, 

42 minimum_allowed_birthdate, 

43 now, 

44 parse_date, 

45 parse_session_cookie, 

46) 

47from proto import auth_pb2, auth_pb2_grpc 

48 

49logger = logging.getLogger(__name__) 

50 

51 

52def _auth_res(user): 

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

54 

55 

56def create_session(context, session, user, long_lived, is_api_key=False, duration=None): 

57 """ 

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

59 

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

61 work here due to the active User object. 

62 

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

64 

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

66 

67 ```py3 

68 token, expiry = create_session(...) 

69 context.send_initial_metadata([("set-cookie", create_session_cookie(token, expiry)),]) 

70 ``` 

71 """ 

72 if user.is_banned: 

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

74 

75 # just double check 

76 assert not user.is_deleted 

77 

78 token = cookiesafe_secure_token() 

79 

80 headers = dict(context.invocation_metadata()) 

81 

82 user_session = UserSession( 

83 token=token, 

84 user=user, 

85 long_lived=long_lived, 

86 ip_address=headers.get("x-forwarded-for"), 

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

88 is_api_key=is_api_key, 

89 ) 

90 if duration: 

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

92 

93 session.add(user_session) 

94 session.commit() 

95 

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

97 return token, user_session.expiry 

98 

99 

100def delete_session(token): 

101 """ 

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

103 

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

105 """ 

106 with session_scope() as session: 

107 user_session = session.execute( 

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

109 ).scalar_one_or_none() 

110 if user_session: 

111 user_session.deleted = func.now() 

112 session.commit() 

113 return True 

114 else: 

115 return False 

116 

117 

118class Auth(auth_pb2_grpc.AuthServicer): 

119 """ 

120 The Auth servicer. 

121 

122 This class services the Auth service/API. 

123 """ 

124 

125 def _username_available(self, username): 

126 """ 

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

128 """ 

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

130 if not is_valid_username(username): 

131 return False 

132 with session_scope() as session: 

133 # check for existing user with that username 

134 user_exists = ( 

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

136 ) 

137 # check for started signup with that username 

138 signup_exists = ( 

139 session.execute(select(SignupFlow).where(SignupFlow.username == username)).scalar_one_or_none() 

140 is not None 

141 ) 

142 # return False if user exists, True otherwise 

143 return not user_exists and not signup_exists 

144 

145 def SignupFlow(self, request, context): 

146 with session_scope() as session: 

147 if request.email_token: 

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

149 flow = session.execute( 

150 select(SignupFlow) 

151 .where(SignupFlow.email_verified == False) 

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

153 .where(SignupFlow.token_is_valid) 

154 ).scalar_one_or_none() 

155 if flow: 

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

157 flow.email_verified = True 

158 flow.email_token = None 

159 flow.email_token_expiry = None 

160 

161 session.flush() 

162 else: 

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

164 flow = session.execute( 

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

166 ).scalar_one_or_none() 

167 if not flow: 

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

169 else: 

170 if not request.flow_token: 

171 # fresh signup 

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

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

174 # TODO: unique across both tables 

175 existing_user = session.execute( 

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

177 ).scalar_one_or_none() 

178 if existing_user: 

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

180 existing_flow = session.execute( 

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

182 ).scalar_one_or_none() 

183 if existing_flow: 

184 send_signup_email(existing_flow) 

185 session.commit() 

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

187 

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

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

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

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

192 

193 flow_token = cookiesafe_secure_token() 

194 

195 flow = SignupFlow( 

196 flow_token=flow_token, 

197 name=request.basic.name, 

198 email=request.basic.email, 

199 ) 

200 session.add(flow) 

201 session.flush() 

202 else: 

203 # not fresh signup 

204 flow = session.execute( 

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

206 ).scalar_one_or_none() 

207 if not flow: 

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

209 if request.HasField("basic"): 

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

211 

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

213 if request.HasField("account"): 

214 if flow.account_is_filled: 

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

216 

217 # check username validity 

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

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

220 

221 if not self._username_available(request.account.username): 

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

223 

224 abort_on_invalid_password(request.account.password, context) 

225 hashed_password = hash_password(request.account.password) 

226 

227 birthdate = parse_date(request.account.birthdate) 

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

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

230 

231 if not request.account.hosting_status: 

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

233 

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

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

236 

237 if not request.account.accept_tos: 

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

239 

240 flow.username = request.account.username 

241 flow.hashed_password = hashed_password 

242 flow.birthdate = birthdate 

243 flow.gender = request.account.gender 

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

245 flow.city = request.account.city 

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

247 flow.geom_radius = request.account.radius 

248 flow.accepted_tos = TOS_VERSION 

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: 

273 send_signup_email(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 ) 

295 

296 session.add(user) 

297 

298 form = ContributorForm( 

299 user=user, 

300 ideas=flow.ideas or None, 

301 features=flow.features or None, 

302 experience=flow.experience or None, 

303 contribute=flow.contribute or None, 

304 contribute_ways=flow.contribute_ways, 

305 expertise=flow.expertise or None, 

306 ) 

307 

308 session.add(form) 

309 

310 user.filled_contributor_form = form.is_filled 

311 

312 session.delete(flow) 

313 session.commit() 

314 

315 enforce_community_memberships_for_user(session, user) 

316 

317 if form.is_filled: 

318 user.filled_contributor_form = True 

319 

320 maybe_send_contributor_form_email(form) 

321 

322 send_onboarding_email(user, email_number=1) 

323 

324 token, expiry = create_session(context, session, user, False) 

325 context.send_initial_metadata( 

326 [ 

327 ("set-cookie", create_session_cookie(token, expiry)), 

328 ] 

329 ) 

330 return auth_pb2.SignupFlowRes( 

331 auth_res=_auth_res(user), 

332 ) 

333 else: 

334 return auth_pb2.SignupFlowRes( 

335 flow_token=flow.flow_token, 

336 need_account=not flow.account_is_filled, 

337 need_feedback=not flow.filled_feedback, 

338 need_verify_email=not flow.email_verified, 

339 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

340 ) 

341 

342 def UsernameValid(self, request, context): 

343 """ 

344 Runs a username availability and validity check. 

345 """ 

346 return auth_pb2.UsernameValidRes(valid=self._username_available(request.username.lower())) 

347 

348 def Login(self, request, context): 

349 """ 

350 Does the first step of the Login flow. 

351 

352 The user is searched for using their id, username, or email. 

353 

354 If the user does not exist or has been deleted, throws a NOT_FOUND rpc error. 

355 

356 If the user has a password, returns NEED_PASSWORD. 

357 

358 If the user exists but does not have a password, generates a login token, send it in the email and returns SENT_LOGIN_EMAIL. 

359 """ 

360 logger.debug(f"Attempting login for {request.user=}") 

361 with session_scope() as session: 

362 # if the user is banned, they can get past this but get an error later in login flow 

363 user = session.execute( 

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

365 ).scalar_one_or_none() 

366 if user: 

367 if user.has_password: 

368 logger.debug(f"Found user with password") 

369 return auth_pb2.LoginRes(next_step=auth_pb2.LoginRes.LoginStep.NEED_PASSWORD) 

370 else: 

371 logger.debug(f"Found user without password, sending login email") 

372 send_login_email(session, user) 

373 return auth_pb2.LoginRes(next_step=auth_pb2.LoginRes.LoginStep.SENT_LOGIN_EMAIL) 

374 else: # user not found 

375 logger.debug(f"Didn't find user") 

376 context.abort(grpc.StatusCode.NOT_FOUND, errors.USER_NOT_FOUND) 

377 

378 def CompleteTokenLogin(self, request, context): 

379 """ 

380 Second step of email-based login. 

381 

382 Validates the given LoginToken (sent in email), creates a new session and returns bearer token. 

383 

384 Or fails with grpc.NOT_FOUND if LoginToken is invalid. 

385 """ 

386 with session_scope() as session: 

387 res = session.execute( 

388 select(LoginToken, User) 

389 .join(User, User.id == LoginToken.user_id) 

390 .where(LoginToken.token == request.login_token) 

391 .where(LoginToken.is_valid) 

392 ).one_or_none() 

393 if res: 

394 login_token, user = res 

395 

396 # delete the login token so it can't be reused 

397 session.delete(login_token) 

398 session.commit() 

399 

400 # create a session 

401 token, expiry = create_session(context, session, user, False) 

402 context.send_initial_metadata( 

403 [ 

404 ("set-cookie", create_session_cookie(token, expiry)), 

405 ] 

406 ) 

407 return _auth_res(user) 

408 else: 

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

410 

411 def Authenticate(self, request, context): 

412 """ 

413 Authenticates a classic password based login request. 

414 

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

416 """ 

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

418 with session_scope() as session: 

419 user = session.execute( 

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

421 ).scalar_one_or_none() 

422 if user: 

423 logger.debug(f"Found user") 

424 if not user.has_password: 

425 logger.debug(f"User doesn't have a password!") 

426 context.abort(grpc.StatusCode.FAILED_PRECONDITION, errors.NO_PASSWORD) 

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

428 logger.debug(f"Right password") 

429 # correct password 

430 token, expiry = create_session(context, session, user, request.remember_device) 

431 context.send_initial_metadata( 

432 [ 

433 ("set-cookie", create_session_cookie(token, expiry)), 

434 ] 

435 ) 

436 return _auth_res(user) 

437 else: 

438 logger.debug(f"Wrong password") 

439 # wrong password 

440 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_USERNAME_OR_PASSWORD) 

441 else: # user not found 

442 logger.debug(f"Didn't find user") 

443 # do about as much work as if the user was found, reduces timing based username enumeration attacks 

444 hash_password(request.password) 

445 context.abort(grpc.StatusCode.NOT_FOUND, errors.INVALID_USERNAME_OR_PASSWORD) 

446 

447 def Deauthenticate(self, request, context): 

448 """ 

449 Removes an active cookie session. 

450 """ 

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

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

453 

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

455 if token: 

456 delete_session(token) 

457 

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

459 context.send_initial_metadata( 

460 [ 

461 ("set-cookie", create_session_cookie("", now())), 

462 ] 

463 ) 

464 

465 return empty_pb2.Empty() 

466 

467 def ResetPassword(self, request, context): 

468 """ 

469 If the user does not exist, do nothing. 

470 

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

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

473 

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

475 """ 

476 with session_scope() as session: 

477 user = session.execute( 

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

479 ).scalar_one_or_none() 

480 if user: 

481 send_password_reset_email(session, user) 

482 

483 notify( 

484 user_id=user.id, 

485 topic="account_recovery", 

486 key="", 

487 action="start", 

488 icon="wrench", 

489 title=f"Password reset initiated", 

490 link=urls.account_settings_link(), 

491 ) 

492 

493 else: # user not found 

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

495 

496 return empty_pb2.Empty() 

497 

498 def CompletePasswordReset(self, request, context): 

499 """ 

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

501 """ 

502 with session_scope() as session: 

503 res = session.execute( 

504 select(PasswordResetToken, User) 

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

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

507 .where(PasswordResetToken.is_valid) 

508 ).one_or_none() 

509 if res: 

510 password_reset_token, user = res 

511 session.delete(password_reset_token) 

512 user.hashed_password = None 

513 session.commit() 

514 

515 notify( 

516 user_id=user.id, 

517 topic="account_recovery", 

518 key="", 

519 action="complete", 

520 icon="wrench", 

521 title=f"Password reset completed", 

522 link=urls.account_settings_link(), 

523 ) 

524 

525 return empty_pb2.Empty() 

526 else: 

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

528 

529 def ConfirmChangeEmail(self, request, context): 

530 with session_scope() as session: 

531 user_with_valid_token_from_old_email = session.execute( 

532 select(User) 

533 .where(User.old_email_token == request.change_email_token) 

534 .where(User.old_email_token_created <= now()) 

535 .where(User.old_email_token_expiry >= now()) 

536 ).scalar_one_or_none() 

537 user_with_valid_token_from_new_email = session.execute( 

538 select(User) 

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

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

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

542 ).scalar_one_or_none() 

543 

544 if user_with_valid_token_from_old_email: 

545 user = user_with_valid_token_from_old_email 

546 user.old_email_token = None 

547 user.old_email_token_created = None 

548 user.old_email_token_expiry = None 

549 user.need_to_confirm_via_old_email = False 

550 elif user_with_valid_token_from_new_email: 

551 user = user_with_valid_token_from_new_email 

552 user.new_email_token = None 

553 user.new_email_token_created = None 

554 user.new_email_token_expiry = None 

555 user.need_to_confirm_via_new_email = False 

556 else: 

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

558 

559 # Using "___ is False" instead of "not ___" so that "None" doesn't pass 

560 if user.need_to_confirm_via_old_email is False and user.need_to_confirm_via_new_email is False: 

561 user.email = user.new_email 

562 user.new_email = None 

563 user.need_to_confirm_via_old_email = None 

564 user.need_to_confirm_via_new_email = None 

565 

566 notify( 

567 user_id=user.id, 

568 topic="email_address", 

569 key="", 

570 action="change", 

571 icon="wrench", 

572 title=f"Your email was changed", 

573 link=urls.account_settings_link(), 

574 ) 

575 

576 return auth_pb2.ConfirmChangeEmailRes(state=auth_pb2.EMAIL_CONFIRMATION_STATE_SUCCESS) 

577 elif user.need_to_confirm_via_old_email: 

578 return auth_pb2.ConfirmChangeEmailRes( 

579 state=auth_pb2.EMAIL_CONFIRMATION_STATE_REQUIRES_CONFIRMATION_FROM_OLD_EMAIL 

580 ) 

581 else: 

582 return auth_pb2.ConfirmChangeEmailRes( 

583 state=auth_pb2.EMAIL_CONFIRMATION_STATE_REQUIRES_CONFIRMATION_FROM_NEW_EMAIL 

584 ) 

585 

586 def ConfirmDeleteAccount(self, request, context): 

587 """ 

588 Confirm account deletion using account delete token 

589 """ 

590 with session_scope() as session: 

591 res = session.execute( 

592 select(User, AccountDeletionToken) 

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

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

595 .where(AccountDeletionToken.is_valid) 

596 ).one_or_none() 

597 

598 if not res: 

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

600 

601 user, account_deletion_token = res 

602 

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

604 

605 undelete_days = 7 

606 user.is_deleted = True 

607 user.undelete_until = now() + timedelta(days=undelete_days) 

608 user.undelete_token = urlsafe_secure_token() 

609 

610 send_account_deletion_successful_email(user, undelete_days) 

611 

612 return empty_pb2.Empty() 

613 

614 def RecoverAccount(self, request, context): 

615 """ 

616 Recovers a recently deleted account 

617 """ 

618 with session_scope() as session: 

619 user = session.execute( 

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

621 ).scalar_one_or_none() 

622 

623 if not user: 

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

625 

626 user.is_deleted = False 

627 user.undelete_token = None 

628 user.undelete_until = None 

629 send_account_recovered_email(user) 

630 

631 return empty_pb2.Empty() 

632 

633 def Unsubscribe(self, request, context): 

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