4.21.3
This commit is contained in:
0
app/app/auth/views/__init__.py
Normal file
0
app/app/auth/views/__init__.py
Normal file
69
app/app/auth/views/activate.py
Normal file
69
app/app/auth/views/activate.py
Normal 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"))
|
30
app/app/auth/views/api_to_cookie.py
Normal file
30
app/app/auth/views/api_to_cookie.py
Normal 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"))
|
35
app/app/auth/views/change_email.py
Normal file
35
app/app/auth/views/change_email.py
Normal 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"))
|
127
app/app/auth/views/facebook.py
Normal file
127
app/app/auth/views/facebook.py
Normal 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
173
app/app/auth/views/fido.py
Normal 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,
|
||||
)
|
42
app/app/auth/views/forgot_password.py
Normal file
42
app/app/auth/views/forgot_password.py
Normal 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)
|
102
app/app/auth/views/github.py
Normal file
102
app/app/auth/views/github.py
Normal 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)
|
125
app/app/auth/views/google.py
Normal file
125
app/app/auth/views/google.py
Normal 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
|
74
app/app/auth/views/login.py
Normal file
74
app/app/auth/views/login.py
Normal 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,
|
||||
)
|
68
app/app/auth/views/login_utils.py
Normal file
68
app/app/auth/views/login_utils.py
Normal 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
|
17
app/app/auth/views/logout.py
Normal file
17
app/app/auth/views/logout.py
Normal 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
107
app/app/auth/views/mfa.py
Normal 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,
|
||||
)
|
190
app/app/auth/views/proton.py
Normal file
190
app/app/auth/views/proton.py
Normal 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)
|
75
app/app/auth/views/recovery.py
Normal file
75
app/app/auth/views/recovery.py
Normal 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)
|
128
app/app/auth/views/register.py
Normal file
128
app/app/auth/views/register.py
Normal 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)
|
44
app/app/auth/views/resend_activation.py
Normal file
44
app/app/auth/views/resend_activation.py
Normal 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)
|
75
app/app/auth/views/reset_password.py
Normal file
75
app/app/auth/views/reset_password.py
Normal 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)
|
14
app/app/auth/views/social.py
Normal file
14
app/app/auth/views/social.py
Normal 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")
|
Reference in New Issue
Block a user