Feature: Add "Authentication Method" to allow existing users to sign in with LDAP (#2143)

* adds authentication method for users

* fix db migration with postgres

* tests for auth method

* update migration ids

* hide auth method on user creation form

* (docs): Added documentation for the new authentication method

* update migration

* add  to auto-form instead of having hidden fields
This commit is contained in:
Carter 2023-02-26 13:12:16 -06:00 committed by GitHub
parent 39012adcc1
commit 2e6ad5da8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 213 additions and 24 deletions

View File

@ -0,0 +1,43 @@
"""add auth_method to user table
Revision ID: 38514b39a824
Revises: b04a08da2108
Create Date: 2023-02-22 21:45:52.900964
"""
import sqlalchemy as sa
import mealie.db.migration_types
from alembic import op
# revision identifiers, used by Alembic.
revision = "38514b39a824"
down_revision = "b04a08da2108"
branch_labels = None
depends_on = None
def is_postgres():
return op.get_context().dialect.name == "postgresql"
authMethod = sa.Enum("MEALIE", "LDAP", name="authmethod")
def upgrade():
if is_postgres():
authMethod.create(op.get_bind())
op.add_column(
"users",
sa.Column("auth_method", authMethod, nullable=False, server_default="MEALIE"),
)
op.execute("UPDATE users SET auth_method = 'LDAP' WHERE password = 'LDAP'")
def downgrade():
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_column("auth_method")
if is_postgres():
authMethod.drop(op.get_bind())

View File

@ -17,7 +17,6 @@
| TZ | UTC | Must be set to get correct date/time on the server |
| ALLOW_SIGNUP | true | Allow user sign-up without token (should match frontend env) |
### Security
| Variables | Default | Description |
@ -36,7 +35,6 @@
| POSTGRES_PORT | 5432 | Postgres database port |
| POSTGRES_DB | mealie | Postgres database name |
### Email
| Variables | Default | Description |
@ -50,6 +48,7 @@
| SMTP_PASSWORD | None | Required if SMTP_AUTH_STRATEGY is 'TLS' or 'SSL' |
### Webworker
Changing the webworker settings may cause unforeseen memory leak issues with Mealie. It's best to leave these at the defaults unless you begin to experience issues with multiple users. Exercise caution when changing these settings
| Variables | Default | Description |
@ -59,9 +58,9 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
| MAX_WORKERS | 1 | Set the maximum number of workers to use. Default is not set meaning unlimited. More info [here][max_workers] |
| WEB_CONCURRENCY | 1 | Override the automatic definition of number of workers. More info [here][web_concurrency] |
### LDAP
<!-- prettier-ignore -->
| Variables | Default | Description |
| ------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------ |
| LDAP_AUTH_ENABLED | False | Authenticate via an external LDAP server in addidion to built-in Mealie auth |
@ -71,7 +70,7 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
| LDAP_BASE_DN | None | Starting point when searching for users authentication (e.g. `CN=Users,DC=xx,DC=yy,DC=de`) |
| LDAP_QUERY_BIND | None | A bind user for LDAP search queries (e.g. `cn=admin,cn=users,dc=example,dc=com`) |
| LDAP_QUERY_PASSWORD | None | The password for the bind user used in LDAP_QUERY_BIND |
| LDAP_USER_FILTER | None | The LDAP search filter to find users (e.g. `(&( | ({id_attribute}={input})({mail_attribute}={input}))(objectClass=person))`).<br/> **Note** `id_attribute` and `mail_attribute` will be replaced with `LDAP_ID_ATTRIBUTE` and `LDAP_MAIL_ATTRIBUTE`, respectively. `input` will be replaced with either the username or email the user logs in with. |
| LDAP_USER_FILTER | None | The LDAP search filter to find users (e.g. `(&(|({id_attribute}={input})({mail_attribute}={input}))(objectClass=person))`).<br/> **Note** `id_attribute` and `mail_attribute` will be replaced with `LDAP_ID_ATTRIBUTE` and `LDAP_MAIL_ATTRIBUTE`, respectively. `input` will be replaced with either the username or email the user logs in with. |
| LDAP_ADMIN_FILTER | None | Optional LDAP filter, which tells Mealie the LDAP user is an admin (e.g. `(memberOf=cn=admins,dc=example,dc=com)`) |
| LDAP_ID_ATTRIBUTE | uid | The LDAP attribute that maps to the user's id |
| LDAP_NAME_ATTRIBUTE | name | The LDAP attribute that maps to the user's name |

View File

@ -0,0 +1,8 @@
# LDAP Authentication
If LDAP is enabled and [configured properly](../installation/backend-config.md), users will be able to log in with their LDAP credentials. If the user does not already have an account in Mealie, then one will be created.
If the user already has an account in Mealie and wants to use their LDAP credentials instead, then you can go to the **User Management** page in the admin panel and change the "Authentication Backend" from `Mealie` to `LDAP`. If for whatever reason, the user no longer wants to use LDAP authentication, then you can switch this back to `Mealie`.
!!! warning "Head's Up"
If you switch a user from `LDAP` to `Mealie` who was initially created by LDAP, then the user will have to reset their password through the password reset flow.

File diff suppressed because one or more lines are too long

View File

@ -75,6 +75,7 @@ nav:
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
- Usage:
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
- LDAP Authentication: "documentation/getting-started/usage/ldap.md"
- Community Guides:
- iOS Shortcuts: "documentation/community-guide/ios.md"

View File

@ -18,7 +18,7 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:disabled="updateMode && inputField.disableUpdate"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
@change="emitBlur"
/>
@ -26,8 +26,8 @@
<v-text-field
v-else-if="inputField.type === fieldTypes.TEXT || inputField.type === fieldTypes.PASSWORD"
v-model="value[inputField.varName]"
:readonly="inputField.disableUpdate && updateMode"
:disabled="inputField.disableUpdate && updateMode"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
filled
:type="inputField.type === fieldTypes.PASSWORD ? 'password' : 'text'"
rounded
@ -46,8 +46,8 @@
<v-textarea
v-else-if="inputField.type === fieldTypes.TEXT_AREA"
v-model="value[inputField.varName]"
:readonly="inputField.disableUpdate && updateMode"
:disabled="inputField.disableUpdate && updateMode"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
filled
rounded
class="rounded-lg"
@ -66,7 +66,8 @@
<v-select
v-else-if="inputField.type === fieldTypes.SELECT"
v-model="value[inputField.varName]"
:readonly="inputField.disableUpdate && updateMode"
:readonly="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
filled
rounded
class="rounded-lg"
@ -75,6 +76,8 @@
:name="inputField.varName"
:items="inputField.options"
:return-object="false"
:hint="inputField.hint"
persistent-hint
lazy-validation
@blur="emitBlur"
>

View File

@ -29,6 +29,14 @@ export const useUserForm = () => {
type: fieldTypes.PASSWORD,
rules: ["required", "minLength:8"],
},
{
label: "Authentication Method",
varName: "authMethod",
type: fieldTypes.SELECT,
hint: "This specifies how a user will authenticate with Mealie. If you're not sure, choose 'Mealie'",
disableCreate: true,
options: [{ text: "Mealie" }, { text: "LDAP" }],
},
{
section: "Permissions",
label: "Administrator",

View File

@ -667,6 +667,7 @@
"admin": "Admin",
"are-you-sure-you-want-to-delete-the-link": "Are you sure you want to delete the link <b>{link}<b/>?",
"are-you-sure-you-want-to-delete-the-user": "Are you sure you want to delete the user <b>{activeName} ID: {activeId}<b/>?",
"auth-method": "Auth Method",
"confirm-link-deletion": "Confirm Link Deletion",
"confirm-password": "Confirm Password",
"confirm-user-deletion": "Confirm User Deletion",

View File

@ -5,6 +5,8 @@
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export type AuthMethod = "Mealie" | "LDAP";
export interface ChangePassword {
currentPassword: string;
newPassword: string;
@ -53,6 +55,7 @@ export interface UserOut {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group: string;
advanced?: boolean;
@ -99,6 +102,7 @@ export interface PrivateUser {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group: string;
advanced?: boolean;
@ -150,6 +154,7 @@ export interface UserBase {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group?: string;
advanced?: boolean;
@ -162,6 +167,7 @@ export interface UserFavorites {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group?: string;
advanced?: boolean;
@ -214,6 +220,7 @@ export interface UserIn {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group?: string;
advanced?: boolean;

View File

@ -67,6 +67,7 @@ export default defineComponent({
canManage: false,
canOrganize: false,
password: "",
authMethod: "Mealie",
},
});
@ -92,5 +93,4 @@ export default defineComponent({
});
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View File

@ -131,6 +131,7 @@ export default defineComponent({
{ text: i18n.t("user.full-name"), value: "fullName" },
{ text: i18n.t("user.email"), value: "email" },
{ text: i18n.t("group.group"), value: "group" },
{ text: i18n.t("user.auth-method"), value: "authMethod" },
{ text: i18n.t("user.admin"), value: "admin" },
{ text: i18n.t("general.delete"), value: "actions", sortable: false, align: "center" },
];

View File

@ -1,5 +1,10 @@
type FormFieldType = "text" | "textarea" | "list" | "select" | "object" | "boolean" | "color" | "password";
export interface FormSelectOption {
text: string;
description?: string;
}
export interface FormField {
section?: string;
sectionDetails?: string;
@ -9,6 +14,8 @@ export interface FormField {
type: FormFieldType;
rules?: string[];
disableUpdate?: boolean;
disableCreate?: boolean;
options?: FormSelectOption[];
}
export type AutoFormItems = FormField[];

View File

@ -6,6 +6,7 @@ from jose import jwt
from mealie.core.config import get_app_settings
from mealie.core.security.hasher import get_hasher
from mealie.db.models.users.users import AuthMethod
from mealie.repos.all_repositories import get_repositories
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.user import PrivateUser
@ -115,6 +116,7 @@ def user_from_ldap(db: AllRepositories, username: str, password: str) -> Private
"full_name": full_name,
"email": email,
"admin": False,
"auth_method": AuthMethod.LDAP,
},
)
@ -134,7 +136,7 @@ def authenticate_user(session, email: str, password: str) -> PrivateUser | bool:
if not user:
user = db.users.get_one(email, "username", any_case=True)
if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP"):
if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP" or user.auth_method == AuthMethod.LDAP):
return user_from_ldap(db, email, password)
if not user:
# To prevent user enumeration we perform the verify_password computation to ensure

View File

@ -1,7 +1,8 @@
import enum
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, orm
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, orm
from sqlalchemy.orm import Mapped, mapped_column
from mealie.core.config import get_app_settings
@ -32,6 +33,11 @@ class LongLiveToken(SqlAlchemyBase, BaseMixins):
self.user_id = user_id
class AuthMethod(enum.Enum):
MEALIE = "Mealie"
LDAP = "LDAP"
class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate)
@ -39,6 +45,7 @@ class User(SqlAlchemyBase, BaseMixins):
username: Mapped[str | None] = mapped_column(String, index=True, unique=True)
email: Mapped[str | None] = mapped_column(String, unique=True, index=True)
password: Mapped[str | None] = mapped_column(String)
auth_method: Mapped[Enum(AuthMethod)] = mapped_column(Enum(AuthMethod), default=AuthMethod.MEALIE)
admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
advanced: Mapped[bool | None] = mapped_column(Boolean, default=False)

View File

@ -11,10 +11,11 @@
"user": {
"user-updated": "User updated",
"password-updated": "Password updated",
"invalid-current-password": "Invalid current password"
"invalid-current-password": "Invalid current password",
"ldap-update-password-unavailable": "Unable to update password, user is controlled by LDAP"
},
"group": {
"report-deleted": "Report deleted."
"report-deleted": "Report deleted."
},
"exceptions": {
"permission_denied": "You do not have permission to perform this action",

View File

@ -2,6 +2,7 @@ from fastapi import Depends, HTTPException, status
from pydantic import UUID4
from mealie.core.security import hash_password, verify_password
from mealie.db.models.users.users import AuthMethod
from mealie.routes._base import BaseAdminController, BaseUserController, controller
from mealie.routes._base.mixins import HttpRepo
from mealie.routes._base.routers import AdminAPIRouter, UserAPIRouter
@ -61,6 +62,10 @@ class UserController(BaseUserController):
@user_router.put("/password")
def update_password(self, password_change: ChangePassword):
"""Resets the User Password"""
if self.user.password == "LDAP" or self.user.auth_method == AuthMethod.LDAP:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, ErrorResponse.respond(self.t("user.ldap-update-password-unavailable"))
)
if not verify_password(password_change.current_password, self.user.password):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, ErrorResponse.respond(self.t("user.invalid-current-password"))

View File

@ -9,6 +9,7 @@ from pydantic.utils import GetterDict
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.db.models.users import User
from mealie.db.models.users.users import AuthMethod
from mealie.schema._mealie import MealieModel
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary
@ -66,6 +67,7 @@ class UserBase(MealieModel):
username: str | None
full_name: str | None = None
email: constr(to_lower=True, strip_whitespace=True) # type: ignore
auth_method: AuthMethod = AuthMethod.MEALIE
admin: bool = False
group: str | None
advanced: bool = False

View File

@ -2,6 +2,7 @@ from fastapi import HTTPException, status
from sqlalchemy.orm.session import Session
from mealie.core.security import hash_password, url_safe_token
from mealie.db.models.users.users import AuthMethod
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user.user_passwords import SavePasswordResetToken
from mealie.services._base_service import BaseService
@ -20,6 +21,9 @@ class PasswordResetService(BaseService):
self.logger.error(f"failed to create password reset for {email=}: user doesn't exists")
# Do not raise exception here as we don't want to confirm to the client that the Email doesn't exists
return None
elif user.password == "LDAP" or user.auth_method == AuthMethod.LDAP:
self.logger.error(f"failed to create password reset for {email=}: user controlled by LDAP")
return None
# Create Reset Token
token = url_safe_token()

View File

@ -42,7 +42,11 @@ LDAP_AUTH_ENABLED=False
# LDAP_BASE_DN=""
# LDAP_QUERY_BIND=""
# LDAP_QUERY_PASSWORD=""
# LDAP_USER_FILTER=""
# Optionally, filter by a particular user group
# (&(|({id_attribute}={input})({mail_attribute}={input}))(objectClass=person)(memberOf=cn=mealie_user,ou=groups,dc=example,dc=com))
# LDAP_USER_FILTER="(&(|({id_attribute}={input})({mail_attribute}={input}))(objectClass=person))"
# LDAP_ADMIN_FILTER=""
# LDAP_ID_ATTRIBUTE=uid
# LDAP_NAME_ATTRIBUTE=name

View File

@ -3,6 +3,9 @@ from typing import Generator
from pytest import fixture
from starlette.testclient import TestClient
from mealie.db.db_setup import session_context
from mealie.db.models.users.users import AuthMethod
from mealie.repos.all_repositories import get_repositories
from tests import utils
from tests.utils import api_routes
@ -181,3 +184,25 @@ def user_token(admin_token, api_client: TestClient):
# Log in as this user
form_data = {"username": create_data["email"], "password": "useruser"}
return utils.login(form_data, api_client)
@fixture(scope="module")
def ldap_user():
# Create an LDAP user directly instead of using TestClient since we don't have
# a LDAP service set up
with session_context() as session:
db = get_repositories(session)
user = db.users.create(
{
"username": utils.random_string(10),
"password": "mealie_password_not_important",
"full_name": utils.random_string(10),
"email": utils.random_string(10),
"admin": False,
"auth_method": AuthMethod.LDAP,
}
)
yield user
with session_context() as session:
db = get_repositories(session)
db.users.delete(user.id)

View File

@ -1,6 +1,7 @@
from fastapi.testclient import TestClient
from mealie.core.config import get_app_settings
from mealie.db.models.users.users import AuthMethod
from tests import utils
from tests.utils import api_routes
from tests.utils.factories import random_email, random_string
@ -55,6 +56,7 @@ def test_create_user(api_client: TestClient, admin_token):
assert user_data["email"] == create_data["email"]
assert user_data["group"] == create_data["group"]
assert user_data["admin"] == create_data["admin"]
assert user_data["authMethod"] == AuthMethod.MEALIE.value
def test_create_user_as_non_admin(api_client: TestClient, user_token):
@ -73,6 +75,7 @@ def test_update_user(api_client: TestClient, admin_user: TestUser):
# Change data
update_data["fullName"] = random_string()
update_data["email"] = random_email()
update_data["authMethod"] = AuthMethod.LDAP.value
response = api_client.put(
api_routes.admin_users_item_id(update_data["id"]), headers=admin_user.token, json=update_data
@ -80,6 +83,11 @@ def test_update_user(api_client: TestClient, admin_user: TestUser):
assert response.status_code == 200
user_data = response.json()
assert user_data["fullName"] == update_data["fullName"]
assert user_data["email"] == update_data["email"]
assert user_data["authMethod"] == update_data["authMethod"]
def test_update_other_user_as_not_admin(api_client: TestClient, unique_user: TestUser, g2_user: TestUser):
settings = get_app_settings()

View File

@ -4,6 +4,7 @@ import pytest
from fastapi.testclient import TestClient
from mealie.db.db_setup import session_context
from mealie.schema.user.user import PrivateUser
from mealie.services.user_services.password_reset_service import PasswordResetService
from tests.utils import api_routes
from tests.utils.factories import random_string
@ -56,3 +57,24 @@ def test_password_reset(api_client: TestClient, unique_user: TestUser, casing: s
# Test successful password reset
response = api_client.post(api_routes.users_reset_password, json=payload)
assert response.status_code == 400
@pytest.mark.parametrize("casing", ["lower", "upper", "mixed"])
def test_password_reset_ldap(ldap_user: PrivateUser, casing: str):
cased_email = ""
if casing == "lower":
cased_email = ldap_user.email.lower()
elif casing == "upper":
cased_email = ldap_user.email.upper()
else:
for i, letter in enumerate(ldap_user.email):
if i % 2 == 0:
cased_email += letter.upper()
else:
cased_email += letter.lower()
cased_email
with session_context() as session:
service = PasswordResetService(session)
token = service.generate_reset_token(cased_email)
assert token is None

View File

@ -7,7 +7,9 @@ from mealie.core import security
from mealie.core.config import get_app_settings
from mealie.core.dependencies import validate_file_token
from mealie.db.db_setup import session_context
from tests.utils.factories import random_string
from mealie.db.models.users.users import AuthMethod
from mealie.schema.user.user import PrivateUser
from tests.utils import random_string
class LdapConnMock:
@ -101,7 +103,7 @@ def test_create_file_token():
assert file_path == validate_file_token(file_token)
def test_ldap_authentication_mocked(monkeypatch: MonkeyPatch):
def test_ldap_user_creation(monkeypatch: MonkeyPatch):
user, mail, name, password, query_bind, query_password = setup_env(monkeypatch)
def ldap_initialize_mock(url):
@ -122,7 +124,7 @@ def test_ldap_authentication_mocked(monkeypatch: MonkeyPatch):
assert result.admin is False
def test_ldap_authentication_failed_mocked(monkeypatch: MonkeyPatch):
def test_ldap_user_creation_fail(monkeypatch: MonkeyPatch):
user, mail, name, password, query_bind, query_password = setup_env(monkeypatch)
def ldap_initialize_mock(url):
@ -139,7 +141,7 @@ def test_ldap_authentication_failed_mocked(monkeypatch: MonkeyPatch):
assert result is False
def test_ldap_authentication_non_admin_mocked(monkeypatch: MonkeyPatch):
def test_ldap_user_creation_non_admin(monkeypatch: MonkeyPatch):
user, mail, name, password, query_bind, query_password = setup_env(monkeypatch)
monkeypatch.setenv("LDAP_ADMIN_FILTER", "(memberOf=cn=admins,dc=example,dc=com)")
@ -161,7 +163,7 @@ def test_ldap_authentication_non_admin_mocked(monkeypatch: MonkeyPatch):
assert result.admin is False
def test_ldap_authentication_admin_mocked(monkeypatch: MonkeyPatch):
def test_ldap_user_creation_admin(monkeypatch: MonkeyPatch):
user, mail, name, password, query_bind, query_password = setup_env(monkeypatch)
monkeypatch.setenv("LDAP_ADMIN_FILTER", "(memberOf=cn=admins,dc=example,dc=com)")
@ -183,7 +185,7 @@ def test_ldap_authentication_admin_mocked(monkeypatch: MonkeyPatch):
assert result.admin
def test_ldap_authentication_disabled_mocked(monkeypatch: MonkeyPatch):
def test_ldap_disabled(monkeypatch: MonkeyPatch):
monkeypatch.setenv("LDAP_AUTH_ENABLED", "False")
user = random_string(10)
@ -212,3 +214,29 @@ def test_ldap_authentication_disabled_mocked(monkeypatch: MonkeyPatch):
with session_context() as session:
security.authenticate_user(session, user, password)
def test_user_login_ldap_auth_method(monkeypatch: MonkeyPatch, ldap_user: PrivateUser):
"""
Test login from a user who was originally created in Mealie, but has since been converted
to LDAP auth method
"""
_, _, name, ldap_password, query_bind, query_password = setup_env(monkeypatch)
def ldap_initialize_mock(url):
assert url == ""
return LdapConnMock(ldap_user.username, ldap_password, False, query_bind, query_password, ldap_user.email, name)
monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock)
get_app_settings.cache_clear()
with session_context() as session:
result = security.authenticate_user(session, ldap_user.username, ldap_password)
assert result
assert result.username == ldap_user.username
assert result.email == ldap_user.email
assert result.full_name == ldap_user.full_name
assert result.admin == ldap_user.admin
assert result.auth_method == AuthMethod.LDAP

View File

@ -2,6 +2,8 @@ from dataclasses import dataclass
from typing import Any
from uuid import UUID
from mealie.db.models.users.users import AuthMethod
@dataclass
class TestUser:
@ -11,6 +13,7 @@ class TestUser:
password: str
_group_id: UUID
token: Any
auth_method = AuthMethod.MEALIE
@property
def group_id(self) -> str: