Enable sending emails in K8S, trigger verification e-mail on registration. (#38)

* k8s: support email configuration
support sending reset password email
fix for #32

* fastapi users: update to latest (8.1.2)
send verification email upon registration

* update to latest fastapi-users(8.1.2), refactor to use UserManager class
ensure verification e-mail sent upon registration, w/o requiring separate apicall
fixes #32

* add email options to default chart/values.yaml

* separate usermanager init from fastapi users init, fix for sending invite emails
This commit is contained in:
Ilya Kreymer 2021-11-30 23:32:55 -08:00 committed by Ilya Kreymer
parent 3fa85c83f2
commit d0b54dd752
8 changed files with 160 additions and 116 deletions

View File

@ -301,7 +301,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
aid=str(archive.id), created=datetime.utcnow(), role=invite.role
)
other_user = await users.db.get_by_email(invite.email)
other_user = await users.user_db.get_by_email(invite.email)
if not other_user:
@ -325,7 +325,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
other_user.invites[invite_code] = invite_pending
await users.db.update(other_user)
await users.user_db.update(other_user)
return {
"invited": "existing_user",
@ -338,7 +338,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
user: User = Depends(user_dep),
):
other_user = await users.db.get_by_email(update.email)
other_user = await users.user_db.get_by_email(update.email)
if not other_user:
raise HTTPException(
status_code=400, detail="No user found for specified e-mail"
@ -359,7 +359,7 @@ def init_archives_api(app, mdb, users, email, user_dep: User):
raise HTTPException(status_code=400, detail="Invalid Invite Code")
await ops.add_user_by_invite(invite, user)
await users.db.update(user)
await users.user_db.update(user)
return {"added": True}
return ops

View File

@ -14,14 +14,14 @@ class EmailSender:
self.password = os.environ.get("EMAIL_PASSWORD")
self.smtp_server = os.environ.get("EMAIL_SMTP_HOST")
self.host = "http://localhost:8000/"
self.host = os.environ.get("APP_ORIGIN")
def _send_encrypted(self, receiver, message):
"""Send Encrypted SMTP Message"""
print(message)
print(message, flush=True)
if not self.smtp_server:
print("Email: No SMTP Server, not sending")
print("Email: No SMTP Server, not sending", flush=True)
return
context = ssl.create_default_context()
@ -54,3 +54,12 @@ You can join by clicking here: {self.host}/app/join/{token}
The invite token is: {token}"""
self._send_encrypted(receiver_email, message)
def send_user_forgot_password(self, receiver_email, token):
"""Send password reset email with token"""
message = f"""
We received your password reset request. Please click here: {self.host}/reset-password?token={token}
to create a new password
"""
self._send_encrypted(receiver_email, message)

View File

@ -5,12 +5,12 @@ supports docker and kubernetes based deployments of multiple browsertrix-crawler
import os
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI
from db import init_db
from emailsender import EmailSender
from users import init_users_api, UserDB
from users import init_users_api, init_user_manager
from archives import init_archives_api
from storages import init_storages_api
@ -22,115 +22,69 @@ app = FastAPI()
# ============================================================================
class BrowsertrixAPI:
"""
Main class for BrowsertrixAPI
"""
def main():
""" init browsertrix cloud api """
# pylint: disable=too-many-instance-attributes
def __init__(self, _app):
self.app = _app
email = EmailSender()
crawl_manager = None
self.email = EmailSender()
self.crawl_manager = None
mdb = init_db()
self.mdb = init_db()
user_manager = init_user_manager(mdb, email)
self.fastapi_users = init_users_api(
self.app,
self.mdb,
self.on_after_register,
self.on_after_forgot_password,
self.on_after_verification_request,
)
fastapi_users = init_users_api(app, user_manager)
current_active_user = self.fastapi_users.current_user(active=True)
current_active_user = fastapi_users.current_user(active=True)
self.archive_ops = init_archives_api(
self.app, self.mdb, self.fastapi_users, self.email, current_active_user
)
archive_ops = init_archives_api(
app, mdb, user_manager, email, current_active_user
)
# pylint: disable=import-outside-toplevel
if os.environ.get("KUBERNETES_SERVICE_HOST"):
from k8sman import K8SManager
user_manager.set_archive_ops(archive_ops)
self.crawl_manager = K8SManager()
else:
from dockerman import DockerManager
# pylint: disable=import-outside-toplevel
if os.environ.get("KUBERNETES_SERVICE_HOST"):
from k8sman import K8SManager
self.crawl_manager = DockerManager(self.archive_ops)
crawl_manager = K8SManager()
else:
from dockerman import DockerManager
init_storages_api(self.archive_ops, self.crawl_manager, current_active_user)
crawl_manager = DockerManager(archive_ops)
self.crawl_config_ops = init_crawl_config_api(
self.mdb,
current_active_user,
self.archive_ops,
self.crawl_manager,
)
init_storages_api(archive_ops, crawl_manager, current_active_user)
self.crawls = init_crawls_api(
self.app,
self.mdb,
os.environ.get("REDIS_URL"),
self.crawl_manager,
self.crawl_config_ops,
self.archive_ops,
)
crawl_config_ops = init_crawl_config_api(
mdb,
current_active_user,
archive_ops,
crawl_manager,
)
self.coll_ops = init_collections_api(
self.mdb, self.crawls, self.archive_ops, self.crawl_manager
)
crawls = init_crawls_api(
app,
mdb,
os.environ.get("REDIS_URL"),
crawl_manager,
crawl_config_ops,
archive_ops,
)
self.crawl_config_ops.set_coll_ops(self.coll_ops)
coll_ops = init_collections_api(
mdb, crawls, archive_ops, crawl_manager
)
self.app.include_router(self.archive_ops.router)
crawl_config_ops.set_coll_ops(coll_ops)
@app.get("/healthz")
async def healthz():
return {}
app.include_router(archive_ops.router)
# pylint: disable=no-self-use, unused-argument
async def on_after_register(self, user: UserDB, request: Request):
"""callback after registeration"""
print(f"User {user.id} has registered.")
req_data = await request.json()
if req_data.get("newArchive"):
print(f"Creating new archive for {user.id}")
archive_name = req_data.get("name") or f"{user.email} Archive"
await self.archive_ops.create_new_archive_for_user(
archive_name=archive_name,
storage_name="default",
user=user,
)
if req_data.get("inviteToken"):
try:
await self.archive_ops.handle_new_user_invite(
req_data.get("inviteToken"), user
)
except HTTPException as exc:
print(exc)
# pylint: disable=no-self-use, unused-argument
def on_after_forgot_password(self, user: UserDB, token: str, request: Request):
"""callback after password forgot"""
print(f"User {user.id} has forgot their password. Reset token: {token}")
# pylint: disable=no-self-use, unused-argument
def on_after_verification_request(self, user: UserDB, token: str, request: Request):
"""callback after verification request"""
self.email.send_user_validation(token, user.email)
@app.get("/healthz")
async def healthz():
return {}
# ============================================================================
@app.on_event("startup")
async def startup():
"""init on startup"""
BrowsertrixAPI(app)
main()

View File

@ -1,5 +1,5 @@
uvicorn
fastapi-users[mongodb]==6.0.0
fastapi-users[mongodb]==8.1.2
loguru
aiofiles
kubernetes-asyncio

View File

@ -4,6 +4,7 @@ FastAPI user handling (via fastapi-users)
import os
import uuid
import asyncio
from datetime import datetime
@ -13,10 +14,14 @@ from enum import IntEnum
from pydantic import BaseModel
from fastapi_users import FastAPIUsers, models
from fastapi import Request, HTTPException
from fastapi_users import FastAPIUsers, models, BaseUserManager
from fastapi_users.authentication import JWTAuthentication
from fastapi_users.db import MongoDBUserDatabase
# ============================================================================
PASSWORD_SECRET = os.environ.get("PASSWORD_SECRET", uuid.uuid4().hex)
@ -72,18 +77,75 @@ class UserDB(User, models.BaseUserDB):
# ============================================================================
# pylint: disable=too-few-public-methods
class UserDBOps(MongoDBUserDatabase):
""" User DB Operations wrapper """
# ============================================================================
def init_users_api(
app,
mdb,
on_after_register=None,
on_after_forgot_password=None,
after_verification_request=None,
):
class UserManager(BaseUserManager[UserCreate, UserDB]):
""" Browsertrix UserManager """
user_db_model = UserDB
reset_password_token_secret = PASSWORD_SECRET
verification_token_secret = PASSWORD_SECRET
def __init__(self, user_db, email):
super().__init__(user_db)
self.email = email
self.archive_ops = None
def set_archive_ops(self, ops):
""" set archive ops """
self.archive_ops = ops
# pylint: disable=no-self-use, unused-argument
async def on_after_register(self, user: UserDB, request: Optional[Request] = None):
"""callback after registeration"""
print(f"User {user.id} has registered.")
req_data = await request.json()
if req_data.get("newArchive"):
print(f"Creating new archive for {user.id}")
archive_name = req_data.get("name") or f"{user.email} Archive"
await self.archive_ops.create_new_archive_for_user(
archive_name=archive_name,
storage_name="default",
user=user,
)
if req_data.get("inviteToken"):
try:
await self.archive_ops.handle_new_user_invite(
req_data.get("inviteToken"), user
)
except HTTPException as exc:
print(exc)
asyncio.create_task(self.request_verify(user, request))
# pylint: disable=no-self-use, unused-argument
async def on_after_forgot_password(
self, user: UserDB, token: str, request: Optional[Request] = None
):
"""callback after password forgot"""
print(f"User {user.id} has forgot their password. Reset token: {token}")
self.email.send_user_forgot_password(user.email, token)
# pylint: disable=no-self-use, unused-argument
async def on_after_request_verify(
self, user: UserDB, token: str, request: Optional[Request] = None
):
"""callback after verification request"""
self.email.send_user_validation(user.email, token)
# ============================================================================
def init_user_manager(mdb, emailsender):
"""
Load users table and init /users routes
"""
@ -92,12 +154,18 @@ def init_users_api(
user_db = UserDBOps(UserDB, user_collection)
return UserManager(user_db, emailsender)
# ============================================================================
def init_users_api(app, user_manager):
""" init fastapi_users """
jwt_authentication = JWTAuthentication(
secret=PASSWORD_SECRET, lifetime_seconds=3600, tokenUrl="/auth/jwt/login"
)
fastapi_users = FastAPIUsers(
user_db,
lambda: user_manager,
[jwt_authentication],
User,
UserCreate,
@ -111,21 +179,17 @@ def init_users_api(
tags=["auth"],
)
app.include_router(
fastapi_users.get_register_router(on_after_register),
fastapi_users.get_register_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_reset_password_router(
PASSWORD_SECRET, after_forgot_password=on_after_forgot_password
),
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
fastapi_users.get_verify_router(
PASSWORD_SECRET, after_verification_request=after_verification_request
),
fastapi_users.get_verify_router(),
prefix="/auth",
tags=["auth"],
)

View File

@ -8,6 +8,8 @@ metadata:
data:
MONGO_HOST: {{ .Values.mongo_host }}
APP_ORIGIN: {{.Values.ingress.scheme }}://{{ .Values.ingress.host | default "localhost:9870" }}
CRAWLER_NAMESPACE: {{ .Values.crawler_namespace }}
CRAWLER_IMAGE: {{ .Values.crawler_image }}
CRAWLER_PULL_POLICY: {{ .Values.crawler_pull_policy }}

View File

@ -17,7 +17,11 @@ stringData:
MC_HOST: "{{ $.Values.minio_scheme }}://{{ .access_key }}:{{ .secret_key }}@{{ $.Values.minio_host }}"
{{- end }}
{{- end }}
EMAIL_SMTP_PORT: "{{ .Values.email.smtp_port }}"
EMAIL_SMTP_HOST: "{{ .Values.email.smtp_host }}"
EMAIL_SENDER: "{{ .Values.email.sender_email }}"
EMAIL_PASSWORD: "{{ .Values.email.password }}"
{{- range $storage := .Values.storages }}
---

View File

@ -96,6 +96,17 @@ storages:
endpoint_url: "http://local-minio.default:9000/"
# Email Options
# =========================================
email:
# email sending is enabled when 'smtp_host' is set to non-empty value
#ex: smtp_host: smtp.gmail.com
smtp_host: ""
smtp_port: 587
sender_email: example@example.com
password: password
# Deployment options
# =========================================