Merge remote-tracking branch 'nguyenkims/master'

This commit is contained in:
Tung Nguyen 2019-12-30 12:33:32 +00:00
commit 1289b08636
49 changed files with 1072 additions and 288 deletions

View File

@ -31,6 +31,9 @@ EMAIL_SERVERS_WITH_PRIORITY=[(10, "email.hostname.")]
# the DKIM private key used to compute DKIM-Signature
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 >>>
@ -55,8 +58,8 @@ AWS_SECRET_ACCESS_KEY=to_fill
# Cloudwatch
# ENABLE_CLOUDWATCH=true
CLOUDWATCH_LOG_GROUP=local
CLOUDWATCH_LOG_STREAM=local
# CLOUDWATCH_LOG_GROUP=local
# CLOUDWATCH_LOG_STREAM=local
# <<< END AWS >>>
# Paddle

View File

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

View File

@ -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`.
Make sure to update the following variables
Make sure to update the following variables and replace these values by yours.
```.env
# Server url
@ -237,6 +237,8 @@ EMAIL_DOMAIN=mydomain.com
SUPPORT_EMAIL=support@mydomain.com
EMAIL_SERVERS_WITH_PRIORITY=[(10, "app.mydomain.com.")]
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.
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
```bash
docker run \
docker run --rm \
--name sl-migration \
-v $(pwd)/dkim.key:/dkim.key \
-v $(pwd)/dkim.pub.key:/dkim.pub.key \
-v $(pwd)/simplelogin.env:/code/.env \
--network="sl-network" \
simplelogin/app flask db upgrade
@ -263,6 +266,7 @@ docker run -d \
--name sl-app \
-v $(pwd)/simplelogin.env:/code/.env \
-v $(pwd)/dkim.key:/dkim.key \
-v $(pwd)/dkim.pub.key:/dkim.pub.key \
-p 7777:7777 \
--network="sl-network" \
simplelogin/app
@ -275,6 +279,7 @@ docker run -d \
--name sl-email \
-v $(pwd)/simplelogin.env:/code/.env \
-v $(pwd)/dkim.key:/dkim.key \
-v $(pwd)/dkim.pub.key:/dkim.pub.key \
-p 20381:20381 \
--network="sl-network" \
simplelogin/app python email_handler.py
@ -287,6 +292,7 @@ docker run -d \
--name sl-cron \
-v $(pwd)/simplelogin.env:/code/.env \
-v $(pwd)/dkim.key:/dkim.key \
-v $(pwd)/dkim.pub.key:/dkim.pub.key \
--network="sl-network" \
simplelogin/app yacron -c /code/crontab.yml
```
@ -329,7 +335,7 @@ All work on SimpleLogin happens directly on GitHub.
### 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
pip3 install -r requirements.txt
@ -396,7 +402,16 @@ Response: a json with following structure. ? means optional field.
[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
@ -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:
> 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.
> 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

View File

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

View 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 %}

View File

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

View File

@ -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 requests_oauthlib import OAuth2Session
from app import email_utils
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.extensions import db
from app.log import LOG
@ -81,10 +82,8 @@ def github_callback():
user = User.get_by(email=email)
if user:
login_user(user)
# create user
else:
if not user:
LOG.d("create github user")
user = User.create(
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")
# The activation link contains the original page, for ex authorize page
if "next" in request.args:
next_url = request.args.get("next")
LOG.debug("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.debug("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
next_url = request.args.get("next") if request.args else None
return after_login(user, next_url)

View File

@ -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 requests_oauthlib import OAuth2Session
@ -9,6 +9,7 @@ from app.extensions import db
from app.log import LOG
from app.models import User, File
from app.utils import random_string
from .login_utils import after_login
_authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth"
_token_url = "https://www.googleapis.com/oauth2/v4/token"
@ -89,8 +90,6 @@ def google_callback():
file = create_file_from_url(picture_url)
user.profile_picture_id = file.id
db.session.commit()
login_user(user)
# create user
else:
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")
next_url = None
# The activation link contains the original page, for ex authorize page
if "google_next_url" in session:
next_url = session["google_next_url"]
@ -115,10 +115,7 @@ def google_callback():
# reset the next_url to avoid user getting redirected at each login :)
session.pop("google_next_url", None)
return redirect(next_url)
else:
LOG.debug("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
return after_login(user, next_url)
def create_file_from_url(url) -> File:

View File

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

View 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
View 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)

View File

@ -58,11 +58,23 @@ else:
IGNORED_EMAILS = []
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"
with open(DKIM_PRIVATE_KEY_PATH) as f:
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"]
# Database
@ -77,9 +89,11 @@ BUCKET = os.environ["BUCKET"]
AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
CLOUDWATCH_LOG_GROUP = CLOUDWATCH_LOG_STREAM = ""
ENABLE_CLOUDWATCH = "ENABLE_CLOUDWATCH" in os.environ
CLOUDWATCH_LOG_GROUP = os.environ["CLOUDWATCH_LOG_GROUP"]
CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
if ENABLE_CLOUDWATCH:
CLOUDWATCH_LOG_GROUP = os.environ["CLOUDWATCH_LOG_GROUP"]
CLOUDWATCH_LOG_STREAM = os.environ["CLOUDWATCH_LOG_STREAM"]
# Paddle
PADDLE_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
HIGHLIGHT_GEN_EMAIL_ID = "highlight_gen_email_id"
MFA_USER_ID = "mfa_user_id"

View File

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

View File

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

View File

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

View 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 %}

View File

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

View 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 %}

View 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 %}

View File

@ -14,18 +14,22 @@
<div class="col-sm-6 col-lg-6">
<div class="card">
<div class="card-body text-center">
<div class="card-category">Premium</div>
<ul class="list-unstyled leading-loose">
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> <em>Unlimited</em> Custom Alias</li>
<div class="h3">Premium</div>
<ul class="list-unstyled leading-loose mb-3">
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Unlimited Alias</li>
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Custom email domain
<span class="badge badge-success">In Beta</span>
<div class="small-text">Please contact us to try out this feature!</div>
Custom Domain
</li>
<li><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>
</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>

View File

@ -1,7 +1,9 @@
{% extends 'default.html' %}
{% set active_page = "setting" %}
{% block title %}
Setting
Settings
{% endblock %}
{% block default_content %}
@ -48,8 +50,20 @@
<button class="btn btn-primary">Update</button>
</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>
<div class="small-text mb-3">You will receive an email containing instructions on how to change password.</div>
<form method="post">
@ -57,6 +71,23 @@
<button class="btn btn-outline-primary">Change password</button>
</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>
<h3 class="mb-0">Export Data</h3>
<div class="small-text mb-3">

View File

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

View 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())

View File

@ -9,7 +9,14 @@ from app.config import HIGHLIGHT_GEN_EMAIL_ID
from app.dashboard.base import dashboard_bp
from app.extensions import db
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
@ -57,7 +64,14 @@ def index():
elif request.form.get("form-name") == "create-random-email":
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()
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),
highlight_gen_email_id=highlight_gen_email_id,
query=query,
AliasGeneratorEnum=AliasGeneratorEnum,
)

View 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)

View 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
)

View File

@ -23,6 +23,7 @@ from app.models import (
DeletedAlias,
CustomDomain,
Client,
AliasGeneratorEnum,
)
from app.utils import random_string
@ -121,6 +122,13 @@ def setting():
logout_user()
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":
data = {
"email": current_user.email,
@ -157,6 +165,7 @@ def setting():
PlanEnum=PlanEnum,
promo_form=promo_form,
pending_email=pending_email,
AliasGeneratorEnum=AliasGeneratorEnum,
)

View File

@ -1,7 +1,9 @@
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:
answers = dns.resolver.query(hostname, "MX")
except dns.resolver.NoAnswer:
@ -11,8 +13,10 @@ def get_mx_domains(hostname) -> [str]:
for a in answers:
record = a.to_text() # for ex '20 alt2.aspmx.l.google.com.'
r = record.split(" ")[1] # alt2.aspmx.l.google.com.
ret.append(r)
if not keep_priority:
record = record.split(" ")[1] # alt2.aspmx.l.google.com.
ret.append(record)
return ret
@ -40,3 +44,37 @@ def get_spf_domain(hostname) -> [str]:
ret.append(part[part.find(_include_spf) + len(_include_spf) :])
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)

View File

@ -1,5 +1,6 @@
import enum
import random
import uuid
import arrow
import bcrypt
@ -83,6 +84,15 @@ class PlanEnum(enum.Enum):
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):
__tablename__ = "users"
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)
name = db.Column(db.String(128), nullable=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)
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)
@classmethod
@ -362,9 +383,18 @@ class OauthToken(db.Model, ModelMixin):
return self.expired < arrow.now()
def generate_email() -> str:
"""generate an email address that does not exist before"""
random_email = random_words() + "@" + EMAIL_DOMAIN
def generate_email(
scheme: int = AliasGeneratorEnum.word.value, in_hex: bool = False
) -> str:
"""generate an email address that does not exist before
:param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
:type in_hex: bool, if the generate scheme is uuid, is hex favorable?
"""
if scheme == AliasGeneratorEnum.uuid.value:
name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
random_email = name + "@" + EMAIL_DOMAIN
else:
random_email = random_words() + "@" + EMAIL_DOMAIN
# check that the client does not exist yet
if not GenEmail.get_by(email=random_email) and not DeletedAlias.get_by(
@ -375,7 +405,7 @@ def generate_email() -> str:
# Rerun the function
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):
@ -408,9 +438,11 @@ class GenEmail(db.Model, ModelMixin):
return GenEmail.create(user_id=user_id, email=email)
@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"""
random_email = generate_email()
random_email = generate_email(scheme=scheme, in_hex=in_hex)
return GenEmail.create(user_id=user_id, email=random_email)
def __repr__(self):
@ -654,6 +686,12 @@ class CustomDomain(db.Model, ModelMixin):
domain = db.Column(db.String(128), unique=True, nullable=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):
return GenEmail.filter_by(custom_domain_id=self.id).count()

View File

@ -47,7 +47,7 @@ from app.email_utils import (
)
from app.extensions import db
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 server import create_app
@ -207,6 +207,12 @@ class MailHandler:
forward_email = ForwardEmail.get_by(reply_email=reply_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
if envelope.mail_from != user_email:
LOG.error(
@ -248,10 +254,13 @@ class MailHandler:
envelope.rcpt_options,
)
# todo: add DKIM-Signature for custom domain
# add DKIM-Signature for non-custom-domain alias
if alias.endswith(EMAIL_DOMAIN):
if alias_domain == EMAIL_DOMAIN:
add_dkim_signature(msg, EMAIL_DOMAIN)
# add DKIM-Signature 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()
smtp.sendmail(

6
local_data/dkim.pub.key Normal file
View File

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

View File

@ -2,7 +2,7 @@
[alembic]
# 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
# the 'revision' command, regardless of autogenerate

View 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 ###

View 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 ###

View 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 ###

View 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 ###

View File

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

View File

@ -70,6 +70,7 @@ pycparser==2.19 # via cffi
pycryptodome==3.9.4
pygments==2.4.2 # via ipython
pyopenssl==19.0.0
pyotp==2.3.0
pyparsing==2.4.0 # via packaging
pytest==4.6.3
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
ruamel.yaml==0.15.97 # via strictyaml
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
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
traitlets==4.3.2 # via ipython
unidecode==1.0.23

View File

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

BIN
static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

@ -4,29 +4,5 @@
{{ 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("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 %}

View File

@ -7,11 +7,3 @@ We built SimpleLogin to help people protecting their online identity, and I hope
Thanks.
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

View File

@ -2,7 +2,7 @@
<div class="container">
<div class="d-flex">
<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>
<div class="d-flex order-lg-2 ml-auto">
@ -42,20 +42,6 @@
</a>
<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() %}
<a class="dropdown-item" href="{{ url_for('dashboard.billing') }}">
<i class="dropdown-icon fe fe-dollar-sign"></i> Billing

View File

@ -7,6 +7,29 @@
</a>
</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">
<a href="{{ url_for('discover.index') }}"

View File

@ -4,10 +4,10 @@
<div class="page-single">
<div class="container">
<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">
<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>
</div>

View File

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

View File

@ -1,4 +1,7 @@
from uuid import UUID
import arrow
import pytest
from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN
from app.extensions import db
@ -9,6 +12,12 @@ def test_generate_email(flask_client):
email = generate_email()
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):
user = User.create(