You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

914 lines
29 KiB

10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
10 years ago
8 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
9 years ago
  1. import base64
  2. from datetime import timedelta as td
  3. from secrets import token_bytes
  4. from urllib.parse import urlparse
  5. import time
  6. import uuid
  7. from django.db import transaction
  8. from django.conf import settings
  9. from django.contrib import messages
  10. from django.contrib.auth import login as auth_login
  11. from django.contrib.auth import logout as auth_logout
  12. from django.contrib.auth import authenticate, update_session_auth_hash
  13. from django.contrib.auth.decorators import login_required
  14. from django.contrib.auth.models import User
  15. from django.core import signing
  16. from django.http import HttpResponse, HttpResponseForbidden, HttpResponseBadRequest
  17. from django.shortcuts import get_object_or_404, redirect, render
  18. from django.utils.timezone import now
  19. from django.urls import resolve, reverse, Resolver404
  20. from django.views.decorators.csrf import csrf_exempt
  21. from django.views.decorators.http import require_POST
  22. from fido2.ctap2 import AttestationObject, AuthenticatorData
  23. from fido2.client import ClientData
  24. from fido2.server import Fido2Server
  25. from fido2.webauthn import PublicKeyCredentialRpEntity
  26. from fido2 import cbor
  27. from hc.accounts import forms
  28. from hc.accounts.decorators import require_sudo_mode
  29. from hc.accounts.models import Credential, Profile, Project, Member
  30. from hc.api.models import Channel, Check, TokenBucket
  31. from hc.payments.models import Subscription
  32. import pyotp
  33. import segno
  34. POST_LOGIN_ROUTES = (
  35. "hc-checks",
  36. "hc-details",
  37. "hc-log",
  38. "hc-channels",
  39. "hc-add-slack",
  40. "hc-add-pushover",
  41. "hc-add-telegram",
  42. "hc-project-settings",
  43. "hc-uncloak",
  44. )
  45. FIDO2_SERVER = Fido2Server(PublicKeyCredentialRpEntity(settings.RP_ID, "healthchecks"))
  46. def _allow_redirect(redirect_url):
  47. if not redirect_url:
  48. return False
  49. parsed = urlparse(redirect_url)
  50. if parsed.netloc:
  51. # Allow redirects only to relative URLs
  52. return False
  53. try:
  54. match = resolve(parsed.path)
  55. except Resolver404:
  56. return False
  57. return match.url_name in POST_LOGIN_ROUTES
  58. def _make_user(email, tz=None, with_project=True):
  59. username = str(uuid.uuid4())[:30]
  60. user = User(username=username, email=email)
  61. user.set_unusable_password()
  62. user.save()
  63. project = None
  64. if with_project:
  65. project = Project(owner=user)
  66. project.badge_key = user.username
  67. project.save()
  68. check = Check(project=project)
  69. check.set_name_slug("My First Check")
  70. check.save()
  71. channel = Channel(project=project)
  72. channel.kind = "email"
  73. channel.value = email
  74. channel.email_verified = True
  75. channel.save()
  76. channel.checks.add(check)
  77. # Ensure a profile gets created
  78. profile = Profile.objects.for_user(user)
  79. if tz:
  80. profile.tz = tz
  81. profile.save()
  82. return user
  83. def _redirect_after_login(request):
  84. """ Redirect to the URL indicated in ?next= query parameter. """
  85. redirect_url = request.GET.get("next")
  86. if _allow_redirect(redirect_url):
  87. return redirect(redirect_url)
  88. if request.user.project_set.count() == 1:
  89. project = request.user.project_set.first()
  90. return redirect("hc-checks", project.code)
  91. return redirect("hc-index")
  92. def _check_2fa(request, user):
  93. have_keys = user.credentials.exists()
  94. profile = Profile.objects.for_user(user)
  95. if have_keys or profile.totp:
  96. # We have verified user's password or token, and now must
  97. # verify their security key. We store the following in user's session:
  98. # - user.id, to look up the user in the login_webauthn view
  99. # - user.email, to make sure email was not changed between the auth steps
  100. # - timestamp, to limit the max time between the auth steps
  101. request.session["2fa_user"] = [user.id, user.email, int(time.time())]
  102. if have_keys:
  103. path = reverse("hc-login-webauthn")
  104. else:
  105. path = reverse("hc-login-totp")
  106. redirect_url = request.GET.get("next")
  107. if _allow_redirect(redirect_url):
  108. path += "?next=%s" % redirect_url
  109. return redirect(path)
  110. auth_login(request, user)
  111. return _redirect_after_login(request)
  112. def login(request):
  113. form = forms.PasswordLoginForm()
  114. magic_form = forms.EmailLoginForm()
  115. if request.method == "POST":
  116. if request.POST.get("action") == "login":
  117. form = forms.PasswordLoginForm(request.POST)
  118. if form.is_valid():
  119. return _check_2fa(request, form.user)
  120. else:
  121. magic_form = forms.EmailLoginForm(request.POST)
  122. if magic_form.is_valid():
  123. redirect_url = request.GET.get("next")
  124. if not _allow_redirect(redirect_url):
  125. redirect_url = None
  126. profile = Profile.objects.for_user(magic_form.user)
  127. profile.send_instant_login_link(redirect_url=redirect_url)
  128. response = redirect("hc-login-link-sent")
  129. # check_token_submit looks for this cookie to decide if
  130. # it needs to do the extra POST step.
  131. response.set_cookie("auto-login", "1", max_age=300, httponly=True)
  132. return response
  133. if request.user.is_authenticated:
  134. return _redirect_after_login(request)
  135. bad_link = request.session.pop("bad_link", None)
  136. ctx = {
  137. "page": "login",
  138. "form": form,
  139. "magic_form": magic_form,
  140. "bad_link": bad_link,
  141. "registration_open": settings.REGISTRATION_OPEN,
  142. "support_email": settings.SUPPORT_EMAIL,
  143. }
  144. return render(request, "accounts/login.html", ctx)
  145. def logout(request):
  146. auth_logout(request)
  147. return redirect("hc-index")
  148. @require_POST
  149. @csrf_exempt
  150. def signup(request):
  151. if not settings.REGISTRATION_OPEN:
  152. return HttpResponseForbidden()
  153. ctx = {}
  154. form = forms.SignupForm(request.POST)
  155. if form.is_valid():
  156. email = form.cleaned_data["identity"]
  157. tz = form.cleaned_data["tz"]
  158. user = _make_user(email, tz)
  159. profile = Profile.objects.for_user(user)
  160. profile.send_instant_login_link()
  161. ctx["created"] = True
  162. else:
  163. ctx = {"form": form}
  164. response = render(request, "accounts/signup_result.html", ctx)
  165. if ctx.get("created"):
  166. response.set_cookie("auto-login", "1", max_age=300, httponly=True)
  167. return response
  168. def login_link_sent(request):
  169. return render(request, "accounts/login_link_sent.html")
  170. def check_token(request, username, token):
  171. if request.user.is_authenticated and request.user.username == username:
  172. # User is already logged in
  173. return _redirect_after_login(request)
  174. # Some email servers open links in emails to check for malicious content.
  175. # To work around this, we sign user in if the method is POST
  176. # *or* if the browser presents a cookie we had set when sending the login link.
  177. #
  178. # If the method is GET and the auto-login cookie isn't present, we serve
  179. # a HTML form with a submit button.
  180. if request.method == "POST" or "auto-login" in request.COOKIES:
  181. user = authenticate(username=username, token=token)
  182. if user is not None and user.is_active:
  183. user.profile.token = ""
  184. user.profile.save()
  185. return _check_2fa(request, user)
  186. request.session["bad_link"] = True
  187. return redirect("hc-login")
  188. return render(request, "accounts/check_token_submit.html")
  189. @login_required
  190. def profile(request):
  191. profile = request.profile
  192. ctx = {
  193. "page": "profile",
  194. "profile": profile,
  195. "my_projects_status": "default",
  196. "2fa_status": "default",
  197. "added_credential_name": request.session.pop("added_credential_name", ""),
  198. "removed_credential_name": request.session.pop("removed_credential_name", ""),
  199. "enabled_totp": request.session.pop("enabled_totp", False),
  200. "disabled_totp": request.session.pop("disabled_totp", False),
  201. "credentials": list(request.user.credentials.order_by("id")),
  202. "use_webauthn": settings.RP_ID,
  203. }
  204. if ctx["added_credential_name"] or ctx["enabled_totp"]:
  205. ctx["2fa_status"] = "success"
  206. if ctx["removed_credential_name"] or ctx["disabled_totp"]:
  207. ctx["2fa_status"] = "info"
  208. if request.session.pop("changed_password", False):
  209. ctx["changed_password"] = True
  210. ctx["email_password_status"] = "success"
  211. if request.method == "POST" and "leave_project" in request.POST:
  212. code = request.POST["code"]
  213. try:
  214. project = Project.objects.get(code=code, member__user=request.user)
  215. except Project.DoesNotExist:
  216. return HttpResponseBadRequest()
  217. Member.objects.filter(project=project, user=request.user).delete()
  218. ctx["left_project"] = project
  219. ctx["my_projects_status"] = "info"
  220. return render(request, "accounts/profile.html", ctx)
  221. @login_required
  222. @require_POST
  223. def add_project(request):
  224. form = forms.ProjectNameForm(request.POST)
  225. if not form.is_valid():
  226. return HttpResponseBadRequest()
  227. project = Project(owner=request.user)
  228. project.code = project.badge_key = str(uuid.uuid4())
  229. project.name = form.cleaned_data["name"]
  230. project.save()
  231. return redirect("hc-checks", project.code)
  232. @login_required
  233. def project(request, code):
  234. project = get_object_or_404(Project, code=code)
  235. is_owner = project.owner_id == request.user.id
  236. if request.user.is_superuser or is_owner:
  237. is_manager = True
  238. rw = True
  239. else:
  240. membership = get_object_or_404(Member, project=project, user=request.user)
  241. is_manager = membership.role == Member.Role.MANAGER
  242. rw = membership.is_rw
  243. ctx = {
  244. "page": "project",
  245. "rw": rw,
  246. "project": project,
  247. "is_owner": is_owner,
  248. "is_manager": is_manager,
  249. "show_api_keys": "show_api_keys" in request.GET,
  250. "enable_prometheus": settings.PROMETHEUS_ENABLED is True,
  251. }
  252. if request.method == "POST":
  253. if "create_api_keys" in request.POST:
  254. if not rw:
  255. return HttpResponseForbidden()
  256. project.set_api_keys()
  257. project.save()
  258. ctx["show_api_keys"] = True
  259. ctx["api_keys_created"] = True
  260. ctx["api_status"] = "success"
  261. elif "revoke_api_keys" in request.POST:
  262. if not rw:
  263. return HttpResponseForbidden()
  264. project.api_key = ""
  265. project.api_key_readonly = ""
  266. project.ping_key = None
  267. project.save()
  268. ctx["api_keys_revoked"] = True
  269. ctx["api_status"] = "info"
  270. elif "show_api_keys" in request.POST:
  271. if not rw:
  272. return HttpResponseForbidden()
  273. ctx["show_api_keys"] = True
  274. elif "invite_team_member" in request.POST:
  275. if not is_manager:
  276. return HttpResponseForbidden()
  277. form = forms.InviteTeamMemberForm(request.POST)
  278. if form.is_valid():
  279. email = form.cleaned_data["email"]
  280. invite_suggestions = project.invite_suggestions()
  281. if not invite_suggestions.filter(email=email).exists():
  282. # We're inviting a new user. Are we within team size limit?
  283. if not project.can_invite_new_users():
  284. return HttpResponseForbidden()
  285. # And are we not hitting a rate limit?
  286. if not TokenBucket.authorize_invite(request.user):
  287. return render(request, "try_later.html")
  288. try:
  289. user = User.objects.get(email=email)
  290. except User.DoesNotExist:
  291. user = _make_user(email, with_project=False)
  292. if project.invite(user, role=form.cleaned_data["role"]):
  293. ctx["team_member_invited"] = email
  294. ctx["team_status"] = "success"
  295. else:
  296. ctx["team_member_duplicate"] = email
  297. ctx["team_status"] = "info"
  298. elif "remove_team_member" in request.POST:
  299. if not is_manager:
  300. return HttpResponseForbidden()
  301. form = forms.RemoveTeamMemberForm(request.POST)
  302. if form.is_valid():
  303. q = User.objects
  304. q = q.filter(email=form.cleaned_data["email"])
  305. q = q.filter(memberships__project=project)
  306. farewell_user = q.first()
  307. if farewell_user is None:
  308. return HttpResponseBadRequest()
  309. if farewell_user == request.user:
  310. return HttpResponseBadRequest()
  311. Member.objects.filter(project=project, user=farewell_user).delete()
  312. ctx["team_member_removed"] = form.cleaned_data["email"]
  313. ctx["team_status"] = "info"
  314. elif "set_project_name" in request.POST:
  315. if not rw:
  316. return HttpResponseForbidden()
  317. form = forms.ProjectNameForm(request.POST)
  318. if form.is_valid():
  319. project.name = form.cleaned_data["name"]
  320. project.save()
  321. ctx["project_name_updated"] = True
  322. ctx["project_name_status"] = "success"
  323. elif "transfer_project" in request.POST:
  324. if not is_owner:
  325. return HttpResponseForbidden()
  326. form = forms.TransferForm(request.POST)
  327. if form.is_valid():
  328. # Look up the proposed new owner
  329. email = form.cleaned_data["email"]
  330. try:
  331. membership = project.member_set.filter(user__email=email).get()
  332. except Member.DoesNotExist:
  333. return HttpResponseBadRequest()
  334. # Revoke any previous transfer requests
  335. project.member_set.update(transfer_request_date=None)
  336. # Initiate the new request
  337. membership.transfer_request_date = now()
  338. membership.save()
  339. # Send an email notification
  340. profile = Profile.objects.for_user(membership.user)
  341. profile.send_transfer_request(project)
  342. ctx["transfer_initiated"] = True
  343. ctx["transfer_status"] = "success"
  344. elif "cancel_transfer" in request.POST:
  345. if not is_owner:
  346. return HttpResponseForbidden()
  347. project.member_set.update(transfer_request_date=None)
  348. ctx["transfer_cancelled"] = True
  349. ctx["transfer_status"] = "success"
  350. elif "accept_transfer" in request.POST:
  351. tr = project.transfer_request()
  352. if not tr or tr.user != request.user:
  353. return HttpResponseForbidden()
  354. if not tr.can_accept():
  355. return HttpResponseBadRequest()
  356. with transaction.atomic():
  357. # 1. Reuse the existing membership, and change its user
  358. tr.user = project.owner
  359. tr.transfer_request_date = None
  360. # The previous owner becomes a regular member
  361. # (not readonly, not manager):
  362. tr.role = Member.Role.REGULAR
  363. tr.save()
  364. # 2. Change project's owner
  365. project.owner = request.user
  366. project.save()
  367. ctx["is_owner"] = True
  368. ctx["is_manager"] = True
  369. messages.success(request, "You are now the owner of this project!")
  370. elif "reject_transfer" in request.POST:
  371. tr = project.transfer_request()
  372. if not tr or tr.user != request.user:
  373. return HttpResponseForbidden()
  374. tr.transfer_request_date = None
  375. tr.save()
  376. q = project.member_set.select_related("user").order_by("user__email")
  377. ctx["memberships"] = list(q)
  378. return render(request, "accounts/project.html", ctx)
  379. @login_required
  380. def notifications(request):
  381. profile = request.profile
  382. ctx = {"status": "default", "page": "profile", "profile": profile}
  383. if request.method == "POST":
  384. form = forms.ReportSettingsForm(request.POST)
  385. if form.is_valid():
  386. if form.cleaned_data["tz"]:
  387. profile.tz = form.cleaned_data["tz"]
  388. profile.reports = form.cleaned_data["reports"]
  389. profile.next_report_date = profile.choose_next_report_date()
  390. if profile.nag_period != form.cleaned_data["nag_period"]:
  391. # Set the new nag period
  392. profile.nag_period = form.cleaned_data["nag_period"]
  393. # and update next_nag_date:
  394. if profile.nag_period:
  395. profile.update_next_nag_date()
  396. else:
  397. profile.next_nag_date = None
  398. profile.save()
  399. ctx["status"] = "info"
  400. return render(request, "accounts/notifications.html", ctx)
  401. @login_required
  402. @require_sudo_mode
  403. def set_password(request):
  404. if request.method == "POST":
  405. form = forms.SetPasswordForm(request.POST)
  406. if form.is_valid():
  407. password = form.cleaned_data["password"]
  408. request.user.set_password(password)
  409. request.user.save()
  410. request.profile.token = ""
  411. request.profile.save()
  412. # update the session with the new password hash so that
  413. # the user doesn't get logged out
  414. update_session_auth_hash(request, request.user)
  415. request.session["changed_password"] = True
  416. return redirect("hc-profile")
  417. return render(request, "accounts/set_password.html", {})
  418. @login_required
  419. @require_sudo_mode
  420. def change_email(request):
  421. if request.method == "POST":
  422. form = forms.ChangeEmailForm(request.POST)
  423. if form.is_valid():
  424. request.user.email = form.cleaned_data["email"]
  425. request.user.set_unusable_password()
  426. request.user.save()
  427. request.profile.token = ""
  428. request.profile.save()
  429. return redirect("hc-change-email-done")
  430. else:
  431. form = forms.ChangeEmailForm()
  432. return render(request, "accounts/change_email.html", {"form": form})
  433. def change_email_done(request):
  434. return render(request, "accounts/change_email_done.html")
  435. @csrf_exempt
  436. def unsubscribe_reports(request, signed_username):
  437. # Some email servers open links in emails to check for malicious content.
  438. # To work around this, for GET requests we serve a confirmation form.
  439. # If the signature is more than 5 minutes old, we also include JS code to
  440. # auto-submit the form.
  441. signer = signing.TimestampSigner(salt="reports")
  442. # First, check the signature without looking at the timestamp:
  443. try:
  444. username = signer.unsign(signed_username)
  445. except signing.BadSignature:
  446. return render(request, "bad_link.html")
  447. try:
  448. user = User.objects.get(username=username)
  449. except User.DoesNotExist:
  450. # This is likely an old unsubscribe link, and the user account has already
  451. # been deleted. Show the "Unsubscribed!" page nevertheless.
  452. return render(request, "accounts/unsubscribed.html")
  453. if request.method != "POST":
  454. # Unsign again, now with max_age set,
  455. # to see if the timestamp is older than 5 minutes
  456. try:
  457. autosubmit = False
  458. username = signer.unsign(signed_username, max_age=300)
  459. except signing.SignatureExpired:
  460. autosubmit = True
  461. ctx = {"autosubmit": autosubmit}
  462. return render(request, "accounts/unsubscribe_submit.html", ctx)
  463. profile = Profile.objects.for_user(user)
  464. profile.reports = "off"
  465. profile.next_report_date = None
  466. profile.nag_period = td()
  467. profile.next_nag_date = None
  468. profile.save()
  469. return render(request, "accounts/unsubscribed.html")
  470. @login_required
  471. @require_sudo_mode
  472. def close(request):
  473. user = request.user
  474. if request.method == "POST":
  475. if request.POST.get("confirmation") == request.user.email:
  476. # Cancel their subscription:
  477. sub = Subscription.objects.filter(user=user).first()
  478. if sub:
  479. sub.cancel()
  480. # Deleting user also deletes its profile, checks, channels etc.
  481. user.delete()
  482. request.session.flush()
  483. return redirect("hc-index")
  484. ctx = {}
  485. if "confirmation" in request.POST:
  486. ctx["wrong_confirmation"] = True
  487. return render(request, "accounts/close_account.html", ctx)
  488. @require_POST
  489. @login_required
  490. def remove_project(request, code):
  491. project = get_object_or_404(Project, code=code, owner=request.user)
  492. project.delete()
  493. return redirect("hc-index")
  494. def _get_credential_data(request, form):
  495. """ Complete WebAuthn registration, return binary credential data.
  496. This function is an interface to the fido2 library. It is separated
  497. out so that we don't need to mock ClientData, AttestationObject,
  498. register_complete separately in tests.
  499. """
  500. try:
  501. auth_data = FIDO2_SERVER.register_complete(
  502. request.session["state"],
  503. ClientData(form.cleaned_data["client_data_json"]),
  504. AttestationObject(form.cleaned_data["attestation_object"]),
  505. )
  506. except ValueError:
  507. return None
  508. return auth_data.credential_data
  509. @login_required
  510. @require_sudo_mode
  511. def add_webauthn(request):
  512. if not settings.RP_ID:
  513. return HttpResponse(status=404)
  514. if request.method == "POST":
  515. form = forms.AddWebAuthnForm(request.POST)
  516. if not form.is_valid():
  517. return HttpResponseBadRequest()
  518. credential_data = _get_credential_data(request, form)
  519. if not credential_data:
  520. return HttpResponseBadRequest()
  521. c = Credential(user=request.user)
  522. c.name = form.cleaned_data["name"]
  523. c.data = credential_data
  524. c.save()
  525. request.session["added_credential_name"] = c.name
  526. return redirect("hc-profile")
  527. credentials = [c.unpack() for c in request.user.credentials.all()]
  528. # User handle is used in a username-less authentication, to map a credential
  529. # received from browser with an user account in the database.
  530. # Since we only use security keys as a second factor,
  531. # the user handle is not of much use to us.
  532. #
  533. # The user handle:
  534. # - must not be blank,
  535. # - must not be a constant value,
  536. # - must not contain personally identifiable information.
  537. # So we use random bytes, and don't store them on our end:
  538. options, state = FIDO2_SERVER.register_begin(
  539. {
  540. "id": token_bytes(16),
  541. "name": request.user.email,
  542. "displayName": request.user.email,
  543. },
  544. credentials,
  545. )
  546. request.session["state"] = state
  547. ctx = {"options": base64.b64encode(cbor.encode(options)).decode()}
  548. return render(request, "accounts/add_credential.html", ctx)
  549. @login_required
  550. @require_sudo_mode
  551. def add_totp(request):
  552. if request.profile.totp:
  553. # TOTP is already configured, refuse to continue
  554. return HttpResponseBadRequest()
  555. if "totp_secret" not in request.session:
  556. request.session["totp_secret"] = pyotp.random_base32()
  557. totp = pyotp.totp.TOTP(request.session["totp_secret"])
  558. if request.method == "POST":
  559. form = forms.TotpForm(totp, request.POST)
  560. if form.is_valid():
  561. request.profile.totp = request.session["totp_secret"]
  562. request.profile.totp_created = now()
  563. request.profile.save()
  564. request.session["enabled_totp"] = True
  565. request.session.pop("totp_secret")
  566. return redirect("hc-profile")
  567. else:
  568. form = forms.TotpForm(totp)
  569. uri = totp.provisioning_uri(name=request.user.email, issuer_name=settings.SITE_NAME)
  570. qr_data_uri = segno.make(uri).png_data_uri(scale=8)
  571. ctx = {"form": form, "qr_data_uri": qr_data_uri}
  572. return render(request, "accounts/add_totp.html", ctx)
  573. @login_required
  574. @require_sudo_mode
  575. def remove_totp(request):
  576. if request.method == "POST" and "disable_totp" in request.POST:
  577. request.profile.totp = None
  578. request.profile.totp_created = None
  579. request.profile.save()
  580. request.session["disabled_totp"] = True
  581. return redirect("hc-profile")
  582. ctx = {"is_last": not request.user.credentials.exists()}
  583. return render(request, "accounts/remove_totp.html", ctx)
  584. @login_required
  585. @require_sudo_mode
  586. def remove_credential(request, code):
  587. if not settings.RP_ID:
  588. return HttpResponse(status=404)
  589. try:
  590. credential = Credential.objects.get(user=request.user, code=code)
  591. except Credential.DoesNotExist:
  592. return HttpResponseBadRequest()
  593. if request.method == "POST" and "remove_credential" in request.POST:
  594. request.session["removed_credential_name"] = credential.name
  595. credential.delete()
  596. return redirect("hc-profile")
  597. if request.profile.totp:
  598. is_last = False
  599. else:
  600. is_last = request.user.credentials.count() == 1
  601. ctx = {"credential": credential, "is_last": is_last}
  602. return render(request, "accounts/remove_credential.html", ctx)
  603. def _check_credential(request, form, credentials):
  604. """ Complete WebAuthn authentication, return True on success.
  605. This function is an interface to the fido2 library. It is separated
  606. out so that we don't need to mock ClientData, AuthenticatorData,
  607. authenticate_complete separately in tests.
  608. """
  609. try:
  610. FIDO2_SERVER.authenticate_complete(
  611. request.session["state"],
  612. credentials,
  613. form.cleaned_data["credential_id"],
  614. ClientData(form.cleaned_data["client_data_json"]),
  615. AuthenticatorData(form.cleaned_data["authenticator_data"]),
  616. form.cleaned_data["signature"],
  617. )
  618. except ValueError:
  619. return False
  620. return True
  621. def login_webauthn(request):
  622. # We require RP_ID. Fail predicably if it is not set:
  623. if not settings.RP_ID:
  624. return HttpResponse(status=500)
  625. # Expect an unauthenticated user
  626. if request.user.is_authenticated:
  627. return HttpResponseBadRequest()
  628. if "2fa_user" not in request.session:
  629. return HttpResponseBadRequest()
  630. user_id, email, timestamp = request.session["2fa_user"]
  631. if timestamp + 300 < time.time():
  632. return redirect("hc-login")
  633. try:
  634. user = User.objects.get(id=user_id, email=email)
  635. except User.DoesNotExist:
  636. return HttpResponseBadRequest()
  637. credentials = [c.unpack() for c in user.credentials.all()]
  638. if request.method == "POST":
  639. form = forms.WebAuthnForm(request.POST)
  640. if not form.is_valid():
  641. return HttpResponseBadRequest()
  642. if not _check_credential(request, form, credentials):
  643. return HttpResponseBadRequest()
  644. request.session.pop("state")
  645. request.session.pop("2fa_user")
  646. auth_login(request, user, "hc.accounts.backends.EmailBackend")
  647. return _redirect_after_login(request)
  648. options, state = FIDO2_SERVER.authenticate_begin(credentials)
  649. request.session["state"] = state
  650. totp_url = None
  651. if user.profile.totp:
  652. totp_url = reverse("hc-login-totp")
  653. redirect_url = request.GET.get("next")
  654. if _allow_redirect(redirect_url):
  655. totp_url += "?next=%s" % redirect_url
  656. ctx = {
  657. "options": base64.b64encode(cbor.encode(options)).decode(),
  658. "totp_url": totp_url,
  659. }
  660. return render(request, "accounts/login_webauthn.html", ctx)
  661. def login_totp(request):
  662. # Expect an unauthenticated user
  663. if request.user.is_authenticated:
  664. return HttpResponseBadRequest()
  665. if "2fa_user" not in request.session:
  666. return HttpResponseBadRequest()
  667. user_id, email, timestamp = request.session["2fa_user"]
  668. if timestamp + 300 < time.time():
  669. return redirect("hc-login")
  670. try:
  671. user = User.objects.get(id=user_id, email=email)
  672. except User.DoesNotExist:
  673. return HttpResponseBadRequest()
  674. if not user.profile.totp:
  675. return HttpResponseBadRequest()
  676. totp = pyotp.totp.TOTP(user.profile.totp)
  677. if request.method == "POST":
  678. # To guard against brute-forcing TOTP codes, we allow
  679. # 96 attempts per user per 24h.
  680. if not TokenBucket.authorize_totp_attempt(user):
  681. return render(request, "try_later.html")
  682. form = forms.TotpForm(totp, request.POST)
  683. if form.is_valid():
  684. # We blacklist an used TOTP code for 90 seconds,
  685. # so an attacker cannot reuse a stolen code.
  686. if not TokenBucket.authorize_totp_code(user, form.cleaned_data["code"]):
  687. return render(request, "try_later.html")
  688. request.session.pop("2fa_user")
  689. auth_login(request, user, "hc.accounts.backends.EmailBackend")
  690. return _redirect_after_login(request)
  691. else:
  692. form = forms.TotpForm(totp)
  693. return render(request, "accounts/login_totp.html", {"form": form})
  694. @login_required
  695. def appearance(request):
  696. profile = request.profile
  697. ctx = {
  698. "page": "appearance",
  699. "profile": profile,
  700. "status": "default",
  701. }
  702. if request.method == "POST":
  703. theme = request.POST.get("theme", "")
  704. if theme in ("", "dark"):
  705. profile.theme = theme
  706. profile.save()
  707. ctx["status"] = "info"
  708. return render(request, "accounts/appearance.html", ctx)