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:
parent
3fa85c83f2
commit
d0b54dd752
@ -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
|
||||
|
@ -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)
|
||||
|
136
backend/main.py
136
backend/main.py
@ -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()
|
||||
|
@ -1,5 +1,5 @@
|
||||
uvicorn
|
||||
fastapi-users[mongodb]==6.0.0
|
||||
fastapi-users[mongodb]==8.1.2
|
||||
loguru
|
||||
aiofiles
|
||||
kubernetes-asyncio
|
||||
|
@ -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"],
|
||||
)
|
||||
|
@ -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 }}
|
||||
|
@ -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 }}
|
||||
---
|
||||
|
@ -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
|
||||
# =========================================
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user