mirror of
https://github.com/simple-login/app.git
synced 2024-09-28 20:51:29 +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
|
||||
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
|
||||
|
4
.github/workflows/pythonpackage.yml
vendored
4
.github/workflows/pythonpackage.yml
vendored
@ -25,3 +25,7 @@ jobs:
|
||||
run: |
|
||||
pip install 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`.
|
||||
|
||||
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
|
||||
|
||||
|
@ -10,4 +10,5 @@ from .views import (
|
||||
google,
|
||||
facebook,
|
||||
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.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)
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
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 = []
|
||||
|
||||
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"
|
||||
|
@ -9,4 +9,7 @@ from .views import (
|
||||
api_key,
|
||||
custom_domain,
|
||||
alias_contact_manager,
|
||||
mfa_setup,
|
||||
mfa_cancel,
|
||||
domain_detail,
|
||||
)
|
||||
|
@ -4,6 +4,8 @@
|
||||
API Key
|
||||
{% endblock %}
|
||||
|
||||
{% set active_page = "api_key" %}
|
||||
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -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 %}
|
||||
|
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>
|
||||
</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 <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({
|
||||
|
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="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>
|
||||
|
@ -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">
|
||||
|
@ -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",
|
||||
|
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.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,
|
||||
)
|
||||
|
||||
|
||||
|
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,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
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]
|
||||
# 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
|
||||
|
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 ###
|
@ -31,4 +31,5 @@ dnspython
|
||||
coloredlogs
|
||||
pycryptodome
|
||||
phpserialize
|
||||
dkimpy
|
||||
dkimpy
|
||||
pyotp
|
@ -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
|
||||
|
@ -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
BIN
static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
@ -66,4 +66,8 @@ em {
|
||||
.copy-btn {
|
||||
font-size: 0.6rem;
|
||||
line-height: 0.75;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
cursor: pointer;
|
||||
}
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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') }}"
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user