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

247 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-12-02 20:27 +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 form = ContributorForm( 

300 user=user, 

301 ideas=flow.ideas or None, 

302 features=flow.features or None, 

303 experience=flow.experience or None, 

304 contribute=flow.contribute or None, 

305 contribute_ways=flow.contribute_ways, 

306 expertise=flow.expertise or None, 

307 ) 

308 

309 session.add(form) 

310 

311 user.filled_contributor_form = form.is_filled 

312 

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

314 

315 session.delete(flow) 

316 session.commit() 

317 

318 enforce_community_memberships_for_user(session, user) 

319 

320 if form.is_filled: 

321 user.filled_contributor_form = True 

322 

323 maybe_send_contributor_form_email(session, form) 

324 

325 # sends onboarding email 

326 notify( 

327 session, 

328 user_id=user.id, 

329 topic_action="onboarding:reminder", 

330 key="1", 

331 ) 

332 

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

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

335 

336 create_session(context, session, user, False) 

337 return auth_pb2.SignupFlowRes( 

338 auth_res=_auth_res(user), 

339 ) 

340 else: 

341 return auth_pb2.SignupFlowRes( 

342 flow_token=flow.flow_token, 

343 need_account=not flow.account_is_filled, 

344 need_feedback=not flow.filled_feedback, 

345 need_verify_email=not flow.email_verified, 

346 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

347 ) 

348 

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

350 """ 

351 Runs a username availability and validity check. 

352 """ 

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

354 

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

356 """ 

357 Authenticates a classic password based login request. 

358 

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

360 """ 

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

362 user = session.execute( 

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

364 ).scalar_one_or_none() 

365 if user: 

366 logger.debug("Found user") 

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

368 logger.debug("Right password") 

369 # correct password 

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

371 return _auth_res(user) 

372 else: 

373 logger.debug("Wrong password") 

374 # wrong password 

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

376 else: # user not found 

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

378 signup_flow = session.execute( 

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

380 ).scalar_one_or_none() 

381 if signup_flow: 

382 send_signup_email(session, signup_flow) 

383 session.commit() 

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

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

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

387 

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

389 if not context.user_id: 

390 return auth_pb2.GetAuthStateRes(logged_in=False) 

391 else: 

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

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

394 

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

396 """ 

397 Removes an active cookie session. 

398 """ 

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

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

401 

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

403 if token: 

404 delete_session(session, token) 

405 

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

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

408 

409 return empty_pb2.Empty() 

410 

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

412 """ 

413 If the user does not exist, do nothing. 

414 

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

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

417 

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

419 """ 

420 user = session.execute( 

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

422 ).scalar_one_or_none() 

423 if user: 

424 password_reset_token = PasswordResetToken( 

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

426 ) 

427 session.add(password_reset_token) 

428 session.flush() 

429 

430 notify( 

431 session, 

432 user_id=user.id, 

433 topic_action="password_reset:start", 

434 data=notification_data_pb2.PasswordResetStart( 

435 password_reset_token=password_reset_token.token, 

436 ), 

437 ) 

438 

439 password_reset_initiations_counter.inc() 

440 else: # user not found 

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

442 

443 return empty_pb2.Empty() 

444 

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

446 """ 

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

448 """ 

449 res = session.execute( 

450 select(PasswordResetToken, User) 

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

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

453 .where(PasswordResetToken.is_valid) 

454 ).one_or_none() 

455 if res: 

456 password_reset_token, user = res 

457 abort_on_invalid_password(request.new_password, context) 

458 user.hashed_password = hash_password(request.new_password) 

459 session.delete(password_reset_token) 

460 

461 session.flush() 

462 

463 notify( 

464 session, 

465 user_id=user.id, 

466 topic_action="password_reset:complete", 

467 ) 

468 

469 create_session(context, session, user, False) 

470 password_reset_completions_counter.inc() 

471 return _auth_res(user) 

472 else: 

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

474 

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

476 user = session.execute( 

477 select(User) 

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

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

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

481 ).scalar_one_or_none() 

482 

483 if not user: 

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

485 

486 user.email = user.new_email 

487 user.new_email = None 

488 user.new_email_token = None 

489 user.new_email_token_created = None 

490 user.new_email_token_expiry = None 

491 

492 notify( 

493 session, 

494 user_id=user.id, 

495 topic_action="email_address:verify", 

496 ) 

497 

498 return empty_pb2.Empty() 

499 

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

501 """ 

502 Confirm account deletion using account delete token 

503 """ 

504 res = session.execute( 

505 select(User, AccountDeletionToken) 

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

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

508 .where(AccountDeletionToken.is_valid) 

509 ).one_or_none() 

510 

511 if not res: 

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

513 

514 user, account_deletion_token = res 

515 

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

517 

518 user.is_deleted = True 

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

520 user.undelete_token = urlsafe_secure_token() 

521 

522 session.flush() 

523 

524 notify( 

525 session, 

526 user_id=user.id, 

527 topic_action="account_deletion:complete", 

528 data=notification_data_pb2.AccountDeletionComplete( 

529 undelete_token=user.undelete_token, 

530 undelete_days=UNDELETE_DAYS, 

531 ), 

532 ) 

533 

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

535 

536 return empty_pb2.Empty() 

537 

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

539 """ 

540 Recovers a recently deleted account 

541 """ 

542 user = session.execute( 

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

544 ).scalar_one_or_none() 

545 

546 if not user: 

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

548 

549 user.is_deleted = False 

550 user.undelete_token = None 

551 user.undelete_until = None 

552 

553 notify( 

554 session, 

555 user_id=user.id, 

556 topic_action="account_deletion:recovered", 

557 ) 

558 

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

560 

561 return empty_pb2.Empty() 

562 

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

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