mirror of
https://github.com/simple-login/app.git
synced 2024-09-30 05:31:30 +02:00
Merge remote-tracking branch 'nguyenkims/master'
This commit is contained in:
commit
1289b08636
@ -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_GROUP=local
|
||||||
CLOUDWATCH_LOG_STREAM=local
|
# CLOUDWATCH_LOG_STREAM=local
|
||||||
# <<< END AWS >>>
|
# <<< END AWS >>>
|
||||||
|
|
||||||
# Paddle
|
# Paddle
|
||||||
|
4
.github/workflows/pythonpackage.yml
vendored
4
.github/workflows/pythonpackage.yml
vendored
@ -25,3 +25,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install pytest
|
pip install pytest
|
||||||
pytest
|
pytest
|
||||||
|
- name: Test formatting
|
||||||
|
run: |
|
||||||
|
pip install black
|
||||||
|
black --check .
|
||||||
|
33
README.md
33
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
|
||||||
|
|
||||||
|
@ -10,4 +10,5 @@ from .views import (
|
|||||||
google,
|
google,
|
||||||
facebook,
|
facebook,
|
||||||
change_email,
|
change_email,
|
||||||
|
mfa,
|
||||||
)
|
)
|
||||||
|
33
app/auth/templates/auth/mfa.html
Normal file
33
app/auth/templates/auth/mfa.html
Normal file
@ -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 %}
|
@ -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)
|
return after_login(user, next_url)
|
||||||
else:
|
|
||||||
LOG.debug("redirect user to dashboard")
|
|
||||||
return redirect(url_for("dashboard.index"))
|
|
||||||
|
@ -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") if request.args else None
|
||||||
next_url = request.args.get("next")
|
|
||||||
LOG.debug("redirect user to %s", next_url)
|
return after_login(user, next_url)
|
||||||
return redirect(next_url)
|
|
||||||
else:
|
|
||||||
LOG.debug("redirect user to dashboard")
|
|
||||||
return redirect(url_for("dashboard.index"))
|
|
||||||
|
@ -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)
|
return after_login(user, next_url)
|
||||||
else:
|
|
||||||
LOG.debug("redirect user to dashboard")
|
|
||||||
return redirect(url_for("dashboard.index"))
|
|
||||||
|
|
||||||
|
|
||||||
def create_file_from_url(url) -> File:
|
def create_file_from_url(url) -> File:
|
||||||
|
@ -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)
|
return after_login(user, next_url)
|
||||||
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 render_template(
|
return render_template(
|
||||||
"auth/login.html",
|
"auth/login.html",
|
||||||
|
30
app/auth/views/login_utils.py
Normal file
30
app/auth/views/login_utils.py
Normal file
@ -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
app/auth/views/mfa.py
Normal file
51
app/auth/views/mfa.py
Normal file
@ -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)
|
@ -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"]
|
if ENABLE_CLOUDWATCH:
|
||||||
CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
|
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"
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
API Key
|
API Key
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% set active_page = "api_key" %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
@ -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 %}
|
<a href="{{ url_for('dashboard.domain_detail', custom_domain_id=custom_domain.id) }}">Details ➡</a>
|
||||||
<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>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
217
app/dashboard/templates/dashboard/domain_detail.html
Normal file
217
app/dashboard/templates/dashboard/domain_detail.html
Normal file
@ -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 %}
|
@ -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">
|
<div class="btn-group" role="group">
|
||||||
<input type="hidden" name="form-name" value="create-random-email">
|
<form method="post">
|
||||||
<button data-toggle="tooltip"
|
<input type="hidden" name="form-name" value="create-random-email">
|
||||||
title="Create a totally random alias"
|
<button data-toggle="tooltip"
|
||||||
class="btn btn-success">Random Alias
|
title="Create a totally random alias"
|
||||||
</button>
|
class="btn btn-success">Random Alias
|
||||||
</form>
|
</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 <i class="dropdown-icon fe fe-trash-2"></i>
|
Delete <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 " +
|
text: `Once <b>${alias}</b> is deleted, people/apps ` +
|
||||||
"who used to contact you via this email address cannot reach you any more," +
|
"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
app/dashboard/templates/dashboard/mfa_cancel.html
Normal file
28
app/dashboard/templates/dashboard/mfa_cancel.html
Normal file
@ -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
app/dashboard/templates/dashboard/mfa_setup.html
Normal file
51
app/dashboard/templates/dashboard/mfa_setup.html
Normal file
@ -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 %}
|
@ -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>
|
<div class="h3">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>
|
<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
|
Custom Domain
|
||||||
<span class="badge badge-success">In Beta</span>
|
|
||||||
<div class="small-text">Please contact us to try out this feature!</div>
|
|
||||||
</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>
|
||||||
|
@ -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">
|
||||||
|
@ -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":
|
if request.form.get("form-name") == "create":
|
||||||
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 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"))
|
return redirect(
|
||||||
elif request.form.get("form-name") == "check-domain":
|
url_for(
|
||||||
custom_domain_id = request.form.get("custom-domain-id")
|
"dashboard.domain_detail", custom_domain_id=new_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",
|
|
||||||
)
|
)
|
||||||
custom_domain.verified = True
|
)
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard/custom_domain.html",
|
"dashboard/custom_domain.html",
|
||||||
|
106
app/dashboard/views/domain_detail.py
Normal file
106
app/dashboard/views/domain_detail.py
Normal file
@ -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())
|
@ -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
app/dashboard/views/mfa_cancel.py
Normal file
37
app/dashboard/views/mfa_cancel.py
Normal file
@ -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
app/dashboard/views/mfa_setup.py
Normal file
49
app/dashboard/views/mfa_setup.py
Normal file
@ -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
|
||||||
|
)
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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.
|
if not keep_priority:
|
||||||
ret.append(r)
|
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)
|
||||||
|
@ -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:
|
def generate_email(
|
||||||
"""generate an email address that does not exist before"""
|
scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
|
||||||
random_email = random_words() + "@" + EMAIL_DOMAIN
|
) -> 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()
|
||||||
|
@ -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
|
if alias_domain == EMAIL_DOMAIN:
|
||||||
# add DKIM-Signature for non-custom-domain alias
|
|
||||||
if alias.endswith(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
local_data/dkim.pub.key
Normal file
6
local_data/dkim.pub.key
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCxhcKgFHz+HbZiuUhH7iGCVsaZ
|
||||||
|
YQ7xzf64ui+09QFlSYzl7d28LVlr7nvM0+xDbwwsgu2D1vweklroWM5FjbfVtJX3
|
||||||
|
HvSnNbwceX5du/m8RHelmX0/vLSfsEcnvdNjBmwl/gSIUb660pEp2yo6dUBDTzTD
|
||||||
|
UBNoL6qmnnTNhriRoQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
@ -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
migrations/versions/2019_122910_696e17c13b8b_.py
Normal file
29
migrations/versions/2019_122910_696e17c13b8b_.py
Normal file
@ -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
migrations/versions/2019_122910_e409f6214b2b_.py
Normal file
29
migrations/versions/2019_122910_e409f6214b2b_.py
Normal file
@ -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
migrations/versions/9e1b06b9df13_.py
Normal file
29
migrations/versions/9e1b06b9df13_.py
Normal file
@ -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
migrations/versions/d4e4488a0032_.py
Normal file
31
migrations/versions/d4e4488a0032_.py
Normal file
@ -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 ###
|
@ -32,3 +32,4 @@ coloredlogs
|
|||||||
pycryptodome
|
pycryptodome
|
||||||
phpserialize
|
phpserialize
|
||||||
dkimpy
|
dkimpy
|
||||||
|
pyotp
|
@ -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
|
||||||
|
@ -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
Normal file
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
@ -67,3 +67,7 @@ em {
|
|||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
line-height: 0.75;
|
line-height: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cursor {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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
|
|
@ -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
|
||||||
|
@ -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') }}"
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user