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

248 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-07-22 16:44 +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.db import session_scope 

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

13from couchers.notifications.notify import notify 

14from couchers.notifications.unsubscribe import unsubscribe 

15from couchers.servicers.account import abort_on_invalid_password, contributeoption2sql 

16from couchers.servicers.api import hostingstatus2sql 

17from couchers.sql import couchers_select as select 

18from couchers.tasks import ( 

19 enforce_community_memberships_for_user, 

20 maybe_send_contributor_form_email, 

21 send_signup_email, 

22) 

23from couchers.utils import ( 

24 create_coordinate, 

25 create_session_cookie, 

26 is_valid_email, 

27 is_valid_name, 

28 is_valid_username, 

29 minimum_allowed_birthdate, 

30 now, 

31 parse_date, 

32 parse_session_cookie, 

33) 

34from proto import auth_pb2, auth_pb2_grpc, notification_data_pb2 

35 

36logger = logging.getLogger(__name__) 

37 

38 

39def _auth_res(user): 

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

41 

42 

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

44 """ 

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

46 

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

48 work here due to the active User object. 

49 

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

51 

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

53 

54 ```py3 

55 token, expiry = create_session(...) 

56 ``` 

57 """ 

58 if user.is_banned: 

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

60 

61 # just double check 

62 assert not user.is_deleted 

63 

64 token = cookiesafe_secure_token() 

65 

66 headers = dict(context.invocation_metadata()) 

67 

68 user_session = UserSession( 

69 token=token, 

70 user=user, 

71 long_lived=long_lived, 

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

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

74 is_api_key=is_api_key, 

75 ) 

76 if duration: 

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

78 

79 session.add(user_session) 

80 session.commit() 

81 

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

83 

84 if set_cookie: 

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

86 return token, user_session.expiry 

87 

88 

89def delete_session(token): 

90 """ 

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

92 

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

94 """ 

95 with session_scope() as session: 

96 user_session = session.execute( 

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

98 ).scalar_one_or_none() 

99 if user_session: 

100 user_session.deleted = func.now() 

101 session.commit() 

102 return True 

103 else: 

104 return False 

105 

106 

107class Auth(auth_pb2_grpc.AuthServicer): 

108 """ 

109 The Auth servicer. 

110 

111 This class services the Auth service/API. 

112 """ 

113 

114 def _username_available(self, username): 

115 """ 

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

117 """ 

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

119 if not is_valid_username(username): 

120 return False 

121 with session_scope() as session: 

122 # check for existing user with that username 

123 user_exists = ( 

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

125 ) 

126 # check for started signup with that username 

127 signup_exists = ( 

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

129 is not None 

130 ) 

131 # return False if user exists, True otherwise 

132 return not user_exists and not signup_exists 

133 

134 def SignupFlow(self, request, context): 

135 with session_scope() as session: 

136 if request.email_token: 

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

138 flow = session.execute( 

139 select(SignupFlow) 

140 .where(SignupFlow.email_verified == False) 

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

142 .where(SignupFlow.token_is_valid) 

143 ).scalar_one_or_none() 

144 if flow: 

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

146 flow.email_verified = True 

147 flow.email_token = None 

148 flow.email_token_expiry = None 

149 

150 session.flush() 

151 else: 

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

153 flow = session.execute( 

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

155 ).scalar_one_or_none() 

156 if not flow: 

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

158 else: 

159 if not request.flow_token: 

160 # fresh signup 

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

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

163 # TODO: unique across both tables 

164 existing_user = session.execute( 

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

166 ).scalar_one_or_none() 

167 if existing_user: 

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

169 existing_flow = session.execute( 

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

171 ).scalar_one_or_none() 

172 if existing_flow: 

173 send_signup_email(existing_flow) 

174 session.commit() 

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

176 

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

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

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

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

181 

182 flow_token = cookiesafe_secure_token() 

183 

184 flow = SignupFlow( 

185 flow_token=flow_token, 

186 name=request.basic.name, 

187 email=request.basic.email, 

188 ) 

189 session.add(flow) 

190 session.flush() 

191 else: 

192 # not fresh signup 

193 flow = session.execute( 

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

195 ).scalar_one_or_none() 

196 if not flow: 

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

198 if request.HasField("basic"): 

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

200 

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

202 if request.HasField("account"): 

203 if flow.account_is_filled: 

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

205 

206 # check username validity 

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

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

209 

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

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

212 

213 abort_on_invalid_password(request.account.password, context) 

214 hashed_password = hash_password(request.account.password) 

215 

216 birthdate = parse_date(request.account.birthdate) 

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

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

219 

220 if not request.account.hosting_status: 

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

222 

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

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

225 

226 if not request.account.accept_tos: 

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

228 

229 flow.username = request.account.username 

230 flow.hashed_password = hashed_password 

231 flow.birthdate = birthdate 

232 flow.gender = request.account.gender 

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

234 flow.city = request.account.city 

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

236 flow.geom_radius = request.account.radius 

237 flow.accepted_tos = TOS_VERSION 

238 flow.opt_out_of_newsletter = request.account.opt_out_of_newsletter 

239 session.flush() 

240 

241 if request.HasField("feedback"): 

242 if flow.filled_feedback: 

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

244 form = request.feedback 

245 

246 flow.filled_feedback = True 

247 flow.ideas = form.ideas 

248 flow.features = form.features 

249 flow.experience = form.experience 

250 flow.contribute = contributeoption2sql[form.contribute] 

251 flow.contribute_ways = form.contribute_ways 

252 flow.expertise = form.expertise 

253 session.flush() 

254 

255 if request.HasField("accept_community_guidelines"): 

256 if not request.accept_community_guidelines.value: 

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

258 flow.accepted_community_guidelines = GUIDELINES_VERSION 

259 session.flush() 

260 

261 # send verification email if needed 

262 if not flow.email_sent: 

263 send_signup_email(flow) 

264 

265 session.flush() 

266 

267 # finish the signup if done 

268 if flow.is_completed: 

269 user = User( 

270 name=flow.name, 

271 email=flow.email, 

272 username=flow.username, 

273 hashed_password=flow.hashed_password, 

274 birthdate=flow.birthdate, 

275 gender=flow.gender, 

276 hosting_status=flow.hosting_status, 

277 city=flow.city, 

278 geom=flow.geom, 

279 geom_radius=flow.geom_radius, 

280 accepted_tos=flow.accepted_tos, 

281 accepted_community_guidelines=flow.accepted_community_guidelines, 

282 onboarding_emails_sent=1, 

283 last_onboarding_email_sent=func.now(), 

284 opt_out_of_newsletter=flow.opt_out_of_newsletter, 

285 ) 

286 

287 session.add(user) 

288 

289 form = ContributorForm( 

290 user=user, 

291 ideas=flow.ideas or None, 

292 features=flow.features or None, 

293 experience=flow.experience or None, 

294 contribute=flow.contribute or None, 

295 contribute_ways=flow.contribute_ways, 

296 expertise=flow.expertise or None, 

297 ) 

298 

299 session.add(form) 

300 

301 user.filled_contributor_form = form.is_filled 

302 

303 session.delete(flow) 

304 session.commit() 

305 

306 enforce_community_memberships_for_user(session, user) 

307 

308 if form.is_filled: 

309 user.filled_contributor_form = True 

310 

311 maybe_send_contributor_form_email(form) 

312 

313 # sends onboarding email 

314 notify( 

315 user_id=user.id, 

316 topic_action="onboarding:reminder", 

317 key="1", 

318 ) 

319 

320 create_session(context, session, user, False) 

321 return auth_pb2.SignupFlowRes( 

322 auth_res=_auth_res(user), 

323 ) 

324 else: 

325 return auth_pb2.SignupFlowRes( 

326 flow_token=flow.flow_token, 

327 need_account=not flow.account_is_filled, 

328 need_feedback=not flow.filled_feedback, 

329 need_verify_email=not flow.email_verified, 

330 need_accept_community_guidelines=flow.accepted_community_guidelines < GUIDELINES_VERSION, 

331 ) 

332 

333 def UsernameValid(self, request, context): 

334 """ 

335 Runs a username availability and validity check. 

336 """ 

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

338 

339 def Authenticate(self, request, context): 

340 """ 

341 Authenticates a classic password based login request. 

342 

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

344 """ 

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

346 with session_scope() as session: 

347 user = session.execute( 

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

349 ).scalar_one_or_none() 

350 if user: 

351 logger.debug("Found user") 

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

353 logger.debug("Right password") 

354 # correct password 

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

356 return _auth_res(user) 

357 else: 

358 logger.debug("Wrong password") 

359 # wrong password 

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

361 else: # user not found 

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

363 signup_flow = session.execute( 

364 select(SignupFlow).where_username_or_email(request.user, model=SignupFlow) 

365 ).scalar_one_or_none() 

366 if signup_flow: 

367 send_signup_email(signup_flow) 

368 session.commit() 

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

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

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

372 

373 def GetAuthState(self, request, context): 

374 if not context.user_id: 

375 return auth_pb2.GetAuthStateRes(logged_in=False) 

376 else: 

377 with session_scope() as session: 

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

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

380 

381 def Deauthenticate(self, request, context): 

382 """ 

383 Removes an active cookie session. 

384 """ 

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

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

387 

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

389 if token: 

390 delete_session(token) 

391 

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

393 context.send_initial_metadata( 

394 [ 

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

396 ] 

397 ) 

398 

399 return empty_pb2.Empty() 

400 

401 def ResetPassword(self, request, context): 

402 """ 

403 If the user does not exist, do nothing. 

404 

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

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

407 

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

409 """ 

410 with session_scope() as session: 

411 user = session.execute( 

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

413 ).scalar_one_or_none() 

414 if user: 

415 password_reset_token = PasswordResetToken( 

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

417 ) 

418 session.add(password_reset_token) 

419 session.flush() 

420 

421 notify( 

422 user_id=user.id, 

423 topic_action="password_reset:start", 

424 data=notification_data_pb2.PasswordResetStart( 

425 password_reset_token=password_reset_token.token, 

426 ), 

427 ) 

428 else: # user not found 

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

430 

431 return empty_pb2.Empty() 

432 

433 def CompletePasswordResetV2(self, request, context): 

434 """ 

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

436 """ 

437 with session_scope() as session: 

438 res = session.execute( 

439 select(PasswordResetToken, User) 

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

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

442 .where(PasswordResetToken.is_valid) 

443 ).one_or_none() 

444 if res: 

445 password_reset_token, user = res 

446 abort_on_invalid_password(request.new_password, context) 

447 user.hashed_password = hash_password(request.new_password) 

448 session.delete(password_reset_token) 

449 

450 session.flush() 

451 

452 notify( 

453 user_id=user.id, 

454 topic_action="password_reset:complete", 

455 ) 

456 

457 create_session(context, session, user, False) 

458 return _auth_res(user) 

459 else: 

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

461 

462 def ConfirmChangeEmailV2(self, request, context): 

463 with session_scope() as session: 

464 user = session.execute( 

465 select(User) 

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

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

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

469 ).scalar_one_or_none() 

470 

471 if not user: 

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

473 

474 user.email = user.new_email 

475 user.new_email = None 

476 user.new_email_token = None 

477 user.new_email_token_created = None 

478 user.new_email_token_expiry = None 

479 

480 notify( 

481 user_id=user.id, 

482 topic_action="email_address:verify", 

483 ) 

484 

485 return empty_pb2.Empty() 

486 

487 def ConfirmDeleteAccount(self, request, context): 

488 """ 

489 Confirm account deletion using account delete token 

490 """ 

491 with session_scope() as session: 

492 res = session.execute( 

493 select(User, AccountDeletionToken) 

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

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

496 .where(AccountDeletionToken.is_valid) 

497 ).one_or_none() 

498 

499 if not res: 

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

501 

502 user, account_deletion_token = res 

503 

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

505 

506 user.is_deleted = True 

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

508 user.undelete_token = urlsafe_secure_token() 

509 

510 session.flush() 

511 

512 notify( 

513 user_id=user.id, 

514 topic_action="account_deletion:complete", 

515 data=notification_data_pb2.AccountDeletionComplete( 

516 undelete_token=user.undelete_token, 

517 undelete_days=UNDELETE_DAYS, 

518 ), 

519 ) 

520 

521 return empty_pb2.Empty() 

522 

523 def RecoverAccount(self, request, context): 

524 """ 

525 Recovers a recently deleted account 

526 """ 

527 with session_scope() as session: 

528 user = session.execute( 

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

530 ).scalar_one_or_none() 

531 

532 if not user: 

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

534 

535 user.is_deleted = False 

536 user.undelete_token = None 

537 user.undelete_until = None 

538 

539 notify( 

540 user_id=user.id, 

541 topic_action="account_deletion:recovered", 

542 ) 

543 

544 return empty_pb2.Empty() 

545 

546 def Unsubscribe(self, request, context): 

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