diff --git a/backend/config/__init__.py b/backend/config/__init__.py index 583f333bc..567ada657 100644 --- a/backend/config/__init__.py +++ b/backend/config/__init__.py @@ -12,6 +12,8 @@ def str_to_bool(value: str) -> bool: return value.lower() in ("true", "1") +ROMM_BASE_URL = os.environ.get("ROMM_BASE_URL", "http://0.0.0.0") + # GUNICORN DEV_MODE: Final = str_to_bool(os.environ.get("DEV_MODE", "false")) DEV_HOST: Final = os.environ.get("DEV_HOST", "127.0.0.1") diff --git a/backend/endpoints/auth.py b/backend/endpoints/auth.py index 4bb8c9626..6c9941761 100644 --- a/backend/endpoints/auth.py +++ b/backend/endpoints/auth.py @@ -12,11 +12,14 @@ from exceptions.auth_exceptions import ( OIDCNotConfiguredException, UserDisabledException, ) -from fastapi import Depends, HTTPException, Request, status +from fastapi import Body, Depends, HTTPException, Request, status from fastapi.responses import RedirectResponse from fastapi.security.http import HTTPBasic from handler.auth import auth_handler, oauth_handler, oidc_handler from handler.database import db_user_handler +from logger.formatter import CYAN +from logger.formatter import highlight as hl +from logger.logger import log from utils.router import APIRouter ACCESS_TOKEN_EXPIRE_MINUTES: Final = 30 @@ -270,3 +273,49 @@ async def auth_openid(request: Request): ) return RedirectResponse(url="/") + + +@router.post("/forgot-password") +def request_password_reset(username: str = Body(..., embed=True)) -> MessageResponse: + """Request password reset by email. + + Args: + email (str): User's email + + Returns: + MessageResponse: Confirmation message + """ + user = db_user_handler.get_user_by_username(username) + + if user: + auth_handler.generate_password_reset_token(user) + else: + log.warning( + f"Reset password link requested for a user {hl(username, color=CYAN)}, but that username does not exist." + ) + + return {"msg": "If the username exists, a reset link has been sent."} + + +@router.post("/reset-password") +def reset_password( + token: str = Body(..., embed=True), + new_password: str = Body(..., embed=True), +) -> MessageResponse: + """Reset password using the token. + + Args: + token (str): Reset token from the URL + new_password (str): New user password + + Returns: + MessageResponse: Confirmation message + """ + user = auth_handler.verify_password_reset_token(token) + + auth_handler.set_user_new_password(user, new_password) + + log.info( + f"Password was successfully reset for user {hl(user.username, color=CYAN)}." + ) + return {"msg": "Password has been reset successfully."} diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index 6580f6a30..c74a4145d 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -2,11 +2,11 @@ import uuid from datetime import datetime, timedelta, timezone from typing import Any -from config import OIDC_ENABLED, ROMM_AUTH_SECRET_KEY +from config import OIDC_ENABLED, ROMM_AUTH_SECRET_KEY, ROMM_BASE_URL from decorators.auth import oauth from exceptions.auth_exceptions import OAuthCredentialsException, UserDisabledException from fastapi import HTTPException, status -from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY +from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY, TokenPurpose from joserfc import jwt from joserfc.errors import BadSignatureError from joserfc.jwk import OctKey @@ -20,6 +20,7 @@ from starlette.requests import HTTPConnection class AuthHandler: def __init__(self) -> None: self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + self.reset_passwd_token_expires_in_minutes = 10 def verify_password(self, plain_password, hashed_password): return self.pwd_context.verify(plain_password, hashed_password) @@ -63,6 +64,77 @@ class AuthHandler: return user + def generate_password_reset_token(self, user: Any) -> None: + now = datetime.now(timezone.utc) + + to_encode = { + "sub": user.username, + "email": user.email, + "type": TokenPurpose.RESET, + "iat": int(now.timestamp()), + "exp": int( + ( + now + timedelta(minutes=self.reset_passwd_token_expires_in_minutes) + ).timestamp() + ), + "jti": str(uuid.uuid4()), + } + token = jwt.encode( + {"alg": ALGORITHM}, to_encode, OctKey.import_key(ROMM_AUTH_SECRET_KEY) + ) + log.info( + f"Reset password link requested for {hl(user.username, color=CYAN)}. Reset link: {hl(f'{ROMM_BASE_URL}/reset-password?token={token}')}" + ) + + def verify_password_reset_token(self, token: str) -> Any: + """Verify the password reset token. + + Args: + token (str): The token to verify. + + Raises: + HTTPException: If the token is invalid or expired. + HTTPException: If the token is missing or malformed. + HTTPException: If the user is not found. + HTTPException: If the token is not for password reset. + """ + from handler.database import db_user_handler + + try: + payload = jwt.decode(token, ROMM_AUTH_SECRET_KEY, algorithms=[ALGORITHM]) + except (BadSignatureError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Invalid token") from exc + + if payload.claims.get("type") != TokenPurpose.RESET: + raise HTTPException(status_code=400, detail="Invalid token purpose") + + username = payload.claims.get("sub") + if not username: + raise HTTPException(status_code=400, detail="Invalid token payload") + + user = db_user_handler.get_user_by_username(username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + now = datetime.now(timezone.utc).timestamp() + if now > payload.claims.get("exp"): + raise HTTPException(status_code=400, detail="Token has expired") + + return user + + def set_user_new_password(self, user: Any, new_password: str) -> None: + """ + Set the new password for the user. + Args: + user (Any): The user object. + new_password (str): The new password to set. + """ + from handler.database import db_user_handler + + db_user_handler.update_user( + user.id, {"hashed_password": self.get_password_hash(new_password)} + ) + class OAuthHandler: def __init__(self) -> None: diff --git a/backend/handler/auth/constants.py b/backend/handler/auth/constants.py index 7b4c8e9b9..a3012e793 100644 --- a/backend/handler/auth/constants.py +++ b/backend/handler/auth/constants.py @@ -59,3 +59,7 @@ READ_SCOPES: Final = list(READ_SCOPES_MAP.keys()) WRITE_SCOPES: Final = READ_SCOPES + list(WRITE_SCOPES_MAP.keys()) EDIT_SCOPES: Final = WRITE_SCOPES + list(EDIT_SCOPES_MAP.keys()) FULL_SCOPES: Final = EDIT_SCOPES + list(FULL_SCOPES_MAP.keys()) + + +class TokenPurpose(enum.StrEnum): + RESET = "reset" diff --git a/docker/init_scripts/init b/docker/init_scripts/init index 460860d7b..ad0516332 100755 --- a/docker/init_scripts/init +++ b/docker/init_scripts/init @@ -113,7 +113,7 @@ start_bin_nginx() { # if container runs as root, drop permissions nginx -g 'user romm;' fi - info_log "🚀 RomM is now available at http://0.0.0.0:8080" + info_log "🚀 RomM is now available at ${ROMM_BASE_URL}:8080" } # Commands to start valkey-server (handling PID creation internally) diff --git a/frontend/src/components/Settings/Administration/Users/Table.vue b/frontend/src/components/Settings/Administration/Users/Table.vue index 430dd1427..511747874 100644 --- a/frontend/src/components/Settings/Administration/Users/Table.vue +++ b/frontend/src/components/Settings/Administration/Users/Table.vue @@ -32,6 +32,12 @@ const HEADERS = [ sortable: true, key: "username", }, + { + title: "Email", + align: "start", + sortable: true, + key: "email", + }, { title: "Role", align: "start", diff --git a/frontend/src/locales/de_DE/login.json b/frontend/src/locales/de_DE/login.json index b4ab6594e..0c5166fe6 100644 --- a/frontend/src/locales/de_DE/login.json +++ b/frontend/src/locales/de_DE/login.json @@ -3,5 +3,11 @@ "login-oidc": "Einloggen mit {oidc}", "or": "oder", "password": "Passwort", - "username": "Benutzername" + "username": "Benutzername", + "forgot-password": "Passwort vergessen?", + "send-reset-link": "Link zum Zurücksetzen senden", + "reset-sent": "Link zum Zurücksetzen wurde gesendet. Kontaktieren Sie Ihren Administrator.", + "confirm-password": "Passwort bestätigen", + "reset-password": "Passwort zurücksetzen", + "back-to-login": "Zurück zum Login" } diff --git a/frontend/src/locales/en_GB/login.json b/frontend/src/locales/en_GB/login.json index 065254497..66cd7bbba 100644 --- a/frontend/src/locales/en_GB/login.json +++ b/frontend/src/locales/en_GB/login.json @@ -3,5 +3,11 @@ "login-oidc": "Login with {oidc}", "or": "or", "password": "Password", - "username": "Username" + "username": "Username", + "forgot-password": "Forgot your password?", + "send-reset-link": "Send reset link", + "reset-sent": "Reset link sent. Contact your admin.", + "confirm-password": "Confirm password", + "reset-password": "Reset password", + "back-to-login": "Back to login" } diff --git a/frontend/src/locales/en_US/login.json b/frontend/src/locales/en_US/login.json index 065254497..66cd7bbba 100644 --- a/frontend/src/locales/en_US/login.json +++ b/frontend/src/locales/en_US/login.json @@ -3,5 +3,11 @@ "login-oidc": "Login with {oidc}", "or": "or", "password": "Password", - "username": "Username" + "username": "Username", + "forgot-password": "Forgot your password?", + "send-reset-link": "Send reset link", + "reset-sent": "Reset link sent. Contact your admin.", + "confirm-password": "Confirm password", + "reset-password": "Reset password", + "back-to-login": "Back to login" } diff --git a/frontend/src/locales/es_ES/login.json b/frontend/src/locales/es_ES/login.json index cd2c777d2..33a6df004 100644 --- a/frontend/src/locales/es_ES/login.json +++ b/frontend/src/locales/es_ES/login.json @@ -3,5 +3,11 @@ "login-oidc": "Iniciar sesión con {oidc}", "or": "o", "password": "Contraseña", - "username": "Usuario" + "username": "Usuario", + "forgot-password": "¿Olvidates tu contraseña?", + "send-reset-link": "Enviar enlace de restablecimiento", + "reset-sent": "Enlace de restablecimiento enviado. Contacta a tu administrador.", + "confirm-password": "Confirmar contraseña", + "reset-password": "Restablecer contraseña", + "back-to-login": "Volver a iniciar sesión" } diff --git a/frontend/src/locales/fr_FR/login.json b/frontend/src/locales/fr_FR/login.json index 304616a5b..d41626279 100644 --- a/frontend/src/locales/fr_FR/login.json +++ b/frontend/src/locales/fr_FR/login.json @@ -3,5 +3,11 @@ "login-oidc": "Se connecter avec {oidc}", "or": "ou", "password": "Mot de passe", - "username": "Nom d'utilisateur" + "username": "Nom d'utilisateur", + "forgot-password": "Mot de passe oublié ?", + "send-reset-link": "Envoyer le lien de réinitialisation", + "reset-sent": "Lien de réinitialisation envoyé. Contactez votre administrateur.", + "confirm-password": "Confirmer le mot de passe", + "reset-password": "Réinitialiser le mot de passe", + "back-to-login": "Retour à la connexion" } diff --git a/frontend/src/locales/it_IT/login.json b/frontend/src/locales/it_IT/login.json index 912931563..8cbf6b976 100644 --- a/frontend/src/locales/it_IT/login.json +++ b/frontend/src/locales/it_IT/login.json @@ -3,5 +3,11 @@ "login-oidc": "Login con {oidc}", "or": "o", "password": "Password", - "username": "Nome utente" + "username": "Nome utente", + "forgot-password": "Hai dimenticato la password?", + "send-reset-link": "Invia link di reimpostazione", + "reset-sent": "Link di reimpostazione inviato. Contatta l'amministratore.", + "confirm-password": "Conferma password", + "reset-password": "Reimposta password", + "back-to-login": "Torna al login" } diff --git a/frontend/src/locales/ja_JP/login.json b/frontend/src/locales/ja_JP/login.json index 90b246a48..a989c42eb 100644 --- a/frontend/src/locales/ja_JP/login.json +++ b/frontend/src/locales/ja_JP/login.json @@ -3,5 +3,11 @@ "login-oidc": "Login with {oidc}", "or": "or", "password": "パスワード", - "username": "ユーザ名" + "username": "ユーザ名", + "forgot-password": "パスワードをお忘れですか?", + "send-reset-link": "リセットリンクを送信", + "reset-sent": "リセットリンクが送信されました。管理者に連絡してください。", + "confirm-password": "パスワードを確認", + "reset-password": "パスワードをリセット", + "back-to-login": "ログインに戻る" } diff --git a/frontend/src/locales/ko_KR/login.json b/frontend/src/locales/ko_KR/login.json index 88ec9403e..4b3dc6465 100644 --- a/frontend/src/locales/ko_KR/login.json +++ b/frontend/src/locales/ko_KR/login.json @@ -3,5 +3,11 @@ "login-oidc": "{oidc}로 로그인", "or": "또는", "password": "패스워드", - "username": "아이디" + "username": "아이디", + "forgot-password": "비밀번호를 잊으셨나요?", + "send-reset-link": "재설정 링크 보내기", + "reset-sent": "재설정 링크가 전송되었습니다. 관리자에게 문의하세요.", + "confirm-password": "비밀번호 확인", + "reset-password": "비밀번호 재설정", + "back-to-login": "로그인으로 돌아가기" } diff --git a/frontend/src/locales/pt_BR/login.json b/frontend/src/locales/pt_BR/login.json index 32a7c8311..0e38907f6 100644 --- a/frontend/src/locales/pt_BR/login.json +++ b/frontend/src/locales/pt_BR/login.json @@ -3,5 +3,11 @@ "login-oidc": "Entrar com {oidc}", "or": "ou", "password": "Senha", - "username": "Nome de usuário" + "username": "Nome de usuário", + "forgot-password": "Esqueceu sua senha?", + "send-reset-link": "Enviar link de redefinição", + "reset-sent": "Link de redefinição enviado. Contate o administrador.", + "confirm-password": "Confirmar senha", + "reset-password": "Redefinir senha", + "back-to-login": "Voltar para o login" } diff --git a/frontend/src/locales/ro_RO/login.json b/frontend/src/locales/ro_RO/login.json index 288133f8d..da1ffe01f 100644 --- a/frontend/src/locales/ro_RO/login.json +++ b/frontend/src/locales/ro_RO/login.json @@ -3,5 +3,11 @@ "login-oidc": "Autentificare cu {oidc}", "or": "sau", "password": "Parolă", - "username": "Nume utilizator" + "username": "Nume utilizator", + "forgot-password": "Ai uitat parola?", + "send-reset-link": "Trimite link de resetare", + "reset-sent": "Linkul de resetare a fost trimis. Contactează administratorul.", + "confirm-password": "Confirmă parola", + "reset-password": "Resetează parola", + "back-to-login": "Înapoi la autentificare" } diff --git a/frontend/src/locales/ru_RU/login.json b/frontend/src/locales/ru_RU/login.json index 0dc139006..d12638ddf 100644 --- a/frontend/src/locales/ru_RU/login.json +++ b/frontend/src/locales/ru_RU/login.json @@ -3,5 +3,11 @@ "login-oidc": "Войти с {oidc}", "or": "или", "password": "Пароль", - "username": "Имя пользователя" + "username": "Имя пользователя", + "forgot-password": "Забыли пароль?", + "send-reset-link": "Отправить ссылку для сброса", + "reset-sent": "Ссылка для сброса отправлена. Обратитесь к администратору.", + "confirm-password": "Подтвердите пароль", + "reset-password": "Сбросить пароль", + "back-to-login": "Вернуться к входу" } diff --git a/frontend/src/locales/zh_CN/login.json b/frontend/src/locales/zh_CN/login.json index 7c3f2a9ae..15ea7151c 100644 --- a/frontend/src/locales/zh_CN/login.json +++ b/frontend/src/locales/zh_CN/login.json @@ -3,5 +3,11 @@ "login-oidc": "使用 {oidc} 登录", "or": "或", "password": "密码", - "username": "用户名" + "username": "用户名", + "forgot-password": "忘记密码?", + "send-reset-link": "发送重置链接", + "reset-sent": "重置链接已发送。请联系管理员。", + "confirm-password": "确认密码", + "reset-password": "重置密码", + "back-to-login": "返回登录" } diff --git a/frontend/src/plugins/router.ts b/frontend/src/plugins/router.ts index f89645de3..cc46e278a 100644 --- a/frontend/src/plugins/router.ts +++ b/frontend/src/plugins/router.ts @@ -15,6 +15,7 @@ import romApi from "@/services/api/rom"; export const ROUTES = { SETUP: "setup", LOGIN: "login", + RESET_PASSWORD: "reset-password", MAIN: "main", HOME: "home", SEARCH: "search", @@ -54,6 +55,17 @@ const routes = [ }, ], }, + { + path: "/reset-password", + component: () => import("@/layouts/Auth.vue"), + children: [ + { + path: "", + name: ROUTES.RESET_PASSWORD, + component: () => import("@/views/Auth/ResetPassword.vue"), + }, + ], + }, { path: "/", name: ROUTES.MAIN, @@ -188,7 +200,12 @@ const routePermissions: RoutePermissions[] = [ function checkRoutePermissions(route: string, user: User | null): boolean { // No checks needed for login and setup pages - if (route === ROUTES.LOGIN || route === ROUTES.SETUP) return true; + if ( + route === ROUTES.LOGIN || + route === ROUTES.SETUP || + route === ROUTES.RESET_PASSWORD + ) + return true; // No user, no access if (!user) return false; @@ -216,7 +233,11 @@ router.beforeEach(async (to, _from, next) => { } // Handle authentication - if (!user.value && currentRoute !== ROUTES.LOGIN) { + if ( + !user.value && + currentRoute !== ROUTES.LOGIN && + currentRoute !== ROUTES.RESET_PASSWORD + ) { return next({ name: ROUTES.LOGIN }); } @@ -238,7 +259,7 @@ router.beforeEach(async (to, _from, next) => { router.afterEach(() => { // Scroll to top to avoid annoying behaviour on mobile - window.scrollTo({ top: 0, left: 0 }); + // window.scrollTo({ top: 0, left: 0 }); }); router.beforeResolve(async () => { diff --git a/frontend/src/services/api/identity.ts b/frontend/src/services/api/identity.ts index 65904926e..b8dadad08 100644 --- a/frontend/src/services/api/identity.ts +++ b/frontend/src/services/api/identity.ts @@ -23,7 +23,22 @@ async function logout(): Promise<{ data: MessageResponse }> { return api.post("/logout"); } +async function requestPasswordReset( + username: string, +): Promise<{ data: MessageResponse }> { + return api.post("/forgot-password", { username }); +} + +async function resetPassword( + token: string, + newPassword: string, +): Promise<{ data: MessageResponse }> { + return api.post("/reset-password", { token, new_password: newPassword }); +} + export default { login, logout, + requestPasswordReset, + resetPassword, }; diff --git a/frontend/src/views/Auth/Login.vue b/frontend/src/views/Auth/Login.vue index e43fe6721..97c8ffc5c 100644 --- a/frontend/src/views/Auth/Login.vue +++ b/frontend/src/views/Auth/Login.vue @@ -10,6 +10,7 @@ import { storeToRefs } from "pinia"; import { inject, ref } from "vue"; import { useRouter } from "vue-router"; import { useI18n } from "vue-i18n"; +import { ROUTES } from "@/plugins/router"; // Props const { t } = useI18n(); @@ -23,11 +24,13 @@ const password = ref(""); const visiblePassword = ref(false); const loggingIn = ref(false); const loggingInOIDC = ref(false); - const { OIDC: { ENABLED: oidcEnabled, PROVIDER: oidcProvider }, FRONTEND: { DISABLE_USERPASS_LOGIN: loginDisabled }, } = heartbeatStore.value; +const forgotMode = ref(false); +const forgotUser = ref(""); +const sendingReset = ref(false); // Functions async function login() { @@ -66,6 +69,28 @@ async function login() { }); } +async function sendReset() { + sendingReset.value = true; + try { + await identityApi.requestPasswordReset(forgotUser.value); + emitter?.emit("snackbarShow", { + msg: t("login.reset-sent"), + icon: "mdi-check-circle", + color: "green", + }); + forgotMode.value = false; + forgotUser.value = ""; + } catch (error: any) { + emitter?.emit("snackbarShow", { + msg: error.response?.data?.detail || error.message || "Error", + icon: "mdi-alert-circle", + color: "red", + }); + } finally { + sendingReset.value = false; + } +} + async function loginOIDC() { loggingInOIDC.value = true; window.open("/api/login/openid", "_self"); @@ -75,93 +100,163 @@ async function loginOIDC() { diff --git a/frontend/src/views/Auth/ResetPassword.vue b/frontend/src/views/Auth/ResetPassword.vue new file mode 100644 index 000000000..95b0086a4 --- /dev/null +++ b/frontend/src/views/Auth/ResetPassword.vue @@ -0,0 +1,118 @@ + + +