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() {
-
-
-
-
-
-
-
- mdi-login
-
- {{ t("login.login") }}
-
-
-
-
-
-
-
-
- {{ t("login.or") }}
-
-
-
-
-
-
-
- mdi-key
-
-
-
-
- {{
- t("login.login-oidc", {
- oidc: oidcProvider || "OIDC",
- })
- }}
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ mdi-login
+
+ {{ t("login.login") }}
+
+
+
+
+
+
+
+
+ {{ t("login.or") }}
+
+
+
+
+
+
+
+ mdi-key
+
+
+
+
+ {{
+ t("login.login-oidc", {
+ oidc: oidcProvider || "OIDC",
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-lock-reset
+
+ {{ t("login.send-reset-link") }}
+
+
+
+
+
+ {{ t("common.cancel") }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-send
+
+ {{ t("login.reset-password") }}
+
+
+
+
+
+
+
+
+
+