This commit is contained in:
2022-12-30 16:23:27 +00:00
parent 02776e8478
commit 20da343c54
1304 changed files with 870224 additions and 0 deletions

View File

View File

@ -0,0 +1,69 @@
from flask import request, redirect, url_for, flash, render_template, g
from flask_login import login_user, current_user
from app import email_utils
from app.auth.base import auth_bp
from app.db import Session
from app.extensions import limiter
from app.log import LOG
from app.models import ActivationCode
from app.utils import sanitize_next_url
@auth_bp.route("/activate", methods=["GET", "POST"])
@limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def activate():
if current_user.is_authenticated:
return (
render_template("auth/activate.html", error="You are already logged in"),
400,
)
code = request.args.get("code")
activation_code: ActivationCode = ActivationCode.get_by(code=code)
if not activation_code:
# Trigger rate limiter
g.deduct_limit = True
return (
render_template(
"auth/activate.html", error="Activation code cannot be found"
),
400,
)
if activation_code.is_expired():
return (
render_template(
"auth/activate.html",
error="Activation code was expired",
show_resend_activation=True,
),
400,
)
user = activation_code.user
user.activated = True
login_user(user)
# activation code is to be used only once
ActivationCode.delete(activation_code.id)
Session.commit()
flash("Your account has been activated", "success")
email_utils.send_welcome_email(user)
# The activation link contains the original page, for ex authorize page
if "next" in request.args:
next_url = sanitize_next_url(request.args.get("next"))
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
# todo: redirect to account_activated page when more features are added into the browser extension
# return redirect(url_for("onboarding.account_activated"))

View File

@ -0,0 +1,30 @@
import arrow
from flask import redirect, url_for, request, flash
from flask_login import login_user
from app.auth.base import auth_bp
from app.models import ApiToCookieToken
from app.utils import sanitize_next_url
@auth_bp.route("/api_to_cookie", methods=["GET"])
def api_to_cookie():
code = request.args.get("token")
if not code:
flash("Missing token", "error")
return redirect(url_for("auth.login"))
token = ApiToCookieToken.get_by(code=code)
if not token or token.created_at < arrow.now().shift(minutes=-5):
flash("Missing token", "error")
return redirect(url_for("auth.login"))
user = token.user
ApiToCookieToken.delete(token.id, commit=True)
login_user(user)
next_url = sanitize_next_url(request.args.get("next"))
if next_url:
return redirect(next_url)
else:
return redirect(url_for("dashboard.index"))

View File

@ -0,0 +1,35 @@
from flask import request, flash, render_template, redirect, url_for
from flask_login import login_user
from app.auth.base import auth_bp
from app.db import Session
from app.models import EmailChange, ResetPasswordCode
@auth_bp.route("/change_email", methods=["GET", "POST"])
def change_email():
code = request.args.get("code")
email_change: EmailChange = EmailChange.get_by(code=code)
if not email_change:
return render_template("auth/change_email.html")
if email_change.is_expired():
# delete the expired email
EmailChange.delete(email_change.id)
Session.commit()
return render_template("auth/change_email.html")
user = email_change.user
user.email = email_change.new_email
EmailChange.delete(email_change.id)
ResetPasswordCode.filter_by(user_id=user.id).delete()
Session.commit()
flash("Your new email has been updated", "success")
login_user(user)
return redirect(url_for("dashboard.index"))

View File

@ -0,0 +1,127 @@
from flask import request, session, redirect, url_for, flash
from requests_oauthlib import OAuth2Session
from requests_oauthlib.compliance_fixes import facebook_compliance_fix
from app.auth.base import auth_bp
from app.auth.views.google import create_file_from_url
from app.config import (
URL,
FACEBOOK_CLIENT_ID,
FACEBOOK_CLIENT_SECRET,
)
from app.db import Session
from app.log import LOG
from app.models import User, SocialAuth
from .login_utils import after_login
from ...utils import sanitize_email, sanitize_next_url
_authorization_base_url = "https://www.facebook.com/dialog/oauth"
_token_url = "https://graph.facebook.com/oauth/access_token"
_scope = ["email"]
# need to set explicitly redirect_uri instead of leaving the lib to pre-fill redirect_uri
# when served behind nginx, the redirect_uri is localhost... and not the real url
_redirect_uri = URL + "/auth/facebook/callback"
@auth_bp.route("/facebook/login")
def facebook_login():
# to avoid flask-login displaying the login error message
session.pop("_flashes", None)
next_url = sanitize_next_url(request.args.get("next"))
# Facebook does not allow to append param to redirect_uri
# we need to pass the next url by session
if next_url:
session["facebook_next_url"] = next_url
facebook = OAuth2Session(
FACEBOOK_CLIENT_ID, scope=_scope, redirect_uri=_redirect_uri
)
facebook = facebook_compliance_fix(facebook)
authorization_url, state = facebook.authorization_url(_authorization_base_url)
# State is used to prevent CSRF, keep this for later.
session["oauth_state"] = state
return redirect(authorization_url)
@auth_bp.route("/facebook/callback")
def facebook_callback():
# user clicks on cancel
if "error" in request.args:
flash("Please use another sign in method then", "warning")
return redirect("/")
facebook = OAuth2Session(
FACEBOOK_CLIENT_ID,
state=session["oauth_state"],
scope=_scope,
redirect_uri=_redirect_uri,
)
facebook = facebook_compliance_fix(facebook)
facebook.fetch_token(
_token_url,
client_secret=FACEBOOK_CLIENT_SECRET,
authorization_response=request.url,
)
# Fetch a protected resource, i.e. user profile
# {
# "email": "abcd@gmail.com",
# "id": "1234",
# "name": "First Last",
# "picture": {
# "data": {
# "url": "long_url"
# }
# }
# }
facebook_user_data = facebook.get(
"https://graph.facebook.com/me?fields=id,name,email,picture{url}"
).json()
email = facebook_user_data.get("email")
# user choose to not share email, cannot continue
if not email:
flash(
"In order to use SimpleLogin, you need to give us a valid email", "warning"
)
return redirect(url_for("auth.register"))
email = sanitize_email(email)
user = User.get_by(email=email)
picture_url = facebook_user_data.get("picture", {}).get("data", {}).get("url")
if user:
if picture_url and not user.profile_picture_id:
LOG.d("set user profile picture to %s", picture_url)
file = create_file_from_url(user, picture_url)
user.profile_picture_id = file.id
Session.commit()
else:
flash(
"Sorry you cannot sign up via Facebook, please use email/password sign-up instead",
"error",
)
return redirect(url_for("auth.register"))
next_url = None
# The activation link contains the original page, for ex authorize page
if "facebook_next_url" in session:
next_url = session["facebook_next_url"]
LOG.d("redirect user to %s", next_url)
# reset the next_url to avoid user getting redirected at each login :)
session.pop("facebook_next_url", None)
if not SocialAuth.get_by(user_id=user.id, social="facebook"):
SocialAuth.create(user_id=user.id, social="facebook")
Session.commit()
return after_login(user, next_url)

173
app/app/auth/views/fido.py Normal file
View File

@ -0,0 +1,173 @@
import json
import secrets
from time import time
import webauthn
from flask import (
request,
render_template,
redirect,
url_for,
flash,
session,
make_response,
g,
)
from flask_login import login_user
from flask_wtf import FlaskForm
from wtforms import HiddenField, validators, BooleanField
from app.auth.base import auth_bp
from app.config import MFA_USER_ID
from app.config import RP_ID, URL
from app.db import Session
from app.extensions import limiter
from app.log import LOG
from app.models import User, Fido, MfaBrowser
from app.utils import sanitize_next_url
class FidoTokenForm(FlaskForm):
sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()])
remember = BooleanField(
"attr", default=False, description="Remember this browser for 30 days"
)
@auth_bp.route("/fido", methods=["GET", "POST"])
@limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def fido():
# passed from login page
user_id = session.get(MFA_USER_ID)
# user access this page directly without passing by login page
if not user_id:
flash("Unknown error, redirect back to main page", "warning")
return redirect(url_for("auth.login"))
user = User.get(user_id)
if not (user and user.fido_enabled()):
flash("Only user with security key linked should go to this page", "warning")
return redirect(url_for("auth.login"))
auto_activate = True
fido_token_form = FidoTokenForm()
next_url = sanitize_next_url(request.args.get("next"))
if request.cookies.get("mfa"):
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash(f"Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
# Trigger rate limiter
g.deduct_limit = True
# Handling POST requests
if fido_token_form.validate_on_submit():
try:
sk_assertion = json.loads(fido_token_form.sk_assertion.data)
except Exception:
flash("Key verification failed. Error: Invalid Payload", "warning")
return redirect(url_for("auth.login"))
challenge = session["fido_challenge"]
try:
fido_key = Fido.get_by(
uuid=user.fido_uuid, credential_id=sk_assertion["id"]
)
webauthn_user = webauthn.WebAuthnUser(
user.fido_uuid,
user.email,
user.name if user.name else user.email,
False,
fido_key.credential_id,
fido_key.public_key,
fido_key.sign_count,
RP_ID,
)
webauthn_assertion_response = webauthn.WebAuthnAssertionResponse(
webauthn_user, sk_assertion, challenge, URL, uv_required=False
)
new_sign_count = webauthn_assertion_response.verify()
except Exception as e:
LOG.w(f"An error occurred in WebAuthn verification process: {e}")
flash("Key verification failed.", "warning")
# Trigger rate limiter
g.deduct_limit = True
auto_activate = False
else:
user.fido_sign_count = new_sign_count
Session.commit()
del session[MFA_USER_ID]
session["sudo_time"] = int(time())
login_user(user)
flash(f"Welcome back!", "success")
# Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index")))
if fido_token_form.remember.data:
browser = MfaBrowser.create_new(user=user)
Session.commit()
response.set_cookie(
"mfa",
value=browser.token,
expires=browser.expires.datetime,
secure=True if URL.startswith("https") else False,
httponly=True,
samesite="Lax",
)
return response
# Prepare information for key registration process
session.pop("challenge", None)
challenge = secrets.token_urlsafe(32)
session["fido_challenge"] = challenge.rstrip("=")
fidos = Fido.filter_by(uuid=user.fido_uuid).all()
webauthn_users = []
for fido in fidos:
webauthn_users.append(
webauthn.WebAuthnUser(
user.fido_uuid,
user.email,
user.name if user.name else user.email,
False,
fido.credential_id,
fido.public_key,
fido.sign_count,
RP_ID,
)
)
webauthn_assertion_options = webauthn.WebAuthnAssertionOptions(
webauthn_users, challenge
)
webauthn_assertion_options = webauthn_assertion_options.assertion_dict
try:
# HACK: We need to upgrade to webauthn > 1 so it can support specifying the transports
for credential in webauthn_assertion_options["allowCredentials"]:
del credential["transports"]
except KeyError:
# Should never happen but...
pass
return render_template(
"auth/fido.html",
fido_token_form=fido_token_form,
webauthn_assertion_options=webauthn_assertion_options,
enable_otp=user.enable_otp,
auto_activate=auto_activate,
next_url=next_url,
)

View File

@ -0,0 +1,42 @@
from flask import request, render_template, redirect, url_for, flash, g
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.dashboard.views.setting import send_reset_password_email
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.utils import sanitize_email, canonicalize_email
class ForgotPasswordForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
@auth_bp.route("/forgot_password", methods=["GET", "POST"])
@limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def forgot_password():
form = ForgotPasswordForm(request.form)
if form.validate_on_submit():
# Trigger rate limiter
g.deduct_limit = True
flash(
"If your email is correct, you are going to receive an email to reset your password",
"success",
)
email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if user:
LOG.d("Send forgot password email to %s", user)
send_reset_password_email(user)
return redirect(url_for("auth.forgot_password"))
return render_template("auth/forgot_password.html", form=form)

View File

@ -0,0 +1,102 @@
from flask import request, session, redirect, flash, url_for
from requests_oauthlib import OAuth2Session
from app.auth.base import auth_bp
from app.auth.views.login_utils import after_login
from app.config import GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, URL
from app.db import Session
from app.log import LOG
from app.models import User, SocialAuth
from app.utils import encode_url, sanitize_email, sanitize_next_url
_authorization_base_url = "https://github.com/login/oauth/authorize"
_token_url = "https://github.com/login/oauth/access_token"
# need to set explicitly redirect_uri instead of leaving the lib to pre-fill redirect_uri
# when served behind nginx, the redirect_uri is localhost... and not the real url
_redirect_uri = URL + "/auth/github/callback"
@auth_bp.route("/github/login")
def github_login():
next_url = sanitize_next_url(request.args.get("next"))
if next_url:
redirect_uri = _redirect_uri + "?next=" + encode_url(next_url)
else:
redirect_uri = _redirect_uri
github = OAuth2Session(
GITHUB_CLIENT_ID, scope=["user:email"], redirect_uri=redirect_uri
)
authorization_url, state = github.authorization_url(_authorization_base_url)
# State is used to prevent CSRF, keep this for later.
session["oauth_state"] = state
return redirect(authorization_url)
@auth_bp.route("/github/callback")
def github_callback():
# user clicks on cancel
if "error" in request.args:
flash("Please use another sign in method then", "warning")
return redirect("/")
github = OAuth2Session(
GITHUB_CLIENT_ID,
state=session["oauth_state"],
scope=["user:email"],
redirect_uri=_redirect_uri,
)
github.fetch_token(
_token_url,
client_secret=GITHUB_CLIENT_SECRET,
authorization_response=request.url,
)
# a dict with "name", "login"
github_user_data = github.get("https://api.github.com/user").json()
# return list of emails
# {
# 'email': 'abcd@gmail.com',
# 'primary': False,
# 'verified': True,
# 'visibility': None
# }
emails = github.get("https://api.github.com/user/emails").json()
# only take the primary email
email = None
for e in emails:
if e.get("verified") and e.get("primary"):
email = e.get("email")
break
if not email:
LOG.e(f"cannot get email for github user {github_user_data} {emails}")
flash(
"Cannot get a valid email from Github, please another way to login/sign up",
"error",
)
return redirect(url_for("auth.login"))
email = sanitize_email(email)
user = User.get_by(email=email)
if not user:
flash(
"Sorry you cannot sign up via Github, please use email/password sign-up instead",
"error",
)
return redirect(url_for("auth.register"))
if not SocialAuth.get_by(user_id=user.id, social="github"):
SocialAuth.create(user_id=user.id, social="github")
Session.commit()
# The activation link contains the original page, for ex authorize page
next_url = sanitize_next_url(request.args.get("next")) if request.args else None
return after_login(user, next_url)

View File

@ -0,0 +1,125 @@
from flask import request, session, redirect, flash, url_for
from requests_oauthlib import OAuth2Session
from app import s3
from app.auth.base import auth_bp
from app.config import URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
from app.db import Session
from app.log import LOG
from app.models import User, File, SocialAuth
from app.utils import random_string, sanitize_email
from .login_utils import after_login
_authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth"
_token_url = "https://www.googleapis.com/oauth2/v4/token"
_scope = [
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"openid",
]
# need to set explicitly redirect_uri instead of leaving the lib to pre-fill redirect_uri
# when served behind nginx, the redirect_uri is localhost... and not the real url
_redirect_uri = URL + "/auth/google/callback"
@auth_bp.route("/google/login")
def google_login():
# to avoid flask-login displaying the login error message
session.pop("_flashes", None)
next_url = request.args.get("next")
# Google does not allow to append param to redirect_url
# we need to pass the next url by session
if next_url:
session["google_next_url"] = next_url
google = OAuth2Session(GOOGLE_CLIENT_ID, scope=_scope, redirect_uri=_redirect_uri)
authorization_url, state = google.authorization_url(_authorization_base_url)
# State is used to prevent CSRF, keep this for later.
session["oauth_state"] = state
return redirect(authorization_url)
@auth_bp.route("/google/callback")
def google_callback():
# user clicks on cancel
if "error" in request.args:
flash("please use another sign in method then", "warning")
return redirect("/")
google = OAuth2Session(
GOOGLE_CLIENT_ID,
# some how Google Login fails with oauth_state KeyError
# state=session["oauth_state"],
scope=_scope,
redirect_uri=_redirect_uri,
)
google.fetch_token(
_token_url,
client_secret=GOOGLE_CLIENT_SECRET,
authorization_response=request.url,
)
# Fetch a protected resource, i.e. user profile
# {
# "email": "abcd@gmail.com",
# "family_name": "First name",
# "given_name": "Last name",
# "id": "1234",
# "locale": "en",
# "name": "First Last",
# "picture": "http://profile.jpg",
# "verified_email": true
# }
google_user_data = google.get(
"https://www.googleapis.com/oauth2/v1/userinfo"
).json()
email = sanitize_email(google_user_data["email"])
user = User.get_by(email=email)
picture_url = google_user_data.get("picture")
if user:
if picture_url and not user.profile_picture_id:
LOG.d("set user profile picture to %s", picture_url)
file = create_file_from_url(user, picture_url)
user.profile_picture_id = file.id
Session.commit()
else:
flash(
"Sorry you cannot sign up via Google, please use email/password sign-up instead",
"error",
)
return redirect(url_for("auth.register"))
next_url = None
# The activation link contains the original page, for ex authorize page
if "google_next_url" in session:
next_url = session["google_next_url"]
LOG.d("redirect user to %s", next_url)
# reset the next_url to avoid user getting redirected at each login :)
session.pop("google_next_url", None)
if not SocialAuth.get_by(user_id=user.id, social="google"):
SocialAuth.create(user_id=user.id, social="google")
Session.commit()
return after_login(user, next_url)
def create_file_from_url(user, url) -> File:
file_path = random_string(30)
file = File.create(path=file_path, user_id=user.id)
s3.upload_from_url(url, file_path)
Session.flush()
LOG.d("upload file %s to s3", file)
return file

View File

@ -0,0 +1,74 @@
from flask import request, render_template, redirect, url_for, flash, g
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.auth.views.login_utils import after_login
from app.config import CONNECT_WITH_PROTON
from app.events.auth_event import LoginEvent
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
class LoginForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
password = StringField("Password", validators=[validators.DataRequired()])
@auth_bp.route("/login", methods=["GET", "POST"])
@limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def login():
next_url = sanitize_next_url(request.args.get("next"))
if current_user.is_authenticated:
if next_url:
LOG.d("user is already authenticated, redirect to %s", next_url)
return redirect(next_url)
else:
LOG.d("user is already authenticated, redirect to dashboard")
return redirect(url_for("dashboard.index"))
form = LoginForm(request.form)
show_resend_activation = False
if form.validate_on_submit():
email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user or not user.check_password(form.password.data):
# Trigger rate limiter
g.deduct_limit = True
form.password.data = None
flash("Email or password incorrect", "error")
LoginEvent(LoginEvent.ActionType.failed).send()
elif user.disabled:
flash(
"Your account is disabled. Please contact SimpleLogin team to re-enable your account.",
"error",
)
LoginEvent(LoginEvent.ActionType.disabled_login).send()
elif not user.activated:
show_resend_activation = True
flash(
"Please check your inbox for the activation email. You can also have this email re-sent",
"error",
)
LoginEvent(LoginEvent.ActionType.not_activated).send()
else:
LoginEvent(LoginEvent.ActionType.success).send()
return after_login(user, next_url)
return render_template(
"auth/login.html",
form=form,
next_url=next_url,
show_resend_activation=show_resend_activation,
connect_with_proton=CONNECT_WITH_PROTON,
)

View File

@ -0,0 +1,68 @@
from time import time
from typing import Optional
from flask import session, redirect, url_for, request
from flask_login import login_user
from app.config import MFA_USER_ID
from app.log import LOG
from app.models import Referral
def after_login(user, next_url, login_from_proton: bool = False):
"""
Redirect to the correct page after login.
If the user is logged in with Proton, do not look at fido nor otp
If user enables MFA: redirect user to MFA page
Otherwise redirect to dashboard page if no next_url
"""
if not login_from_proton:
if user.fido_enabled():
# Use the same session for FIDO so that we can easily
# switch between these two 2FA option
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.fido", next=next_url))
else:
return redirect(url_for("auth.fido"))
elif user.enable_otp:
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.mfa", next=next_url))
else:
return redirect(url_for("auth.mfa"))
LOG.d("log user %s in", user)
login_user(user)
session["sudo_time"] = int(time())
# User comes to login page from another page
if next_url:
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
# name of the cookie that stores the referral code
_REFERRAL_COOKIE = "slref"
def get_referral() -> Optional[Referral]:
"""Get the eventual referral stored in cookie"""
# whether user arrives via a referral
referral = None
if request.cookies:
ref_code = request.cookies.get(_REFERRAL_COOKIE)
referral = Referral.get_by(code=ref_code)
if not referral:
if "slref" in session:
ref_code = session["slref"]
referral = Referral.get_by(code=ref_code)
if referral:
LOG.d("referral found %s", referral)
return referral

View File

@ -0,0 +1,17 @@
from flask import redirect, url_for, flash, make_response
from app.auth.base import auth_bp
from app.config import SESSION_COOKIE_NAME
from app.session import logout_session
@auth_bp.route("/logout")
def logout():
logout_session()
flash("You are logged out", "success")
response = make_response(redirect(url_for("auth.login")))
response.delete_cookie(SESSION_COOKIE_NAME)
response.delete_cookie("mfa")
response.delete_cookie("dark-mode")
return response

107
app/app/auth/views/mfa.py Normal file
View File

@ -0,0 +1,107 @@
import pyotp
from flask import (
render_template,
redirect,
url_for,
flash,
session,
make_response,
request,
g,
)
from flask_login import login_user
from flask_wtf import FlaskForm
from wtforms import BooleanField, StringField, validators
from app.auth.base import auth_bp
from app.config import MFA_USER_ID, URL
from app.db import Session
from app.email_utils import send_invalid_totp_login_email
from app.extensions import limiter
from app.models import User, MfaBrowser
from app.utils import sanitize_next_url
class OtpTokenForm(FlaskForm):
token = StringField("Token", validators=[validators.DataRequired()])
remember = BooleanField(
"attr", default=False, description="Remember this browser for 30 days"
)
@auth_bp.route("/mfa", methods=["GET", "POST"])
@limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def mfa():
# passed from login page
user_id = session.get(MFA_USER_ID)
# user access this page directly without passing by login page
if not user_id:
flash("Unknown error, redirect back to main page", "warning")
return redirect(url_for("auth.login"))
user = User.get(user_id)
if not (user and user.enable_otp):
flash("Only user with MFA enabled should go to this page", "warning")
return redirect(url_for("auth.login"))
otp_token_form = OtpTokenForm()
next_url = sanitize_next_url(request.args.get("next"))
if request.cookies.get("mfa"):
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash(f"Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
# Trigger rate limiter
g.deduct_limit = True
if otp_token_form.validate_on_submit():
totp = pyotp.TOTP(user.otp_secret)
token = otp_token_form.token.data.replace(" ", "")
if totp.verify(token, valid_window=2) and user.last_otp != token:
del session[MFA_USER_ID]
user.last_otp = token
Session.commit()
login_user(user)
flash(f"Welcome back!", "success")
# Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index")))
if otp_token_form.remember.data:
browser = MfaBrowser.create_new(user=user)
Session.commit()
response.set_cookie(
"mfa",
value=browser.token,
expires=browser.expires.datetime,
secure=True if URL.startswith("https") else False,
httponly=True,
samesite="Lax",
)
return response
else:
flash("Incorrect token", "warning")
# Trigger rate limiter
g.deduct_limit = True
otp_token_form.token.data = None
send_invalid_totp_login_email(user, "TOTP")
return render_template(
"auth/mfa.html",
otp_token_form=otp_token_form,
enable_fido=(user.fido_enabled()),
next_url=next_url,
)

View File

@ -0,0 +1,190 @@
import requests
from flask import request, session, redirect, flash, url_for
from flask_limiter.util import get_remote_address
from flask_login import current_user
from requests_oauthlib import OAuth2Session
from typing import Optional
from app.auth.base import auth_bp
from app.auth.views.login_utils import after_login
from app.config import (
PROTON_BASE_URL,
PROTON_CLIENT_ID,
PROTON_CLIENT_SECRET,
PROTON_EXTRA_HEADER_NAME,
PROTON_EXTRA_HEADER_VALUE,
PROTON_VALIDATE_CERTS,
URL,
)
from app.log import LOG
from app.models import ApiKey, User
from app.proton.proton_client import HttpProtonClient, convert_access_token
from app.proton.proton_callback_handler import (
ProtonCallbackHandler,
Action,
)
from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url, sanitize_scheme
_authorization_base_url = PROTON_BASE_URL + "/oauth/authorize"
_token_url = PROTON_BASE_URL + "/oauth/token"
# need to set explicitly redirect_uri instead of leaving the lib to pre-fill redirect_uri
# when served behind nginx, the redirect_uri is localhost... and not the real url
_redirect_uri = URL + "/auth/proton/callback"
SESSION_ACTION_KEY = "oauth_action"
SESSION_STATE_KEY = "oauth_state"
DEFAULT_SCHEME = "auth.simplelogin"
def get_api_key_for_user(user: User) -> str:
ak = ApiKey.create(
user_id=user.id,
name="Created via Login with Proton on mobile app",
commit=True,
)
return ak.code
def extract_action() -> Optional[Action]:
action = request.args.get("action")
if action is not None:
if action == "link":
return Action.Link
elif action == "login":
return Action.Login
else:
LOG.w(f"Unknown action received: {action}")
return None
return Action.Login
def get_action_from_state() -> Action:
oauth_action = session[SESSION_ACTION_KEY]
if oauth_action == Action.Login.value:
return Action.Login
elif oauth_action == Action.Link.value:
return Action.Link
raise Exception(f"Unknown action in state: {oauth_action}")
@auth_bp.route("/proton/login")
def proton_login():
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
return redirect(url_for("auth.login"))
action = extract_action()
if action is None:
return redirect(url_for("auth.login"))
if action == Action.Link and not current_user.is_authenticated:
return redirect(url_for("auth.login"))
next_url = sanitize_next_url(request.args.get("next"))
if next_url:
session["oauth_next"] = next_url
elif "oauth_next" in session:
del session["oauth_next"]
scheme = sanitize_scheme(request.args.get("scheme"))
if scheme:
session["oauth_scheme"] = scheme
elif "oauth_scheme" in session:
del session["oauth_scheme"]
mode = request.args.get("mode", "session")
if mode == "apikey":
session["oauth_mode"] = "apikey"
else:
session["oauth_mode"] = "session"
proton = OAuth2Session(PROTON_CLIENT_ID, redirect_uri=_redirect_uri)
authorization_url, state = proton.authorization_url(_authorization_base_url)
# State is used to prevent CSRF, keep this for later.
session[SESSION_STATE_KEY] = state
session[SESSION_ACTION_KEY] = action.value
return redirect(authorization_url)
@auth_bp.route("/proton/callback")
def proton_callback():
if SESSION_STATE_KEY not in session or SESSION_STATE_KEY not in session:
flash("Invalid state, please retry", "error")
return redirect(url_for("auth.login"))
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
return redirect(url_for("auth.login"))
# user clicks on cancel
if "error" in request.args:
flash("Please use another sign in method then", "warning")
return redirect("/")
proton = OAuth2Session(
PROTON_CLIENT_ID,
state=session[SESSION_STATE_KEY],
redirect_uri=_redirect_uri,
)
def check_status_code(response: requests.Response) -> requests.Response:
if response.status_code != 200:
raise Exception(
f"Bad Proton API response [status={response.status_code}]: {response.json()}"
)
return response
proton.register_compliance_hook("access_token_response", check_status_code)
headers = None
if PROTON_EXTRA_HEADER_NAME and PROTON_EXTRA_HEADER_VALUE:
headers = {PROTON_EXTRA_HEADER_NAME: PROTON_EXTRA_HEADER_VALUE}
try:
token = proton.fetch_token(
_token_url,
client_secret=PROTON_CLIENT_SECRET,
authorization_response=request.url,
verify=PROTON_VALIDATE_CERTS,
method="GET",
include_client_id=True,
headers=headers,
)
except Exception as e:
LOG.warning(f"Error fetching Proton token: {e}")
flash("There was an error in the login process", "error")
return redirect(url_for("auth.login"))
credentials = convert_access_token(token["access_token"])
action = get_action_from_state()
proton_client = HttpProtonClient(
PROTON_BASE_URL, credentials, get_remote_address(), verify=PROTON_VALIDATE_CERTS
)
handler = ProtonCallbackHandler(proton_client)
proton_partner = get_proton_partner()
next_url = session.get("oauth_next")
if action == Action.Login:
res = handler.handle_login(proton_partner)
elif action == Action.Link:
res = handler.handle_link(current_user, proton_partner)
else:
raise Exception(f"Unknown Action: {action.name}")
if res.flash_message is not None:
flash(res.flash_message, res.flash_category)
oauth_scheme = session.get("oauth_scheme")
if session.get("oauth_mode", "session") == "apikey":
apikey = get_api_key_for_user(res.user)
scheme = oauth_scheme or DEFAULT_SCHEME
return redirect(f"{scheme}:///login?apikey={apikey}")
if res.redirect_to_login:
return redirect(url_for("auth.login"))
if next_url and next_url[0] == "/" and oauth_scheme:
next_url = f"{oauth_scheme}://{next_url}"
redirect_url = next_url or res.redirect
return after_login(res.user, redirect_url, login_from_proton=True)

View File

@ -0,0 +1,75 @@
import arrow
from flask import request, render_template, redirect, url_for, flash, session, g
from flask_login import login_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.config import MFA_USER_ID
from app.db import Session
from app.email_utils import send_invalid_totp_login_email
from app.extensions import limiter
from app.log import LOG
from app.models import User, RecoveryCode
from app.utils import sanitize_next_url
class RecoveryForm(FlaskForm):
code = StringField("Code", validators=[validators.DataRequired()])
@auth_bp.route("/recovery", methods=["GET", "POST"])
@limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def recovery_route():
# passed from login page
user_id = session.get(MFA_USER_ID)
# user access this page directly without passing by login page
if not user_id:
flash("Unknown error, redirect back to main page", "warning")
return redirect(url_for("auth.login"))
user = User.get(user_id)
if not user.two_factor_authentication_enabled():
flash("Only user with MFA enabled should go to this page", "warning")
return redirect(url_for("auth.login"))
recovery_form = RecoveryForm()
next_url = sanitize_next_url(request.args.get("next"))
if recovery_form.validate_on_submit():
code = recovery_form.code.data
recovery_code = RecoveryCode.find_by_user_code(user, code)
if recovery_code:
if recovery_code.used:
# Trigger rate limiter
g.deduct_limit = True
flash("Code already used", "error")
else:
del session[MFA_USER_ID]
login_user(user)
flash(f"Welcome back!", "success")
recovery_code.used = True
recovery_code.used_at = arrow.now()
Session.commit()
# User comes to login page from another page
if next_url:
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
else:
# Trigger rate limiter
g.deduct_limit = True
flash("Incorrect code", "error")
send_invalid_totp_login_email(user, "recovery")
return render_template("auth/recovery.html", recovery_form=recovery_form)

View File

@ -0,0 +1,128 @@
import requests
from flask import request, flash, render_template, redirect, url_for
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import email_utils, config
from app.auth.base import auth_bp
from app.config import CONNECT_WITH_PROTON
from app.auth.views.login_utils import get_referral
from app.config import URL, HCAPTCHA_SECRET, HCAPTCHA_SITEKEY
from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,
personal_email_already_used,
)
from app.events.auth_event import RegisterEvent
from app.log import LOG
from app.models import User, ActivationCode, DailyMetric
from app.utils import random_string, encode_url, sanitize_email, canonicalize_email
class RegisterForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
password = StringField(
"Password",
validators=[validators.DataRequired(), validators.Length(min=8, max=100)],
)
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
LOG.d("user is already authenticated, redirect to dashboard")
flash("You are already logged in", "warning")
return redirect(url_for("dashboard.index"))
if config.DISABLE_REGISTRATION:
flash("Registration is closed", "error")
return redirect(url_for("auth.login"))
form = RegisterForm(request.form)
next_url = request.args.get("next")
if form.validate_on_submit():
# only check if hcaptcha is enabled
if HCAPTCHA_SECRET:
# check with hCaptcha
token = request.form.get("h-captcha-response")
params = {"secret": HCAPTCHA_SECRET, "response": token}
hcaptcha_res = requests.post(
"https://hcaptcha.com/siteverify", data=params
).json()
# return something like
# {'success': True,
# 'challenge_ts': '2020-07-23T10:03:25',
# 'hostname': '127.0.0.1'}
if not hcaptcha_res["success"]:
LOG.w(
"User put wrong captcha %s %s",
form.email.data,
hcaptcha_res,
)
flash("Wrong Captcha", "error")
RegisterEvent(RegisterEvent.ActionType.catpcha_failed).send()
return render_template(
"auth/register.html",
form=form,
next_url=next_url,
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
)
email = canonicalize_email(form.email.data)
if not email_can_be_used_as_mailbox(email):
flash("You cannot use this email address as your personal inbox.", "error")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
else:
sanitized_email = sanitize_email(form.email.data)
if personal_email_already_used(email) or personal_email_already_used(
sanitized_email
):
flash(f"Email {email} already used", "error")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
else:
LOG.d("create user %s", email)
user = User.create(
email=email,
name=form.email.data,
password=form.password.data,
referral=get_referral(),
)
Session.commit()
try:
send_activation_email(user, next_url)
RegisterEvent(RegisterEvent.ActionType.success).send()
DailyMetric.get_or_create_today_metric().nb_new_web_non_proton_user += (
1
)
Session.commit()
except Exception:
flash("Invalid email, are you sure the email is correct?", "error")
RegisterEvent(RegisterEvent.ActionType.invalid_email).send()
return redirect(url_for("auth.register"))
return render_template("auth/register_waiting_activation.html")
return render_template(
"auth/register.html",
form=form,
next_url=next_url,
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
connect_with_proton=CONNECT_WITH_PROTON,
)
def send_activation_email(user, next_url):
# the activation code is valid for 1h
activation = ActivationCode.create(user_id=user.id, code=random_string(30))
Session.commit()
# Send user activation email
activation_link = f"{URL}/auth/activate?code={activation.code}"
if next_url:
LOG.d("redirect user to %s after activation", next_url)
activation_link = activation_link + "&next=" + encode_url(next_url)
email_utils.send_activation_email(user.email, activation_link)

View File

@ -0,0 +1,44 @@
from flask import request, flash, render_template, redirect, url_for
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.auth.views.register import send_activation_email
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.utils import sanitize_email, canonicalize_email
class ResendActivationForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
@auth_bp.route("/resend_activation", methods=["GET", "POST"])
@limiter.limit("10/hour")
def resend_activation():
form = ResendActivationForm(request.form)
if form.validate_on_submit():
email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user:
flash("There is no such email", "warning")
return render_template("auth/resend_activation.html", form=form)
if user.activated:
flash("Your account was already activated, please login", "success")
return redirect(url_for("auth.login"))
# user is not activated
LOG.d("user %s is not activated", user)
flash(
"An activation email has been sent to you. Please check your inbox/spam folder.",
"warning",
)
send_activation_email(user, request.args.get("next"))
return render_template("auth/register_waiting_activation.html")
return render_template("auth/resend_activation.html", form=form)

View File

@ -0,0 +1,75 @@
import uuid
from flask import request, flash, render_template, url_for, g
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.auth.views.login_utils import after_login
from app.db import Session
from app.extensions import limiter
from app.models import ResetPasswordCode
class ResetPasswordForm(FlaskForm):
password = StringField(
"Password",
validators=[validators.DataRequired(), validators.Length(min=8, max=100)],
)
@auth_bp.route("/reset_password", methods=["GET", "POST"])
@limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def reset_password():
form = ResetPasswordForm(request.form)
reset_password_code_str = request.args.get("code")
reset_password_code: ResetPasswordCode = ResetPasswordCode.get_by(
code=reset_password_code_str
)
if not reset_password_code:
# Trigger rate limiter
g.deduct_limit = True
error = (
"The reset password link can be used only once. "
"Please request a new link to reset password."
)
return render_template("auth/reset_password.html", form=form, error=error)
if reset_password_code.is_expired():
error = "The link has been already expired. Please make a new request of the reset password link"
return render_template("auth/reset_password.html", form=form, error=error)
if form.validate_on_submit():
user = reset_password_code.user
new_password = form.password.data
# avoid user reusing the old password
if user.check_password(new_password):
error = "You cannot reuse the same password"
return render_template("auth/reset_password.html", form=form, error=error)
user.set_password(new_password)
flash("Your new password has been set", "success")
# this can be served to activate user too
user.activated = True
# remove the reset password code
ResetPasswordCode.delete(reset_password_code.id)
# change the alternative_id to log user out on other browsers
user.alternative_id = str(uuid.uuid4())
Session.commit()
# do not use login_user(user) here
# to make sure user needs to go through MFA if enabled
return after_login(user, url_for("dashboard.index"))
return render_template("auth/reset_password.html", form=form)

View File

@ -0,0 +1,14 @@
from flask import render_template, redirect, url_for
from flask_login import current_user
from app.auth.base import auth_bp
from app.log import LOG
@auth_bp.route("/social", methods=["GET", "POST"])
def social():
if current_user.is_authenticated:
LOG.d("user is already authenticated, redirect to dashboard")
return redirect(url_for("dashboard.index"))
return render_template("auth/social.html")