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