create BaseForm to enable CSRF

register page

redirect user to dashboard if they are logged in

enable csrf for login page

Set models more strict

bootstrap developer page

add helper method to ModelMixin, remove CRUDMixin

display list of clients on developer index, add copy client-secret to clipboard using clipboardjs

add toastr and use jquery non slim

display a toast when user copies the client-secret

create new client, generate client-id using unidecode

client detail page: can edit client

add delete client

implement /oauth/authorize and /oauth/allow-deny

implement /oauth/token

add /oauth/user_info endpoint

handle scopes: wip

take into account scope: display scope, return user data according to scope

create virtual-domain, gen email, client_user model WIP

create authorize_nonlogin_user page

user can choose to generate a new email

no need to interfere with root logger

log for before and after request

if user has already allowed a client: generate a auth-code and redirect user to client

get_user_info takes into account gen email

display list of clients that have user has authorised

use yk-client domain instead of localhost as cookie depends on the domain name

use wtforms instead of flask_wtf

Dockerfile

delete virtual domain

EMAIL_DOMAIN can come from env var

bind to host 0.0.0.0

fix signup error: use session as default csrf_context

rename yourkey to simplelogin

add python-dotenv, ipython, sqlalchemy_utils

create DB_URI, FLASK_SECRET. Load config from CONFIG file if exist

add shortcuts to logging

create shell

add psycopg2

do not add local data in Dockerfile

add drop_db into shell

add shell.prepare_db()

fix prepare_db

setup sentry

copy assets from tabler/dist

add icon downloaded from https://commons.wikimedia.org/wiki/File:Simpleicons_Interface_key-tool-1.svg

integrate tabler - login and register page

add favicon

template: default, header. Use gravatar for user avatar url

use default template for dashboard, developer page

use another icon

add clipboard and notie

prettify dashboard

add notie css

add fake gen email and client-user

prettify list client page, use notie for toast

add email, name scope to new client

display client scope in client list

prettify new-client, client-detail

add sentry-sdk and blinker

add arrow, add dt jinja filter, prettify logout, dashboard

comment "last used" in dashboard for now

prettify date display

add copy email to clipboard to dashboard

use "users" as table name for User as "user" is reserved key in postgres

call prepare_db() when creating new db

error page 400, 401, 403, 404

prettify authorize_login_user

create already_authorize.html for user who has already authorized a client

user can generate new email

display all other generated emails

add ENV variable, only reset DB when ENV=local

fix: not return other users gen emails

display nb users for each client

refactor shell: remove prepare_db()

add sendgrid

add /favicon.ico route

add new config: URL, SUPPORT_EMAIL, SENDGRID_API_KEY

user needs to activate their account before login

create copy button on dashboard

client can have multiple redirect uris, in client detail can add/remove redirect-uri,

use redirect_uri passed in /authorize

refactor: move get_user_info into ClientUser model

dashboard: display all apps, all generated emails

add "id" into user_info

add trigger email button

invalidate the session at each new version by changing the secret

centralize Client creation into Client.create_new

user can enable/disable email forwarding

setup auto dismiss alert: just add .alert-auto-dismiss

move name down in register form

add shell.add_real_data

move blueprint template to its own package

prettify authorize page for non-authenticated user

update readme, return error if not redirect_uri

add flask-wtf, use psycopg2-binary

use flask-wtf FlaskForm instead of Form

rename email -> email_utils

add AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY to config

add s3 module

add File model, add Client.icon_id

handle client icon update

can create client with icon

display client icon in client list page

add Client.home_url

take into account Client.home_url

add boto3

register: ask name first

only show "trigger test email" if email forwarding is enabled

display gen email in alphabetical order, client in client.name alphabetical order

better error page

the modal does not get close when user clicks outside of modal

add Client.published column

discover page that displays all published Client

add missing bootstrap.bundle.min.js.map

developer can publish/unpublish their app in discover

use notie for display flash message

create hotmail account

fix missing jquery

add footer, add global jinja2 variable

strengthen model: use nullable=False whenever possible,

rename client_id to oauth_client_id, client_secret to oauth_client_secret

add flask-migrate

init migrate

1st migrate version

fix rename client_id -> oauth_client_id

prettify UI

use flask_migrate.upgrade() instead of db.create_all()

make sure requirejs.config is called for all page

enable sentry for js, use uppercase for global jinja2 variables

add flask-admin

add User.is_admin column

setup flask admin, only accessible to admin user

fix migration: add server_default

replace session[redirect_after_login] by "next" request args

add pyproject.toml: ignore migrations/ in black

add register waiting_activation_email page

better email wording

add pytest

add get_host_name_and_scheme and tests

example fail test

fix test

fix client-id display

add flask-cors

/user_info supports cors, add /me as /user_info synonym

return client in /me

support implicit flow

no need to use with "app.app_context()"

add watchtower to requirement

add param ENABLE_CLOUDWATCH, CLOUDWATCH_LOG_GROUP, CLOUDWATCH_LOG_STREAM

add cloudwatch logger if cloudwatch is enabled

add 500 error page

add help text for list of used client

display list of app/website that an email has been used

click on client name brings to client detail page

create style.css to add additional style, append its url with the current sha1 to avoid cache

POC on how to send email using postfix

add sqlalchemy-utils

use arrow instead of datetime

add new params STRIPE_API, STRIPE_YEARLY_SKU, STRIPE_MONTHLY_PLAN

show full error in local

add plan, plan_expiration to User, need to create enum directly in migration script, cf https://github.com/sqlalchemy/alembic/issues/67

reformat all html files: use space instead of tab

new user will have trial plan for 15 days

add new param MAX_NB_EMAIL_FREE_PLAN

only user with enough quota can create new email

if user cannot create new gen email, pick randomly one from existing gen emails. Use flush instead of commit

rename STRIPE_YEARLY_SKU -> STRIPE_YEARLY_PLAN

open client page in discover in a new tab

add stripe

not logging /static call: disable flask logging, replace by after_request

add param STRIPE_SECRET_KEY

add 3 columns stripe_customer_id, stripe_card_token, stripe_subscription_id

user can upgrade their pricing

add setting page as coming-soon

add GenEmail, ClientUser to admin

ignore /admin/static logging

add more fake data

add ondelete="cascade" whenever possible

rename plan_expiration -> trial_expiration

reset migration: delete old migrations, create new one

rename test_send_email -> poc_send_email to avoid the file being called by pytest

add new param LYRA_ANALYTICS_ID, add lyra analytics

add how to create new migration into readme

add drift to base.html

notify admin when new user signs up or pays subscription

log exception in case of 500

use sendgrid to notify admin

add alias /userinfo to user_info endpoint

add change_password to shell

add info on how payment is handled

invite user to retry if card not working

remove drift and add "contact us" link

move poc_send_email into poc/

support getting client-id, client-secret from form-data in addition to basic auth

client-id, client-secret is passed in form-data by passport-oauth2 for ex

add jwtRS256 private and public key

add jwk-jws-jwt poc

add new param OPENID_PRIVATE_KEY_PATH, OPENID_PRIVATE_KEY_PATH

add scope, redirect_url to AuthorizationCode and OauthToken

take into scope when creating oauth-token, authorization-code

add jwcrypto

add jose_utils: make_id_token and verify_id_token

add &scope to redirect uri

add "email_verified": True into user_info

fix user not activated

add /oauth2 as alias for /oauth

handle case where scope and state are empty

remove threaded=False

Use Email Alias as wording

remove help text

user can re-send activation email

add "expired" into ActivationCode

Handle the case activation code is expired

reformat: use form.validate_on_submit instead of request.method == post && form.validate

use error text instead of flash()

display client oauth-id and oauth-secret on client detail page

not display oauth-secret on client listing

fix expiration check

improve page title, footer

add /jwks and /.well-known/openid-configuration

init properly tests, fix blueprint conflict bug in flask-admin

create oauth_models module

rename Scope -> ScopeE to distinguish with Scope DB model

set app.url_map.strict_slashes = False

use ScopeE instead of SCOPE_NAME, ...

support access_token passed as args in /userinfo

merge /allow-deny into /authorize

improve wording

take into account the case response_type=code and openid is in scope

take into account response_type=id_token, id_token token, id_token code

make sure to use in-memory db in test

fix scope can be null

allow cross_origin for /.well-known/openid-configuration and /jwks

fix footer link

center authorize form

rename trial_expiration to plan_expiration

move stripe init to create_app()

use real email to be able to receive email notification

add user.profile_picture_id column

use user profile picture and fallback to gravatar

use nguyenkims+local@gm to distinguish with staging

handle plan cancel, reactivation, user profile update

fix can_create_new_email

create cron.py that set plan to free when expired

add crontab.yml

add yacron

use notify_admin instead of LOG.error

add ResetPasswordCode model

user can change password in setting

increase display time for notie

add forgot_password page

If login error: redirect to this page upon success login.

hide discover tab

add column user.is_developer

only show developer menu to developer

comment out the publish button

set local user to developer

make sure only developer can access /developer blueprint

User is invited to upgrade if they are in free plan or their trial ends soon

not sending email when in local mode

create Partner model

create become partner page

use normal error handling on local

fix migration

add "import sqlalchemy_utils" into migration template

small refactoring on setting page

handle promo code. TODO: add migration file

add migration for user.promo_codes

move email alias on top of apps in dashboard

add introjs

move encode_url to utils

create GenEmail.create_new_gen_email

create a first alias mail to show user how to use when they login

show intro when user visits the website the first time

fix register
This commit is contained in:
Son NK 2019-07-02 10:20:12 +03:00 committed by Son NK
parent 0b3dd21a06
commit c18d9f5280
604 changed files with 106295 additions and 228 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
db.sqlite

5
.gitignore vendored
View File

@ -1,3 +1,6 @@
.idea/
*.pyc
db.sqlite
db.sqlite
.env
.pytest_cache
.vscode

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.7
RUN apt-get update
WORKDIR /code
COPY ./requirements.txt ./
RUN pip3 install -r requirements.txt
COPY . .
CMD gunicorn wsgi:app -b 0.0.0.0:5000 -w 2 --timeout 15 --log-level DEBUG
#CMD ["/usr/local/bin/gunicorn", "wsgi:app", "-k", "gthread", "-b", "0.0.0.0:5000", "-w", "2", "--timeout", "15", "--log-level", "DEBUG"]

110
README.md Normal file
View File

@ -0,0 +1,110 @@
## OAuth flow
Authorization code flow:
http://sl-server:5000/oauth/authorize?client_id=client-id&state=123456&response_type=code&redirect_uri=http%3A%2F%2Fsl-client%3A7000%2Fcallback&state=dvoQ6Jtv0PV68tBUgUMM035oFiZw57
Implicit flow:
http://sl-server:5000/oauth/authorize?client_id=client-id&state=123456&response_type=token&redirect_uri=http%3A%2F%2Fsl-client%3A7000%2Fcallback&state=dvoQ6Jtv0PV68tBUgUMM035oFiZw57
Exchange the code to get the token with `{code}` replaced by the code obtained in previous step.
http -f -a client-id:client-secret http://localhost:5000/oauth/token grant_type=authorization_code code={code}
Get user info:
http http://localhost:5000/oauth/user_info 'Authorization:Bearer {token}'
## Template structure
base
single: for login, register page
default: for all pages when user log ins
## How to create new migration
Whenever the model changes, a new migration needs to be created
Set the database connection to use staging environment:
> set -x CONFIG ~/config/simplelogin/staging.env
Generate the migration script and make sure to review it:
> flask db migrate
## Code structure
local_data/: contain files used only locally. In deployment, these files should be replaced.
- jwtRS256.key: generated using
```bash
ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
# Don't add passphrase
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub
```
## OpenID, OAuth2 response_type & scope
According to https://medium.com/@darutk/diagrams-of-all-the-openid-connect-flows-6968e3990660
- `response_type` can be either `code, token, id_token` or any combination.
- `scope` can contain `openid` or not
Below is the different combinations that are taken into account until now:
response_type=code
scope:
with `openid` in scope, return `id_token` at /token: OK
without: OK
response_type=token
scope:
with and without `openid`, nothing to do: OK
response_type=id_token
return `id_token` in /authorization endpoint
response_type=id_token token
return `id_token` in addition to `access_token` in /authorization endpoint
response_type=id_token code
return `id_token` in addition to `authorization_code` in /authorization endpoint
# Plan Upgrade, downgrade flow
Here's an example:
July 2019: user takes yearly plan, valid until July 2020
user.plan=yearly, user.plan_expiration=None
set user.stripe card-token, customer-id, subscription-id
December 2019: user cancels his plan.
set plan_expiration to "period end of subscription", ie July 2020
call stripe:
stripe.Subscription.modify(
user.stripe_subscription_id,
cancel_at_period_end=True
)
There are 2 possible scenarios at this point:
1) user decides to renew on March 2020:
set plan_expiration = None
stripe.Subscription.modify(
user.stripe_subscription_id,
cancel_at_period_end=False
)
2) the plan ends on July 2020.
The cronjob set
- user stripe_subscription_id , stripe_card_token, stripe_customer_id to None
- user.plan=free, user.plan_expiration=None
- delete customer on stripe
user decides to take the premium plan again: go through all normal flow

22
app/admin_model.py Normal file
View File

@ -0,0 +1,22 @@
from flask import redirect, url_for, request
from flask_admin import expose, AdminIndexView
from flask_admin.contrib import sqla
from flask_login import current_user
class SLModelView(sqla.ModelView):
def is_accessible(self):
return current_user.is_authenticated and current_user.is_admin
def inaccessible_callback(self, name, **kwargs):
# redirect to login page if user doesn't have access
return redirect(url_for("auth.login", next=request.url))
class SLAdminIndexView(AdminIndexView):
@expose("/")
def index(self):
if not current_user.is_authenticated or not current_user.is_admin:
return redirect(url_for("auth.login", next=request.url))
return super(SLAdminIndexView, self).index()

View File

@ -1 +1,9 @@
from .views import login, logout
from .views import (
login,
logout,
register,
activate,
resend_activation,
reset_password,
forgot_password,
)

View File

@ -1,3 +1,5 @@
from flask import Blueprint
auth_bp = Blueprint(name="auth", import_name=__name__, url_prefix="/auth")
auth_bp = Blueprint(
name="auth", import_name=__name__, url_prefix="/auth", template_folder="templates"
)

View File

@ -0,0 +1,16 @@
{% extends "error.html" %}
{% block error_name %}
{{ error }}
{% endblock %}
{% block error_description %}
{% if show_resend_activation %}
<div class="text-center text-muted small mt-4">
Ask for another activation email?
<a href="{{ url_for('auth.resend_activation') }}" style="color: #4d21ff">Resend</a>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,37 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends "single.html" %}
{% block title %}
Forgot Password
{% endblock %}
{% block single_content %}
{% if error %}
<div class="text-danger text-center mb-4">{{ error }}</div>
{% endif %}
<form class="card" method="post">
{{ form.csrf_token }}
<div class="card-body p-6">
<div class="card-title">Forgot password</div>
<p class="text-muted">Enter your email address and your will receive an email to reset your password.</p>
<div class="form-group">
<label class="form-label">Email address</label>
{{ form.email(class="form-control", type="email", placeholder="Enter email") }}
{{ render_field_errors(form.email) }}
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Reset Password</button>
</div>
</div>
</form>
<div class="text-center text-muted">
Forget it, <a href="{{ url_for('auth.login') }}">send me back</a> to the sign in screen.
</div>
{% endblock %}

View File

@ -0,0 +1,63 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends "single.html" %}
{% block title %}
Login
{% endblock %}
{% block single_content %}
{% if error %}
<div class="text-danger text-center mb-4">{{ error }}</div>
{% endif %}
{% if show_resend_activation %}
<div class="text-center text-muted small mb-4">
You haven't received the activation email?
<a href="{{ url_for('auth.resend_activation') }}">Resend</a>
</div>
{% endif %}
<form class="card" method="post">
{{ form.csrf_token }}
<div class="card-body p-6">
<div class="card-title">Login to your account</div>
<div class="form-group">
<label class="form-label">Email address</label>
{{ form.email(class="form-control", type="email") }}
{{ render_field_errors(form.email) }}
</div>
<div class="form-group">
<label class="form-label">
Password
<a href="{{ url_for('auth.forgot_password') }}" class="float-right small">
I forgot password
</a>
</label>
{{ form.password(class="form-control", type="password") }}
{{ render_field_errors(form.password) }}
</div>
<!-- TODO: add remember me
<div class="form-group">
<label class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input"/>
<span class="custom-control-label">Remember me</span>
</label>
</div>
-->
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
</div>
</div>
</form>
<div class="text-center text-muted">
Don't have account yet? <a href="{{ url_for('auth.register') }}">Sign up</a>
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "single.html" %}
{% block title %}
Logout
{% endblock %}
{% block single_content %}
<div class="text-center text-muted">
You are logged out.
<a href="{{ url_for('auth.login') }}">Login</a>
</div>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends "single.html" %}
{% block title %}
Register
{% endblock %}
{% block single_content %}
<form class="card" method="post">
{{ form.csrf_token }}
<div class="card-body p-6">
<div class="card-title">Create new account</div>
<div class="form-group">
<label class="form-label">How should we call you?</label>
{{ form.name(class="form-control") }}
{{ render_field_errors(form.name) }}
</div>
<div class="form-group">
<label class="form-label">Email address</label>
{{ form.email(class="form-control", type="email") }}
{{ render_field_errors(form.email) }}
</div>
<div class="form-group">
<label class="form-label">Password</label>
{{ form.password(class="form-control", type="password") }}
{{ render_field_errors(form.password) }}
</div>
<!-- TODO: add terms
<div class="form-group">
<label class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input"/>
<span class="custom-control-label">Agree the <a href="terms.html">terms and policy</a></span>
</label>
</div>
-->
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Create new account</button>
</div>
</div>
</form>
<div class="text-center text-muted">
Already have account? <a href="{{ url_for('auth.login') }}">Sign in</a>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "single.html" %}
{% block title %}
Activation Email Sent
{% endblock %}
{% block single_content %}
<div class="text-center">
<h1>
An email to validate your email is on its way.
</h1>
<h3>
Please check your inbox/spam folder.
</h3>
<small>
Yeah we know. An email to confirm an email ...
</small>
</h1>
</div>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends "single.html" %}
{% block title %}
Resend activation email
{% endblock %}
{% block single_content %}
<form class="card" method="post">
{{ form.csrf_token }}
<div class="card-body p-6">
<div class="card-title">Resend activation email</div>
<div class="form-group">
<label class="form-label">Email address</label>
{{ form.email(class="form-control", type="email") }}
{{ render_field_errors(form.email) }}
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Resend</button>
</div>
</div>
</form>
<div class="text-center text-muted">
Don't have account yet? <a href="{{ url_for('auth.register') }}">Sign up</a>
</div>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends "single.html" %}
{% block title %}
Reset password
{% endblock %}
{% block single_content %}
{% if error %}
<div class="text-danger text-center mb-4">{{ error }}</div>
{% endif %}
<form class="card" method="post">
{{ form.csrf_token }}
<div class="card-body p-6">
<div class="card-title">Reset your password</div>
<div class="form-group">
<label class="form-label">Password</label>
{{ form.password(class="form-control", type="password") }}
{{ render_field_errors(form.password) }}
</div>
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Reset</button>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,56 @@
import arrow
from flask import request, redirect, url_for, flash, render_template
from flask_login import login_user, current_user
from app.auth.base import auth_bp
from app.extensions import db
from app.log import LOG
from app.models import ActivationCode
@auth_bp.route("/activate", methods=["GET", "POST"])
def activate():
if current_user.is_authenticated:
return (
render_template("auth/activate.html", error="You are already logged in"),
400,
)
code = request.args.get("code")
activation_code: ActivationCode = ActivationCode.get_by(code=code)
if not activation_code:
return (
render_template("auth/activate.html", error="Activation code not found"),
400,
)
if activation_code.expired and activation_code.expired < arrow.now():
return (
render_template(
"auth/activate.html",
error="Activation code is expired",
show_resend_activation=True,
),
400,
)
user = activation_code.user
user.activated = True
login_user(user)
# activation code is to be used only once
activation_code.delete()
db.session.commit()
flash("Your account has been activated", "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"))

View File

@ -0,0 +1,30 @@
from flask import request, render_template, redirect, url_for
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.dashboard.views.setting import send_reset_password_email
from app.models import User
class ForgotPasswordForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
@auth_bp.route("/forgot_password", methods=["GET", "POST"])
def forgot_password():
form = ForgotPasswordForm(request.form)
if form.validate_on_submit():
email = form.email.data
user = User.get_by(email=email)
if not user:
error = "No such user, are you sure the email is correct?"
return render_template("auth/forgot_password.html", form=form, error=error)
send_reset_password_email(user)
return redirect(url_for("auth.forgot_password"))
return render_template("auth/forgot_password.html", form=form)

View File

@ -1,13 +1,14 @@
from flask import request, flash, render_template, redirect, url_for
from flask import request, render_template, redirect, url_for
from flask_login import login_user
from wtforms import Form, StringField, validators
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.log import LOG
from app.models import User
class LoginForm(Form):
class LoginForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
password = StringField("Password", validators=[validators.DataRequired()])
@ -16,21 +17,35 @@ class LoginForm(Form):
def login():
form = LoginForm(request.form)
if request.method == "POST":
if form.validate():
user = User.query.filter_by(email=form.email.data).first()
if form.validate_on_submit():
user = User.filter_by(email=form.email.data).first()
if not user:
flash("No such email", "warning")
return render_template("auth/login.html", form=form)
if not user:
return render_template(
"auth/login.html", form=form, error="Email not exist in our system"
)
if not user.check_password(form.password.data):
flash("Wrong password", "warning")
return render_template("auth/login.html", form=form)
if not user.check_password(form.password.data):
return render_template("auth/login.html", form=form, error="Wrong password")
LOG.debug("log user %s in", user)
login_user(user)
if not user.activated:
return render_template(
"auth/login.html",
form=form,
show_resend_activation=True,
error="Please check your inbox for the activation email. You can also have this email re-sent",
)
LOG.debug("log user %s in", user)
login_user(user)
# User comes to login page from another 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"))
return render_template("auth/login.html", form=form)

View File

@ -0,0 +1,89 @@
import arrow
from flask import request, flash, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import email_utils
from app.auth.base import auth_bp
from app.config import URL
from app.email_utils import notify_admin
from app.extensions import db
from app.log import LOG
from app.models import User, ActivationCode, PlanEnum, GenEmail
from app.utils import random_string, encode_url
class RegisterForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
password = StringField(
"Password", validators=[validators.DataRequired(), validators.Length(min=8)]
)
name = StringField("Name", validators=[validators.DataRequired()])
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
form = RegisterForm(request.form)
if form.validate_on_submit():
user = User.filter_by(email=form.email.data).first()
if user:
flash(f"Email {form.email.data} already exists", "warning")
return render_template("auth/register.html", form=form)
LOG.debug("create user %s", form.email.data)
user = User.create(email=form.email.data, name=form.name.data)
user.set_password(form.password.data)
# by default new user will be trial period
user.plan = PlanEnum.trial
user.plan_expiration = arrow.now().shift(days=+15)
db.session.flush()
# create a first alias mail to show user how to use when they login
GenEmail.create_new_gen_email(user_id=user.id)
db.session.commit()
send_activation_email(user)
notify_admin(
f"new user signs up {user.email}", f"{user.name} signs up at {arrow.now()}"
)
return render_template("auth/register_waiting_activation.html")
return render_template("auth/register.html", form=form)
def send_activation_email(user):
activation = ActivationCode.create(user_id=user.id, code=random_string(30))
db.session.commit()
# Send user activation email
activation_link = f"{URL}/auth/activate?code={activation.code}"
if "next" in request.args:
LOG.d("redirect user to %s after activation", request.args["next"])
activation_link = activation_link + "&next=" + encode_url(request.args["next"])
email_utils.send(
user.email,
f"Welcome to SimpleLogin {user.name} - just one more step!",
html_content=f"""
Welcome to SimpleLogin! <br><br>
Our mission is to make the login process as smooth and as secure as possible. This should be easy. <br><br>
To get started, we need to confirm your email address, so please click this <a href="{activation_link}">link</a>
to finish creating your account. Or you can paste this link into your browser: <br><br>
{activation_link} <br><br>
Your feedbacks are very important to us. Please feel free to reply to this email to let us know any
of your suggestion! <br><br>
Thanks! <br><br>
SimpleLogin team.
""",
)

View File

@ -0,0 +1,39 @@
from flask import request, flash, render_template, redirect, url_for
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.auth.views.register import send_activation_email
from app.log import LOG
from app.models import User
class ResendActivationForm(FlaskForm):
email = StringField("Email", validators=[validators.DataRequired()])
@auth_bp.route("/resend_activation", methods=["GET", "POST"])
def resend_activation():
form = ResendActivationForm(request.form)
if form.validate_on_submit():
user = User.filter_by(email=form.email.data).first()
if not user:
flash("There's no such email", "warning")
return render_template("auth/resend_activation.html", form=form)
if user.activated:
flash("your account is already activated, please login", "success")
return redirect(url_for("auth.login"))
# user is not activated
LOG.d("user %s is not activated", user)
flash(
"An activation email is on its way, please check your inbox/spam folder",
"warning",
)
send_activation_email(user)
return render_template("auth/register_waiting_activation.html")
return render_template("auth/resend_activation.html", form=form)

View File

@ -0,0 +1,59 @@
import arrow
from flask import request, flash, render_template, redirect, url_for
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.extensions import db
from app.models import ResetPasswordCode
class ResetPasswordForm(FlaskForm):
password = StringField(
"Password", validators=[validators.DataRequired(), validators.Length(min=8)]
)
@auth_bp.route("/reset_password", methods=["GET", "POST"])
def reset_password():
form = ResetPasswordForm(request.form)
reset_password_code_str = request.args.get("code")
reset_password_code: ResetPasswordCode = ResetPasswordCode.get_by(
code=reset_password_code_str
)
if not reset_password_code:
error = (
"The reset password link can be used only once. "
"Please make a new request to reset password"
)
return render_template("auth/reset_password.html", form=form, error=error)
if reset_password_code.expired < arrow.now():
error = (
"The link is already expired. Please make a new request to reset password"
)
return render_template("auth/reset_password.html", form=form, error=error)
if form.validate_on_submit():
user = reset_password_code.user
user.set_password(form.password.data)
flash("Your new password has been set", "success")
# this can be served to activate user too
user.activated = True
# remove the reset password code
reset_password_code.delete()
db.session.commit()
login_user(user)
return redirect(url_for("dashboard.index"))
return render_template("auth/reset_password.html", form=form)

59
app/config.py Normal file
View File

@ -0,0 +1,59 @@
import os
import subprocess
from dotenv import load_dotenv
SHA1 = subprocess.getoutput("git rev-parse HEAD")
config_file = os.environ.get("CONFIG")
if config_file:
print("load config file", config_file)
load_dotenv(config_file)
else:
load_dotenv()
URL = os.environ.get("URL") or "http://sl-server:5000"
EMAIL_DOMAIN = os.environ.get("EMAIL_DOMAIN") or "sl"
SUPPORT_EMAIL = os.environ.get("SUPPORT_EMAIL") or "support@sl"
SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY")
DB_URI = os.environ.get("DB_URI") or "sqlite:///db.sqlite"
FLASK_SECRET = os.environ.get("FLASK_SECRET") or "secret"
# invalidate the session at each new version by changing the secret
FLASK_SECRET = FLASK_SECRET + SHA1
ENABLE_SENTRY = "ENABLE_SENTRY" in os.environ
ENV = os.environ.get("ENV")
print("email domain is", EMAIL_DOMAIN)
AWS_REGION = "eu-west-3"
BUCKET = os.environ.get("BUCKET") or "local.sl"
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
ENABLE_CLOUDWATCH = "ENABLE_CLOUDWATCH" in os.environ
CLOUDWATCH_LOG_GROUP = os.environ.get("CLOUDWATCH_LOG_GROUP")
CLOUDWATCH_LOG_STREAM = os.environ.get("CLOUDWATCH_LOG_STREAM")
STRIPE_API = os.environ.get("STRIPE_API") # Stripe public key
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
STRIPE_YEARLY_PLAN = os.environ.get("STRIPE_YEARLY_PLAN")
STRIPE_MONTHLY_PLAN = os.environ.get("STRIPE_MONTHLY_PLAN")
# Max number emails user can generate for free plan
MAX_NB_EMAIL_FREE_PLAN = int(os.environ.get("MAX_NB_EMAIL_FREE_PLAN"))
LYRA_ANALYTICS_ID = os.environ.get("LYRA_ANALYTICS_ID")
# Used to sign id_token
OPENID_PRIVATE_KEY_PATH = os.environ.get("OPENID_PRIVATE_KEY_PATH")
OPENID_PUBLIC_KEY_PATH = os.environ.get("OPENID_PUBLIC_KEY_PATH")
PARTNER_CODES = ["SL2019"]
# Allow user to have 1 year of premium: set the expiration_date to 1 year more
PROMO_CODE = "SIMPLEISBETTER"

View File

@ -1 +1 @@
from .views import index
from .views import index, pricing, setting

View File

@ -1,5 +1,8 @@
from flask import Blueprint
dashboard_bp = Blueprint(
name="dashboard", import_name=__name__, url_prefix="/dashboard"
name="dashboard",
import_name=__name__,
url_prefix="/dashboard",
template_folder="templates",
)

View File

@ -0,0 +1,244 @@
{% extends 'default.html' %}
{% set active_page = "dashboard" %}
{% block title %}
Dashboard
{% endblock %}
{% block default_content %}
<div class="page-header row">
<h3 class="page-title col"
data-intro="Here, you find the list of all <b>email alias</b> created. <br><br>
Emails sent to an <b>alias</b> will be forwarded to your personal email. <br><br>
Please note that email alias is <b>NOT</b> temporary, meaning an alias works forever! <br><br>
Email alias is a great way to hide your personal email so feel free to
use it whenever possible, for ex on untrusted websites.">
Email Alias
</h3>
<form method="post" class="col text-right">
<input type="hidden" name="form-name" value="create-new-email">
<button class="btn btn-success">Create email alias</button>
</form>
</div>
<div class="row row-cards row-deck mt-4">
<div class="col-12">
<div class="card">
<div class="table-responsive">
<table class="table table-hover table-outline table-vcenter text-nowrap card-table">
<thead>
<tr>
<th>Email</th>
<th>
Used On
<i class="fe fe-help-circle" data-toggle="tooltip"
title="List of app/website that has received this email"></i>
</th>
<th>Actions</th>
<th>
Enable/Disable Email Forwarding
</th>
<th>Created At</th>
</tr>
</thead>
<tbody>
{% for gen_email in gen_emails %}
<tr>
<td>
<div>
<a href="mailto: {{ gen_email.email }}">{{ gen_email.email }}</a>
</div>
</td>
<td>
{% for client_user in gen_email.client_users %}
{{ client_user.client.name }} <br>
{% endfor %}
</td>
<td>
<div class="btn-group">
<button class="clipboard btn btn-secondary btn-sm"
data-clipboard-text="{{ gen_email.email }}">
Copy
</button>
<form method="post">
<input type="hidden" name="form-name" value="trigger-email">
<input type="hidden" name="gen-email-id" value="{{ gen_email.id }}">
{% if gen_email.enabled %}
<button class="btn btn-secondary btn-sm"
{% if loop.index ==1 %}
data-intro="By triggering the test email,
SimpleLogin server will send an email to this alias
and this email should arrive to your personal email"
{% endif %}
>Trigger Test Email
</button>
{% endif %}
</form>
</div>
</td>
<td>
<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"
{% if loop.index ==1 %}
data-intro="By turning off an alias, emails sent to this alias will <b>NOT</b>
be forwarded to your personal email. <br><br>
This should only be used with care as others might
not be able to reach you after ...
"
{% endif %}
>
<input type="checkbox" class="custom-switch-input"
{{ "checked" if gen_email.enabled else "" }}>
<span class="custom-switch-indicator"></span>
</label>
</form>
</td>
<td>
{{ gen_email.created_at | dt }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="page-header row">
<h3 class="page-title col" data-intro="Here you can find the list of website/app on which
you have used the <b>Connect with SimpleLogin</b> button <br><br>
You also see what information that SimpleLogin has communicated to these website/app when you sign in.
">
Apps
</h3>
</div>
<div class="row row-cards row-deck mt-4">
<div class="col-12">
<div class="card">
<div class="table-responsive">
<table class="table table-hover table-outline table-vcenter text-nowrap card-table">
<thead>
<tr>
<th>
App
</th>
<th>
Information
<i class="fe fe-help-circle" data-toggle="tooltip"
title="Information sent to this app/website"></i>
</th>
<th class="text-center">
First used
<i class="fe fe-help-circle" data-toggle="tooltip"
title="The first time you have used the SimpleLogin on this app/website"></i>
</th>
<!--<th class="text-center">Last used</th>-->
</tr>
</thead>
<tbody>
{% for client_user in client_users %}
<tr>
<td>
{{ client_user.client.name }}
</td>
<td>
{% for scope, val in client_user.get_user_info().items() %}
<div>
{% if scope == "email" %}
Email: <a href="mailto:{{ val }}">{{ val }}</a>
{% elif scope == "name" %}
Name: {{ val }}
{% endif %}
</div>
{% endfor %}
</td>
<td class="text-center">
{{ client_user.created_at | dt }}
</td>
{# TODO: add last_used#}
<!--
<td class="text-center">
<div>4 minutes ago</div>
</td>
-->
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
require(['clipboard', 'notie', 'jquery', 'intro'], function (Clipboard, notie, $, intro) {
var clipboard = new Clipboard('.clipboard');
var introShown = localStorage.getItem("introShown");
console.log(introShown);
if ("yes" !== introShown) {
intro().start();
localStorage.setItem("introShown", "yes")
}
clipboard.on('success', function (e) {
notie.alert({
type: "success",
text: "Copied to clipboard",
time: 1,
});
e.clearSelection();
});
// the modal does not get close when user clicks outside of modal
// necessary for obligatory modal such as the one displayed when user enable/display email forwarding
notie.setOptions({
overlayClickDismiss: false,
});
$(".custom-switch-input").change(function (e) {
var message = "";
if (e.target.checked) {
message = `After this, you will start receiving email sent to this email address, please confirm`;
} else {
message = `After this, you will stop receiving email sent to this email address, please confirm`;
}
notie.confirm({
text: message,
cancelCallback: () => {
// reset to the original value
var oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
},
submitCallback: () => {
$(this).closest("form").submit();
}
});
})
})
</script>
{% endblock %}

View File

@ -0,0 +1,182 @@
{% extends 'default.html' %}
{% set active_page = "dashboard" %}
{% block title %}
Pricing
{% endblock %}
{% block head %}
<style type="text/css">
/**
* The CSS shown here will not be introduced in the Quickstart guide, but shows
* how you can use CSS to style your Element's container.
*/
.StripeElement {
box-sizing: border-box;
height: 40px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 4px;
background-color: white;
box-shadow: 0 1px 3px 0 #e6ebf1;
-webkit-transition: box-shadow 150ms ease;
transition: box-shadow 150ms ease;
}
.StripeElement--focus {
box-shadow: 0 1px 3px 0 #cfd7df;
}
.StripeElement--invalid {
border-color: #fa755a;
}
.StripeElement--webkit-autofill {
background-color: #fefde5 !important;
}
</style>
{% endblock %}
{% block default_content %}
<script src="https://js.stripe.com/v3/"></script>
<div class="row">
<div class="col-sm-6 col-lg-6">
<div class="card">
<div class="card-body text-center">
<div class="card-category">Premium</div>
<div class="display-4 my-6">$10/year</div>
<div class="display-5 my-6">or</div>
<div class="display-4 my-6">$1/month</div>
<ul class="list-unstyled leading-loose">
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Privacy protected</li>
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Infinite Login</li>
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i> Infinite Emails</li>
<li><i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Support us and our application partners
</li>
</ul>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-6">
<div class="display-6">
The payment is processed by <a href="https://stripe.com" target="_blank">Stripe</a>. <br>
Your card number is never stored on our server.
</div>
<hr>
<form method="post" id="payment-form">
<div class="form-group">
<label for="card-element" class="form-label">
Credit or debit card
</label>
<div id="card-element">
<!-- A Stripe Element will be inserted here. -->
</div>
<!-- Used to display form errors. -->
<div id="card-errors" role="alert" class="text-danger"></div>
</div>
<div class="form-group">
<div class="form-label">Plan</div>
<div class="custom-controls-stacked">
<label class="custom-control custom-radio custom-control-inline">
<input type="radio" class="custom-control-input" name="plan" value="yearly" checked>
<span class="custom-control-label">Yearly</span>
</label>
<label class="custom-control custom-radio custom-control-inline">
<input type="radio" class="custom-control-input" name="plan" value="monthly">
<span class="custom-control-label">Monthly</span>
</label>
</div>
</div>
<button type="submit" class="btn btn-success">Upgrade</button>
</form>
</div>
</div>
<script>
// Create a Stripe client.
var stripe = Stripe('{{ stripe_api }}');
// Create an instance of Elements.
var elements = stripe.elements();
// Custom styling can be passed to options when creating an Element.
// (Note that this demo uses a wider set of styles than the guide below.)
var style = {
base: {
color: '#32325d',
fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
fontSmoothing: 'antialiased',
fontSize: '16px',
'::placeholder': {
color: '#aab7c4'
}
},
invalid: {
color: '#fa755a',
iconColor: '#fa755a'
}
};
// Create an instance of the card Element.
// the postal code is asked on Safari but not on other browsers ...
// Disable it explicitly
var card = elements.create('card', {hidePostalCode: true, style: style});
// Add an instance of the card Element into the `card-element` <div>.
card.mount('#card-element');
// Handle real-time validation errors from the card Element.
card.addEventListener('change', function (event) {
var displayError = document.getElementById('card-errors');
if (event.error) {
displayError.textContent = event.error.message;
} else {
displayError.textContent = '';
}
});
// Handle form submission.
var form = document.getElementById('payment-form');
form.addEventListener('submit', function (event) {
event.preventDefault();
stripe.createToken(card).then(function (result) {
if (result.error) {
// Inform the user if there was an error.
var errorElement = document.getElementById('card-errors');
errorElement.textContent = result.error.message;
} else {
// Send the token to your server.
stripeTokenHandler(result.token);
}
});
});
// Submit the form with the token ID.
function stripeTokenHandler(token) {
// Insert the token ID into the form so it gets submitted to the server
var form = document.getElementById('payment-form');
var hiddenInput = document.createElement('input');
hiddenInput.setAttribute('type', 'hidden');
hiddenInput.setAttribute('name', 'stripeToken');
hiddenInput.setAttribute('value', token.id);
form.appendChild(hiddenInput);
// Submit the form
form.submit();
}
</script>
{% endblock %}

View File

@ -0,0 +1,101 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends 'default.html' %}
{% set active_page = "dashboard" %}
{% block title %}
Setting
{% endblock %}
{% block default_content %}
<div class="col-md-8 offset-md-2">
<form method="post" enctype="multipart/form-data">
{{ form.csrf_token }}
<input type="hidden" name="form-name" value="update-profile">
<h3>Profile</h3>
<div class="form-group">
<label class="form-label">Name</label>
{{ form.name(class="form-control", value=current_user.name) }}
{{ render_field_errors(form.name) }}
</div>
<div class="form-group">
<div class="form-label">Profile picture</div>
{{ form.profile_picture(class="form-control-file") }}
{{ render_field_errors(form.profile_picture) }}
<img src="{{ current_user.profile_picture_url() }}" class="profile-picture">
</div>
<button class="btn btn-primary">Update</button>
</form>
<hr>
<h3>Current subscription</h3>
Your current plan is
{% if current_user.is_premium() %}
<b>{{ current_user.plan.name }}</b>
<br>
{% if current_user.plan_expiration %}
Ends {{ current_user.plan_expiration.humanize() }}
{% else %}
Renewed {{ current_user.plan_current_period_end().humanize() }}
{% endif %}
{% else %}
<b>{{ current_user.plan.name }}</b><br>
{% if current_user.plan == PlanEnum.trial %}
Ends {{ current_user.plan_expiration.humanize() }}<br>
{% endif %}
<a href="{{ url_for('dashboard.pricing') }}" class="btn btn-sm btn-outline-primary">
Upgrade To Premium
</a>
<br><br>
<form method="post">
{{ promo_form.csrf_token }}
<input type="hidden" name="form-name" value="promo-code">
<h5>If you have a promo code, you can enter it here</h5>
<p class="text-muted">You can use a given promo code only once :)</p>
<div class="form-group">
<label class="form-label">Promo code</label>
{{ promo_form.code(class="form-control") }}
{{ render_field_errors(promo_form.code) }}
</div>
<button class="btn btn-primary">Apply</button>
</form>
{% endif %}
{% if current_user.is_premium() %}
<!-- This corresponds to the more rare case where user has upgraded the plan,
downgraded it and decides to upgrade again before the end of the previous plan -->
{% if current_user.plan_expiration %}
<form method="post">
<input type="hidden" name="form-name" value="reactivate-subscription">
<br><br>
<button class="btn btn-warning">Reactivate subscription</button>
</form>
{% else %}
<!-- current_user.plan_expiration=None, this corresponds to the usual case
where user has upgraded the plan, and now decide to downgrade it. -->
<form method="post"
onsubmit="return confirm('Your plan will be downgraded to free plan {{ current_user.plan_current_period_end().humanize() }}, please confirm.')">
<input type="hidden" name="form-name" value="cancel-subscription">
<br><br>
<button class="btn btn-warning">Cancel subscription</button>
</form>
{% endif %}
{% endif %}
<hr>
<h3>Change password</h3>
<form method="post">
<input type="hidden" name="form-name" value="change-password">
<button class="btn btn-outline-primary">Change password</button>
</form>
</div>
{% endblock %}

View File

@ -1,10 +1,89 @@
from flask import render_template
from flask_login import login_required
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from sqlalchemy.orm import joinedload
from app import email_utils
from app.dashboard.base import dashboard_bp
from app.extensions import db
from app.log import LOG
from app.models import GenEmail, ClientUser
@dashboard_bp.route("/")
@dashboard_bp.route("/", methods=["GET", "POST"])
@login_required
def index():
return render_template("dashboard/index.html")
# User generates a new email
if request.method == "POST":
if request.form.get("form-name") == "trigger-email":
gen_email_id = request.form.get("gen-email-id")
gen_email = GenEmail.get(gen_email_id)
LOG.d("trigger an email to %s", gen_email)
email_utils.send(
gen_email.email,
"A Test Email",
f"""
Hi {current_user.name} ! <br><br>
This is a test email to make sure you receive email sent at {gen_email.email} <br><br>
If you have any question, feel free to reply to this email :) <br><br>
Have a nice day <br><br>
SimpleLogin team.
""",
)
flash(
f"An email sent to {gen_email.email} is on its way, please check your inbox/spam folder",
"success",
)
elif request.form.get("form-name") == "create-new-email":
can_create_new_email = current_user.can_create_new_email()
if can_create_new_email:
gen_email = GenEmail.create_new_gen_email(user_id=current_user.id)
db.session.commit()
LOG.d("generate new email %s for user %s", gen_email, current_user)
flash(f"Email {gen_email.email} has been created", "success")
else:
flash(f"You need to upgrade your plan to create new email.", "warning")
elif request.form.get("form-name") == "switch-email-forwarding":
gen_email_id = request.form.get("gen-email-id")
gen_email: GenEmail = GenEmail.get(gen_email_id)
LOG.d("switch email forwarding for %s", gen_email)
gen_email.enabled = not gen_email.enabled
if gen_email.enabled:
flash(
f"The email forwarding for {gen_email.email} has been enabled",
"success",
)
else:
flash(
f"The email forwarding for {gen_email.email} has been disabled",
"warning",
)
db.session.commit()
return redirect(url_for("dashboard.index"))
client_users = (
ClientUser.filter_by(user_id=current_user.id)
.options(joinedload(ClientUser.client))
.options(joinedload(ClientUser.gen_email))
.all()
)
sorted(client_users, key=lambda cu: cu.client.name)
gen_emails = (
GenEmail.filter_by(user_id=current_user.id)
.order_by(GenEmail.email)
.options(joinedload(GenEmail.client_users))
.all()
)
return render_template(
"dashboard/index.html", client_users=client_users, gen_emails=gen_emails
)

View File

@ -0,0 +1,90 @@
import stripe
from flask import render_template, request, flash, redirect, url_for
from flask_login import login_required, current_user
from stripe.error import CardError
from app.config import STRIPE_API, STRIPE_MONTHLY_PLAN, STRIPE_YEARLY_PLAN
from app.dashboard.base import dashboard_bp
from app.email_utils import notify_admin
from app.extensions import db
from app.log import LOG
from app.models import PlanEnum
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
@login_required
def pricing():
# sanity check: make sure this page is only for free user that has never subscribed before
# case user unsubscribe and re-subscribe will be handled later
if current_user.is_premium():
flash("You are already a premium user", "warning")
return redirect(url_for("dashboard.index"))
if (
current_user.stripe_customer_id
or current_user.stripe_card_token
or current_user.stripe_subscription_id
):
raise Exception("only user not exist on stripe can view this page")
if stripe.Customer.list(email=current_user.email):
raise Exception("user email is already used on stripe!")
if request.method == "POST":
plan_str = request.form.get("plan") # either monthly or yearly
if plan_str == "monthly":
plan = PlanEnum.monthly
elif plan_str == "yearly":
plan = PlanEnum.yearly
else:
raise Exception("Plan must be either yearly or monthly")
stripe_token = request.form.get("stripeToken")
LOG.d("stripe card token %s for plan %s", stripe_token, plan)
current_user.stripe_card_token = stripe_token
try:
customer = stripe.Customer.create(
source=stripe_token,
email=current_user.email,
metadata={"id": current_user.id},
name=current_user.name,
)
except CardError as e:
LOG.exception("payment problem, code:%s", e.code)
flash(
"Payment refused with error {e.message}. Could you re-try with another card please?",
"danger",
)
else:
LOG.d("stripe customer %s", customer)
current_user.stripe_customer_id = customer.id
stripe_plan = (
STRIPE_MONTHLY_PLAN if plan == PlanEnum.monthly else STRIPE_YEARLY_PLAN
)
subscription = stripe.Subscription.create(
customer=current_user.stripe_customer_id,
items=[{"plan": stripe_plan}],
expand=["latest_invoice.payment_intent"],
)
LOG.d("stripe subscription %s", subscription)
current_user.stripe_subscription_id = subscription.id
db.session.commit()
if subscription.latest_invoice.payment_intent.status == "succeeded":
LOG.d("payment successful for user %s", current_user)
current_user.plan = plan
current_user.plan_expiration = None
db.session.commit()
flash("Thanks for your subscription!", "success")
notify_admin(
f"user {current_user.email} has finished subscription",
f"plan: {plan}",
)
return redirect(url_for("dashboard.index"))
return render_template("dashboard/pricing.html", stripe_api=STRIPE_API)

View File

@ -0,0 +1,173 @@
from io import BytesIO
import arrow
import stripe
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileField
from wtforms import StringField, validators
from app import s3, email_utils
from app.config import URL, PROMO_CODE
from app.dashboard.base import dashboard_bp
from app.email_utils import notify_admin
from app.extensions import db
from app.log import LOG
from app.models import PlanEnum, File, ResetPasswordCode
from app.utils import random_string
class SettingForm(FlaskForm):
name = StringField("Name", validators=[validators.DataRequired()])
profile_picture = FileField("Profile Picture")
class PromoCodeForm(FlaskForm):
code = StringField("Name", validators=[validators.DataRequired()])
@dashboard_bp.route("/setting", methods=["GET", "POST"])
@login_required
def setting():
form = SettingForm()
promo_form = PromoCodeForm()
if request.method == "POST":
if request.form.get("form-name") == "update-profile":
if form.validate():
# update user info
current_user.name = form.name.data
if form.profile_picture.data:
file_path = random_string(30)
file = File.create(path=file_path)
s3.upload_from_bytesio(
file_path, BytesIO(form.profile_picture.data.read())
)
db.session.flush()
LOG.d("upload file %s to s3", file)
current_user.profile_picture_id = file.id
db.session.flush()
db.session.commit()
flash(f"Your profile has been updated", "success")
elif request.form.get("form-name") == "cancel-subscription":
# sanity check
if not (current_user.is_premium() and current_user.plan_expiration is None):
raise Exception("user cannot cancel subscription")
notify_admin(f"user {current_user} cancels subscription")
# the plan will finish at the end of the current period
current_user.plan_expiration = current_user.plan_current_period_end()
stripe.Subscription.modify(
current_user.stripe_subscription_id, cancel_at_period_end=True
)
db.session.commit()
flash(
f"Your plan will be downgraded {current_user.plan_expiration.humanize()}",
"success",
)
elif request.form.get("form-name") == "reactivate-subscription":
if not (current_user.is_premium() and current_user.plan_expiration):
raise Exception("user cannot reactivate subscription")
notify_admin(f"user {current_user} reactivates subscription")
# the plan will finish at the end of the current period
current_user.plan_expiration = None
stripe.Subscription.modify(
current_user.stripe_subscription_id, cancel_at_period_end=False
)
db.session.commit()
flash(f"Your plan is reactivated now, thank you!", "success")
elif request.form.get("form-name") == "change-password":
send_reset_password_email(current_user)
elif request.form.get("form-name") == "promo-code":
if promo_form.validate():
promo_code = promo_form.code.data.upper()
if promo_code != PROMO_CODE:
flash(
"Unknown promo code. Are you sure this is the right code?",
"warning",
)
return render_template(
"dashboard/setting.html",
form=form,
PlanEnum=PlanEnum,
promo_form=promo_form,
)
elif promo_code in current_user.get_promo_codes():
flash(
"You have already used this promo code. A code can be used only once :(",
"warning",
)
return render_template(
"dashboard/setting.html",
form=form,
PlanEnum=PlanEnum,
promo_form=promo_form,
)
else:
LOG.d("apply promo code %s for user %s", promo_code, current_user)
current_user.plan = PlanEnum.trial
if current_user.plan_expiration:
LOG.d("extend the current plan 1 year")
current_user.plan_expiration = current_user.plan_expiration.shift(
years=1
)
else:
LOG.d("set plan_expiration to 1 year from now")
current_user.plan_expiration = arrow.now().shift(years=1)
current_user.save_new_promo_code(promo_code)
db.session.commit()
flash(
"The promo code has been applied successfully to your account!",
"success",
)
return redirect(url_for("dashboard.setting"))
return render_template(
"dashboard/setting.html", form=form, PlanEnum=PlanEnum, promo_form=promo_form
)
def send_reset_password_email(user):
"""
generate a new ResetPasswordCode and send it over email to user
"""
reset_password_code = ResetPasswordCode.create(
user_id=user.id, code=random_string(60)
)
db.session.commit()
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
email_utils.send(
user.email,
f"Reset your password on SimpleLogin",
html_content=f"""
Hi {user.name}! <br><br>
To reset or change your password, please follow this link <a href="{reset_password_link}">reset password</a>.
Or you can paste this link into your browser: <br><br>
{reset_password_link} <br><br>
Cheers,
SimpleLogin team.
""",
)
flash(
"You are going to receive an email containing instruction to change your password",
"success",
)

View File

@ -0,0 +1 @@
from .views import index, new_client, client_detail

18
app/developer/base.py Normal file
View File

@ -0,0 +1,18 @@
from flask import Blueprint, render_template
from flask_login import current_user
from app.log import LOG
developer_bp = Blueprint(
name="developer",
import_name=__name__,
url_prefix="/developer",
template_folder="templates",
)
@developer_bp.before_request
def before_request():
if current_user.is_authenticated and not current_user.is_developer:
LOG.error("User %s tries to go developer tab")
return render_template("error/403.html"), 403

View File

@ -0,0 +1,150 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends 'default.html' %}
{% set active_page = "developer" %}
{% block title %}
Developer - Edit client
{% endblock %}
{% block default_content %}
<div class="col-md-8 offset-md-2">
<form method="post" enctype="multipart/form-data">
{{ form.csrf_token }}
<h3>App Information</h3>
<div class="form-group">
<label class="form-label">App Name</label>
{{ form.name(class="form-control", value=client.name) }}
{{ render_field_errors(form.name) }}
</div>
<div class="form-group">
<label class="form-label">Website Url</label>
{{ form.home_url(class="form-control", type="url", value=client.home_url or "") }}
{{ render_field_errors(form.home_url) }}
</div>
<div class="form-group">
<div class="form-label">App Icon</div>
{{ form.icon(class="form-control-file") }}
{{ render_field_errors(form.icon) }}
{% if client.icon_id %}
<img src="{{ client.icon.get_url() }}" class="client-icon">
{% endif %}
</div>
<hr>
<h3>OpenID/OAuth2 parameters</h3>
<div class="form-group">
<label class="form-label">OAuth2 Client ID</label>
<div class="input-group mt-2">
<input type="text" value="{{ client.oauth_client_id }}" class="form-control">
<span class="input-group-append">
<button
data-clipboard-text="{{ client.oauth_client_id }}"
class="clipboard btn btn-primary" type="button">
<i class="fe fe-clipboard"></i>
</button>
</span>
</div>
</div>
<div class="form-group">
<label class="form-label">OAuth2 Client Secret</label>
<div class="input-group mt-2">
<input type="password" value="{{ client.oauth_client_secret }}" class="form-control">
<span class="input-group-append">
<button
data-clipboard-text="{{ client.oauth_client_secret }}"
class="clipboard btn btn-primary" type="button">
<i class="fe fe-clipboard"></i>
</button>
</span>
</div>
</div>
<div class="form-group">
<label class="form-label">Authorized URIs</label>
{% for redirect_uri in client.redirect_uris %}
<div class="input-group mt-2">
<input type="url" name="uri" class="form-control" value="{{ redirect_uri.uri }}" required>
<span class="input-group-append">
<button class="remove-uri btn btn-primary" type="button">
<i class="fe fe-x"></i>
</button>
</span>
</div>
{% endfor %}
<div id="new-uris">
<!-- New uri will be put here -->
</div>
<button type="button" id="create-new-uri" class="mt-2 btn btn-outline-secondary">Add new uri</button>
</div>
<hr>
<button type="submit" class="btn btn-primary btn-lg">Update</button>
</form>
<!-- template for new uri -->
<div class="input-group mt-2" id="hidden-uri" style="display: none">
<input type="url" name="uri" class="form-control" required>
<span class="input-group-append">
<button class="remove-uri btn btn-primary" type="button">
<i class="fe fe-x"></i>
</button>
</span>
</div>
</div>
{% endblock %}
{% block script %}
<script type="text/x-template" id="course-detail">
<h1> ALO </h1>
</script>
<script>
require(["jquery", "notie", "clipboard"], function ($, notie, Clipboard) {
$("#create-new-uri").on("click", function (e) {
var clone = $("#hidden-uri").clone(true, true); // (true, true) to clone withDataAndEvents, deepWithDataAndEvents
clone.removeAttr("id");
$("#new-uris").append(clone);
clone.show();
});
$(".remove-uri").click(function (e) {
var currentElement = $(this);
currentElement.parent().parent().remove();
});
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function (e) {
notie.alert({
type: "success",
text: "Copied to clipboard",
time: 2,
});
e.clearSelection();
});
})
</script>
{% endblock %}

View File

@ -0,0 +1,164 @@
{% from "_formhelpers.html" import render_field %}
{% extends 'default.html' %}
{% set active_page = "developer" %}
{% block title %}
Developer
{% endblock %}
{% block default_content %}
<div class="row">
<div class="col-4">
<a href="{{ url_for('developer.new_client') }}" class="btn btn-success">Create new app</a>
</div>
</div>
<div class="row row-cards row-deck mt-4">
<div class="col-12">
<div class="card">
<div class="table-responsive">
<table class="table table-hover table-outline table-vcenter text-nowrap card-table">
<thead>
<tr>
<th class="text-center w-1"><i class="icon-people"></i></th>
<th>Name</th>
<th>OAuth2 Client ID</th>
<th>Scopes</th>
<th>Number Users</th>
<th>Edit</th>
<!--<th>Publish</th>-->
<th>Delete</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr>
<td class="text-center">
{% if client.icon_id %}
<div class="avatar d-block" style="background-image: url({{ client.icon.get_url() }})">
<span class="avatar-status bg-green"></span>
</div>
{% endif %}
</td>
<td>
<div>
<a href="{{ url_for('developer.client_detail', client_id=client.id) }}">
{{ client.name }}
</a>
</div>
<div class="small text-muted">
Created at: {{ client.created_at |dt }}
</div>
</td>
<td>
{{ client.oauth_client_id }}
</td>
<td class="align-middle">
<ul class="list-unstyled mb-0">
{% for scope in client.scopes %}
<li>
<i class="fe fe-check"></i>
{{ scope.name }}
</li>
{% endfor %}
</ul>
</td>
<td>
{{ client.nb_user() }}
</td>
<td>
<a href="{{ url_for('developer.client_detail', client_id=client.id) }}" class="btn btn-info">
<i class="fe fe-edit"></i>
</a>
</td>
<!-- TODO: uncomment when bringing back "Discover" feature
<td>
<form method="post">
<input type="hidden" name="form-name" value="switch-client-publish">
<input type="hidden" name="client-id" value="{{ client.id }}">
<label class="custom-switch">
<input type="checkbox" class="custom-switch-input"
{{ "checked" if client.published else "" }}>
<span class="custom-switch-indicator"></span>
</label>
</form>
</td>
-->
<td>
<form method="post"
onsubmit="return confirm('Please make sure no user is using this client. This operation is not reversible');">
<input type="hidden" name="form-name" value="delete-client">
<input type="hidden" name="client-id" value="{{ client.id }}">
<button type="submit" class="btn btn-danger">
<i class="fe fe-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
require(['clipboard', 'notie', 'jquery'], function (Clipboard, notie, $) {
var clipboard = new Clipboard('.btn');
clipboard.on('success', function (e) {
notie.alert({
type: "success",
text: "Copied to clipboard",
time: 1,
});
e.clearSelection();
});
// the modal does not get close when user clicks outside of modal
// necessary for obligatory modal such as the one displayed when user enable/display email forwarding
notie.setOptions({
overlayClickDismiss: false,
});
$(".custom-switch-input").change(function (e) {
// Only ask for confirmation when publishing, not when un-publishing
if (e.target.checked) {
var message = `After this, your app/website will made available in "Discover", please confirm`;
notie.confirm({
text: message,
cancelCallback: () => {
// reset to the original value
var oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
},
submitCallback: () => {
$(this).closest("form").submit();
}
});
} else {
$(this).closest("form").submit();
}
})
});
</script>
{% endblock %}

View File

@ -0,0 +1,37 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends 'default.html' %}
{% set active_page = "developer" %}
{% block title %}
Developer - Create new client
{% endblock %}
{% block default_content %}
<form method="post" enctype="multipart/form-data">
{{ form.csrf_token }}
<div class="form-group">
<label class="form-label">App Name</label>
{{ form.name(class="form-control") }}
{{ render_field_errors(form.name) }}
</div>
<div class="form-group">
<label class="form-label">Website Url</label>
{{ form.home_url(class="form-control", type="url") }}
{{ render_field_errors(form.home_url) }}
</div>
<div class="form-group">
<div class="form-label">App Icon</div>
{{ form.icon(class="form-control-file") }}
{{ render_field_errors(form.icon) }}
</div>
<button type="submit" class="btn btn-primary">Create</button>
</form>
{% endblock %}

View File

View File

@ -0,0 +1,71 @@
from io import BytesIO
from flask import request, render_template, redirect, url_for, flash
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from flask_wtf.file import FileField
from wtforms import StringField, validators
from app import s3
from app.developer.base import developer_bp
from app.extensions import db
from app.log import LOG
from app.models import Client, RedirectUri, File
from app.utils import random_string
class EditClientForm(FlaskForm):
name = StringField("Name", validators=[validators.DataRequired()])
icon = FileField("Icon")
home_url = StringField("Home Url")
@developer_bp.route("/clients/<client_id>", methods=["GET", "POST"])
@login_required
def client_detail(client_id):
form = EditClientForm()
client = Client.get(client_id)
if not client:
flash("no such client", "warning")
return redirect(url_for("developer.index"))
if client.user_id != current_user.id:
flash("you cannot see this client", "warning")
return redirect(url_for("developer.index"))
if request.method == "POST":
if form.validate():
client.name = form.name.data
client.home_url = form.home_url.data
if form.icon.data:
# todo: remove current icon if any
# todo: handle remove icon
file_path = random_string(30)
file = File.create(path=file_path)
s3.upload_from_bytesio(file_path, BytesIO(form.icon.data.read()))
db.session.commit()
LOG.d("upload file %s to s3", file)
client.icon_id = file.id
db.session.commit()
uris = request.form.getlist("uri")
# replace all uris. TODO: optimize this?
for redirect_uri in client.redirect_uris:
redirect_uri.delete()
for uri in uris:
RedirectUri.create(client_id=client_id, uri=uri)
db.session.commit()
flash(f"client {client.name} has been updated", "success")
return redirect(url_for("developer.client_detail", client_id=client.id))
return render_template("developer/client_detail.html", form=form, client=client)

View File

@ -0,0 +1,52 @@
"""List of clients"""
from flask import render_template, request, flash, redirect, url_for
from flask_login import current_user, login_required
from app.developer.base import developer_bp
from app.extensions import db
from app.log import LOG
from app.models import Client
@developer_bp.route("/", methods=["GET", "POST"])
@login_required
def index():
# delete client
if request.method == "POST":
if request.form.get("form-name") == "delete-client":
client_id = int(request.form.get("client-id"))
client = Client.get(client_id)
if client.user_id != current_user.id:
flash("You cannot remove this client", "warning")
else:
client_name = client.name
client.delete()
db.session.commit()
LOG.d("Remove client %s", client)
flash(f"Client {client_name} has been deleted successfully", "success")
elif request.form.get("form-name") == "switch-client-publish":
client_id = int(request.form.get("client-id"))
client = Client.get(client_id)
if client.user_id != current_user.id:
flash("You cannot modify this client", "warning")
else:
client.published = not client.published
db.session.commit()
LOG.d("Switch client.published %s", client)
if client.published:
flash(
f"Client {client.name} has been published on Discover",
"success",
)
else:
flash(f"Client {client.name} has been un-published", "success")
return redirect(url_for("developer.index"))
clients = Client.filter_by(user_id=current_user.id).all()
return render_template("developer/index.html", clients=clients)

View File

@ -0,0 +1,50 @@
from io import BytesIO
from flask import request, render_template, redirect, url_for, flash
from flask_login import current_user, login_required
from flask_wtf import FlaskForm
from flask_wtf.file import FileField
from wtforms import StringField, validators
from app import s3
from app.developer.base import developer_bp
from app.extensions import db
from app.log import LOG
from app.models import Client, File
from app.utils import random_string
class NewClientForm(FlaskForm):
name = StringField("Name", validators=[validators.DataRequired()])
icon = FileField("Icon")
home_url = StringField("Home Url")
@developer_bp.route("/new_client", methods=["GET", "POST"])
@login_required
def new_client():
form = NewClientForm()
if request.method == "POST":
if form.validate():
client = Client.create_new(form.name.data, current_user.id)
client.home_url = form.home_url.data
db.session.commit()
if form.icon.data:
file_path = random_string(30)
file = File.create(path=file_path)
s3.upload_from_bytesio(file_path, BytesIO(form.icon.data.read()))
db.session.commit()
LOG.d("upload file %s to s3", file)
client.icon_id = file.id
db.session.commit()
flash("New client has been created", "success")
return redirect(url_for("developer.client_detail", client_id=client.id))
return render_template("developer/new_client.html", form=form)

1
app/discover/__init__.py Normal file
View File

@ -0,0 +1 @@
from .views import index

8
app/discover/base.py Normal file
View File

@ -0,0 +1,8 @@
from flask import Blueprint
discover_bp = Blueprint(
name="discover",
import_name=__name__,
url_prefix="/discover",
template_folder="templates",
)

View File

@ -0,0 +1,41 @@
{% extends 'default.html' %}
{% set active_page = "discover" %}
{% block title %}
Discover
{% endblock %}
{% block default_content %}
<h3>Apps</h3>
<p class="text-muted">
App/Website that have implemented <b>Connect with SimpeLogin</b>
</p>
<div class="row row-cards row-deck">
{% for client in clients %}
<div class="col-sm-4 col-xl-2">
<div class="card">
<a href="{{ client.home_url }}" target="_blank">
<img class="card-img-top" src="{{ client.get_icon_url() }}">
</a>
<div class="card-body d-flex flex-column">
<h4><a href="{{ client.home_url }}">
{{ client.name }}
</a></h4>
<div class="text-muted">
{{ client.home_url }}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

View File

@ -0,0 +1,12 @@
from flask import render_template
from flask_login import login_required
from app.discover.base import discover_bp
from app.models import Client
@discover_bp.route("/", methods=["GET", "POST"])
@login_required
def index():
clients = Client.filter_by(published=True).all()
return render_template("discover/index.html", clients=clients)

38
app/email_utils.py Normal file
View File

@ -0,0 +1,38 @@
# using SendGrid's Python Library
# https://github.com/sendgrid/sendgrid-python
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail
from app.config import SUPPORT_EMAIL, SENDGRID_API_KEY, ENV
from app.log import LOG
def send(to_email, subject, html_content):
# On local only print out email content
if ENV == "local":
LOG.d(
"send mail to %s, subject:%s, content:%s", to_email, subject, html_content
)
return
message = Mail(
from_email=SUPPORT_EMAIL,
to_emails=to_email,
subject=subject,
html_content=html_content,
)
sg = SendGridAPIClient(SENDGRID_API_KEY)
response = sg.send(message)
LOG.d("sendgrid res:%s, email:%s", response.status_code, to_email)
def notify_admin(subject, html_content):
send(
SUPPORT_EMAIL,
subject,
f"""
<html><body>
{html_content}
</body></html>""",
)

View File

@ -1,34 +1,8 @@
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy, Model
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
class CRUDMixin(Model):
"""Mixin that adds convenience methods for CRUD (create, read, update, delete) operations."""
@classmethod
def create(cls, **kwargs):
"""Create a new record and save it the database."""
instance = cls(**kwargs)
return instance.save()
def update(self, commit=True, **kwargs):
"""Update specific fields of a record."""
for attr, value in kwargs.items():
setattr(self, attr, value)
return commit and self.save() or self
def save(self, commit=True):
"""Save the record."""
db.session.add(self)
if commit:
db.session.commit()
return self
def delete(self, commit=True):
"""Remove the record from the database."""
db.session.delete(self)
return commit and db.session.commit()
db = SQLAlchemy(model_class=CRUDMixin)
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate(db=db)

47
app/jose_utils.py Normal file
View File

@ -0,0 +1,47 @@
import arrow
from jwcrypto import jwk, jwt
from app.config import OPENID_PRIVATE_KEY_PATH, URL
from app.log import LOG
from app.models import ClientUser
with open(OPENID_PRIVATE_KEY_PATH, "rb") as f:
key = jwk.JWK.from_pem(f.read())
def get_jwk_key() -> dict:
return key._public_params()
def make_id_token(client_user: ClientUser):
"""Make id_token for OpenID Connect
According to RFC 7519, these claims are mandatory:
- iss
- sub
- aud
- exp
- iat
"""
claims = {
"iss": URL,
"sub": str(client_user.id),
"aud": client_user.client.oauth_client_id,
"exp": arrow.now().shift(hours=1).timestamp,
"iat": arrow.now().timestamp,
}
claims = {**claims, **client_user.get_user_info()}
jwt_token = jwt.JWT(header={"alg": "RS256", "kid": "simple-login"}, claims=claims)
jwt_token.make_signed_token(key)
return jwt_token.serialize()
def verify_id_token(id_token) -> bool:
try:
jwt.JWT(key=key, jwt=id_token)
except Exception:
LOG.exception("id token not verified")
return False
else:
return True

View File

@ -2,22 +2,50 @@ import logging
import sys
import time
import boto3
import watchtower
from app.config import (
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_REGION,
CLOUDWATCH_LOG_GROUP,
ENABLE_CLOUDWATCH,
CLOUDWATCH_LOG_STREAM,
)
_log_format = "%(asctime)s - %(name)s - %(levelname)s - %(process)d - %(module)s:%(lineno)d - %(funcName)s - %(message)s"
_log_formatter = logging.Formatter(_log_format)
def _get_console_handler(level=None):
def _get_console_handler():
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(_log_formatter)
console_handler.formatter.converter = time.gmtime
if level:
console_handler.setLevel(level)
return console_handler
def get_logger(name):
def _get_watchtower_handler():
session = boto3.Session(
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
region_name=AWS_REGION,
)
handler = watchtower.CloudWatchLogHandler(
log_group=CLOUDWATCH_LOG_GROUP,
stream_name=CLOUDWATCH_LOG_STREAM,
send_interval=5, # every 5 sec
boto3_session=session,
)
handler.setFormatter(_log_formatter)
return handler
def _get_logger(name):
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
@ -25,7 +53,16 @@ def get_logger(name):
# leave the handlers level at NOTSET so the level checking is only handled by the logger
logger.addHandler(_get_console_handler())
# no propagation to avoid unexpected behaviour
if ENABLE_CLOUDWATCH:
print(
"enable cloudwatch, log group",
CLOUDWATCH_LOG_GROUP,
"; log stream:",
CLOUDWATCH_LOG_STREAM,
)
logger.addHandler(_get_watchtower_handler())
# no propagation to avoid propagating to root logger
logger.propagate = False
return logger
@ -33,13 +70,12 @@ def get_logger(name):
print(f">>> init logging <<<")
# ### config root logger ###
# do not use the default (buggy) logger
logging.root.handlers.clear()
# Disable flask logs such as 127.0.0.1 - - [15/Feb/2013 10:52:22] "GET /index.html HTTP/1.1" 200
log = logging.getLogger("werkzeug")
log.disabled = True
# add handlers with the default level = "warn"
# need to add level at handler level as there's no level check in root logger
# all the libs logs having level >= WARN will be handled by these 2 handlers
logging.root.addHandler(_get_console_handler(logging.WARN))
# Set some shortcuts
logging.Logger.d = logging.Logger.debug
logging.Logger.i = logging.Logger.info
LOG = get_logger("yourkey")
LOG = _get_logger("sl")

View File

@ -1,30 +1,141 @@
# <<< Models >>>
from datetime import datetime
import enum
import hashlib
import arrow
import bcrypt
import stripe
from arrow import Arrow
from flask_login import UserMixin
from sqlalchemy_utils import ArrowType
from app import s3
from app.config import URL, MAX_NB_EMAIL_FREE_PLAN, EMAIL_DOMAIN
from app.extensions import db
from app.log import LOG
from app.oauth_models import ScopeE
from app.utils import convert_to_id, random_string
class ModelMixin(object):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=None, onupdate=datetime.utcnow)
created_at = db.Column(ArrowType, default=arrow.utcnow, nullable=False)
updated_at = db.Column(ArrowType, default=None, onupdate=arrow.utcnow)
_repr_hide = ["created_at", "updated_at"]
@classmethod
def query(cls):
return db.session.query(cls)
@classmethod
def get(cls, id):
return cls.query.get(id)
@classmethod
def get_by(cls, **kw):
return cls.query.filter_by(**kw).first()
@classmethod
def filter_by(cls, **kw):
return cls.query.filter_by(**kw)
@classmethod
def get_or_create(cls, **kw):
r = cls.get_by(**kw)
if not r:
r = cls(**kw)
db.session.add(r)
return r
@classmethod
def create(cls, **kw):
r = cls(**kw)
db.session.add(r)
return r
def save(self):
db.session.add(self)
def delete(self):
db.session.delete(self)
def __repr__(self):
values = ", ".join(
"%s=%r" % (n, getattr(self, n))
for n in self.__table__.c.keys()
if n not in self._repr_hide
)
return "%s(%s)" % (self.__class__.__name__, values)
class Client(db.Model, ModelMixin):
client_id = db.Column(db.String(128), unique=True)
client_secret = db.Column(db.String(128))
redirect_uri = db.Column(db.String(1024))
name = db.Column(db.String(128))
class File(db.Model, ModelMixin):
path = db.Column(db.String(128), unique=True, nullable=False)
def get_url(self):
return s3.get_url(self.path)
class PlanEnum(enum.Enum):
free = 0
trial = 1
monthly = 2
yearly = 3
class User(db.Model, ModelMixin, UserMixin):
email = db.Column(db.String(128), unique=True)
__tablename__ = "users"
email = db.Column(db.String(128), unique=True, nullable=False)
salt = db.Column(db.String(128), nullable=False)
password = db.Column(db.String(128), nullable=False)
name = db.Column(db.String(128))
name = db.Column(db.String(128), nullable=False)
is_admin = db.Column(db.Boolean, nullable=False, default=False)
activated = db.Column(db.Boolean, default=False, nullable=False)
plan = db.Column(
db.Enum(PlanEnum),
nullable=False,
default=PlanEnum.free,
server_default=PlanEnum.free.name,
)
# only relevant for trial period
plan_expiration = db.Column(ArrowType)
stripe_customer_id = db.Column(db.String(128), unique=True)
stripe_card_token = db.Column(db.String(128), unique=True)
stripe_subscription_id = db.Column(db.String(128), unique=True)
profile_picture_id = db.Column(db.ForeignKey(File.id), nullable=True)
is_developer = db.Column(db.Boolean, nullable=False, server_default="0")
# contain the list of promo codes user has used. Promo codes are separated by ","
promo_codes = db.Column(db.Text, nullable=True)
profile_picture = db.relationship(File)
def should_upgrade(self):
"""User is invited to upgrade if they are in free plan or their trial ends soon"""
if self.plan == PlanEnum.free:
return True
elif self.plan == PlanEnum.trial and self.plan_expiration < arrow.now().shift(
weeks=1
):
return True
return False
def is_premium(self):
return self.plan in (PlanEnum.monthly, PlanEnum.yearly)
def can_create_new_email(self):
if self.is_premium():
return True
# plan not expired yet
elif self.plan == PlanEnum.trial and self.plan_expiration > arrow.now():
return True
else: # free or trial expired
return GenEmail.filter_by(user_id=self.id).count() < MAX_NB_EMAIL_FREE_PLAN
def set_password(self, password):
salt = bcrypt.gensalt()
@ -36,16 +147,259 @@ class User(db.Model, ModelMixin, UserMixin):
password_hash = bcrypt.hashpw(password.encode(), self.salt.encode())
return self.password.encode() == password_hash
def profile_picture_url(self):
if self.profile_picture_id:
return self.profile_picture.get_url()
else: # use gravatar
hash_email = hashlib.md5(self.email.encode("utf-8")).hexdigest()
return f"https://www.gravatar.com/avatar/{hash_email}"
def plan_current_period_end(self) -> Arrow:
if not self.stripe_subscription_id:
LOG.error(
"plan_current_period_end should not be called with empty stripe_subscription_id"
)
return None
current_period_end_ts = stripe.Subscription.retrieve(
self.stripe_subscription_id
)["current_period_end"]
return arrow.get(current_period_end_ts)
def get_promo_codes(self) -> [str]:
if not self.promo_codes:
return []
return self.promo_codes.split(",")
def save_new_promo_code(self, promo_code):
current_promo_codes = self.get_promo_codes()
current_promo_codes.append(promo_code)
self.promo_codes = ",".join(current_promo_codes)
class ActivationCode(db.Model, ModelMixin):
"""For activate user account"""
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
code = db.Column(db.String(128), unique=True, nullable=False)
user = db.relationship(User)
# the activation code is valid for 1h
expired = db.Column(ArrowType, default=arrow.now().shift(hours=1))
class ResetPasswordCode(db.Model, ModelMixin):
"""For resetting password"""
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
code = db.Column(db.String(128), unique=True, nullable=False)
user = db.relationship(User)
# the activation code is valid for 1h
expired = db.Column(ArrowType, default=arrow.now().shift(hours=1), nullable=False)
class Partner(db.Model, ModelMixin):
email = db.Column(db.String(128))
name = db.Column(db.String(128))
website = db.Column(db.String(1024))
additional_information = db.Column(db.Text)
# If apply from a authenticated user, set user_id to the user who has applied for partnership
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=True)
# <<< OAUTH models >>>
client_scope = db.Table(
"client_scope",
db.Column(
"client_id",
db.Integer,
db.ForeignKey("client.id", ondelete="cascade"),
primary_key=True,
nullable=False,
),
db.Column(
"scope_id",
db.Integer,
db.ForeignKey("scope.id", ondelete="cascade"),
primary_key=True,
nullable=False,
),
)
def generate_oauth_client_id(client_name) -> str:
oauth_client_id = convert_to_id(client_name) + "-" + random_string()
# check that the client does not exist yet
if not Client.get_by(oauth_client_id=oauth_client_id):
LOG.debug("generate oauth_client_id %s", oauth_client_id)
return oauth_client_id
# Rerun the function
LOG.warning(
"client_id %s already exists, generate a new client_id", oauth_client_id
)
return generate_oauth_client_id(client_name)
class Client(db.Model, ModelMixin):
oauth_client_id = db.Column(db.String(128), unique=True, nullable=False)
oauth_client_secret = db.Column(db.String(128), nullable=False)
name = db.Column(db.String(128), nullable=False)
home_url = db.Column(db.String(1024))
published = db.Column(db.Boolean, default=False, nullable=False)
# user who created this client
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
icon_id = db.Column(db.ForeignKey(File.id), nullable=True)
scopes = db.relationship("Scope", secondary=client_scope, lazy="subquery")
icon = db.relationship(File)
def nb_user(self):
return ClientUser.filter_by(client_id=self.id).count()
@classmethod
def create_new(cls, name, user_id) -> "Client":
# generate a client-id
oauth_client_id = generate_oauth_client_id(name)
oauth_client_secret = random_string(40)
client = Client.create(
name=name,
oauth_client_id=oauth_client_id,
oauth_client_secret=oauth_client_secret,
user_id=user_id,
)
# By default, add email and name scope
client.scopes.append(Scope.get_by(name=ScopeE.NAME.value))
client.scopes.append(Scope.get_by(name=ScopeE.EMAIL.value))
return client
def get_icon_url(self):
if self.icon_id:
return self.icon.get_url()
else:
return URL + "/static/default-icon.svg"
class RedirectUri(db.Model, ModelMixin):
"""Valid redirect uris for a client"""
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
uri = db.Column(db.String(1024), nullable=False)
client = db.relationship(Client, backref="redirect_uris")
class AuthorizationCode(db.Model, ModelMixin):
code = db.Column(db.String(128), unique=True)
client_id = db.Column(db.ForeignKey(Client.id))
user_id = db.Column(db.ForeignKey(User.id))
code = db.Column(db.String(128), unique=True, nullable=False)
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
scope = db.Column(db.String(128))
redirect_uri = db.Column(db.String(1024))
user = db.relationship(User, lazy=False)
client = db.relationship(Client, lazy=False)
class OauthToken(db.Model, ModelMixin):
access_token = db.Column(db.String(128), unique=True)
client_id = db.Column(db.ForeignKey(Client.id))
user_id = db.Column(db.ForeignKey(User.id))
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
scope = db.Column(db.String(128))
redirect_uri = db.Column(db.String(1024))
user = db.relationship(User)
client = db.relationship(Client)
class Scope(db.Model, ModelMixin):
name = db.Column(db.String(128), unique=True, nullable=False)
def generate_email() -> str:
"""generate an email address that does not exist before"""
random_email = random_string(40) + "@" + EMAIL_DOMAIN
# check that the client does not exist yet
if not GenEmail.get_by(email=random_email):
LOG.debug("generate email %s", random_email)
return random_email
# Rerun the function
LOG.warning("email %s already exists, generate a new email", random_email)
return generate_email()
class GenEmail(db.Model, ModelMixin):
"""Generated email"""
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
email = db.Column(db.String(128), unique=True, nullable=False)
enabled = db.Column(db.Boolean(), default=True, nullable=False)
@classmethod
def create_new_gen_email(cls, user_id):
random_email = generate_email()
return GenEmail.create(user_id=user_id, email=random_email)
def __repr__(self):
return f"<GenEmail {self.id} {self.email}>"
class ClientUser(db.Model, ModelMixin):
__table_args__ = (
db.UniqueConstraint("user_id", "client_id", name="uq_client_user"),
)
user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False)
client_id = db.Column(db.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
# Null means client has access to user original email
gen_email_id = db.Column(
db.ForeignKey(GenEmail.id, ondelete="cascade"), nullable=True
)
gen_email = db.relationship(GenEmail, backref="client_users")
user = db.relationship(User)
client = db.relationship(Client)
def get_email(self):
return self.gen_email.email if self.gen_email_id else self.user.email
def get_user_info(self) -> dict:
"""return user info according to client scope
Return dict with key being scope name
"""
res = {"id": self.id, "client": self.client.name, "email_verified": True}
for scope in self.client.scopes:
if scope.name == ScopeE.NAME.value:
res[ScopeE.NAME.value] = self.user.name
elif scope.name == ScopeE.EMAIL.value:
# Use generated email
if self.gen_email_id:
LOG.debug(
"Use gen email for user %s, client %s", self.user, self.client
)
res[ScopeE.EMAIL.value] = self.gen_email.email
# Use user original email
else:
res[ScopeE.EMAIL.value] = self.user.email
return res

View File

@ -1,9 +1,6 @@
import subprocess
from app.config import SHA1
from app.monitor.base import monitor_bp
SHA1 = subprocess.getoutput("git rev-parse HEAD")
@monitor_bp.route("/git")
def git_sha1():

1
app/oauth/__init__.py Normal file
View File

@ -0,0 +1 @@
from .views import authorize, token, user_info

5
app/oauth/base.py Normal file
View File

@ -0,0 +1,5 @@
from flask import Blueprint
oauth_bp = Blueprint(
name="oauth", import_name=__name__, url_prefix="/oauth", template_folder="templates"
)

View File

@ -0,0 +1,81 @@
{% extends 'default.html' %}
{% block title %}
Authorization
{% endblock %}
{% block default_content %}
<div class="col-md-6 offset-md-3">
<form class="card" method="post">
<div class="card-body p-6">
<!-- User has already authorized this client -->
{% if client_user %}
<div class="card-title">
You have already authorized <b>{{ client.name }}</b>.
</div>
<div>
<b>{{ client.name }}</b> has access to the following information:
</div>
<ul>
{% for scope in client.scopes %}
<li>{{ scope.name }}: {{ user_info[scope.name] }}</li>
{% endfor %}
</ul>
{% else %}
<div class="card-title">
<b>{{ client.name }}</b> will receive your following information:
</div>
<ul>
{% for scope in client.scopes %}
<li>{{ scope.name }}</li>
{% endfor %}
</ul>
{% endif %}
{% if client_user %}
<div class="form-footer">
<div class="btn-group" role="group" aria-label="Basic example">
<button type="submit" name="button" value="allow"
class="btn btn-success">Allow
</button>
<a class="btn btn-light" href="javascript:history.back()">
Cancel
</a>
</div>
</div>
{% else %}
<div class="form-group">
<div class="custom-controls-stacked">
<label class="custom-control custom-checkbox">
<input type="checkbox" name="gen-email"
class="custom-control-input" checked>
<span class="custom-control-label">Generate a new email</span>
</label>
</div>
<small class="form-text text-muted">
If checked, a new random email address will be generated for this app.
</small>
</div>
<div class="form-footer">
<div class="btn-group btn-block" role="group" aria-label="Basic example">
<button type="submit" name="button" value="allow"
class="btn btn-success">Allow
</button>
<button type="submit" name="button" value="deny"
class="btn btn-light">Deny
</button>
</div>
</div>
{% endif %}
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,39 @@
{% extends "single.html" %}
{% block single_content %}
<div class="row">
<b>{{ client.name }}</b> &nbsp; would like to have access to your following data:
<ul class="mt-3">
{% for scope in client.scopes %}
<li>{{ scope.name }}</li>
{% endfor %}
</ul>
<label>
In order to accept the request, you need to login or sign up.
</label>
</div>
<div class="row mt-4">
<div class="btn-group w-100">
<a href="{{ url_for('auth.login', next=next) }}" class="btn btn-success">
Login
</a>
<a href="{{ url_for('auth.register', next=next) }}" class="btn btn-info">
Sign Up
</a>
</div>
</div>
<hr>
<div class="row">
<a class="btn btn-block btn-secondary" href="javascript:history.back()">
<i class="fe fe-arrow-left mr-2"></i>Cancel
</a>
</div>
{% endblock %}

View File

View File

@ -0,0 +1,197 @@
import random
from typing import Dict
from urllib.parse import urlparse
from flask import request, render_template, redirect
from flask_login import current_user
from app.extensions import db
from app.jose_utils import make_id_token
from app.log import LOG
from app.models import (
Client,
AuthorizationCode,
ClientUser,
GenEmail,
RedirectUri,
OauthToken,
)
from app.oauth.base import oauth_bp
from app.oauth_models import get_response_types, ResponseType
from app.utils import random_string, encode_url
@oauth_bp.route("/authorize", methods=["GET", "POST"])
def authorize():
"""
Redirected from client when user clicks on "Login with Server".
This is a GET request with the following field in url
- client_id
- (optional) state
- response_type: must be code
"""
oauth_client_id = request.args.get("client_id")
state = request.args.get("state")
scope = request.args.get("scope")
redirect_uri = request.args.get("redirect_uri")
try:
response_types: [ResponseType] = get_response_types(request)
except ValueError:
return (
"response_type must be code, token, id_token or certain combination of these."
" Please see /.well-known/openid-configuration to see what response_type are supported ",
400,
)
if not redirect_uri:
LOG.d("no redirect uri")
return "redirect_uri must be set", 400
client = Client.get_by(oauth_client_id=oauth_client_id)
if not client:
return f"no such client with oauth-client-id {oauth_client_id}", 400
# check if redirect_uri is valid
# allow localhost by default
# todo: only allow https
hostname, scheme = get_host_name_and_scheme(redirect_uri)
if hostname != "localhost":
if not RedirectUri.get_by(client_id=client.id, uri=redirect_uri):
return f"{redirect_uri} is not authorized", 400
# redirect from client website
if request.method == "GET":
if current_user.is_authenticated:
# user has already allowed this client
client_user: ClientUser = ClientUser.get_by(
client_id=client.id, user_id=current_user.id
)
user_info = {}
if client_user:
LOG.debug("user %s has already allowed client %s", current_user, client)
user_info = client_user.get_user_info()
return render_template(
"oauth/authorize.html", client=client, user_info=user_info
)
else:
# after user logs in, redirect user back to this page
return render_template(
"oauth/authorize_nonlogin_user.html", client=client, next=request.url
)
else: # user allows or denies
gen_new_email = request.form.get("gen-email") == "on"
if request.form.get("button") == "deny":
LOG.debug("User %s denies Client %s", current_user, client)
final_redirect_uri = f"{redirect_uri}?error=deny&state={state}"
return redirect(final_redirect_uri)
LOG.debug("User %s allows Client %s", current_user, client)
client_user = ClientUser.get_by(client_id=client.id, user_id=current_user.id)
# user has already allowed this client
if client_user:
LOG.d("user %s has already allowed client %s", current_user, client)
# User cannot choose to gen new email
gen_new_email = False
else:
client_user = ClientUser.create(
client_id=client.id, user_id=current_user.id
)
db.session.flush()
LOG.d("create client-user for client %s, user %s", client, current_user)
redirect_args = {}
if state:
redirect_args["state"] = state
else:
LOG.warning(
"more security reason, state should be added. client %s", client
)
if scope:
redirect_args["scope"] = scope
for response_type in response_types:
if response_type == ResponseType.CODE:
# Create authorization code
auth_code = AuthorizationCode.create(
client_id=client.id,
user_id=current_user.id,
code=random_string(),
scope=scope,
redirect_uri=redirect_uri,
)
db.session.add(auth_code)
redirect_args["code"] = auth_code.code
elif response_type == ResponseType.TOKEN:
# create access-token
oauth_token = OauthToken.create(
client_id=client.id,
user_id=current_user.id,
scope=scope,
redirect_uri=redirect_uri,
access_token=generate_access_token(),
)
db.session.add(oauth_token)
redirect_args["access_token"] = oauth_token.access_token
elif response_type == ResponseType.ID_TOKEN:
redirect_args["id_token"] = make_id_token(client_user)
if gen_new_email:
client_user.gen_email_id = create_or_choose_gen_email(current_user).id
db.session.commit()
# construct redirect_uri with redirect_args
return redirect(construct_url(redirect_uri, redirect_args))
def create_or_choose_gen_email(user) -> GenEmail:
can_create_new_email = user.can_create_new_email()
if can_create_new_email:
gen_email = GenEmail.create_new_gen_email(user_id=user.id)
db.session.flush()
LOG.debug("generate email %s for user %s", gen_email.email, user)
else: # need to reuse one of the gen emails created
LOG.d("pick a random email for gen emails for user %s", current_user)
gen_emails = GenEmail.filter_by(user_id=current_user.id).all()
gen_email = random.choice(gen_emails)
return gen_email
def construct_url(url, args: Dict[str, str]):
for i, (k, v) in enumerate(args.items()):
# make sure to escape v
v = encode_url(v)
if i == 0:
url += f"?{k}={v}"
else:
url += f"&{k}={v}"
return url
def generate_access_token() -> str:
"""generate an access-token that does not exist before"""
access_token = random_string(40)
if not OauthToken.get_by(access_token=access_token):
return access_token
# Rerun the function
LOG.warning("access token already exists, generate a new one")
return generate_access_token()
def get_host_name_and_scheme(url: str) -> (str, str):
"""http://localhost:5000?a=b -> (localhost, http) """
url_comp = urlparse(url)
return url_comp.hostname, url_comp.scheme

88
app/oauth/views/token.py Normal file
View File

@ -0,0 +1,88 @@
from flask import request, jsonify
from app.extensions import db
from app.jose_utils import make_id_token
from app.log import LOG
from app.models import Client, AuthorizationCode, OauthToken, ClientUser
from app.oauth.base import oauth_bp
from app.oauth.views.authorize import generate_access_token
from app.oauth_models import ScopeE
@oauth_bp.route("/token", methods=["POST"])
def get_access_token():
"""
Calls by client to exchange the access token given the authorization code.
The client authentications using Basic Authentication.
The form contains the following data:
- grant_type: must be "authorization_code"
- code: the code obtained in previous step
"""
# Basic authentication
oauth_client_id = (
request.authorization and request.authorization.username
) or request.form.get("client_id")
oauth_client_secret = (
request.authorization and request.authorization.password
) or request.form.get("client_secret")
client = Client.filter_by(
oauth_client_id=oauth_client_id, oauth_client_secret=oauth_client_secret
).first()
if not client:
return jsonify(error="wrong client-id or client-secret"), 400
# Get code from form data
grant_type = request.form.get("grant_type")
code = request.form.get("code")
# sanity check
if grant_type != "authorization_code":
return jsonify(error="grant_type must be authorization_code"), 400
auth_code: AuthorizationCode = AuthorizationCode.filter_by(code=code).first()
if not auth_code:
return jsonify(error=f"no such authorization code {code}"), 400
if auth_code.client_id != client.id:
return jsonify(error=f"are you sure this code belongs to you?"), 400
LOG.debug(
"Create Oauth token for user %s, client %s", auth_code.user, auth_code.client
)
# Create token
oauth_token = OauthToken.create(
client_id=auth_code.client_id,
user_id=auth_code.user_id,
scope=auth_code.scope,
redirect_uri=auth_code.redirect_uri,
access_token=generate_access_token(),
)
db.session.add(oauth_token)
# Auth code can be used only once
db.session.delete(auth_code)
db.session.commit()
client_user: ClientUser = ClientUser.get_by(
client_id=auth_code.client_id, user_id=auth_code.user_id
)
user_data = client_user.get_user_info()
res = {
"access_token": oauth_token.access_token,
"token_type": "bearer",
"expires_in": 3600,
"scope": "",
"user": user_data,
}
if oauth_token.scope and ScopeE.OPENID.value in oauth_token.scope:
res["id_token"] = make_id_token(client_user)
return jsonify(res)

View File

@ -0,0 +1,30 @@
from flask import request, jsonify
from flask_cors import cross_origin
from app.models import OauthToken, ClientUser
from app.oauth.base import oauth_bp
@oauth_bp.route("/user_info")
@oauth_bp.route("/me")
@oauth_bp.route("/userinfo")
@cross_origin()
def user_info():
"""
Call by client to get user information
Usually bearer token is used.
"""
if "AUTHORIZATION" in request.headers:
access_token = request.headers["AUTHORIZATION"].replace("Bearer ", "")
else:
access_token = request.args.get("access_token")
oauth_token: OauthToken = OauthToken.get_by(access_token=access_token)
if not oauth_token:
return jsonify(error="Invalid access token"), 400
client_user = ClientUser.get_or_create(
client_id=oauth_token.client_id, user_id=oauth_token.user_id
)
return jsonify(client_user.get_user_info())

57
app/oauth_models.py Normal file
View File

@ -0,0 +1,57 @@
import enum
from typing import Set, Union
import flask
class ScopeE(enum.Enum):
"""ScopeE to distinguish with Scope model"""
EMAIL = "email"
NAME = "name"
OPENID = "openid"
class ResponseType(enum.Enum):
CODE = "code"
TOKEN = "token"
ID_TOKEN = "id_token"
def get_scopes(request: flask.Request) -> Set[ScopeE]:
scope_strs = _split_arg(request.args.getlist("scope"))
return set([ScopeE(scope_str) for scope_str in scope_strs])
def get_response_types(request: flask.Request) -> Set[ResponseType]:
response_type_strs = _split_arg(request.args.getlist("response_type"))
return set([ResponseType(r) for r in response_type_strs])
def _split_arg(arg_input: Union[str, list]) -> Set[str]:
"""convert input response_type/scope into a set of string.
arg_input = request.args.getlist(response_type|scope)
Take into account different variations and their combinations
- the split character is " " or ","
- the response_type/scope passed as a list ?scope=scope_1&scope=scope_2
"""
res = set()
if type(arg_input) is str:
if " " in arg_input:
for x in arg_input.split(" "):
if x:
res.add(x.lower())
elif "," in arg_input:
for x in arg_input.split(","):
if x:
res.add(x.lower())
else:
res.add(arg_input)
else:
for arg in arg_input:
res = res.union(_split_arg(arg))
return res

1
app/partner/__init__.py Normal file
View File

@ -0,0 +1 @@
from .views import become

8
app/partner/base.py Normal file
View File

@ -0,0 +1,8 @@
from flask import Blueprint
partner_bp = Blueprint(
name="partner",
import_name=__name__,
url_prefix="/partner",
template_folder="templates",
)

View File

@ -0,0 +1,58 @@
{% from "_formhelpers.html" import render_field, render_field_errors %}
{% extends "single.html" %}
{% block title %}
Become Partner
{% endblock %}
{% block single_content %}
{% if error %}
<div class="text-danger text-center mb-4">{{ error }}</div>
{% endif %}
<form class="card" method="post">
{{ form.csrf_token }}
<div class="card-body p-6">
<div class="card-title">Together, let's create the best login experience for users!</div>
<p class="text-muted">Becoming a partner will give you access to technical resources on SimpleLogin.</p>
<div class="form-group">
<label class="form-label">Your Email</label>
{{ form.email(class="form-control", type="email", placeholder="partner@my-app.com", value=current_user.email) }}
{{ render_field_errors(form.email) }}
</div>
<div class="form-group">
<label class="form-label">Your Business Name</label>
{{ form.name(class="form-control", placeholder="My App Inc", value=current_user.name) }}
{{ render_field_errors(form.name) }}
</div>
<div class="form-group">
<label class="form-label">Your Website/App URL</label>
{{ form.website(class="form-control", type="url", placeholder="https://my-app.com") }}
{{ render_field_errors(form.website) }}
</div>
<!-- Possibility to bypass using promo code. Only applied for user already authenticated -->
{% if current_user.is_authenticated %}
<hr>
<h4 class="text-center">
Or if you have a <em>partner code</em>, you can become a partner right away!
</h4>
<br>
<div class="form-group">
<label class="form-label">Partner Code</label>
{{ form.partner_code(class="form-control", type="text", placeholder="Partner Code") }}
{{ render_field_errors(form.partner_code) }}
</div>
{% endif %}
<div class="form-footer">
<button type="submit" class="btn btn-primary btn-block">Become a Partner</button>
</div>
</div>
</form>
{% endblock %}

View File

View File

@ -0,0 +1,77 @@
from flask import request, render_template, redirect, url_for, flash
from flask_login import current_user
from flask_wtf import FlaskForm
from wtforms import StringField
from app.config import PARTNER_CODES
from app.email_utils import notify_admin
from app.extensions import db
from app.models import Partner
from app.partner.base import partner_bp
class BecomePartnerForm(FlaskForm):
email = StringField("Email")
name = StringField("Name")
website = StringField("Website")
additional_information = StringField("Additional Information")
partner_code = StringField("Partner Code")
@partner_bp.route("/become", methods=["GET", "POST"])
def become():
form = BecomePartnerForm(request.form)
if form.validate_on_submit():
# bypass the application
if form.partner_code.data:
if not current_user.is_authenticated:
raise Exception("only authenticated user can enter partner code")
if form.partner_code.data in PARTNER_CODES:
notify_admin(
f"User {current_user.name} has become partner!",
{current_user.email},
)
current_user.is_developer = True
db.session.commit()
flash(
"Congratulations, you are now a SimpleLogin partner! "
"You will have access to tech resources on SimpleLogin.",
"success",
)
return redirect(url_for("developer.index"))
else:
error = (
"The partner code is unknown. Are you sure this is the right code?"
)
return render_template("partner/become.html", form=form, error=error)
else:
partner = Partner.create(
email=form.email.data,
name=form.name.data,
website=form.website.data,
additional_information=form.additional_information.data,
)
if current_user.is_authenticated:
partner.user_id = current_user.id
db.session.commit()
notify_admin(
f"New partner {partner.name} {partner.email} has signed up!",
partner.website,
)
flash(
"Your request has been submitted, we'll come back to you asap!",
"success",
)
return redirect(url_for("partner.become"))
return render_template("partner/become.html", form=form)

38
app/s3.py Normal file
View File

@ -0,0 +1,38 @@
from io import BytesIO
import boto3
from app.config import AWS_REGION, BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
session = boto3.Session(
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
region_name=AWS_REGION,
)
def upload_from_bytesio(key: str, bs: BytesIO, content_type="string") -> None:
bs.seek(0)
session.resource("s3").Bucket(BUCKET).put_object(
Key=key, Body=bs, ContentType=content_type
)
def delete_file(key: str) -> None:
o = session.resource("s3").Bucket(BUCKET).Object(key)
o.delete()
def get_url(key: str) -> str:
"""by default the link will expire in 1h (3600 seconds)"""
s3_client = session.client("s3")
return s3_client.generate_presigned_url(
ClientMethod="get_object", Params={"Bucket": BUCKET, "Key": key}
)
if __name__ == "__main__":
with open("/tmp/1.png", "rb") as f:
upload_from_bytesio("1.png", BytesIO(f.read()))
print(get_url(BUCKET, "1.png"))

View File

@ -1,8 +1,23 @@
import random
import string
import urllib.parse
from unidecode import unidecode
def random_string(length=10):
"""Generate a random string of fixed length """
letters = string.ascii_lowercase
return "".join(random.choice(letters) for _ in range(length))
def convert_to_id(s: str):
"""convert a string to id-like: remove space, remove special accent"""
s = s.replace(" ", "")
s = s.lower()
s = unidecode(s)
return s
def encode_url(url):
return urllib.parse.quote(url, safe="")

37
cron.py Normal file
View File

@ -0,0 +1,37 @@
import arrow
import stripe
from app.extensions import db
from app.log import LOG
from app.models import User, PlanEnum
from server import create_app
def downgrade_expired_plan():
"""set user plan to free when plan is expired, ie plan_expiration < now
"""
for user in User.query.filter(
User.plan != PlanEnum.free, User.plan_expiration < arrow.now()
).all():
LOG.d("set user %s to free plan", user)
user.plan_expiration = None
user.plan = PlanEnum.free
if user.stripe_customer_id:
LOG.d("delete user %s on stripe", user.stripe_customer_id)
stripe.Customer.delete(user.stripe_customer_id)
user.stripe_card_token = None
user.stripe_customer_id = None
user.stripe_subscription_id = None
db.session.commit()
if __name__ == "__main__":
LOG.d("Start running cronjob")
app = create_app()
with app.app_context():
downgrade_expired_plan()

6
crontab.yml Normal file
View File

@ -0,0 +1,6 @@
jobs:
- name: downgrade_expired_plan
command: python /code/cron.py
shell: /bin/bash
schedule: "0 0 * * *"
captureStderr: true

51
local_data/jwtRS256.key Normal file
View File

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKQIBAAKCAgEAveotF/UeMVHdm1FSgxflIbJr0yJZ1vyDGlQRK9DFx8HU8TVp
9iqbY4CQEcOaa7cIVI5U0fWHW7kqByJ0BwLQciHienNZKnQishmMAkqNwfK3iJNc
GFlNMhhrRGhEpWLox5qfpizK4xd7LK1tu2X5mEMWZtJs+wLr0SyVOPhdYCvOnSeT
/SMSgDxvFCM1tlAv/wOV0SIF6xEIKb4lyHN3YKcs4z1IkqyPtHSeaq2BHeaFTPGq
fAL2k4W7ziHxv7dsjCN9j11UnVQRKo+/kNJtOftH08l1J2FuG3YdTxX0R0KAl7wN
QgvbGKjns1pj2az5uQKsne5SZBFjSe86Hbk8OKUJoJqy3LV29r7eZjj2wQoIBqbh
BkMgPJY6rC9umWkaQKi79a24KeOEZPpTvbKy+LvoWh4UAs+7hfrKHRimQj2k74Jk
SYxwrFejpxMYt+GJqPhympcz4gv4qIuiH2CV623/K9H+WrddIHpGCjpqkGtTZSQZ
xyPMcEp+I26MuS1dFoaK39WFx2M62OhTenhDmOgPyWp1a71eYxzwfYtBECPJ4Agq
SJrNIHu8/h9uZ+OTGXGN5k97BiWvqLEuz12PwH1QXX/sVzfjYi3khN0yxLYPooht
fSQa8hg2VPJHVhVZNNCytC84E0xU5yNfIuUdIZjxJVcfV4C6dtt/QVQl9lsCAwEA
AQKCAgAvuig2+xzpXB+LJvbLhzfILiS23M0jIDZ6aWIfVso9l1LCg5/rg22lpeuO
609lfowTY+mhEklAHdqYDGqIUIa+CBH4oABqkOEfTRhIgx/4+9xv8EiWveqOimB6
wpFt1tuVPiCdDGi4hXApHDSVgd0mDMYWdQ96TZOh78hYluIwhxHXoNiqJyRBIe7w
aqDW/nPxbJ87/YbrOk6I3wZzx8Dag2jeespAQimjOiONv6jRMNuTKLCllcEN9e/q
r9EnUxtuZITrgJMBLt1ZiuKjrJ5SkfnNGbXdfbjEIfzfoS7Qsb/LYjEaxgv7uIby
JecuDzB69FcZIYmHKG+BZyN90M13J/bgpaMyYtdCZg+lJRO2gJvY34cw9oE9/M6O
Lpfhx2viMQ2Mij1XNn9Qz0NIe89m8A0s1YbuDWieXU9iP4iA9YDPvsI0CwZoDT+W
rLsSL3z7ltj8Ku6ySb655TFDPZysbMM2Oc8MmC9n7xuDfhuAr8eOqNgTtLLB0cnz
aasASouAVtl5dN1hs5LakUq414wWhLzDqXd8kwRKFkT1WIBHy8+mk9MQj/m2rM+2
avrIVKvdewRAB3TCwy0BdnWeiJER6r+Ae/Kglbo9NuDHJIkqwLZNbtX5xleJ+5Tp
SoG/Lmz6AH+clL0IQYg6zLViI1tgPlYPt1ZZKp7bn+qDCn1/oQKCAQEA4eJwQ/gf
3BtFvxmwpWKJnhKSACiJEfHimHIp/kAYtmtlhaSbEhYp3V699iqc6ziVHvAGssVi
QCLGAuwdyaxAuWUYg+LSUic+hJagv5U+iJLYyYqI2PT1RjMnc/VoG+yqLpv7XyHd
5/b64A2XNAsaX2DaUpdbTskxCZQ/l1ifRLR0mpxQQtZvXt4+2I1T3fvGcY6562G7
dCSunm6fP5yvVKjg+j1ezCapF3aHJAV2OG6Mvu+shZxyACDQRmHpl9ujT64Ibcc6
p1SmeHHr8/gOJY54gg/Iujne8GVGix7lTS1dqWXEF4xLlTomYD1FNZnt6bUqjqga
9YZIvzID9FJ5cwKCAQEA1zwR1ajM27H4GvAGi+MfE2MTa99PEGGbJghAlMBzF8Bl
He9SCADawOCejTiVBuWghU+qg3cb2JP/Qxnokd0eXXTiuHfJB3PwZPpfsiVUMKhN
X1ypA06qvL2VLQNpCkgLuZB3pxkxn36EYM/NPqfZmQv25qsLC/eM5mRyWTu31kIw
C4zRsHvy0IgHJJz9YJmcS/0PRnMvy96yXx/biYK80x3Zui7foCvRmPYeCCr/qoSb
A9olFtv2yUPKt1m0lwxknl0tEhi7EiVNnOuWP416MhvJq0pz12CuYr5MHo3Zwmrw
pyK0hlCmMePRQTe080oSDZP8UM/DkeMaFB+uw3N1eQKCAQBmoMsBFqrjBkEaIkHv
4mVEPIu5JrGgRZX+TWBm9BhGSWVG4xLRlOBQg8srHRFOjdayx7tDXgrVuPbePQkL
qAeAND5/LX8BdHMjKoy+fsB6rL1yVE74w9LsojE6rjUu+sgXhScggfKggcZaJdKd
Aq5ox0hqXfpOQXrWL1T1Hn6+aH7SAFM3CtZu8+r52LxSDyKKVZ6DI1RX4JK1yOzx
qe6/ODt/doKrnqUU0/VymEiuOwwXdC2eRwZEqKP4VmQbat84RInv1qT/gaZg8uGR
ZxKGXcTC0wkQE1sHPfxfGRp1hjcXz/TX/hYZJuJot23KfLVribRcPGSDSQ+kTsUd
LJuhAoIBAQCrjvfwREI2A59taVDug7SrcVdzrmWI+yP9pqpDZzrV/ccbmzzZoES9
ZM08Z5NyEepnGF8jtvb9JMpco/QbABNKDvcAbopQZHuDIYbRqqt2tVAm6ObW+gdh
tgOIA6XgShj+akbVbGF/bgr6V+iTPptVQJImvsNpYIJwyjPTKKSaJdvB+RbTA5lB
2otHBdN5Ajfw4d8hGoNIj1PCOtR0wT7dUHfRzbb2JrdEozjA7fUn59bftSvHEsGd
H2ofx2MI2xoAmOhp+khyaEV7BNWYBp8V/cw7unangCrADksCN7MRIsh7kFAwl2xB
bAPJZivXmHzXUdPWXiTWzhxlWfOlWwyRAoIBAQCzAvoyoh/T6l9wrA7fbmiyHIJa
82wKBkKXsbXqsxRuFYz4J9d5AmxE/QjIQpP8jfQwNDR6vB2Gzd8aQbb3edLV5gzM
19X1brn5qluQOuzK+J76RKvrJKvC4YvYKwSFxujXQgELTQtPsqMuYiXEdAlS9V6/
p8l5KlA9fEySPJGmQjfQkEvS082rMGQil2jjazuiRKxGabJ/kOpWeXURhw11MbbT
AIfult3Mt6XxdGEWUm0ERHiuF3sr5QpYrwCPxOn0z4T4j4hJPMgbU+om8d1Oqp1k
4+L6jF/eCYArqJOTS5oQ2SchKLrF5OYRNUDWLQtt3NiGxeJVfB++sp4losCx
-----END RSA PRIVATE KEY-----

View File

@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAveotF/UeMVHdm1FSgxfl
IbJr0yJZ1vyDGlQRK9DFx8HU8TVp9iqbY4CQEcOaa7cIVI5U0fWHW7kqByJ0BwLQ
ciHienNZKnQishmMAkqNwfK3iJNcGFlNMhhrRGhEpWLox5qfpizK4xd7LK1tu2X5
mEMWZtJs+wLr0SyVOPhdYCvOnSeT/SMSgDxvFCM1tlAv/wOV0SIF6xEIKb4lyHN3
YKcs4z1IkqyPtHSeaq2BHeaFTPGqfAL2k4W7ziHxv7dsjCN9j11UnVQRKo+/kNJt
OftH08l1J2FuG3YdTxX0R0KAl7wNQgvbGKjns1pj2az5uQKsne5SZBFjSe86Hbk8
OKUJoJqy3LV29r7eZjj2wQoIBqbhBkMgPJY6rC9umWkaQKi79a24KeOEZPpTvbKy
+LvoWh4UAs+7hfrKHRimQj2k74JkSYxwrFejpxMYt+GJqPhympcz4gv4qIuiH2CV
623/K9H+WrddIHpGCjpqkGtTZSQZxyPMcEp+I26MuS1dFoaK39WFx2M62OhTenhD
mOgPyWp1a71eYxzwfYtBECPJ4AgqSJrNIHu8/h9uZ+OTGXGN5k97BiWvqLEuz12P
wH1QXX/sVzfjYi3khN0yxLYPoohtfSQa8hg2VPJHVhVZNNCytC84E0xU5yNfIuUd
IZjxJVcfV4C6dtt/QVQl9lsCAwEAAQ==
-----END PUBLIC KEY-----

45
migrations/alembic.ini Normal file
View File

@ -0,0 +1,45 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

96
migrations/env.py Normal file
View File

@ -0,0 +1,96 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
from flask import current_app
config.set_main_option(
'sqlalchemy.url', current_app.config.get(
'SQLALCHEMY_DATABASE_URI').replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
migrations/script.py.mako Normal file
View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: 0256244cd7c8
Revises: 3cd10cfce8c3
Create Date: 2019-06-28 11:19:50.401222
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0256244cd7c8'
down_revision = '3cd10cfce8c3'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activation_code', sa.Column('expired', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('activation_code', 'expired')
# ### end Alembic commands ###

View File

@ -0,0 +1,24 @@
"""empty message
Revision ID: 213fcca48483
Revises: 0256244cd7c8
Create Date: 2019-06-30 11:11:51.823062
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '213fcca48483'
down_revision = '0256244cd7c8'
branch_labels = None
depends_on = None
def upgrade():
op.alter_column('users', 'trial_expiration', new_column_name='plan_expiration')
def downgrade():
op.alter_column('users', 'plan_expiration', new_column_name='trial_expiration')

View File

@ -0,0 +1,28 @@
"""empty message
Revision ID: 2fe19381f386
Revises: d03e433dc248
Create Date: 2019-07-01 11:47:24.934574
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2fe19381f386'
down_revision = 'd03e433dc248'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('is_developer', sa.Boolean(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'is_developer')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""empty message
Revision ID: 3cd10cfce8c3
Revises: 5e549314e1e2
Create Date: 2019-06-27 10:40:12.606337
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3cd10cfce8c3'
down_revision = '5e549314e1e2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('authorization_code', sa.Column('redirect_uri', sa.String(length=1024), nullable=True))
op.add_column('authorization_code', sa.Column('scope', sa.String(length=128), nullable=True))
op.add_column('oauth_token', sa.Column('redirect_uri', sa.String(length=1024), nullable=True))
op.add_column('oauth_token', sa.Column('scope', sa.String(length=128), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('oauth_token', 'scope')
op.drop_column('oauth_token', 'redirect_uri')
op.drop_column('authorization_code', 'scope')
op.drop_column('authorization_code', 'redirect_uri')
# ### end Alembic commands ###

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: 590d89f981c0
Revises: b20ee72fd9a4
Create Date: 2019-07-01 21:46:58.613910
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '590d89f981c0'
down_revision = 'b20ee72fd9a4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('promo_codes', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'promo_codes')
# ### end Alembic commands ###

View File

@ -0,0 +1,172 @@
"""empty message
Revision ID: 5e549314e1e2
Revises:
Create Date: 2019-06-23 16:02:14.692075
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
from sqlalchemy.dialects.postgresql import ENUM
revision = '5e549314e1e2'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# alembic cannot handle enum for now
enum = ENUM("free", "trial", "monthly", "yearly", name="plan_enum", create_type=False)
enum.create(op.get_bind(), checkfirst=False)
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('file',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('path', sa.String(length=128), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('path')
)
op.create_table('scope',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('name', sa.String(length=128), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('users',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('email', sa.String(length=128), nullable=False),
sa.Column('salt', sa.String(length=128), nullable=False),
sa.Column('password', sa.String(length=128), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('activated', sa.Boolean(), nullable=False),
sa.Column('plan', enum, server_default='free', nullable=False),
sa.Column('trial_expiration', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('stripe_customer_id', sa.String(length=128), nullable=True),
sa.Column('stripe_card_token', sa.String(length=128), nullable=True),
sa.Column('stripe_subscription_id', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('stripe_card_token'),
sa.UniqueConstraint('stripe_customer_id'),
sa.UniqueConstraint('stripe_subscription_id')
)
op.create_table('activation_code',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=128), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_table('client',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('oauth_client_id', sa.String(length=128), nullable=False),
sa.Column('oauth_client_secret', sa.String(length=128), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('home_url', sa.String(length=1024), nullable=True),
sa.Column('published', sa.Boolean(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('icon_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['icon_id'], ['file.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('oauth_client_id')
)
op.create_table('gen_email',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=128), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('authorization_code',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('code', sa.String(length=128), nullable=False),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
op.create_table('client_scope',
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('scope_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['scope_id'], ['scope.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('client_id', 'scope_id')
)
op.create_table('client_user',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('gen_email_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['gen_email_id'], ['gen_email.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'client_id', name='uq_client_user')
)
op.create_table('oauth_token',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('access_token', sa.String(length=128), nullable=True),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('access_token')
)
op.create_table('redirect_uri',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('uri', sa.String(length=1024), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['client.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('redirect_uri')
op.drop_table('oauth_token')
op.drop_table('client_user')
op.drop_table('client_scope')
op.drop_table('authorization_code')
op.drop_table('gen_email')
op.drop_table('client')
op.drop_table('activation_code')
op.drop_table('users')
op.drop_table('scope')
op.drop_table('file')
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""empty message
Revision ID: b20ee72fd9a4
Revises: 2fe19381f386
Create Date: 2019-07-01 13:15:05.391100
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b20ee72fd9a4'
down_revision = '2fe19381f386'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('partner',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('email', sa.String(length=128), nullable=True),
sa.Column('name', sa.String(length=128), nullable=True),
sa.Column('website', sa.String(length=1024), nullable=True),
sa.Column('additional_information', sa.Text(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('partner')
# ### end Alembic commands ###

View File

@ -0,0 +1,39 @@
"""empty message
Revision ID: d03e433dc248
Revises: f234688f5ebd
Create Date: 2019-06-30 23:24:28.486465
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd03e433dc248'
down_revision = 'f234688f5ebd'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('reset_password_code',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=128), nullable=False),
sa.Column('expired', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('reset_password_code')
# ### end Alembic commands ###

View File

@ -0,0 +1,30 @@
"""empty message
Revision ID: f234688f5ebd
Revises: 213fcca48483
Create Date: 2019-06-30 18:30:55.295040
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f234688f5ebd'
down_revision = '213fcca48483'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('profile_picture_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'users', 'file', ['profile_picture_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', type_='foreignkey')
op.drop_column('users', 'profile_picture_id')
# ### end Alembic commands ###

0
poc/__init__.py Normal file
View File

17
poc/jwt-jws-jwk.py Normal file
View File

@ -0,0 +1,17 @@
"""
ssh-keygen -t rsa -b 4096 -m PEM -f jwtRS256.key
# Don't add passphrase
openssl rsa -in jwtRS256.key -pubout -outform PEM -out jwtRS256.key.pub
"""
from jwcrypto import jwk, jws, jwt
with open("jwtRS256.key", "rb") as f:
key = jwk.JWK.from_pem(f.read())
Token = jwt.JWT(header={"alg": "RS256"}, claims={"info": "I'm a signed token"})
Token.make_signed_token(key)
print(Token.serialize())
# verify
jwt.JWT(key=key, jwt=Token.serialize())

33
poc/poc_send_email.py Normal file
View File

@ -0,0 +1,33 @@
"""POC on how to send email through postfix directly
TODO: need to improve email score before using this
"""
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
fromaddr = "hello@u.sl.meo.ovh"
toaddr = "test-7hxfo@mail-tester.com"
# alternative is necessary so email client will display html version first, then use plain one as fall-back
msg = MIMEMultipart("alternative")
msg["From"] = fromaddr
msg["To"] = toaddr
msg["Subject"] = "test subject 2"
msg.attach(MIMEText("test plain body", "plain"))
msg.attach(
MIMEText(
"""
<html>
<body>
<b>Test body</b>
</body>
</html>""",
"html",
)
)
with smtplib.SMTP(host="localhost") as server:
server.sendmail(fromaddr, toaddr, msg.as_string())

18
pyproject.toml Normal file
View File

@ -0,0 +1,18 @@
[tool.black]
exclude = '''
(
/(
\.eggs # exclude a few common directories in the
| \.git # root of the project
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
| migrations # migrations/ is generated by alembic
)/
)
'''

27
requirements.in Normal file
View File

@ -0,0 +1,27 @@
flask_sqlalchemy
flask
flask_login
wtforms
unidecode
gunicorn
pip-tools
bcrypt
python-dotenv
ipython
sqlalchemy_utils
psycopg2-binary
sentry_sdk
blinker
arrow
sendgrid
Flask-WTF
boto3
Flask-Migrate
flask_admin
pytest
flask-cors
watchtower
sqlalchemy-utils
stripe
jwcrypto
yacron

View File

@ -1,4 +1,89 @@
flask_sqlalchemy
flask
flask_login
wtforms
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements.txt requirements.in
#
aiohttp==3.5.4 # via raven-aiohttp, yacron
aiosmtplib==1.0.6 # via yacron
alembic==1.0.10 # via flask-migrate
appnope==0.1.0 # via ipython
arrow==0.14.2
asn1crypto==0.24.0 # via cryptography
async-timeout==3.0.1 # via aiohttp
atomicwrites==1.3.0 # via pytest
attrs==19.1.0 # via aiohttp, pytest
backcall==0.1.0 # via ipython
bcrypt==3.1.6
blinker==1.4
boto3==1.9.167
botocore==1.12.167 # via boto3, s3transfer
certifi==2019.3.9 # via requests, sentry-sdk
cffi==1.12.3 # via bcrypt, cryptography
chardet==3.0.4 # via aiohttp, requests
click==7.0 # via flask, pip-tools
crontab==0.22.5 # via yacron
cryptography==2.7 # via jwcrypto
decorator==4.4.0 # via ipython, traitlets
docutils==0.14 # via botocore
flask-admin==1.5.3
flask-cors==3.0.8
flask-login==0.4.1
flask-migrate==2.5.2
flask-sqlalchemy==2.4.0
flask-wtf==0.14.2
flask==1.0.3
gunicorn==19.9.0
idna==2.8 # via requests, yarl
importlib-metadata==0.18 # via pluggy, pytest
ipython-genutils==0.2.0 # via traitlets
ipython==7.5.0
itsdangerous==1.1.0 # via flask
jedi==0.13.3 # via ipython
jinja2==2.10.1 # via flask, yacron
jmespath==0.9.4 # via boto3, botocore
jwcrypto==0.6.0
mako==1.0.12 # via alembic
markupsafe==1.1.1 # via jinja2, mako
more-itertools==7.0.0 # via pytest
multidict==4.5.2 # via aiohttp, yarl
packaging==19.0 # via pytest
parso==0.4.0 # via jedi
pexpect==4.7.0 # via ipython
pickleshare==0.7.5 # via ipython
pip-tools==3.8.0
pluggy==0.12.0 # via pytest
prompt-toolkit==2.0.9 # via ipython
psycopg2-binary==2.8.2
ptyprocess==0.6.0 # via pexpect
py==1.8.0 # via pytest
pycparser==2.19 # via cffi
pygments==2.4.2 # via ipython
pyparsing==2.4.0 # via packaging
pytest==4.6.3
python-dateutil==2.8.0 # via alembic, arrow, botocore, strictyaml
python-dotenv==0.10.3
python-editor==1.0.4 # via alembic
python-http-client==3.1.0 # via sendgrid
raven-aiohttp==0.7.0 # via yacron
raven==6.10.0 # via raven-aiohttp, yacron
requests==2.22.0 # via stripe
ruamel.yaml==0.15.97 # via strictyaml
s3transfer==0.2.1 # via boto3
sendgrid==6.0.5
sentry-sdk==0.9.0
six==1.12.0 # via bcrypt, cryptography, flask-cors, packaging, pip-tools, prompt-toolkit, pytest, python-dateutil, sqlalchemy-utils, traitlets
sqlalchemy-utils==0.33.11
sqlalchemy==1.3.4 # via alembic, flask-sqlalchemy, sqlalchemy-utils
strictyaml==1.0.2 # via yacron
stripe==2.30.1
traitlets==4.3.2 # via ipython
unidecode==1.0.23
urllib3==1.25.3 # via botocore, requests, sentry-sdk
watchtower==0.6.0
wcwidth==0.1.7 # via prompt-toolkit, pytest
werkzeug==0.15.4 # via flask
wtforms==2.2.1
yacron==0.9.0
yarl==1.3.0 # via aiohttp
zipp==0.5.1 # via importlib-metadata

219
server.py
View File

@ -1,50 +1,112 @@
import os
from flask import Flask
import arrow
import sentry_sdk
import stripe
from flask import Flask, redirect, url_for, render_template, request, jsonify
from flask_admin import Admin
from flask_cors import cross_origin
from flask_login import current_user
from sentry_sdk.integrations.flask import FlaskIntegration
from app.admin_model import SLModelView, SLAdminIndexView
from app.auth.base import auth_bp
from app.config import (
DB_URI,
FLASK_SECRET,
ENABLE_SENTRY,
ENV,
URL,
SHA1,
LYRA_ANALYTICS_ID,
STRIPE_SECRET_KEY,
)
from app.dashboard.base import dashboard_bp
from app.extensions import db, login_manager
from app.developer.base import developer_bp
from app.discover.base import discover_bp
from app.extensions import db, login_manager, migrate
from app.jose_utils import get_jwk_key
from app.log import LOG
from app.models import Client, User
from app.models import Client, User, Scope, ClientUser, GenEmail, RedirectUri, PlanEnum
from app.monitor.base import monitor_bp
from app.oauth.base import oauth_bp
from app.oauth_models import ScopeE
from app.partner.base import partner_bp
if ENABLE_SENTRY:
LOG.d("enable sentry")
sentry_sdk.init(
dsn="https://ad2187ed843340a1b4165bd8d5d6cdce@sentry.io/1478143",
integrations=[FlaskIntegration()],
)
def create_app() -> Flask:
app = Flask(__name__)
app.url_map.strict_slashes = False
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///db.sqlite"
app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
app.secret_key = "secret"
app.secret_key = FLASK_SECRET
app.config["TEMPLATES_AUTO_RELOAD"] = True
init_extensions(app)
register_blueprints(app)
set_index_page(app)
jinja2_filter(app)
setup_error_page(app)
setup_favicon_route(app)
setup_openid_metadata(app)
stripe.api_key = STRIPE_SECRET_KEY
return app
def fake_data():
LOG.d("create fake data")
# Remove db if exist
if os.path.exists("db.sqlite"):
LOG.d("remove existing db file")
os.remove("db.sqlite")
# Create all tables
db.create_all()
# fake data
client = Client(
client_id="client-id",
client_secret="client-secret",
redirect_uri="http://localhost:7000/callback",
name="Continental",
Scope.create(name=ScopeE.NAME.value)
Scope.create(name=ScopeE.EMAIL.value)
db.session.commit()
# Create a user
user = User.create(
email="nguyenkims+local@gmail.com",
name="Son Local",
activated=True,
is_admin=True,
is_developer=True,
)
db.session.add(client)
user = User(id=1, email="john@wick.com", name="John Wick")
user.set_password("password")
db.session.add(user)
user.plan = PlanEnum.trial
user.plan_expiration = arrow.now().shift(weeks=2)
db.session.commit()
GenEmail.create_new_gen_email(user_id=user.id)
# Create a client
client1 = Client.create_new(name="Demo", user_id=user.id)
client1.home_url = "http://sl-client:7000"
client1.oauth_client_id = "client-id"
client1.oauth_client_secret = "client-secret"
client1.published = True
db.session.commit()
RedirectUri.create(client_id=client1.id, uri="http://sl-client:7000/callback")
RedirectUri.create(client_id=client1.id, uri="http://sl-client:7000/implicit")
RedirectUri.create(client_id=client1.id, uri="http://sl-client:7000/implicit-jso")
db.session.commit()
@ -59,18 +121,141 @@ def register_blueprints(app: Flask):
app.register_blueprint(auth_bp)
app.register_blueprint(monitor_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(developer_bp)
app.register_blueprint(partner_bp)
app.register_blueprint(oauth_bp, url_prefix="/oauth")
app.register_blueprint(oauth_bp, url_prefix="/oauth2")
app.register_blueprint(discover_bp)
def set_index_page(app):
@app.route("/")
def index():
if current_user.is_authenticated:
return redirect(url_for("dashboard.index"))
else:
return redirect(url_for("auth.login"))
@app.after_request
def after_request(res):
# not logging /static call
if not request.path.startswith("/static") and not request.path.startswith(
"/admin/static"
):
LOG.debug(
"%s %s %s %s %s",
request.remote_addr,
request.method,
request.path,
request.args,
res.status_code,
)
return res
def setup_openid_metadata(app):
@app.route("/.well-known/openid-configuration")
@cross_origin()
def openid_config():
res = {
"issuer": URL,
"authorization_endpoint": URL + "/oauth2/authorize",
"token_endpoint": URL + "/oauth2/token",
"jwks_uri": URL + "/jwks",
"response_types_supported": [
"code",
"token",
"id_token",
"id_token token",
"id_token code",
],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
# todo: add introspection and revocation endpoints
"introspection_endpoint": URL + "/oauth2/token/introspection",
"revocation_endpoint": URL + "/oauth2/token/revocation",
}
return jsonify(res)
@app.route("/jwks")
@cross_origin()
def jwks():
res = {"keys": [get_jwk_key()]}
return jsonify(res)
def setup_error_page(app):
@app.errorhandler(400)
def page_not_found(e):
return render_template("error/400.html"), 400
@app.errorhandler(401)
def page_not_found(e):
return render_template("error/401.html", current_url=request.full_path), 401
@app.errorhandler(403)
def page_not_found(e):
return render_template("error/403.html"), 403
@app.errorhandler(404)
def page_not_found(e):
return render_template("error/404.html"), 404
@app.errorhandler(Exception)
def error_handler(e):
LOG.exception(e)
return render_template("error/500.html"), 500
def setup_favicon_route(app):
@app.route("/favicon.ico")
def favicon():
return redirect("/static/favicon.ico")
def jinja2_filter(app):
def format_datetime(value):
dt = arrow.get(value)
return dt.humanize()
app.jinja_env.filters["dt"] = format_datetime
@app.context_processor
def inject_stage_and_region():
return dict(
YEAR=arrow.now().year,
URL=URL,
ENABLE_SENTRY=ENABLE_SENTRY,
VERSION=SHA1,
LYRA_ANALYTICS_ID=LYRA_ANALYTICS_ID,
)
def init_extensions(app: Flask):
LOG.debug("init extensions")
login_manager.init_app(app)
db.init_app(app)
migrate.init_app(app)
def init_admin(app):
admin = Admin(name="SimpleLogin", template_mode="bootstrap3")
admin.init_app(app, index_view=SLAdminIndexView())
admin.add_view(SLModelView(User, db.session))
admin.add_view(SLModelView(Client, db.session))
admin.add_view(SLModelView(GenEmail, db.session))
admin.add_view(SLModelView(ClientUser, db.session))
if __name__ == "__main__":
app = create_app()
with app.app_context():
fake_data()
if ENV == "local":
LOG.d("reset db, add fake data")
with app.app_context():
fake_data()
app.run(debug=True, threaded=False)
app.run(debug=True, host="0.0.0.0")

65
shell.py Normal file
View File

@ -0,0 +1,65 @@
import flask_migrate
import stripe
from IPython import embed
from sqlalchemy_utils import create_database, database_exists, drop_database
from app.config import DB_URI
from app.models import *
from app.oauth_models import ScopeE
from server import create_app
def create_db():
if not database_exists(DB_URI):
LOG.debug("db not exist, create database")
create_database(DB_URI)
# Create all tables
# Use flask-migrate instead of db.create_all()
flask_migrate.upgrade()
scope_name = Scope.create(name=ScopeE.NAME.value)
db.session.add(scope_name)
scope_email = Scope.create(name=ScopeE.EMAIL.value)
db.session.add(scope_email)
db.session.commit()
def add_real_data():
"""after the db is reset, add some accounts
TODO: remove this after adding alembic"""
user = User.create(email="nguyenkims@gmail.com", name="Son GM", activated=True)
user.set_password("password")
db.session.commit()
# Create a client
client1 = Client.create_new(name="Demo", user_id=user.id)
client1.oauth_client_id = "client-id"
client1.oauth_client_secret = "client-secret"
db.session.commit()
RedirectUri.create(client_id=client1.id, uri="http://demo.sl.meo.ovh/callback")
db.session.commit()
user2 = User.create(email="nguyenkims@hotmail.com", name="Son HM", activated=True)
user2.set_password("password")
db.session.commit()
def change_password(user_id, new_password):
user = User.get(user_id)
user.set_password(new_password)
db.session.commit()
def reset_db():
if database_exists(DB_URI):
drop_database(DB_URI)
create_db()
add_real_data()
app = create_app()
with app.app_context():
embed()

20346
static/assets/css/dashboard.css Executable file

File diff suppressed because one or more lines are too long

20346
static/assets/css/dashboard.rtl.css Executable file

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -32 100 100" preserveAspectRatio="xMinYMin"><g transform="translate(-208.45127,-644.63366)"><g transform="matrix(0.2576927,0,0,0.2576927,155.23992,508.16265)"><path d="M420,564.1C385.5,564.1 359.4,590.9 359.4,624.1C359.4,659.1 386.6,684.1 420,684.1C453.4,684.1 480.5,659.1 480.5,624.1C480.5,590.9 454.5,564.1 420,564.1z M420,595.8C434.9,595.7 447.1,608.4 447.1,624.1C447.1,639.7 434.9,652.4 420,652.4C405.1,652.4 392.9,639.7 392.9,624.1C392.9,608.4 405.1,595.8 420,595.8z" style="stroke:none;stroke-width:0.43820944"/><path d="M507,397.4C507,409 497.6,418.4 486,418.4C474.4,418.4 465,409 465,397.4C465,385.8 474.4,376.4 486,376.4C497.6,376.4 507,385.8 507,397.4z" style="stroke:none;stroke-width:0.1" transform="translate(85.630073,265.696)"/><path style="stroke:none;stroke-width:1px" d="M531.5,680.1L498.5,680.1L498.5,531.1L531.5,531.1L531.5,680.1z"/><path d="M208.5,680.1L268.5,531.1L299.5,531.1L358.5,680.1L316.5,680.1L309.5,659.1L257.5,659.1L250.5,680.1L208.5,680.1z M299.5,628.1L268.5,628.1L284,578.1L299.5,628.1z" style="fill-rule:evenodd;stroke:none;stroke-width:0.2"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1 @@
<svg width="39" height="39" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin"><title>blackberry</title><desc>Created with Sketch.</desc><g><title>Layer 1</title><g fill-rule="evenodd" fill="none" id="Page-1"><path fill="#000" id="blackberry" d="m12.267,11.864c0,-1.264 -0.774,-2.864 -4.027,-2.864l-5.009,0l-1.424,6.588l5.222,0c4.077,0 5.238,-1.93 5.238,-3.724l0,0zm13.493,0c0,-1.264 -0.772,-2.864 -4.024,-2.864l-5.01,0l-1.423,6.587l5.219,0c4.079,0.001 5.238,-1.929 5.238,-3.723l0,0zm-15.3,9.915c0,-1.264 -0.774,-2.868 -4.027,-2.868l-5.009,0l-1.424,6.592l5.22,0c4.078,0 5.24,-1.935 5.24,-3.724zm13.493,0c0,-1.264 -0.775,-2.868 -4.025,-2.868l-5.009,0l-1.426,6.592l5.222,0c4.079,0 5.238,-1.935 5.238,-3.724l0,0zm14.117,-4.021c0,-1.265 -0.775,-2.868 -4.025,-2.868l-5.009,0l-1.426,6.591l5.22,0c4.079,0 5.24,-1.93 5.24,-3.723l0,0zm-1.946,10.323c0,-1.265 -0.773,-2.864 -4.025,-2.864l-5.009,0l-1.424,6.588l5.22,0c4.078,0 5.238,-1.935 5.238,-3.724zm-14.11,4.022c0,-1.27 -0.772,-2.873 -4.022,-2.873l-5.012,0l-1.424,6.591l5.22,0c4.079,0.001 5.238,-1.929 5.238,-3.718l0,0z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More