diff --git a/media_manager/auth/config.py b/media_manager/auth/config.py index 966eab3..0913c7c 100644 --- a/media_manager/auth/config.py +++ b/media_manager/auth/config.py @@ -10,11 +10,20 @@ class AuthConfig(BaseSettings): token_secret: str = Field(default_factory=secrets.token_hex) session_lifetime: int = 60 * 60 * 24 admin_email: list[str] = [] + email_password_resets: bool = False @property def jwt_signing_key(self): return self._jwt_signing_key +class EmailConfig(BaseSettings): + model_config = SettingsConfigDict(env_prefix="EMAIL_") + smtp_host: str + smtp_port: int + smtp_user: str + smtp_password: str + from_email: str + use_tls: bool = False class OpenIdConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="OPENID_") diff --git a/media_manager/auth/users.py b/media_manager/auth/users.py index f787398..66305a8 100644 --- a/media_manager/auth/users.py +++ b/media_manager/auth/users.py @@ -1,3 +1,4 @@ +import logging import os import uuid from typing import Optional @@ -13,13 +14,20 @@ from fastapi_users.authentication import ( from fastapi_users.db import SQLAlchemyUserDatabase from httpx_oauth.clients.openid import OpenID from fastapi.responses import RedirectResponse, Response +from pydantic import AnyHttpUrl from starlette import status -from media_manager.auth.config import AuthConfig, OpenIdConfig +from media_manager.auth.config import AuthConfig, OpenIdConfig, EmailConfig from media_manager.auth.db import User, get_user_db from media_manager.auth.schemas import UserUpdate from media_manager.config import BasicConfig +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +log = logging.getLogger(__name__) + config = AuthConfig() SECRET = config.token_secret LIFETIME = config.session_lifetime @@ -47,7 +55,7 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): verification_token_secret = SECRET async def on_after_register(self, user: User, request: Optional[Request] = None): - print(f"User {user.id} has registered.") + log.info(f"User {user.id} has registered.") if user.email in config.admin_email: updated_user = UserUpdate(is_superuser=True, is_verified=True) await self.update(user=user, user_update=updated_user) @@ -55,20 +63,55 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): async def on_after_forgot_password( self, user: User, token: str, request: Optional[Request] = None ): - print(f"User {user.id} has forgot their password. Reset token: {token}") + link = f"{BasicConfig().FRONTEND_URL}login/reset-password?token={token}" + log.info(f"User {user.id} has forgot their password. Reset Link: {link}") + if not config.email_password_resets: + log.info("Email password resets are disabled, not sending email.") + return + + email_conf = EmailConfig() + subject = "MediaManager - Password Reset Request" + html = f"""\ + + +

Hi {user.email}, +
+
+ if you forgot your password, reset you password here.
+ If you did not request a password reset, you can ignore this email.

+
+
+ If the link does not work, copy the following link into your browser: {link}
+ + + """ + + message = MIMEMultipart() + message["From"] = email_conf.from_email + message["To"] = user.email + message["Subject"] = subject + message.attach(MIMEText(html, "html")) + + with smtplib.SMTP(email_conf.smtp_host, email_conf.smtp_port) as server: + if email_conf.use_tls: + server.starttls() + server.login(email_conf.smtp_user, email_conf.smtp_password) + server.sendmail(email_conf.from_email,user.email, message.as_string()) + log.info(f"Sent password reset email to {user.email}") + async def on_after_reset_password( self, user: User, request: Optional[Request] = None ): - print(f"User {user.id} has reset their password.") + log.info(f"User {user.id} has reset their password.") async def on_after_request_verify( self, user: User, token: str, request: Optional[Request] = None ): - print(f"Verification requested for user {user.id}. Verification token: {token}") + log.info(f"Verification requested for user {user.id}. Verification token: {token}") async def on_after_verify(self, user: User, request: Optional[Request] = None): - print(f"User {user.id} has been verified") + log.info(f"User {user.id} has been verified") async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)): diff --git a/media_manager/config.py b/media_manager/config.py index ecfb626..05f1913 100644 --- a/media_manager/config.py +++ b/media_manager/config.py @@ -9,6 +9,6 @@ class BasicConfig(BaseSettings): tv_directory: Path = "/data/tv" movie_directory: Path = "/data/movies" torrent_directory: Path = "/data/torrents" - FRONTEND_URL: AnyHttpUrl = "http://localhost:3000" + FRONTEND_URL: AnyHttpUrl = "http://localhost:3000/" CORS_URLS: list[str] = [] DEVELOPMENT: bool = False diff --git a/web/src/lib/components/login-form.svelte b/web/src/lib/components/login-form.svelte index bdd9c76..194cb70 100644 --- a/web/src/lib/components/login-form.svelte +++ b/web/src/lib/components/login-form.svelte @@ -184,8 +184,7 @@
- - + Forgot your password?
diff --git a/web/src/routes/login/forgot-password/+page.svelte b/web/src/routes/login/forgot-password/+page.svelte new file mode 100644 index 0000000..fcc4d9d --- /dev/null +++ b/web/src/routes/login/forgot-password/+page.svelte @@ -0,0 +1,140 @@ + + +
+
+ +
+
+ + + Forgot Password + + {#if isSuccess} + We've sent a password reset link to your email address if a SMTP server is configured. Check your inbox and follow the instructions to reset your password. + If you didn't receive an email, please contact an administrator, the reset link will be in the logs of MediaManager. + {:else} + Enter your email address and we'll send you a link to reset your password. + {/if} + + + + {#if isSuccess} +
+
+

+ Password reset email sent successfully! +

+
+
+

Didn't receive the email? Check your spam folder or

+ +
+
+ {:else} +
+
+ + +
+ +
+ {/if} + +
+
+
+
+
+ +
diff --git a/web/src/routes/login/reset-password/+page.svelte b/web/src/routes/login/reset-password/+page.svelte new file mode 100644 index 0000000..44ab156 --- /dev/null +++ b/web/src/routes/login/reset-password/+page.svelte @@ -0,0 +1,151 @@ + + +
+
+ +
+
+ + + Reset Password + + Enter your new password below. + + + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+
+
+ +
diff --git a/web/src/routes/login/reset-password/+page.ts b/web/src/routes/login/reset-password/+page.ts new file mode 100644 index 0000000..3c72626 --- /dev/null +++ b/web/src/routes/login/reset-password/+page.ts @@ -0,0 +1,6 @@ +import type {PageLoad} from './$types'; + +export function load({ url }) { + console.log("got token: ",url.searchParams.get('token')); + return {token: url.searchParams.get('token') }; +} \ No newline at end of file