server.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686
  1. import json
  2. import os
  3. import ssl
  4. from datetime import timedelta
  5. import arrow
  6. import flask_profiler
  7. import sentry_sdk
  8. from flask import (
  9. Flask,
  10. redirect,
  11. url_for,
  12. render_template,
  13. request,
  14. jsonify,
  15. flash,
  16. session,
  17. )
  18. from flask_admin import Admin
  19. from flask_cors import cross_origin, CORS
  20. from flask_debugtoolbar import DebugToolbarExtension
  21. from flask_login import current_user
  22. from sentry_sdk.integrations.flask import FlaskIntegration
  23. from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
  24. from werkzeug.middleware.proxy_fix import ProxyFix
  25. from app import paddle_utils
  26. from app.admin_model import SLModelView, SLAdminIndexView
  27. from app.api.base import api_bp
  28. from app.auth.base import auth_bp
  29. from app.config import (
  30. DB_URI,
  31. FLASK_SECRET,
  32. SENTRY_DSN,
  33. URL,
  34. SHA1,
  35. PADDLE_MONTHLY_PRODUCT_ID,
  36. RESET_DB,
  37. FLASK_PROFILER_PATH,
  38. FLASK_PROFILER_PASSWORD,
  39. SENTRY_FRONT_END_DSN,
  40. FIRST_ALIAS_DOMAIN,
  41. SESSION_COOKIE_NAME,
  42. PLAUSIBLE_HOST,
  43. PLAUSIBLE_DOMAIN,
  44. GITHUB_CLIENT_ID,
  45. GOOGLE_CLIENT_ID,
  46. FACEBOOK_CLIENT_ID,
  47. LANDING_PAGE_URL,
  48. STATUS_PAGE_URL,
  49. SUPPORT_EMAIL,
  50. )
  51. from app.dashboard.base import dashboard_bp
  52. from app.developer.base import developer_bp
  53. from app.discover.base import discover_bp
  54. from app.email_utils import send_email, render
  55. from app.extensions import db, login_manager, migrate, limiter
  56. from app.jose_utils import get_jwk_key
  57. from app.log import LOG
  58. from app.models import (
  59. Client,
  60. User,
  61. ClientUser,
  62. Alias,
  63. RedirectUri,
  64. Subscription,
  65. PlanEnum,
  66. ApiKey,
  67. CustomDomain,
  68. LifetimeCoupon,
  69. Directory,
  70. Mailbox,
  71. Referral,
  72. AliasMailbox,
  73. Notification,
  74. SLDomain,
  75. )
  76. from app.monitor.base import monitor_bp
  77. from app.oauth.base import oauth_bp
  78. if SENTRY_DSN:
  79. LOG.d("enable sentry")
  80. sentry_sdk.init(
  81. dsn=SENTRY_DSN,
  82. integrations=[
  83. FlaskIntegration(),
  84. SqlalchemyIntegration(),
  85. ],
  86. )
  87. # the app is served behin nginx which uses http and not https
  88. os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1"
  89. def create_light_app() -> Flask:
  90. app = Flask(__name__)
  91. app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI
  92. app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
  93. db.init_app(app)
  94. return app
  95. def create_app() -> Flask:
  96. app = Flask(__name__)
  97. # SimpleLogin is deployed behind NGINX
  98. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_host=1)
  99. limiter.init_app(app)
  100. app.url_map.strict_slashes = False
  101. app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI
  102. app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
  103. # enable to print all queries generated by sqlalchemy
  104. # app.config["SQLALCHEMY_ECHO"] = True
  105. app.secret_key = FLASK_SECRET
  106. app.config["TEMPLATES_AUTO_RELOAD"] = True
  107. # to avoid conflict with other cookie
  108. app.config["SESSION_COOKIE_NAME"] = SESSION_COOKIE_NAME
  109. if URL.startswith("https"):
  110. app.config["SESSION_COOKIE_SECURE"] = True
  111. app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
  112. setup_error_page(app)
  113. init_extensions(app)
  114. register_blueprints(app)
  115. set_index_page(app)
  116. jinja2_filter(app)
  117. setup_favicon_route(app)
  118. setup_openid_metadata(app)
  119. init_admin(app)
  120. setup_paddle_callback(app)
  121. setup_do_not_track(app)
  122. if FLASK_PROFILER_PATH:
  123. LOG.d("Enable flask-profiler")
  124. app.config["flask_profiler"] = {
  125. "enabled": True,
  126. "storage": {"engine": "sqlite", "FILE": FLASK_PROFILER_PATH},
  127. "basicAuth": {
  128. "enabled": True,
  129. "username": "admin",
  130. "password": FLASK_PROFILER_PASSWORD,
  131. },
  132. "ignore": ["^/static/.*", "/git", "/exception"],
  133. }
  134. flask_profiler.init_app(app)
  135. # enable CORS on /api endpoints
  136. CORS(app, resources={r"/api/*": {"origins": "*"}})
  137. # set session to permanent so user stays signed in after quitting the browser
  138. # the cookie is valid for 7 days
  139. @app.before_request
  140. def make_session_permanent():
  141. session.permanent = True
  142. app.permanent_session_lifetime = timedelta(days=7)
  143. return app
  144. def fake_data():
  145. LOG.d("create fake data")
  146. # Remove db if exist
  147. if os.path.exists("db.sqlite"):
  148. LOG.d("remove existing db file")
  149. os.remove("db.sqlite")
  150. # Create all tables
  151. db.create_all()
  152. # Create a user
  153. user = User.create(
  154. email="john@wick.com",
  155. name="John Wick",
  156. password="password",
  157. activated=True,
  158. is_admin=True,
  159. enable_otp=False,
  160. otp_secret="base32secret3232",
  161. intro_shown=True,
  162. fido_uuid=None,
  163. )
  164. db.session.commit()
  165. user.trial_end = None
  166. LifetimeCoupon.create(code="coupon", nb_used=10)
  167. db.session.commit()
  168. # Create a subscription for user
  169. Subscription.create(
  170. user_id=user.id,
  171. cancel_url="https://checkout.paddle.com/subscription/cancel?user=1234",
  172. update_url="https://checkout.paddle.com/subscription/update?user=1234",
  173. subscription_id="123",
  174. event_time=arrow.now(),
  175. next_bill_date=arrow.now().shift(days=10).date(),
  176. plan=PlanEnum.monthly,
  177. )
  178. db.session.commit()
  179. api_key = ApiKey.create(user_id=user.id, name="Chrome")
  180. api_key.code = "code"
  181. api_key = ApiKey.create(user_id=user.id, name="Firefox")
  182. api_key.code = "codeFF"
  183. m1 = Mailbox.create(
  184. user_id=user.id,
  185. email="m1@cd.ef",
  186. verified=True,
  187. pgp_finger_print="fake fingerprint",
  188. )
  189. db.session.commit()
  190. for i in range(3):
  191. if i % 2 == 0:
  192. a = Alias.create(
  193. email=f"e{i}@{FIRST_ALIAS_DOMAIN}", user_id=user.id, mailbox_id=m1.id
  194. )
  195. else:
  196. a = Alias.create(
  197. email=f"e{i}@{FIRST_ALIAS_DOMAIN}",
  198. user_id=user.id,
  199. mailbox_id=user.default_mailbox_id,
  200. )
  201. db.session.commit()
  202. if i % 5 == 0:
  203. if i % 2 == 0:
  204. AliasMailbox.create(alias_id=a.id, mailbox_id=user.default_mailbox_id)
  205. else:
  206. AliasMailbox.create(alias_id=a.id, mailbox_id=m1.id)
  207. db.session.commit()
  208. # some aliases don't have any activity
  209. # if i % 3 != 0:
  210. # contact = Contact.create(
  211. # user_id=user.id,
  212. # alias_id=a.id,
  213. # website_email=f"contact{i}@example.com",
  214. # reply_email=f"rep{i}@sl.local",
  215. # )
  216. # db.session.commit()
  217. # for _ in range(3):
  218. # EmailLog.create(user_id=user.id, contact_id=contact.id)
  219. # db.session.commit()
  220. # have some disabled alias
  221. if i % 5 == 0:
  222. a.enabled = False
  223. db.session.commit()
  224. CustomDomain.create(user_id=user.id, domain="ab.cd", verified=True)
  225. CustomDomain.create(
  226. user_id=user.id, domain="very-long-domain.com.net.org", verified=True
  227. )
  228. db.session.commit()
  229. Directory.create(user_id=user.id, name="abcd")
  230. Directory.create(user_id=user.id, name="xyzt")
  231. db.session.commit()
  232. # Create a client
  233. client1 = Client.create_new(name="Demo", user_id=user.id)
  234. client1.oauth_client_id = "client-id"
  235. client1.oauth_client_secret = "client-secret"
  236. client1.published = True
  237. db.session.commit()
  238. RedirectUri.create(client_id=client1.id, uri="https://ab.com")
  239. client2 = Client.create_new(name="Demo 2", user_id=user.id)
  240. client2.oauth_client_id = "client-id2"
  241. client2.oauth_client_secret = "client-secret2"
  242. client2.published = True
  243. db.session.commit()
  244. ClientUser.create(user_id=user.id, client_id=client1.id, name="Fake Name")
  245. referral = Referral.create(user_id=user.id, code="REFCODE", name="First referral")
  246. db.session.commit()
  247. for i in range(6):
  248. Notification.create(user_id=user.id, message=f"""Hey hey <b>{i}</b> """ * 10)
  249. db.session.commit()
  250. User.create(
  251. email="winston@continental.com",
  252. name="Winston",
  253. password="password",
  254. activated=True,
  255. referral_id=referral.id,
  256. )
  257. db.session.commit()
  258. @login_manager.user_loader
  259. def load_user(user_id):
  260. user = User.get(user_id)
  261. if user and user.disabled:
  262. return None
  263. return user
  264. def register_blueprints(app: Flask):
  265. app.register_blueprint(auth_bp)
  266. app.register_blueprint(monitor_bp)
  267. app.register_blueprint(dashboard_bp)
  268. app.register_blueprint(developer_bp)
  269. app.register_blueprint(oauth_bp, url_prefix="/oauth")
  270. app.register_blueprint(oauth_bp, url_prefix="/oauth2")
  271. app.register_blueprint(discover_bp)
  272. app.register_blueprint(api_bp)
  273. def set_index_page(app):
  274. @app.route("/", methods=["GET", "POST"])
  275. def index():
  276. if current_user.is_authenticated:
  277. return redirect(url_for("dashboard.index"))
  278. else:
  279. return redirect(url_for("auth.login"))
  280. @app.after_request
  281. def after_request(res):
  282. # not logging /static call
  283. if (
  284. not request.path.startswith("/static")
  285. and not request.path.startswith("/admin/static")
  286. and not request.path.startswith("/_debug_toolbar")
  287. ):
  288. LOG.debug(
  289. "%s %s %s %s %s",
  290. request.remote_addr,
  291. request.method,
  292. request.path,
  293. request.args,
  294. res.status_code,
  295. )
  296. return res
  297. def setup_openid_metadata(app):
  298. @app.route("/.well-known/openid-configuration")
  299. @cross_origin()
  300. def openid_config():
  301. res = {
  302. "issuer": URL,
  303. "authorization_endpoint": URL + "/oauth2/authorize",
  304. "token_endpoint": URL + "/oauth2/token",
  305. "userinfo_endpoint": URL + "/oauth2/userinfo",
  306. "jwks_uri": URL + "/jwks",
  307. "response_types_supported": [
  308. "code",
  309. "token",
  310. "id_token",
  311. "id_token token",
  312. "id_token code",
  313. ],
  314. "subject_types_supported": ["public"],
  315. "id_token_signing_alg_values_supported": ["RS256"],
  316. # todo: add introspection and revocation endpoints
  317. # "introspection_endpoint": URL + "/oauth2/token/introspection",
  318. # "revocation_endpoint": URL + "/oauth2/token/revocation",
  319. }
  320. return jsonify(res)
  321. @app.route("/jwks")
  322. @cross_origin()
  323. def jwks():
  324. res = {"keys": [get_jwk_key()]}
  325. return jsonify(res)
  326. def setup_error_page(app):
  327. @app.errorhandler(400)
  328. def bad_request(e):
  329. if request.path.startswith("/api/"):
  330. return jsonify(error="Bad Request"), 400
  331. else:
  332. return render_template("error/400.html"), 400
  333. @app.errorhandler(401)
  334. def unauthorized(e):
  335. if request.path.startswith("/api/"):
  336. return jsonify(error="Unauthorized"), 401
  337. else:
  338. flash("You need to login to see this page", "error")
  339. return redirect(url_for("auth.login", next=request.full_path))
  340. @app.errorhandler(403)
  341. def forbidden(e):
  342. if request.path.startswith("/api/"):
  343. return jsonify(error="Forbidden"), 403
  344. else:
  345. return render_template("error/403.html"), 403
  346. @app.errorhandler(429)
  347. def forbidden(e):
  348. LOG.warning("Client hit rate limit on path %s", request.path)
  349. if request.path.startswith("/api/"):
  350. return jsonify(error="Rate limit exceeded"), 429
  351. else:
  352. return render_template("error/429.html"), 429
  353. @app.errorhandler(404)
  354. def page_not_found(e):
  355. if request.path.startswith("/api/"):
  356. return jsonify(error="No such endpoint"), 404
  357. else:
  358. return render_template("error/404.html"), 404
  359. @app.errorhandler(405)
  360. def wrong_method(e):
  361. if request.path.startswith("/api/"):
  362. return jsonify(error="Method not allowed"), 405
  363. else:
  364. return render_template("error/405.html"), 405
  365. @app.errorhandler(Exception)
  366. def error_handler(e):
  367. LOG.exception(e)
  368. if request.path.startswith("/api/"):
  369. return jsonify(error="Internal error"), 500
  370. else:
  371. return render_template("error/500.html"), 500
  372. def setup_favicon_route(app):
  373. @app.route("/favicon.ico")
  374. def favicon():
  375. return redirect("/static/favicon.ico")
  376. def jinja2_filter(app):
  377. def format_datetime(value):
  378. dt = arrow.get(value)
  379. return dt.humanize()
  380. app.jinja_env.filters["dt"] = format_datetime
  381. @app.context_processor
  382. def inject_stage_and_region():
  383. return dict(
  384. YEAR=arrow.now().year,
  385. URL=URL,
  386. SENTRY_DSN=SENTRY_FRONT_END_DSN,
  387. VERSION=SHA1,
  388. FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
  389. PLAUSIBLE_HOST=PLAUSIBLE_HOST,
  390. PLAUSIBLE_DOMAIN=PLAUSIBLE_DOMAIN,
  391. GITHUB_CLIENT_ID=GITHUB_CLIENT_ID,
  392. GOOGLE_CLIENT_ID=GOOGLE_CLIENT_ID,
  393. FACEBOOK_CLIENT_ID=FACEBOOK_CLIENT_ID,
  394. LANDING_PAGE_URL=LANDING_PAGE_URL,
  395. STATUS_PAGE_URL=STATUS_PAGE_URL,
  396. SUPPORT_EMAIL=SUPPORT_EMAIL,
  397. )
  398. def setup_paddle_callback(app: Flask):
  399. @app.route("/paddle", methods=["GET", "POST"])
  400. def paddle():
  401. LOG.debug(f"paddle callback {request.form.get('alert_name')} {request.form}")
  402. # make sure the request comes from Paddle
  403. if not paddle_utils.verify_incoming_request(dict(request.form)):
  404. LOG.exception(
  405. "request not coming from paddle. Request data:%s", dict(request.form)
  406. )
  407. return "KO", 400
  408. if (
  409. request.form.get("alert_name") == "subscription_created"
  410. ): # new user subscribes
  411. # the passthrough is json encoded, e.g.
  412. # request.form.get("passthrough") = '{"user_id": 88 }'
  413. passthrough = json.loads(request.form.get("passthrough"))
  414. user_id = passthrough.get("user_id")
  415. user = User.get(user_id)
  416. if (
  417. int(request.form.get("subscription_plan_id"))
  418. == PADDLE_MONTHLY_PRODUCT_ID
  419. ):
  420. plan = PlanEnum.monthly
  421. else:
  422. plan = PlanEnum.yearly
  423. sub = Subscription.get_by(user_id=user.id)
  424. if not sub:
  425. LOG.d(f"create a new Subscription for user {user}")
  426. Subscription.create(
  427. user_id=user.id,
  428. cancel_url=request.form.get("cancel_url"),
  429. update_url=request.form.get("update_url"),
  430. subscription_id=request.form.get("subscription_id"),
  431. event_time=arrow.now(),
  432. next_bill_date=arrow.get(
  433. request.form.get("next_bill_date"), "YYYY-MM-DD"
  434. ).date(),
  435. plan=plan,
  436. )
  437. else:
  438. LOG.d(f"Update an existing Subscription for user {user}")
  439. sub.cancel_url = request.form.get("cancel_url")
  440. sub.update_url = request.form.get("update_url")
  441. sub.subscription_id = request.form.get("subscription_id")
  442. sub.event_time = arrow.now()
  443. sub.next_bill_date = arrow.get(
  444. request.form.get("next_bill_date"), "YYYY-MM-DD"
  445. ).date()
  446. sub.plan = plan
  447. # make sure to set the new plan as not-cancelled
  448. # in case user cancels a plan and subscribes a new plan
  449. sub.cancelled = False
  450. LOG.debug("User %s upgrades!", user)
  451. db.session.commit()
  452. elif request.form.get("alert_name") == "subscription_payment_succeeded":
  453. subscription_id = request.form.get("subscription_id")
  454. LOG.debug("Update subscription %s", subscription_id)
  455. sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
  456. # when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
  457. # at that time, subscription object does not exist yet
  458. if sub:
  459. sub.event_time = arrow.now()
  460. sub.next_bill_date = arrow.get(
  461. request.form.get("next_bill_date"), "YYYY-MM-DD"
  462. ).date()
  463. db.session.commit()
  464. elif request.form.get("alert_name") == "subscription_cancelled":
  465. subscription_id = request.form.get("subscription_id")
  466. sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
  467. if sub:
  468. # cancellation_effective_date should be the same as next_bill_date
  469. LOG.warning(
  470. "Cancel subscription %s %s on %s, next bill date %s",
  471. subscription_id,
  472. sub.user,
  473. request.form.get("cancellation_effective_date"),
  474. sub.next_bill_date,
  475. )
  476. sub.event_time = arrow.now()
  477. sub.cancelled = True
  478. db.session.commit()
  479. user = sub.user
  480. send_email(
  481. user.email,
  482. f"SimpleLogin - what can we do to improve the product?",
  483. render(
  484. "transactional/subscription-cancel.txt",
  485. name=user.name or "",
  486. end_date=request.form.get("cancellation_effective_date"),
  487. ),
  488. )
  489. else:
  490. return "No such subscription", 400
  491. elif request.form.get("alert_name") == "subscription_updated":
  492. subscription_id = request.form.get("subscription_id")
  493. sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
  494. if sub:
  495. LOG.debug(
  496. "Update subscription %s %s on %s, next bill date %s",
  497. subscription_id,
  498. sub.user,
  499. request.form.get("cancellation_effective_date"),
  500. sub.next_bill_date,
  501. )
  502. if (
  503. int(request.form.get("subscription_plan_id"))
  504. == PADDLE_MONTHLY_PRODUCT_ID
  505. ):
  506. plan = PlanEnum.monthly
  507. else:
  508. plan = PlanEnum.yearly
  509. sub.cancel_url = request.form.get("cancel_url")
  510. sub.update_url = request.form.get("update_url")
  511. sub.event_time = arrow.now()
  512. sub.next_bill_date = arrow.get(
  513. request.form.get("next_bill_date"), "YYYY-MM-DD"
  514. ).date()
  515. sub.plan = plan
  516. # make sure to set the new plan as not-cancelled
  517. sub.cancelled = False
  518. db.session.commit()
  519. else:
  520. return "No such subscription", 400
  521. return "OK"
  522. def init_extensions(app: Flask):
  523. login_manager.init_app(app)
  524. db.init_app(app)
  525. migrate.init_app(app)
  526. def init_admin(app):
  527. admin = Admin(name="SimpleLogin", template_mode="bootstrap3")
  528. admin.init_app(app, index_view=SLAdminIndexView())
  529. admin.add_view(SLModelView(User, db.session))
  530. admin.add_view(SLModelView(Client, db.session))
  531. admin.add_view(SLModelView(Alias, db.session))
  532. admin.add_view(SLModelView(ClientUser, db.session))
  533. def setup_do_not_track(app):
  534. @app.route("/dnt")
  535. def do_not_track():
  536. return """
  537. <script src="/static/local-storage-polyfill.js"></script>
  538. <script>
  539. // Disable GoatCounter if this script is called
  540. store.set('goatcounter-ignore', 't');
  541. alert("GoatCounter disabled");
  542. window.location.href = "/";
  543. </script>
  544. """
  545. def local_main():
  546. app = create_app()
  547. # enable flask toolbar
  548. app.config["DEBUG_TB_PROFILER_ENABLED"] = True
  549. app.config["DEBUG_TB_INTERCEPT_REDIRECTS"] = False
  550. app.debug = True
  551. DebugToolbarExtension(app)
  552. # warning: only used in local
  553. if RESET_DB:
  554. from init_app import add_sl_domains
  555. LOG.warning("reset db, add fake data")
  556. with app.app_context():
  557. fake_data()
  558. add_sl_domains()
  559. if URL.startswith("https"):
  560. LOG.d("enable https")
  561. context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
  562. context.load_cert_chain("local_data/cert.pem", "local_data/key.pem")
  563. app.run(debug=True, port=7777, ssl_context=context)
  564. else:
  565. app.run(debug=True, port=7777)
  566. if __name__ == "__main__":
  567. local_main()