Prechádzať zdrojové kódy

Merge remote-tracking branch 'nguyenkims/master'

Tung Nguyen 5 rokov pred
rodič
commit
1289b08636
49 zmenil súbory, kde vykonal 1071 pridanie a 287 odobranie
  1. 5 2
      .env.example
  2. 4 0
      .github/workflows/pythonpackage.yml
  3. 27 6
      README.md
  4. 1 0
      app/auth/__init__.py
  5. 33 0
      app/auth/templates/auth/mfa.html
  6. 3 5
      app/auth/views/facebook.py
  7. 6 11
      app/auth/views/github.py
  8. 4 7
      app/auth/views/google.py
  9. 5 13
      app/auth/views/login.py
  10. 30 0
      app/auth/views/login_utils.py
  11. 51 0
      app/auth/views/mfa.py
  12. 17 2
      app/config.py
  13. 3 0
      app/dashboard/__init__.py
  14. 2 0
      app/dashboard/templates/dashboard/api_key.html
  15. 6 96
      app/dashboard/templates/dashboard/custom_domain.html
  16. 217 0
      app/dashboard/templates/dashboard/domain_detail.html
  17. 41 15
      app/dashboard/templates/dashboard/index.html
  18. 28 0
      app/dashboard/templates/dashboard/mfa_cancel.html
  19. 51 0
      app/dashboard/templates/dashboard/mfa_setup.html
  20. 11 7
      app/dashboard/templates/dashboard/pricing.html
  21. 32 1
      app/dashboard/templates/dashboard/setting.html
  22. 6 53
      app/dashboard/views/custom_domain.py
  23. 106 0
      app/dashboard/views/domain_detail.py
  24. 17 2
      app/dashboard/views/index.py
  25. 37 0
      app/dashboard/views/mfa_cancel.py
  26. 49 0
      app/dashboard/views/mfa_setup.py
  27. 9 0
      app/dashboard/views/setting.py
  28. 41 3
      app/dns_utils.py
  29. 44 6
      app/models.py
  30. 13 4
      email_handler.py
  31. 6 0
      local_data/dkim.pub.key
  32. 1 1
      migrations/alembic.ini
  33. 29 0
      migrations/versions/2019_122910_696e17c13b8b_.py
  34. 29 0
      migrations/versions/2019_122910_e409f6214b2b_.py
  35. 29 0
      migrations/versions/9e1b06b9df13_.py
  36. 31 0
      migrations/versions/d4e4488a0032_.py
  37. 2 1
      requirements.in
  38. 3 2
      requirements.txt
  39. 1 0
      server.py
  40. BIN
      static/logo.png
  41. 4 0
      static/style.css
  42. 1 1
      templates/default.html
  43. 0 24
      templates/emails/welcome.html
  44. 0 8
      templates/emails/welcome.txt
  45. 1 15
      templates/header.html
  46. 23 0
      templates/menu.html
  47. 2 2
      templates/single.html
  48. 1 0
      tests/env.test
  49. 9 0
      tests/test_models.py

+ 5 - 2
.env.example

@@ -31,6 +31,9 @@ EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
 
 
 # the DKIM private key used to compute DKIM-Signature
 # the DKIM private key used to compute DKIM-Signature
 DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
 DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
+
+# the DKIM public key used to setup custom domain DKIM
+DKIM_PUBLIC_KEY_PATH=local_data/dkim.pub.key
 # <<< END Email related settings >>>
 # <<< END Email related settings >>>
 
 
 
 
@@ -55,8 +58,8 @@ AWS_SECRET_ACCESS_KEY=to_fill
 
 
 # Cloudwatch
 # Cloudwatch
 # ENABLE_CLOUDWATCH=true
 # ENABLE_CLOUDWATCH=true
-CLOUDWATCH_LOG_GROUP=local
-CLOUDWATCH_LOG_STREAM=local
+# CLOUDWATCH_LOG_GROUP=local
+# CLOUDWATCH_LOG_STREAM=local
 # <<< END AWS >>>
 # <<< END AWS >>>
 
 
 # Paddle
 # Paddle

+ 4 - 0
.github/workflows/pythonpackage.yml

@@ -25,3 +25,7 @@ jobs:
       run: |
       run: |
         pip install pytest
         pip install pytest
         pytest
         pytest
+    - name: Test formatting
+      run: |
+        pip install black
+        black --check .

+ 27 - 6
README.md

@@ -228,7 +228,7 @@ To run the server, you need a config file. Please have a look at [config example
 
 
 Let's put your config file at `~/simplelogin.env`.
 Let's put your config file at `~/simplelogin.env`.
 
 
-Make sure to update the following variables
+Make sure to update the following variables and replace these values by yours.
 
 
 ```.env
 ```.env
 # Server url
 # Server url
@@ -237,6 +237,8 @@ EMAIL_DOMAIN=mydomain.com
 SUPPORT_EMAIL=support@mydomain.com
 SUPPORT_EMAIL=support@mydomain.com
 EMAIL_SERVERS_WITH_PRIORITY=[(10, "app.mydomain.com.")]
 EMAIL_SERVERS_WITH_PRIORITY=[(10, "app.mydomain.com.")]
 DKIM_PRIVATE_KEY_PATH=/dkim.key
 DKIM_PRIVATE_KEY_PATH=/dkim.key
+DKIM_PUBLIC_KEY_PATH=/dkim.pub.key
+DB_URI=postgresql://myuser:mypassword@sl-db:5432/simplelogin
 
 
 # optional, to have more choices for random alias.
 # optional, to have more choices for random alias.
 WORDS_FILE_PATH=local_data/words_alpha.txt
 WORDS_FILE_PATH=local_data/words_alpha.txt
@@ -246,9 +248,10 @@ WORDS_FILE_PATH=local_data/words_alpha.txt
 Before running the webapp, you need to prepare the database by running the migration
 Before running the webapp, you need to prepare the database by running the migration
 
 
 ```bash
 ```bash
-docker run \
+docker run --rm \
     --name sl-migration \
     --name sl-migration \
     -v $(pwd)/dkim.key:/dkim.key \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/simplelogin.env:/code/.env \
     --network="sl-network" \
     --network="sl-network" \
     simplelogin/app flask db upgrade
     simplelogin/app flask db upgrade
@@ -263,6 +266,7 @@ docker run -d \
     --name sl-app \
     --name sl-app \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/dkim.key:/dkim.key \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     -p 7777:7777 \
     -p 7777:7777 \
     --network="sl-network" \
     --network="sl-network" \
     simplelogin/app
     simplelogin/app
@@ -275,6 +279,7 @@ docker run -d \
     --name sl-email \
     --name sl-email \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/dkim.key:/dkim.key \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     -p 20381:20381 \
     -p 20381:20381 \
     --network="sl-network" \
     --network="sl-network" \
     simplelogin/app python email_handler.py
     simplelogin/app python email_handler.py
@@ -287,6 +292,7 @@ docker run -d \
     --name sl-cron \
     --name sl-cron \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/simplelogin.env:/code/.env \
     -v $(pwd)/dkim.key:/dkim.key \
     -v $(pwd)/dkim.key:/dkim.key \
+    -v $(pwd)/dkim.pub.key:/dkim.pub.key \
     --network="sl-network" \
     --network="sl-network" \
     simplelogin/app yacron -c /code/crontab.yml
     simplelogin/app yacron -c /code/crontab.yml
 ```
 ```
@@ -329,7 +335,7 @@ All work on SimpleLogin happens directly on GitHub.
 
 
 ### Run code locally
 ### Run code locally
 
 
-The project uses Python 3.6+. First, install all dependencies by running the following command. Feel free to use `virtualenv` or similar tools to isolate development environment.
+The project uses Python 3.7+. First, install all dependencies by running the following command. Feel free to use `virtualenv` or similar tools to isolate development environment.
 
 
 ```bash
 ```bash
 pip3 install -r requirements.txt
 pip3 install -r requirements.txt
@@ -396,7 +402,16 @@ Response: a json with following structure. ? means optional field.
 		[email1, email2, ...]
 		[email1, email2, ...]
 ```
 ```
 
 
-- `/alias/custom/new`: allows user to create a new customised alias.
+- `/alias/custom/new`: allows user to create a new custom alias.
+
+To try out the endpoint, you can use the following command. The command uses [httpie](https://httpie.org). 
+Make sure to replace `{api_key}` by your API Key obtained on https://app.simplelogin.io/dashboard/api_key
+
+```
+http https://app.simplelogin.io/api/alias/options \
+    Authentication:{api_key} \
+    hostname==www.google.com
+```
 
 
 ```
 ```
 POST /alias/custom/new
 POST /alias/custom/new
@@ -417,11 +432,17 @@ Whenever the model changes, a new migration has to be created
 
 
 Set the database connection to use a current database (i.e. the one without the model changes you just made), for example, if you have a staging config at `~/config/simplelogin/staging.env`, you can do: 
 Set the database connection to use a current database (i.e. the one without the model changes you just made), for example, if you have a staging config at `~/config/simplelogin/staging.env`, you can do: 
 
 
-> ln -sf ~/config/simplelogin/staging.env .env
+```bash
+ln -sf ~/config/simplelogin/staging.env .env
+```
 
 
 Generate the migration script and make sure to review it before committing it. Sometimes (very rarely though), the migration generation can go wrong.
 Generate the migration script and make sure to review it before committing it. Sometimes (very rarely though), the migration generation can go wrong.
 
 
-> flask db migrate
+```bash
+flask db migrate
+```
+
+In local the database creation in Sqlite doesn't use migration and uses directly `db.create_all()` (cf `fake_data()` method). This is because Sqlite doesn't handle well the migration. As sqlite is only used during development, the database is deleted and re-populated at each run.
 
 
 ### Code structure
 ### Code structure
 
 

+ 1 - 0
app/auth/__init__.py

@@ -10,4 +10,5 @@ from .views import (
     google,
     google,
     facebook,
     facebook,
     change_email,
     change_email,
+    mfa,
 )
 )

+ 33 - 0
app/auth/templates/auth/mfa.html

@@ -0,0 +1,33 @@
+{% extends "single.html" %}
+
+
+{% block title %}
+  MFA
+{% endblock %}
+
+
+{% block single_content %}
+  <div class="bg-white p-6" style="margin: auto">
+
+    <div>
+      Your account is protected with multi-factor authentication (MFA). <br>
+      To continue with the sign-in you need to provide the access code from your authenticator.
+    </div>
+
+    <form method="post">
+      {{ otp_token_form.csrf_token }}
+      <input type="hidden" name="form-name" value="create">
+
+      <div class="font-weight-bold mt-5">Token</div>
+      <div class="small-text">Please enter the 6-digit number displayed in your MFA application (Google Authenticator,
+        Authy) here
+      </div>
+
+      {{ otp_token_form.token(class="form-control", placeholder="") }}
+      {{ render_field_errors(otp_token_form.token) }}
+      <button class="btn btn-success mt-2">Validate</button>
+    </form>
+
+  </div>
+
+{% endblock %}

+ 3 - 5
app/auth/views/facebook.py

@@ -10,6 +10,7 @@ from app.config import URL, FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET
 from app.extensions import db
 from app.extensions import db
 from app.log import LOG
 from app.log import LOG
 from app.models import User
 from app.models import User
+from .login_utils import after_login
 
 
 _authorization_base_url = "https://www.facebook.com/dialog/oauth"
 _authorization_base_url = "https://www.facebook.com/dialog/oauth"
 _token_url = "https://graph.facebook.com/oauth/access_token"
 _token_url = "https://graph.facebook.com/oauth/access_token"
@@ -99,7 +100,6 @@ def facebook_callback():
             user.profile_picture_id = file.id
             user.profile_picture_id = file.id
             db.session.commit()
             db.session.commit()
 
 
-        login_user(user)
     # create user
     # create user
     else:
     else:
         LOG.d("create facebook user with %s", facebook_user_data)
         LOG.d("create facebook user with %s", facebook_user_data)
@@ -116,6 +116,7 @@ def facebook_callback():
 
 
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
 
 
+    next_url = None
     # The activation link contains the original page, for ex authorize page
     # The activation link contains the original page, for ex authorize page
     if "facebook_next_url" in session:
     if "facebook_next_url" in session:
         next_url = session["facebook_next_url"]
         next_url = session["facebook_next_url"]
@@ -124,7 +125,4 @@ def facebook_callback():
         # reset the next_url to avoid user getting redirected at each login :)
         # reset the next_url to avoid user getting redirected at each login :)
         session.pop("facebook_next_url", None)
         session.pop("facebook_next_url", None)
 
 
-        return redirect(next_url)
-    else:
-        LOG.debug("redirect user to dashboard")
-        return redirect(url_for("dashboard.index"))
+    return after_login(user, next_url)

+ 6 - 11
app/auth/views/github.py

@@ -1,9 +1,10 @@
-from flask import request, session, redirect, url_for, flash
+from flask import request, session, redirect, flash
 from flask_login import login_user
 from flask_login import login_user
 from requests_oauthlib import OAuth2Session
 from requests_oauthlib import OAuth2Session
 
 
 from app import email_utils
 from app import email_utils
 from app.auth.base import auth_bp
 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.config import GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, URL
 from app.extensions import db
 from app.extensions import db
 from app.log import LOG
 from app.log import LOG
@@ -81,10 +82,8 @@ def github_callback():
 
 
     user = User.get_by(email=email)
     user = User.get_by(email=email)
 
 
-    if user:
-        login_user(user)
     # create user
     # create user
-    else:
+    if not user:
         LOG.d("create github user")
         LOG.d("create github user")
         user = User.create(
         user = User.create(
             email=email, name=github_user_data.get("name") or "", activated=True
             email=email, name=github_user_data.get("name") or "", activated=True
@@ -96,10 +95,6 @@ def github_callback():
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
 
 
     # The activation link contains the original page, for ex authorize page
     # The activation link contains the original page, for ex authorize page
-    if "next" in request.args:
-        next_url = request.args.get("next")
-        LOG.debug("redirect user to %s", next_url)
-        return redirect(next_url)
-    else:
-        LOG.debug("redirect user to dashboard")
-        return redirect(url_for("dashboard.index"))
+    next_url = request.args.get("next") if request.args else None
+
+    return after_login(user, next_url)

+ 4 - 7
app/auth/views/google.py

@@ -1,4 +1,4 @@
-from flask import request, session, redirect, url_for, flash
+from flask import request, session, redirect, flash
 from flask_login import login_user
 from flask_login import login_user
 from requests_oauthlib import OAuth2Session
 from requests_oauthlib import OAuth2Session
 
 
@@ -9,6 +9,7 @@ from app.extensions import db
 from app.log import LOG
 from app.log import LOG
 from app.models import User, File
 from app.models import User, File
 from app.utils import random_string
 from app.utils import random_string
+from .login_utils import after_login
 
 
 _authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth"
 _authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth"
 _token_url = "https://www.googleapis.com/oauth2/v4/token"
 _token_url = "https://www.googleapis.com/oauth2/v4/token"
@@ -89,8 +90,6 @@ def google_callback():
             file = create_file_from_url(picture_url)
             file = create_file_from_url(picture_url)
             user.profile_picture_id = file.id
             user.profile_picture_id = file.id
             db.session.commit()
             db.session.commit()
-
-        login_user(user)
     # create user
     # create user
     else:
     else:
         LOG.d("create google user with %s", google_user_data)
         LOG.d("create google user with %s", google_user_data)
@@ -107,6 +106,7 @@ def google_callback():
 
 
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
         flash(f"Welcome to SimpleLogin {user.name}!", "success")
 
 
+    next_url = None
     # The activation link contains the original page, for ex authorize page
     # The activation link contains the original page, for ex authorize page
     if "google_next_url" in session:
     if "google_next_url" in session:
         next_url = session["google_next_url"]
         next_url = session["google_next_url"]
@@ -115,10 +115,7 @@ def google_callback():
         # reset the next_url to avoid user getting redirected at each login :)
         # reset the next_url to avoid user getting redirected at each login :)
         session.pop("google_next_url", None)
         session.pop("google_next_url", None)
 
 
-        return redirect(next_url)
-    else:
-        LOG.debug("redirect user to dashboard")
-        return redirect(url_for("dashboard.index"))
+    return after_login(user, next_url)
 
 
 
 
 def create_file_from_url(url) -> File:
 def create_file_from_url(url) -> File:

+ 5 - 13
app/auth/views/login.py

@@ -1,9 +1,10 @@
 from flask import request, render_template, redirect, url_for, flash
 from flask import request, render_template, redirect, url_for, flash
-from flask_login import login_user, current_user
+from flask_login import current_user
 from flask_wtf import FlaskForm
 from flask_wtf import FlaskForm
 from wtforms import StringField, validators
 from wtforms import StringField, validators
 
 
 from app.auth.base import auth_bp
 from app.auth.base import auth_bp
+from app.auth.views.login_utils import after_login
 from app.log import LOG
 from app.log import LOG
 from app.models import User
 from app.models import User
 
 
@@ -27,9 +28,9 @@ def login():
         user = User.filter_by(email=form.email.data).first()
         user = User.filter_by(email=form.email.data).first()
 
 
         if not user:
         if not user:
-            flash("The email entered does not exist in our system", "error")
+            flash("Email or password incorrect", "error")
         elif not user.check_password(form.password.data):
         elif not user.check_password(form.password.data):
-            flash("Wrong password", "error")
+            flash("Email or password incorrect", "error")
         elif not user.activated:
         elif not user.activated:
             show_resend_activation = True
             show_resend_activation = True
             flash(
             flash(
@@ -37,16 +38,7 @@ def login():
                 "error",
                 "error",
             )
             )
         else:
         else:
-            LOG.debug("log user %s in", user)
-            login_user(user)
-
-            # User comes to login page from another page
-            if next_url:
-                LOG.debug("redirect user to %s", next_url)
-                return redirect(next_url)
-            else:
-                LOG.debug("redirect user to dashboard")
-                return redirect(url_for("dashboard.index"))
+            return after_login(user, next_url)
 
 
     return render_template(
     return render_template(
         "auth/login.html",
         "auth/login.html",

+ 30 - 0
app/auth/views/login_utils.py

@@ -0,0 +1,30 @@
+from flask import session, redirect, url_for
+from flask_login import login_user
+
+from app.config import MFA_USER_ID
+from app.log import LOG
+
+
+def after_login(user, next_url):
+    """
+    Redirect to the correct page after login.
+    If user enables MFA: redirect user to MFA page
+    Otherwise redirect to dashboard page if no next_url
+    """
+    if user.enable_otp:
+        session[MFA_USER_ID] = user.id
+        if next_url:
+            return redirect(url_for("auth.mfa", next_url=next_url))
+        else:
+            return redirect(url_for("auth.mfa"))
+    else:
+        LOG.debug("log user %s in", user)
+        login_user(user)
+
+        # User comes to login page from another page
+        if next_url:
+            LOG.debug("redirect user to %s", next_url)
+            return redirect(next_url)
+        else:
+            LOG.debug("redirect user to dashboard")
+            return redirect(url_for("dashboard.index"))

+ 51 - 0
app/auth/views/mfa.py

@@ -0,0 +1,51 @@
+import pyotp
+from flask import request, render_template, redirect, url_for, flash, session
+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.log import LOG
+from app.models import User
+
+
+class OtpTokenForm(FlaskForm):
+    token = StringField("Token", validators=[validators.DataRequired()])
+
+
+@auth_bp.route("/mfa", methods=["GET", "POST"])
+def mfa():
+    # passed from login page
+    user_id = session[MFA_USER_ID]
+    user = User.get(user_id)
+
+    if not user.enable_otp:
+        raise Exception("Only user with MFA enabled should go to this page. %s", user)
+
+    otp_token_form = OtpTokenForm()
+    next_url = request.args.get("next")
+
+    if otp_token_form.validate_on_submit():
+        totp = pyotp.TOTP(user.otp_secret)
+
+        token = otp_token_form.token.data
+
+        if totp.verify(token):
+            del session[MFA_USER_ID]
+
+            login_user(user)
+            flash(f"Welcome back {user.name}!")
+
+            # User comes to login page from another page
+            if next_url:
+                LOG.debug("redirect user to %s", next_url)
+                return redirect(next_url)
+            else:
+                LOG.debug("redirect user to dashboard")
+                return redirect(url_for("dashboard.index"))
+
+        else:
+            flash("Incorrect token", "warning")
+
+    return render_template("auth/mfa.html", otp_token_form=otp_token_form)

+ 17 - 2
app/config.py

@@ -58,11 +58,23 @@ else:
     IGNORED_EMAILS = []
     IGNORED_EMAILS = []
 
 
 DKIM_PRIVATE_KEY_PATH = get_abs_path(os.environ["DKIM_PRIVATE_KEY_PATH"])
 DKIM_PRIVATE_KEY_PATH = get_abs_path(os.environ["DKIM_PRIVATE_KEY_PATH"])
+DKIM_PUBLIC_KEY_PATH = get_abs_path(os.environ["DKIM_PUBLIC_KEY_PATH"])
 DKIM_SELECTOR = b"dkim"
 DKIM_SELECTOR = b"dkim"
 
 
 with open(DKIM_PRIVATE_KEY_PATH) as f:
 with open(DKIM_PRIVATE_KEY_PATH) as f:
     DKIM_PRIVATE_KEY = f.read()
     DKIM_PRIVATE_KEY = f.read()
 
 
+
+with open(DKIM_PUBLIC_KEY_PATH) as f:
+    DKIM_DNS_VALUE = (
+        f.read()
+        .replace("-----BEGIN PUBLIC KEY-----", "")
+        .replace("-----END PUBLIC KEY-----", "")
+        .replace("\r", "")
+        .replace("\n", "")
+    )
+
+
 DKIM_HEADERS = [b"from", b"to", b"subject"]
 DKIM_HEADERS = [b"from", b"to", b"subject"]
 
 
 # Database
 # Database
@@ -77,9 +89,11 @@ BUCKET = os.environ["BUCKET"]
 AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
 AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
 AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
 AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
 
 
+CLOUDWATCH_LOG_GROUP = CLOUDWATCH_LOG_STREAM = ""
 ENABLE_CLOUDWATCH = "ENABLE_CLOUDWATCH" in os.environ
 ENABLE_CLOUDWATCH = "ENABLE_CLOUDWATCH" in os.environ
-CLOUDWATCH_LOG_GROUP = os.environ["CLOUDWATCH_LOG_GROUP"]
-CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
+if ENABLE_CLOUDWATCH:
+    CLOUDWATCH_LOG_GROUP = os.environ["CLOUDWATCH_LOG_GROUP"]
+    CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
 
 
 # Paddle
 # Paddle
 PADDLE_VENDOR_ID = int(os.environ["PADDLE_VENDOR_ID"])
 PADDLE_VENDOR_ID = int(os.environ["PADDLE_VENDOR_ID"])
@@ -111,3 +125,4 @@ AVATAR_URL_EXPIRATION = 3600 * 24 * 7  # 1h*24h/d*7d=1week
 
 
 # session key
 # session key
 HIGHLIGHT_GEN_EMAIL_ID = "highlight_gen_email_id"
 HIGHLIGHT_GEN_EMAIL_ID = "highlight_gen_email_id"
+MFA_USER_ID = "mfa_user_id"

+ 3 - 0
app/dashboard/__init__.py

@@ -9,4 +9,7 @@ from .views import (
     api_key,
     api_key,
     custom_domain,
     custom_domain,
     alias_contact_manager,
     alias_contact_manager,
+    mfa_setup,
+    mfa_cancel,
+    domain_detail,
 )
 )

+ 2 - 0
app/dashboard/templates/dashboard/api_key.html

@@ -4,6 +4,8 @@
   API Key
   API Key
 {% endblock %}
 {% endblock %}
 
 
+{% set active_page = "api_key" %}
+
 {% block head %}
 {% block head %}
 {% endblock %}
 {% endblock %}
 
 

+ 6 - 96
app/dashboard/templates/dashboard/custom_domain.html

@@ -1,4 +1,5 @@
 {% extends 'default.html' %}
 {% extends 'default.html' %}
+{% set active_page = "custom_domain" %}
 
 
 {% block title %}
 {% block title %}
   Custom Domains
   Custom Domains
@@ -16,9 +17,11 @@
         <div class="card" style="max-width: 50rem">
         <div class="card" style="max-width: 50rem">
           <div class="card-body">
           <div class="card-body">
             <h5 class="card-title">
             <h5 class="card-title">
-              {{ custom_domain.domain }}
+              <a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">{{ custom_domain.domain }}</a>
               {% if custom_domain.verified %}
               {% if custom_domain.verified %}
-                <i class="fe fe-check" style="color: green"></i>
+                <span class="cursor" data-toggle="tooltip" data-original-title="Domain Verified">✅</span>
+              {% else %}
+                <span class="cursor" data-toggle="tooltip" data-original-title="Domain Not Verified">🚫 </span>
               {% endif %}
               {% endif %}
             </h5>
             </h5>
             <h6 class="card-subtitle mb-2 text-muted">
             <h6 class="card-subtitle mb-2 text-muted">
@@ -26,100 +29,7 @@
               <span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
               <span class="font-weight-bold">{{ custom_domain.nb_alias() }}</span> aliases.
             </h6>
             </h6>
 
 
-            {% if not custom_domain.verified %}
-              <hr>
-              <div class="mb-3">Please follow the following steps to set up your domain: </div>
-
-              <div class="row">
-                <div class="col-1">
-                  <span class="badge badge-primary badge-pill">1</span>
-                </div>
-
-                <div class="col-11">
-                  Add the following MX DNS record to your domain
-                  {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
-                    <div class="ml-2 mb-3 p-3" style="background-color: #eee">
-                      Domain: <em>{{ custom_domain.domain }}</em> <br>
-                      Priority: 10 <br>
-                      Target: <em>{{ email_server }}</em> <br>
-                    </div>
-                  {% endfor %}
-
-                  Or if you edit your DNS record in text format, use the following code: <br>
-
-                  <pre class="ml-3">{% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}{{ custom_domain.domain }} IN MX {{ priority }} {{ email_server }}<br>{% endfor %}</pre>
-                </div>
-              </div>
-
-              <div class="row">
-                <div class="col-1"><span class="badge badge-primary badge-pill">2</span></div>
-                <div class="col-11">
-                  <span class="font-weight-bold">[Optional]</span>
-                  Setup <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank">
-                    SPF <i class="fe fe-external-link"></i></a> record.
-                  This can avoid emails forwarded to your personal inbox classified as spam. <br>
-                  Please note that some email providers can still classify these forwards as spam, in this case
-                  do not hesitate to create rules to avoid these emails mistakenly gone into spam.
-                  You can find how to whitelist a domain on
-                  <a href="https://www.simplelogin.io/help" target="_blank">Whitelist domain<i class="fe fe-external-link"></i></a><br>
-
-                  Please add the following TXT DNS record to your domain:
-
-                  <div class="ml-3 mb-2 p-3" style="background-color: #eee">
-                    Domain: <em>{{ custom_domain.domain }}</em> <br>
-                    Value:
-                    <em>
-                      v=spf1
-                      {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
-                        include:{{ email_server[:-1] }}
-                      {% endfor %} -all
-                    </em>
-                  </div>
-
-                  Or if you edit your DNS record in text format, use the following code: <br>
-
-                  <pre class="ml-3">{{ custom_domain.domain }} IN TXT "v=spf1 {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}include:{{ email_server[:-1] }} {% endfor %}-all"</pre>
-                </div>
-              </div>
-
-              <div class="row">
-                <div class="col-1">
-                  <span class="badge badge-primary badge-pill mr-2">3</span>
-                </div>
-
-                <div class="col-11">
-                  Verify 👇🏽
-                  <form method="post">
-                    <input type="hidden" name="form-name" value="check-domain">
-                    <input type="hidden" name="custom-domain-id" value="{{ custom_domain.id }}">
-                    <button type="submit" class="btn btn-primary">Verify</button>
-                  </form>
-
-                  {% if custom_domain.id in errors %}
-                    <div class="text-danger">
-                      {{ errors.get(custom_domain.id) }}
-                    </div>
-                  {% endif %}
-
-                  As the change could take up to 24 hours, do not hesitate to come back to this page and verify again.
-                </div>
-              </div>
-            {% endif %}
-          </div>
-
-          <div class="card-footer">
-            <div class="row">
-              <div class="col">
-                <form method="post">
-                  <input type="hidden" name="form-name" value="delete">
-                  <input type="hidden" name="custom-domain-id" value="{{ custom_domain.id }}">
-                  <span class="card-link btn btn-link float-right delete-custom-domain">
-                    Delete
-                  </span>
-                </form>
-              </div>
-            </div>
-
+            <a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">Details ➡</a>
           </div>
           </div>
         </div>
         </div>
       {% endfor %}
       {% endfor %}

+ 217 - 0
app/dashboard/templates/dashboard/domain_detail.html

@@ -0,0 +1,217 @@
+{% extends 'default.html' %}
+{% set active_page = "custom_domain" %}
+
+{% block title %}
+  {{ custom_domain.domain }}
+{% endblock %}
+
+{% block head %}
+{% endblock %}
+
+{% block default_content %}
+  <div class="bg-white p-4" style="max-width: 60rem; margin: auto">
+    <h1 class="h3"> {{ custom_domain.domain }} </h1>
+    <div class="">Please follow the steps below to set up your domain.</div>
+
+    <div class="small-text mb-5">
+      DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1
+      minute or in our experience).
+    </div>
+
+    <div>
+      <div class="font-weight-bold">1. MX record
+
+        {% if custom_domain.verified %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="MX Record Verified">✅</span>
+        {% else %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="MX Record Not Verified">🚫 </span>
+        {% endif %}
+      </div>
+
+      <div class="mb-2">Add the following MX DNS record to your domain</div>
+
+      {% for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY %}
+        <div class="mb-3 p-3" style="background-color: #eee">
+          Domain: <em>{{ custom_domain.domain }}</em> <br>
+          Priority: 10 <br>
+          Target: <em>{{ email_server }}</em> <br>
+        </div>
+      {% endfor %}
+
+      <form method="post">
+        <input type="hidden" name="form-name" value="check-mx">
+        {% if custom_domain.verified %}
+          <button type="submit" class="btn btn-outline-primary">
+            Re-verify
+          </button>
+        {% else %}
+          <button type="submit" class="btn btn-primary">
+            Verify
+          </button>
+        {% endif %}
+      </form>
+
+      {% if not mx_ok %}
+        <div class="text-danger mt-4">
+          Your DNS is not correctly set. The MX record we obtain is:
+          <div class="mb-3 p-3" style="background-color: #eee">
+            {% for r in mx_errors %}
+              {{ r }} <br>
+            {% endfor %}
+          </div>
+          {% if custom_domain.verified %}
+            Please make sure to fix this ASAP - your aliases might not work properly.
+          {% endif %}
+        </div>
+      {% endif %}
+    </div>
+
+    <hr>
+
+    <div>
+      <div class="font-weight-bold">2. SPF (Optional)
+        {% if custom_domain.spf_verified %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
+        {% else %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="SPF Not Verified">🚫 </span>
+        {% endif %}
+      </div>
+
+      <div>
+        SPF <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" target="_blank">(Wikipedia↗)</a> is an email
+        authentication method
+        designed to detect forging sender addresses during the delivery of the email. <br>
+        Setting up SPF is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
+      </div>
+
+      <div class="mb-2">Add the following TXT DNS record to your domain</div>
+
+      <div class="mb-2 p-3" style="background-color: #eee">
+        Domain: <em>{{ custom_domain.domain }}</em> <br>
+        Value:
+        <em>
+          {{ spf_record }}
+        </em>
+      </div>
+
+      <form method="post">
+        <input type="hidden" name="form-name" value="check-spf">
+        {% if custom_domain.spf_verified %}
+          <button type="submit" class="btn btn-outline-primary">
+            Re-verify
+          </button>
+        {% else %}
+          <button type="submit" class="btn btn-primary">
+            Verify
+          </button>
+        {% endif %}
+      </form>
+
+      {% if not spf_ok %}
+        <div class="text-danger mt-4">
+          Your DNS is not correctly set. The TXT record we obtain is:
+          <div class="mb-3 p-3" style="background-color: #eee">
+            {% for r in spf_errors %}
+              {{ r }} <br>
+            {% endfor %}
+          </div>
+          {% if custom_domain.spf_verified %}
+            Without SPF setup, emails you sent from your alias might end up in Spam/Junk folder.
+          {% endif %}
+        </div>
+      {% endif %}
+    </div>
+
+    <hr>
+
+    <div>
+      <div class="font-weight-bold">3. DKIM (Optional)
+        {% if custom_domain.dkim_verified %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="SPF Verified">✅</span>
+        {% else %}
+            <span class="cursor" data-toggle="tooltip" data-original-title="DKIM Not Verified">🚫 </span>
+        {% endif %}
+      </div>
+
+      <div>
+        DKIM <a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail" target="_blank">(Wikipedia↗)</a> is an
+        email
+        authentication method
+        designed to avoid email spoofing. <br>
+        Setting up DKIM is highly recommended to reduce the chance your emails ending up in the recipient's Spam folder.
+      </div>
+
+      <div class="mb-2">Add the following TXT DNS record to your domain</div>
+
+      <div class="mb-2 p-3" style="background-color: #eee">
+        Domain: <em>dkim._domainkey.{{ custom_domain.domain }}</em> <br>
+        Value:
+        <em style="overflow-wrap: break-word">
+          {{ dkim_record }}
+        </em>
+      </div>
+
+      <form method="post">
+        <input type="hidden" name="form-name" value="check-dkim">
+        {% if custom_domain.dkim_verified %}
+          <button type="submit" class="btn btn-outline-primary">
+            Re-verify
+          </button>
+        {% else %}
+          <button type="submit" class="btn btn-primary">
+            Verify
+          </button>
+        {% endif %}
+      </form>
+
+      {% if not dkim_ok %}
+        <div class="text-danger mt-4">
+          Your DNS is not correctly set.
+          {% if dkim_errors %}
+            The TXT record we obtain for
+            <em>dkim._domainkey.{{ custom_domain.domain }}</em> is:
+
+            <div class="mb-3 p-3" style="background-color: #eee">
+              {% for r in dkim_errors %}
+                {{ r }} <br>
+              {% endfor %}
+            </div>
+          {% endif %}
+
+          {% if custom_domain.dkim_verified %}
+            Without DKIM setup, emails you sent from your alias might end up in Spam/Junk folder.
+          {% endif %}
+        </div>
+      {% endif %}
+    </div>
+
+    <hr>
+    <h3 class="mb-0">Delete Domain</h3>
+    <div class="small-text mb-3">Please note that this operation is irreversible.
+      All aliases associated with this domain will be also deleted
+    </div>
+
+    <form method="post">
+      <input type="hidden" name="form-name" value="delete">
+      <span class="delete-custom-domain btn btn-outline-danger">Delete domain</span>
+    </form>
+
+  </div>
+{% endblock %}
+
+{% block script %}
+  <script>
+    $(".delete-custom-domain").on("click", function (e) {
+      notie.confirm({
+        text: "All aliases associated with <b>{{ custom_domain.domain }}</b> will be also deleted, " +
+          " please confirm.",
+        cancelCallback: () => {
+          // nothing to do
+        },
+        submitCallback: () => {
+          $(this).closest("form").submit();
+        }
+      });
+    });
+  </script>
+{% endblock %}

+ 41 - 15
app/dashboard/templates/dashboard/index.html

@@ -24,7 +24,7 @@
       </form>
       </form>
     </div>
     </div>
 
 
-    <div class="col-lg-3 offset-lg-6 pr-0 mt-1">
+    <div class="col-lg-4 offset-lg-5 pr-0 mt-1">
       <div class="btn-group float-right" role="group">
       <div class="btn-group float-right" role="group">
         <form method="post">
         <form method="post">
           <input type="hidden" name="form-name" value="create-custom-email">
           <input type="hidden" name="form-name" value="create-custom-email">
@@ -33,17 +33,37 @@
                   class="btn btn-primary mr-2">New Email Alias
                   class="btn btn-primary mr-2">New Email Alias
           </button>
           </button>
         </form>
         </form>
-        <form method="post">
-          <input type="hidden" name="form-name" value="create-random-email">
-          <button data-toggle="tooltip"
-                  title="Create a totally random alias"
-                  class="btn btn-success">Random Alias
-          </button>
-        </form>
+        <div class="btn-group" role="group">
+            <form method="post">
+                <input type="hidden" name="form-name" value="create-random-email">
+                <button data-toggle="tooltip"
+                        title="Create a totally random alias"
+                        class="btn btn-success">Random Alias
+                </button>
+            </form>
+            <button id="btnGroupDrop1" type="button" class="btn btn-success dropdown-toggle"
+                    data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+            </button>
+            <div class="dropdown-menu dropdown-menu-right" aria-labelledby="btnGroupDrop1">
+                <div class="">
+                    <form method="post">
+                        <input type="hidden" name="form-name" value="create-random-email">
+                        <input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.word.value }}">
+                        <button class="dropdown-item">By random words</button>
+                    </form>
+                </div>
+                <div class="">
+                    <form method="post">
+                        <input type="hidden" name="form-name" value="create-random-email">
+                        <input type="hidden" name="generator_scheme" value="{{ AliasGeneratorEnum.uuid.value }}">
+                        <button class="dropdown-item">By UUID</button>
+                    </form>
+                </div>
+            </div>
+        </div>
       </div>
       </div>
     </div>
     </div>
 
 
-
   </div>
   </div>
 
 
   <div class="row">
   <div class="row">
@@ -60,7 +80,7 @@
       >
       >
         <div class="card p-3 {% if alias_info.highlight %} highlight-row {% endif %}">
         <div class="card p-3 {% if alias_info.highlight %} highlight-row {% endif %}">
           <div>
           <div>
-            <span class="clipboard mb-0"
+            <span class="clipboard cursor mb-0"
                 {% if gen_email.enabled %}
                 {% if gen_email.enabled %}
                   data-toggle="tooltip"
                   data-toggle="tooltip"
                   title="Copy to clipboard"
                   title="Copy to clipboard"
@@ -98,7 +118,7 @@
             <form method="post">
             <form method="post">
               <input type="hidden" name="form-name" value="switch-email-forwarding">
               <input type="hidden" name="form-name" value="switch-email-forwarding">
               <input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
               <input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
-              <label class="custom-switch mt-2"
+              <label class="custom-switch cursor mt-2"
                      data-toggle="tooltip"
                      data-toggle="tooltip"
                   {% if gen_email.enabled %}
                   {% if gen_email.enabled %}
                      title="Block Alias"
                      title="Block Alias"
@@ -115,8 +135,10 @@
                   {% endif %}
                   {% endif %}
                      style="padding-left: 0px"
                      style="padding-left: 0px"
               >
               >
+                <input type="hidden" name="alias" class="alias" value="{{ gen_email.email }}">
                 <input type="checkbox" class="custom-switch-input"
                 <input type="checkbox" class="custom-switch-input"
                     {{ "checked" if gen_email.enabled else "" }}>
                     {{ "checked" if gen_email.enabled else "" }}>
+
                 <span class="custom-switch-indicator"></span>
                 <span class="custom-switch-indicator"></span>
               </label>
               </label>
             </form>
             </form>
@@ -143,6 +165,8 @@
               <form method="post">
               <form method="post">
                 <input type="hidden" name="form-name" value="delete-email">
                 <input type="hidden" name="form-name" value="delete-email">
                 <input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
                 <input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
+                <input type="hidden" name="alias" class="alias" value="{{ gen_email.email }}">
+
                 <span class="delete-email  btn btn-link btn-sm float-right">
                 <span class="delete-email  btn btn-link btn-sm float-right">
                   Delete&nbsp; &nbsp;<i class="dropdown-icon fe fe-trash-2"></i>
                   Delete&nbsp; &nbsp;<i class="dropdown-icon fe fe-trash-2"></i>
                 </span>
                 </span>
@@ -248,9 +272,10 @@
 
 
 
 
     $(".delete-email").on("click", function (e) {
     $(".delete-email").on("click", function (e) {
+      let alias = $(this).parent().find(".alias").val();
       notie.confirm({
       notie.confirm({
-        text: "Once an alias is deleted, people/apps " +
-          "who used to contact you via this email address cannot reach you any more," +
+        text: `Once <b>${alias}</b> is deleted, people/apps ` +
+          "who used to contact you via this alias cannot reach you any more," +
           " please confirm.",
           " please confirm.",
         cancelCallback: () => {
         cancelCallback: () => {
           // nothing to do
           // nothing to do
@@ -276,11 +301,12 @@
 
 
     $(".custom-switch-input").change(function (e) {
     $(".custom-switch-input").change(function (e) {
       var message = "";
       var message = "";
+      let alias = $(this).parent().find(".alias").val();
 
 
       if (e.target.checked) {
       if (e.target.checked) {
-        message = `After this, you will start receiving email sent to this alias, please confirm.`;
+        message = `After this, you will start receiving email sent to <b>${alias}</b>, please confirm.`;
       } else {
       } else {
-        message = `After this, you will stop receiving email sent to this alias, please confirm.`;
+        message = `After this, you will stop receiving email sent to <b>${alias}</b>, please confirm.`;
       }
       }
 
 
       notie.confirm({
       notie.confirm({

+ 28 - 0
app/dashboard/templates/dashboard/mfa_cancel.html

@@ -0,0 +1,28 @@
+{% extends 'default.html' %}
+{% set active_page = "setting" %}
+{% block title %}
+  Cancel MFA
+{% endblock %}
+
+
+{% block default_content %}
+  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
+    <h1 class="h2">Multi Factor Authentication</h1>
+    <p>
+      To cancel MFA, please enter the 6-digit number in your TOTP application (Google Authenticator, Authy, etc) here.
+    </p>
+
+    <form method="post">
+      {{ otp_token_form.csrf_token }}
+
+      <div class="font-weight-bold mt-5">Token</div>
+      <div class="small-text">The 6-digit number displayed on your phone.</div>
+
+      {{ otp_token_form.token(class="form-control", placeholder="") }}
+      {{ render_field_errors(otp_token_form.token) }}
+      <button class="btn btn-lg btn-danger mt-2">Cancel MFA</button>
+    </form>
+
+
+  </div>
+{% endblock %}

+ 51 - 0
app/dashboard/templates/dashboard/mfa_setup.html

@@ -0,0 +1,51 @@
+{% extends 'default.html' %}
+{% set active_page = "setting" %}
+{% block title %}
+  MFA Setup
+{% endblock %}
+
+{% block head %}
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
+{% endblock %}
+
+{% block default_content %}
+  <div class="bg-white p-6" style="max-width: 60em; margin: auto">
+    <h1 class="h2">Multi Factor Authentication</h1>
+    <p>Please open a TOTP application (Google Authenticator, Authy, etc)
+      on your smartphone and scan the following QR Code:
+    </p>
+
+    <canvas id="qr"></canvas>
+
+    <script>
+      (function () {
+        var qr = new QRious({
+          element: document.getElementById('qr'),
+          value: '{{otp_uri}}'
+        });
+      })();
+    </script>
+
+    <div class="mt-3 mb-2">
+      Or you can use the manual entry with the following key:
+    </div>
+
+    <div class="mb-3 p-3" style="background-color: #eee">
+      {{ current_user.otp_secret }}
+    </div>
+
+
+    <form method="post">
+      {{ otp_token_form.csrf_token }}
+
+      <div class="font-weight-bold mt-5">Token</div>
+      <div class="small-text">Please enter the 6-digit number displayed on your phone.</div>
+
+      {{ otp_token_form.token(class="form-control", placeholder="") }}
+      {{ render_field_errors(otp_token_form.token) }}
+      <button class="btn btn-lg btn-success mt-2">Validate</button>
+    </form>
+
+
+  </div>
+{% endblock %}

+ 11 - 7
app/dashboard/templates/dashboard/pricing.html

@@ -14,18 +14,22 @@
     <div class="col-sm-6 col-lg-6">
     <div class="col-sm-6 col-lg-6">
       <div class="card">
       <div class="card">
         <div class="card-body text-center">
         <div class="card-body text-center">
-          <div class="card-category">Premium</div>
-          <ul class="list-unstyled leading-loose">
-            <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> <em>Unlimited</em> Custom Alias</li>
+          <div class="h3">Premium</div>
+
+          <ul class="list-unstyled leading-loose mb-3">
+            <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Unlimited Alias</li>
             <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
             <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
-              Custom email domain
-              <span class="badge badge-success">In Beta</span>
-              <div class="small-text">Please contact us to try out this feature!</div>
+              Custom Domain
             </li>
             </li>
             <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
             <li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
-              Support us
+              Directory (or Username)
+              <span class="badge badge-info">Coming Soon</span>
             </li>
             </li>
           </ul>
           </ul>
+
+          <div class="small-text">More info on our <a href="https://simplelogin.io/pricing" target="_blank">Pricing
+            Page <i class="fe fe-external-link"></i>
+          </a></div>
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>

+ 32 - 1
app/dashboard/templates/dashboard/setting.html

@@ -1,7 +1,9 @@
 {% extends 'default.html' %}
 {% extends 'default.html' %}
 
 
+{% set active_page = "setting" %}
+
 {% block title %}
 {% block title %}
-  Setting
+  Settings
 {% endblock %}
 {% endblock %}
 
 
 {% block default_content %}
 {% block default_content %}
@@ -48,8 +50,20 @@
       <button class="btn btn-primary">Update</button>
       <button class="btn btn-primary">Update</button>
     </form>
     </form>
 
 
+
     <hr>
     <hr>
+    <h3 class="mb-0">Multi-Factor Authentication (MFA)</h3>
+    <div class="small-text mb-3">
+      Secure your account with Multi-Factor Authentication.
+      This requires having applications like Google Authenticator, Authy, FreeOTP, etc.
+    </div>
+    {% if not current_user.enable_otp %}
+      <a href="{{ url_for('dashboard.mfa_setup') }}" class="btn btn-outline-primary">Enable</a>
+    {% else %}
+      <a href="{{ url_for('dashboard.mfa_cancel') }}" class="btn btn-outline-danger">Cancel MFA</a>
+    {% endif %}
 
 
+    <hr>
     <h3 class="mb-0">Change password</h3>
     <h3 class="mb-0">Change password</h3>
     <div class="small-text mb-3">You will receive an email containing instructions on how to change password.</div>
     <div class="small-text mb-3">You will receive an email containing instructions on how to change password.</div>
     <form method="post">
     <form method="post">
@@ -57,6 +71,23 @@
       <button class="btn btn-outline-primary">Change password</button>
       <button class="btn btn-outline-primary">Change password</button>
     </form>
     </form>
 
 
+    <hr>
+
+    <h3 class="mb-0">Random Alias</h3>
+    <div class="small-text mb-3">Choose how to create your email alias by default</div>
+    <form method="post" class="form-inline">
+      <input type="hidden" name="form-name" value="change-alias-generator">
+      <select class="custom-select mr-sm-2" name="alias-generator-scheme">
+        <option value="{{ AliasGeneratorEnum.word.value }}"
+            {% if current_user.alias_generator == AliasGeneratorEnum.word.value %} selected {% endif %} >Based on
+          Random {{ AliasGeneratorEnum.word.name.capitalize() }}</option>
+        <option value="{{ AliasGeneratorEnum.uuid.value }}"
+            {% if current_user.alias_generator == AliasGeneratorEnum.uuid.value %} selected {% endif %} >Based
+          on {{ AliasGeneratorEnum.uuid.name.upper() }}</option>
+      </select>
+      <button class="btn btn-outline-primary">Update Preference</button>
+    </form>
+
     <hr>
     <hr>
     <h3 class="mb-0">Export Data</h3>
     <h3 class="mb-0">Export Data</h3>
     <div class="small-text mb-3">
     <div class="small-text mb-3">

+ 6 - 53
app/dashboard/views/custom_domain.py

@@ -3,9 +3,8 @@ from flask_login import login_required, current_user
 from flask_wtf import FlaskForm
 from flask_wtf import FlaskForm
 from wtforms import StringField, validators
 from wtforms import StringField, validators
 
 
-from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_SERVERS
+from app.config import EMAIL_SERVERS_WITH_PRIORITY
 from app.dashboard.base import dashboard_bp
 from app.dashboard.base import dashboard_bp
-from app.dns_utils import get_mx_domains, get_spf_domain
 from app.extensions import db
 from app.extensions import db
 from app.models import CustomDomain
 from app.models import CustomDomain
 
 
@@ -30,25 +29,7 @@ def custom_domain():
     errors = {}
     errors = {}
 
 
     if request.method == "POST":
     if request.method == "POST":
-        if request.form.get("form-name") == "delete":
-            custom_domain_id = request.form.get("custom-domain-id")
-            custom_domain = CustomDomain.get(custom_domain_id)
-
-            if not custom_domain:
-                flash("Unknown error. Refresh the page", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-            elif custom_domain.user_id != current_user.id:
-                flash("You cannot delete this domain", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-
-            name = custom_domain.domain
-            CustomDomain.delete(custom_domain_id)
-            db.session.commit()
-            flash(f"Domain {name} has been deleted successfully", "success")
-
-            return redirect(url_for("dashboard.custom_domain"))
-
-        elif request.form.get("form-name") == "create":
+        if request.form.get("form-name") == "create":
             if new_custom_domain_form.validate():
             if new_custom_domain_form.validate():
                 new_custom_domain = CustomDomain.create(
                 new_custom_domain = CustomDomain.create(
                     domain=new_custom_domain_form.domain.data, user_id=current_user.id
                     domain=new_custom_domain_form.domain.data, user_id=current_user.id
@@ -60,39 +41,11 @@ def custom_domain():
                     "success",
                     "success",
                 )
                 )
 
 
-                return redirect(url_for("dashboard.custom_domain"))
-        elif request.form.get("form-name") == "check-domain":
-            custom_domain_id = request.form.get("custom-domain-id")
-            custom_domain = CustomDomain.get(custom_domain_id)
-
-            if not custom_domain:
-                flash("Unknown error. Refresh the page", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-            elif custom_domain.user_id != current_user.id:
-                flash("You cannot delete this domain", "warning")
-                return redirect(url_for("dashboard.custom_domain"))
-            else:
-                spf_domains = get_spf_domain(custom_domain.domain)
-                for email_server in EMAIL_SERVERS:
-                    email_server = email_server[:-1]  # remove the trailing .
-                    if email_server not in spf_domains:
-                        flash(
-                            f"{email_server} is not included in your SPF record.",
-                            "warning",
-                        )
-
-                mx_domains = get_mx_domains(custom_domain.domain)
-                if mx_domains != EMAIL_SERVERS:
-                    errors[
-                        custom_domain.id
-                    ] = f"""Your DNS is not correctly set. The MX record we obtain is: {",".join(mx_domains)}"""
-                else:
-                    flash(
-                        "Your domain is verified. Now it can be used to create custom alias",
-                        "success",
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=new_custom_domain.id
                     )
                     )
-                    custom_domain.verified = True
-                    db.session.commit()
+                )
 
 
     return render_template(
     return render_template(
         "dashboard/custom_domain.html",
         "dashboard/custom_domain.html",

+ 106 - 0
app/dashboard/views/domain_detail.py

@@ -0,0 +1,106 @@
+from flask import render_template, request, redirect, url_for, flash
+from flask_login import login_required, current_user
+
+from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_SERVERS, DKIM_DNS_VALUE
+from app.dashboard.base import dashboard_bp
+from app.dns_utils import (
+    get_mx_domains,
+    get_spf_domain,
+    get_dkim_record,
+    get_txt_record,
+)
+from app.extensions import db
+from app.models import CustomDomain
+
+
+@dashboard_bp.route("/domains/<int:custom_domain_id>", methods=["GET", "POST"])
+@login_required
+def domain_detail(custom_domain_id):
+    # only premium user can see custom domain
+    if not current_user.is_premium():
+        flash("Only premium user can add custom domains", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    custom_domain = CustomDomain.get(custom_domain_id)
+    if not custom_domain or custom_domain.user_id != current_user.id:
+        flash("You cannot see this page", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    mx_ok = spf_ok = dkim_ok = True
+    mx_errors = spf_errors = dkim_errors = []
+
+    if request.method == "POST":
+        if request.form.get("form-name") == "check-mx":
+            mx_domains = get_mx_domains(custom_domain.domain)
+
+            if mx_domains != EMAIL_SERVERS:
+                mx_ok = False
+                mx_errors = get_mx_domains(custom_domain.domain, keep_priority=True)
+            else:
+                flash(
+                    "Your domain is verified. Now it can be used to create custom alias",
+                    "success",
+                )
+                custom_domain.verified = True
+                db.session.commit()
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=custom_domain.id
+                    )
+                )
+        elif request.form.get("form-name") == "check-spf":
+            spf_domains = get_spf_domain(custom_domain.domain)
+            for email_server in EMAIL_SERVERS:
+                email_server = email_server[:-1]  # remove the trailing .
+                if email_server not in spf_domains:
+                    flash(
+                        f"{email_server} is not included in your SPF record.", "warning"
+                    )
+                    spf_ok = False
+
+            if spf_ok:
+                custom_domain.spf_verified = True
+                db.session.commit()
+                flash("The SPF is setup correctly", "success")
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=custom_domain.id
+                    )
+                )
+            else:
+                spf_errors = get_txt_record(custom_domain.domain)
+
+        elif request.form.get("form-name") == "check-dkim":
+            dkim_record = get_dkim_record(custom_domain.domain)
+            correct_dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}"
+            if dkim_record == correct_dkim_record:
+                flash("The DKIM is setup correctly.", "success")
+                custom_domain.dkim_verified = True
+                db.session.commit()
+
+                return redirect(
+                    url_for(
+                        "dashboard.domain_detail", custom_domain_id=custom_domain.id
+                    )
+                )
+            else:
+                dkim_ok = False
+                dkim_errors = get_txt_record(f"dkim._domainkey.{custom_domain.domain}")
+
+        elif request.form.get("form-name") == "delete":
+            name = custom_domain.domain
+            CustomDomain.delete(custom_domain_id)
+            db.session.commit()
+            flash(f"Domain {name} has been deleted successfully", "success")
+
+            return redirect(url_for("dashboard.custom_domain"))
+
+    spf_include_records = []
+    for priority, email_server in EMAIL_SERVERS_WITH_PRIORITY:
+        spf_include_records.append(f"include:{email_server[:-1]}")
+
+    spf_record = f"v=spf1 {' '.join(spf_include_records)} -all"
+
+    dkim_record = f"v=DKIM1; k=rsa; p={DKIM_DNS_VALUE}"
+
+    return render_template("dashboard/domain_detail.html", **locals())

+ 17 - 2
app/dashboard/views/index.py

@@ -9,7 +9,14 @@ from app.config import HIGHLIGHT_GEN_EMAIL_ID
 from app.dashboard.base import dashboard_bp
 from app.dashboard.base import dashboard_bp
 from app.extensions import db
 from app.extensions import db
 from app.log import LOG
 from app.log import LOG
-from app.models import GenEmail, ClientUser, ForwardEmail, ForwardEmailLog, DeletedAlias
+from app.models import (
+    GenEmail,
+    ClientUser,
+    ForwardEmail,
+    ForwardEmailLog,
+    DeletedAlias,
+    AliasGeneratorEnum,
+)
 
 
 
 
 @dataclass
 @dataclass
@@ -57,7 +64,14 @@ def index():
 
 
         elif request.form.get("form-name") == "create-random-email":
         elif request.form.get("form-name") == "create-random-email":
             if current_user.can_create_new_alias():
             if current_user.can_create_new_alias():
-                gen_email = GenEmail.create_new_random(user_id=current_user.id)
+                scheme = int(
+                    request.form.get("generator_scheme") or current_user.alias_generator
+                )
+                if not scheme or not AliasGeneratorEnum.has_value(scheme):
+                    scheme = current_user.alias_generator
+                gen_email = GenEmail.create_new_random(
+                    user_id=current_user.id, scheme=scheme
+                )
                 db.session.commit()
                 db.session.commit()
 
 
                 LOG.d("generate new email %s for user %s", gen_email, current_user)
                 LOG.d("generate new email %s for user %s", gen_email, current_user)
@@ -112,6 +126,7 @@ def index():
         aliases=get_alias_info(current_user.id, query, highlight_gen_email_id),
         aliases=get_alias_info(current_user.id, query, highlight_gen_email_id),
         highlight_gen_email_id=highlight_gen_email_id,
         highlight_gen_email_id=highlight_gen_email_id,
         query=query,
         query=query,
+        AliasGeneratorEnum=AliasGeneratorEnum,
     )
     )
 
 
 
 

+ 37 - 0
app/dashboard/views/mfa_cancel.py

@@ -0,0 +1,37 @@
+import pyotp
+from flask import render_template, flash, redirect, url_for
+from flask_login import login_required, current_user
+from flask_wtf import FlaskForm
+from wtforms import StringField, validators
+
+from app.dashboard.base import dashboard_bp
+from app.extensions import db
+
+
+class OtpTokenForm(FlaskForm):
+    token = StringField("Token", validators=[validators.DataRequired()])
+
+
+@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
+@login_required
+def mfa_cancel():
+    if not current_user.enable_otp:
+        flash("you don't have MFA enabled", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    otp_token_form = OtpTokenForm()
+    totp = pyotp.TOTP(current_user.otp_secret)
+
+    if otp_token_form.validate_on_submit():
+        token = otp_token_form.token.data
+
+        if totp.verify(token):
+            current_user.enable_otp = False
+            current_user.otp_secret = None
+            db.session.commit()
+            flash("MFA is now disabled", "warning")
+            return redirect(url_for("dashboard.index"))
+        else:
+            flash("Incorrect token", "warning")
+
+    return render_template("dashboard/mfa_cancel.html", otp_token_form=otp_token_form)

+ 49 - 0
app/dashboard/views/mfa_setup.py

@@ -0,0 +1,49 @@
+import pyotp
+from flask import render_template, flash, redirect, url_for
+from flask_login import login_required, current_user
+from flask_wtf import FlaskForm
+from wtforms import StringField, validators
+
+from app.dashboard.base import dashboard_bp
+from app.extensions import db
+from app.log import LOG
+
+
+class OtpTokenForm(FlaskForm):
+    token = StringField("Token", validators=[validators.DataRequired()])
+
+
+@dashboard_bp.route("/mfa_setup", methods=["GET", "POST"])
+@login_required
+def mfa_setup():
+    if current_user.enable_otp:
+        flash("you have already enabled MFA", "warning")
+        return redirect(url_for("dashboard.index"))
+
+    otp_token_form = OtpTokenForm()
+
+    if not current_user.otp_secret:
+        LOG.d("Generate otp_secret for user %s", current_user)
+        current_user.otp_secret = pyotp.random_base32()
+        db.session.commit()
+
+    totp = pyotp.TOTP(current_user.otp_secret)
+
+    if otp_token_form.validate_on_submit():
+        token = otp_token_form.token.data
+
+        if totp.verify(token):
+            current_user.enable_otp = True
+            db.session.commit()
+            flash("MFA has been activated", "success")
+            return redirect(url_for("dashboard.index"))
+        else:
+            flash("Incorrect token", "warning")
+
+    otp_uri = pyotp.totp.TOTP(current_user.otp_secret).provisioning_uri(
+        name=current_user.email, issuer_name="SimpleLogin"
+    )
+
+    return render_template(
+        "dashboard/mfa_setup.html", otp_token_form=otp_token_form, otp_uri=otp_uri
+    )

+ 9 - 0
app/dashboard/views/setting.py

@@ -23,6 +23,7 @@ from app.models import (
     DeletedAlias,
     DeletedAlias,
     CustomDomain,
     CustomDomain,
     Client,
     Client,
+    AliasGeneratorEnum,
 )
 )
 from app.utils import random_string
 from app.utils import random_string
 
 
@@ -121,6 +122,13 @@ def setting():
             logout_user()
             logout_user()
             return redirect(url_for("auth.register"))
             return redirect(url_for("auth.register"))
 
 
+        elif request.form.get("form-name") == "change-alias-generator":
+            scheme = int(request.form.get("alias-generator-scheme"))
+            if AliasGeneratorEnum.has_value(scheme):
+                current_user.alias_generator = scheme
+                db.session.commit()
+            flash("Your preference has been updated", "success")
+
         elif request.form.get("form-name") == "export-data":
         elif request.form.get("form-name") == "export-data":
             data = {
             data = {
                 "email": current_user.email,
                 "email": current_user.email,
@@ -157,6 +165,7 @@ def setting():
         PlanEnum=PlanEnum,
         PlanEnum=PlanEnum,
         promo_form=promo_form,
         promo_form=promo_form,
         pending_email=pending_email,
         pending_email=pending_email,
+        AliasGeneratorEnum=AliasGeneratorEnum,
     )
     )
 
 
 
 

+ 41 - 3
app/dns_utils.py

@@ -1,7 +1,9 @@
 import dns.resolver
 import dns.resolver
 
 
 
 
-def get_mx_domains(hostname) -> [str]:
+def get_mx_domains(hostname, keep_priority=False) -> [str]:
+    """return list of (domain name). priority is also included if `keep_priority`
+    """
     try:
     try:
         answers = dns.resolver.query(hostname, "MX")
         answers = dns.resolver.query(hostname, "MX")
     except dns.resolver.NoAnswer:
     except dns.resolver.NoAnswer:
@@ -11,8 +13,10 @@ def get_mx_domains(hostname) -> [str]:
 
 
     for a in answers:
     for a in answers:
         record = a.to_text()  # for ex '20 alt2.aspmx.l.google.com.'
         record = a.to_text()  # for ex '20 alt2.aspmx.l.google.com.'
-        r = record.split(" ")[1]  # alt2.aspmx.l.google.com.
-        ret.append(r)
+        if not keep_priority:
+            record = record.split(" ")[1]  # alt2.aspmx.l.google.com.
+
+        ret.append(record)
 
 
     return ret
     return ret
 
 
@@ -40,3 +44,37 @@ def get_spf_domain(hostname) -> [str]:
                         ret.append(part[part.find(_include_spf) + len(_include_spf) :])
                         ret.append(part[part.find(_include_spf) + len(_include_spf) :])
 
 
     return ret
     return ret
+
+
+def get_txt_record(hostname) -> [str]:
+    try:
+        answers = dns.resolver.query(hostname, "TXT")
+    except dns.resolver.NoAnswer:
+        return []
+
+    ret = []
+
+    for a in answers:  # type: dns.rdtypes.ANY.TXT.TXT
+        for record in a.strings:
+            record = record.decode()  # record is bytes
+
+            ret.append(a)
+
+    return ret
+
+
+def get_dkim_record(hostname) -> str:
+    """query the dkim._domainkey.{hostname} record and returns its value"""
+    try:
+        answers = dns.resolver.query(f"dkim._domainkey.{hostname}", "TXT")
+    except dns.resolver.NoAnswer:
+        return ""
+
+    ret = []
+    for a in answers:  # type: dns.rdtypes.ANY.TXT.TXT
+        for record in a.strings:
+            record = record.decode()  # record is bytes
+
+            ret.append(record)
+
+    return "".join(ret)

+ 44 - 6
app/models.py

@@ -1,5 +1,6 @@
 import enum
 import enum
 import random
 import random
+import uuid
 
 
 import arrow
 import arrow
 import bcrypt
 import bcrypt
@@ -83,6 +84,15 @@ class PlanEnum(enum.Enum):
     yearly = 3
     yearly = 3
 
 
 
 
+class AliasGeneratorEnum(enum.Enum):
+    word = 1  # aliases are generated based on random words
+    uuid = 2  # aliases are generated based on uuid
+
+    @classmethod
+    def has_value(cls, value: int) -> bool:
+        return value in set(item.value for item in cls)
+
+
 class User(db.Model, ModelMixin, UserMixin):
 class User(db.Model, ModelMixin, UserMixin):
     __tablename__ = "users"
     __tablename__ = "users"
     email = db.Column(db.String(128), unique=True, nullable=False)
     email = db.Column(db.String(128), unique=True, nullable=False)
@@ -90,11 +100,22 @@ class User(db.Model, ModelMixin, UserMixin):
     password = db.Column(db.String(128), nullable=False)
     password = db.Column(db.String(128), nullable=False)
     name = db.Column(db.String(128), nullable=False)
     name = db.Column(db.String(128), nullable=False)
     is_admin = db.Column(db.Boolean, nullable=False, default=False)
     is_admin = db.Column(db.Boolean, nullable=False, default=False)
+    alias_generator = db.Column(
+        db.Integer,
+        nullable=False,
+        default=AliasGeneratorEnum.word.value,
+        server_default=str(AliasGeneratorEnum.word.value),
+    )
 
 
     activated = db.Column(db.Boolean, default=False, nullable=False)
     activated = db.Column(db.Boolean, default=False, nullable=False)
 
 
     profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
     profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
 
 
+    otp_secret = db.Column(db.String(16), nullable=True)
+    enable_otp = db.Column(
+        db.Boolean, nullable=False, default=False, server_default="0"
+    )
+
     profile_picture = db.relationship(File)
     profile_picture = db.relationship(File)
 
 
     @classmethod
     @classmethod
@@ -362,9 +383,18 @@ class OauthToken(db.Model, ModelMixin):
         return self.expired < arrow.now()
         return self.expired < arrow.now()
 
 
 
 
-def generate_email() -> str:
-    """generate an email address that does not exist before"""
-    random_email = random_words() + "@" + EMAIL_DOMAIN
+def generate_email(
+    scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
+) -> str:
+    """generate an email address that does not exist before
+    :param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
+    :type in_hex: bool, if the generate scheme is uuid, is hex favorable?
+    """
+    if scheme == AliasGeneratorEnum.uuid.value:
+        name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
+        random_email = name + "@" + EMAIL_DOMAIN
+    else:
+        random_email = random_words() + "@" + EMAIL_DOMAIN
 
 
     # check that the client does not exist yet
     # check that the client does not exist yet
     if not GenEmail.get_by(email=random_email) and not DeletedAlias.get_by(
     if not GenEmail.get_by(email=random_email) and not DeletedAlias.get_by(
@@ -375,7 +405,7 @@ def generate_email() -> str:
 
 
     # Rerun the function
     # Rerun the function
     LOG.warning("email %s already exists, generate a new email", random_email)
     LOG.warning("email %s already exists, generate a new email", random_email)
-    return generate_email()
+    return generate_email(scheme=scheme, in_hex=in_hex)
 
 
 
 
 class GenEmail(db.Model, ModelMixin):
 class GenEmail(db.Model, ModelMixin):
@@ -408,9 +438,11 @@ class GenEmail(db.Model, ModelMixin):
         return GenEmail.create(user_id=user_id, email=email)
         return GenEmail.create(user_id=user_id, email=email)
 
 
     @classmethod
     @classmethod
-    def create_new_random(cls, user_id):
+    def create_new_random(
+        cls, user_id, scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
+    ):
         """create a new random alias"""
         """create a new random alias"""
-        random_email = generate_email()
+        random_email = generate_email(scheme=scheme, in_hex=in_hex)
         return GenEmail.create(user_id=user_id, email=random_email)
         return GenEmail.create(user_id=user_id, email=random_email)
 
 
     def __repr__(self):
     def __repr__(self):
@@ -654,6 +686,12 @@ class CustomDomain(db.Model, ModelMixin):
     domain = db.Column(db.String(128), unique=True, nullable=False)
     domain = db.Column(db.String(128), unique=True, nullable=False)
 
 
     verified = db.Column(db.Boolean, nullable=False, default=False)
     verified = db.Column(db.Boolean, nullable=False, default=False)
+    dkim_verified = db.Column(
+        db.Boolean, nullable=False, default=False, server_default="0"
+    )
+    spf_verified = db.Column(
+        db.Boolean, nullable=False, default=False, server_default="0"
+    )
 
 
     def nb_alias(self):
     def nb_alias(self):
         return GenEmail.filter_by(custom_domain_id=self.id).count()
         return GenEmail.filter_by(custom_domain_id=self.id).count()

+ 13 - 4
email_handler.py

@@ -47,7 +47,7 @@ from app.email_utils import (
 )
 )
 from app.extensions import db
 from app.extensions import db
 from app.log import LOG
 from app.log import LOG
-from app.models import GenEmail, ForwardEmail, ForwardEmailLog
+from app.models import GenEmail, ForwardEmail, ForwardEmailLog, CustomDomain
 from app.utils import random_string
 from app.utils import random_string
 from server import create_app
 from server import create_app
 
 
@@ -207,6 +207,12 @@ class MailHandler:
         forward_email = ForwardEmail.get_by(reply_email=reply_email)
         forward_email = ForwardEmail.get_by(reply_email=reply_email)
         alias: str = forward_email.gen_email.email
         alias: str = forward_email.gen_email.email
 
 
+        # alias must end with EMAIL_DOMAIN or custom-domain
+        alias_domain = alias[alias.find("@") + 1 :]
+        if alias_domain != EMAIL_DOMAIN:
+            if not CustomDomain.get_by(domain=alias_domain):
+                return "550 alias unknown by SimpleLogin"
+
         user_email = forward_email.gen_email.user.email
         user_email = forward_email.gen_email.user.email
         if envelope.mail_from != user_email:
         if envelope.mail_from != user_email:
             LOG.error(
             LOG.error(
@@ -248,10 +254,13 @@ class MailHandler:
             envelope.rcpt_options,
             envelope.rcpt_options,
         )
         )
 
 
-        # todo: add DKIM-Signature for custom domain
-        # add DKIM-Signature for non-custom-domain alias
-        if alias.endswith(EMAIL_DOMAIN):
+        if alias_domain == EMAIL_DOMAIN:
             add_dkim_signature(msg, EMAIL_DOMAIN)
             add_dkim_signature(msg, EMAIL_DOMAIN)
+        # add DKIM-Signature for non-custom-domain alias
+        else:
+            custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain)
+            if custom_domain.dkim_verified:
+                add_dkim_signature(msg, alias_domain)
 
 
         msg_raw = msg.as_string().encode()
         msg_raw = msg.as_string().encode()
         smtp.sendmail(
         smtp.sendmail(

+ 6 - 0
local_data/dkim.pub.key

@@ -0,0 +1,6 @@
+-----BEGIN PUBLIC KEY-----
+MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxhcKgFHz+HbZiuUhH7iGCVsaZ
+YQ7xzf64ui+09QFlSYzl7d28LVlr7nvM0+xDbwwsgu2D1vweklroWM5FjbfVtJX3
+HvSnNbwceX5du/m8RHelmX0/vLSfsEcnvdNjBmwl/gSIUb660pEp2yo6dUBDTzTD
+UBNoL6qmnnTNhriRoQIDAQAB
+-----END PUBLIC KEY-----

+ 1 - 1
migrations/alembic.ini

@@ -2,7 +2,7 @@
 
 
 [alembic]
 [alembic]
 # template used to generate migration files
 # template used to generate migration files
-# file_template = %%(rev)s_%%(slug)s
+file_template = %%(year)d_%%(month).2d%%(day).2d%%(hour).2d_%%(rev)s_%%(slug)s
 
 
 # set to 'true' to run the environment during
 # set to 'true' to run the environment during
 # the 'revision' command, regardless of autogenerate
 # the 'revision' command, regardless of autogenerate

+ 29 - 0
migrations/versions/2019_122910_696e17c13b8b_.py

@@ -0,0 +1,29 @@
+"""empty message
+
+Revision ID: 696e17c13b8b
+Revises: e409f6214b2b
+Create Date: 2019-12-29 10:43:29.169736
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '696e17c13b8b'
+down_revision = 'e409f6214b2b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('custom_domain', sa.Column('spf_verified', sa.Boolean(), server_default='0', nullable=False))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('custom_domain', 'spf_verified')
+    # ### end Alembic commands ###

+ 29 - 0
migrations/versions/2019_122910_e409f6214b2b_.py

@@ -0,0 +1,29 @@
+"""empty message
+
+Revision ID: e409f6214b2b
+Revises: d4e4488a0032
+Create Date: 2019-12-29 10:29:44.979846
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'e409f6214b2b'
+down_revision = 'd4e4488a0032'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('alias_generator', sa.Integer(), server_default='1', nullable=False))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'alias_generator')
+    # ### end Alembic commands ###

+ 29 - 0
migrations/versions/9e1b06b9df13_.py

@@ -0,0 +1,29 @@
+"""empty message
+
+Revision ID: 9e1b06b9df13
+Revises: 18e934d58f55
+Create Date: 2019-12-25 17:22:27.887481
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '9e1b06b9df13'
+down_revision = '18e934d58f55'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('custom_domain', sa.Column('dkim_verified', sa.Boolean(), server_default='0', nullable=False))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('custom_domain', 'dkim_verified')
+    # ### end Alembic commands ###

+ 31 - 0
migrations/versions/d4e4488a0032_.py

@@ -0,0 +1,31 @@
+"""empty message
+
+Revision ID: d4e4488a0032
+Revises: 9e1b06b9df13
+Create Date: 2019-12-27 15:19:11.060497
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd4e4488a0032'
+down_revision = '9e1b06b9df13'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('enable_otp', sa.Boolean(), server_default='0', nullable=False))
+    op.add_column('users', sa.Column('otp_secret', sa.String(length=16), nullable=True))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'otp_secret')
+    op.drop_column('users', 'enable_otp')
+    # ### end Alembic commands ###

+ 2 - 1
requirements.in

@@ -31,4 +31,5 @@ dnspython
 coloredlogs
 coloredlogs
 pycryptodome
 pycryptodome
 phpserialize
 phpserialize
-dkimpy
+dkimpy
+pyotp

+ 3 - 2
requirements.txt

@@ -70,6 +70,7 @@ pycparser==2.19           # via cffi
 pycryptodome==3.9.4
 pycryptodome==3.9.4
 pygments==2.4.2           # via ipython
 pygments==2.4.2           # via ipython
 pyopenssl==19.0.0
 pyopenssl==19.0.0
+pyotp==2.3.0
 pyparsing==2.4.0          # via packaging
 pyparsing==2.4.0          # via packaging
 pytest==4.6.3
 pytest==4.6.3
 python-dateutil==2.8.0    # via alembic, arrow, botocore, strictyaml
 python-dateutil==2.8.0    # via alembic, arrow, botocore, strictyaml
@@ -81,10 +82,10 @@ requests-oauthlib==1.2.0
 requests==2.22.0          # via requests-oauthlib
 requests==2.22.0          # via requests-oauthlib
 ruamel.yaml==0.15.97      # via strictyaml
 ruamel.yaml==0.15.97      # via strictyaml
 s3transfer==0.2.1         # via boto3
 s3transfer==0.2.1         # via boto3
-sentry-sdk==0.13.2
+sentry-sdk==0.13.5
 six==1.12.0               # via bcrypt, cryptography, flask-cors, packaging, pip-tools, prompt-toolkit, pyopenssl, pytest, python-dateutil, sqlalchemy-utils, traitlets
 six==1.12.0               # via bcrypt, cryptography, flask-cors, packaging, pip-tools, prompt-toolkit, pyopenssl, pytest, python-dateutil, sqlalchemy-utils, traitlets
 sqlalchemy-utils==0.33.11
 sqlalchemy-utils==0.33.11
-sqlalchemy==1.3.4         # via alembic, flask-sqlalchemy, sqlalchemy-utils
+sqlalchemy==1.3.12        # via alembic, flask-sqlalchemy, sqlalchemy-utils
 strictyaml==1.0.2         # via yacron
 strictyaml==1.0.2         # via yacron
 traitlets==4.3.2          # via ipython
 traitlets==4.3.2          # via ipython
 unidecode==1.0.23
 unidecode==1.0.23

+ 1 - 0
server.py

@@ -97,6 +97,7 @@ def fake_data():
         password="password",
         password="password",
         activated=True,
         activated=True,
         is_admin=True,
         is_admin=True,
+        otp_secret="base32secret3232",
     )
     )
     db.session.commit()
     db.session.commit()
 
 

BIN
static/logo.png


+ 4 - 0
static/style.css

@@ -66,4 +66,8 @@ em {
 .copy-btn {
 .copy-btn {
     font-size: 0.6rem;
     font-size: 0.6rem;
     line-height: 0.75;
     line-height: 0.75;
+}
+
+.cursor {
+    cursor: pointer;
 }
 }

+ 1 - 1
templates/default.html

@@ -5,7 +5,7 @@
     {% include "header.html" %}
     {% include "header.html" %}
 
 
     <div class="my-2 my-md-2">
     <div class="my-2 my-md-2">
-      <div class="container">
+      <div class="container pt-3">
         {% block default_content %}
         {% block default_content %}
         {% endblock %}
         {% endblock %}
       </div>
       </div>

+ 0 - 24
templates/emails/welcome.html

@@ -4,29 +4,5 @@
   {{ render_text("Welcome " + name + "  🎉!") }}
   {{ render_text("Welcome " + name + "  🎉!") }}
   {{ render_text("I really appreciate you signing up for SimpleLogin, and I'm sure you'll love it when you see how *simple* it is to use.") }}
   {{ render_text("I really appreciate you signing up for SimpleLogin, and I'm sure you'll love it when you see how *simple* it is to use.") }}
   {{ render_text("We built SimpleLogin to help people protecting their online identity, and I hope that we can achieve that for you.") }}
   {{ render_text("We built SimpleLogin to help people protecting their online identity, and I hope that we can achieve that for you.") }}
-
-  <!-- LINE -->
-  <!-- Set line color -->
-  <tr>
-    <td align="center" valign="top" style="border-collapse: collapse; border-spacing: 0; margin: 0; padding: 0; padding-left: 6.25%; padding-right: 6.25%; width: 87.5%;
-			padding-top: 25px;" class="line">
-      <hr
-          color="#E0E0E0" align="center" width="100%" size="1" noshade style="margin: 0; padding: 0;"/>
-    </td>
-  </tr>
-
-  <tr>
-    <td align="left" valign="top" style="border-collapse: collapse; border-spacing: 0; margin: 0; padding: 0; padding-left: 6.25%; padding-right: 6.25%; width: 87.5%; font-size: 24px; font-weight: 700; line-height: 200%;
-			padding-top: 25px;
-			color: #000000;
-			font-family: sans-serif;" class="paragraph">
-      Join our Community
-    </td>
-  </tr>
-
-  {{ render_text("Click the button 👇 to join our community on Spectrum and learn news about the SimpleLogin. We are community driven and we value feedbacks from you!") }}
-
-  {{ render_button("Join Spectrum Community", "https://spectrum.chat/simplelogin") }}
-
 {% endblock %}
 {% endblock %}
 
 

+ 0 - 8
templates/emails/welcome.txt

@@ -7,11 +7,3 @@ We built SimpleLogin to help people protecting their online identity, and I hope
 Thanks.
 Thanks.
 
 
 Son - SimpleLogin founder.
 Son - SimpleLogin founder.
-
----
-
-Join our Community
-
-Open the link 👇 to join our community on Spectrum and learn news about the SimpleLogin. We are community driven and we value feedbacks from you!
-
-https://spectrum.chat/simplelogin

+ 1 - 15
templates/header.html

@@ -2,7 +2,7 @@
   <div class="container">
   <div class="container">
     <div class="d-flex">
     <div class="d-flex">
       <a class="header-brand" href="{{ url_for('dashboard.index') }}">
       <a class="header-brand" href="{{ url_for('dashboard.index') }}">
-        <img src="/static/icon.svg" class="header-brand-img" alt="logo">
+        <img src="/static/logo.png" class="header-brand-img" alt="logo">
       </a>
       </a>
 
 
       <div class="d-flex order-lg-2 ml-auto">
       <div class="d-flex order-lg-2 ml-auto">
@@ -42,20 +42,6 @@
           </a>
           </a>
 
 
           <div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
           <div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
-            <a class="dropdown-item" href="{{ url_for('dashboard.setting') }}">
-              <i class="dropdown-icon fe fe-settings"></i> Settings
-            </a>
-
-            <a class="dropdown-item" href="{{ url_for('dashboard.api_key') }}">
-              <i class="dropdown-icon fe fe-chrome"></i> API Key
-            </a>
-
-            {% if current_user.is_premium() %}
-              <a class="dropdown-item" href="{{ url_for('dashboard.custom_domain') }}">
-                <i class="dropdown-icon fe fe-server"></i> Custom Domains <span class="badge badge-info">Beta</span>
-              </a>
-            {% endif %}
-
             {% if current_user.is_premium() %}
             {% if current_user.is_premium() %}
               <a class="dropdown-item" href="{{ url_for('dashboard.billing') }}">
               <a class="dropdown-item" href="{{ url_for('dashboard.billing') }}">
                 <i class="dropdown-icon fe fe-dollar-sign"></i> Billing
                 <i class="dropdown-icon fe fe-dollar-sign"></i> Billing

+ 23 - 0
templates/menu.html

@@ -7,6 +7,29 @@
     </a>
     </a>
   </li>
   </li>
 
 
+  <li class="nav-item">
+    <a href="{{ url_for('dashboard.setting') }}"
+       class="nav-link {{ 'active' if active_page == 'setting' }}">
+      <i class="fe fe-settings"></i>
+      Settings
+    </a>
+  </li>
+
+  <li class="nav-item">
+    <a href="{{ url_for('dashboard.api_key') }}"
+       class="nav-link {{ 'active' if active_page == 'api_key' }}">
+      <i class="fe fe-chrome"></i> API Key
+    </a>
+  </li>
+
+  <li class="nav-item">
+    <a href="{{ url_for('dashboard.custom_domain') }}"
+       class="nav-link {{ 'active' if active_page == 'custom_domain' }}">
+      <i class="fe fe-server"></i> Custom Domains
+      <span class="badge badge-success" style="font-size: .5rem; top: 5px">Premium</span>
+    </a>
+  </li>
+
   <!--
   <!--
   <li class="nav-item">
   <li class="nav-item">
     <a href="{{ url_for('discover.index') }}"
     <a href="{{ url_for('discover.index') }}"

+ 2 - 2
templates/single.html

@@ -4,10 +4,10 @@
   <div class="page-single">
   <div class="page-single">
     <div class="container">
     <div class="container">
       <div class="row">
       <div class="row">
-        <div class="col col-login mx-auto">
+        <div class="col mx-auto" style="max-width: 50rem">
           <div class="text-center mb-6">
           <div class="text-center mb-6">
             <a href="https://simplelogin.io">
             <a href="https://simplelogin.io">
-              <img src="/static/icon.svg" class="h-6" alt="">
+              <img src="/static/logo.png" style="background-color: transparent; height: 40px">
             </a>
             </a>
           </div>
           </div>
 
 

+ 1 - 0
tests/env.test

@@ -11,6 +11,7 @@ ADMIN_EMAIL=to_fill
 MAX_NB_EMAIL_FREE_PLAN=3
 MAX_NB_EMAIL_FREE_PLAN=3
 EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
 EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
 DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
 DKIM_PRIVATE_KEY_PATH=local_data/dkim.key
+DKIM_PUBLIC_KEY_PATH=local_data/dkim.pub.key
 
 
 # Database
 # Database
 RESET_DB=true
 RESET_DB=true

+ 9 - 0
tests/test_models.py

@@ -1,4 +1,7 @@
+from uuid import UUID
+
 import arrow
 import arrow
+import pytest
 
 
 from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN
 from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN
 from app.extensions import db
 from app.extensions import db
@@ -9,6 +12,12 @@ def test_generate_email(flask_client):
     email = generate_email()
     email = generate_email()
     assert email.endswith("@" + EMAIL_DOMAIN)
     assert email.endswith("@" + EMAIL_DOMAIN)
 
 
+    with pytest.raises(ValueError):
+        UUID(email.split("@")[0], version=4)
+
+    email_uuid = generate_email(scheme=2)
+    assert UUID(email_uuid.split("@")[0], version=4)
+
 
 
 def test_profile_picture_url(flask_client):
 def test_profile_picture_url(flask_client):
     user = User.create(
     user = User.create(