mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
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:
parent
39012adcc1
commit
2e6ad5da8e
@ -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())
|
@ -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 |
|
||||
|
8
docs/docs/documentation/getting-started/usage/ldap.md
Normal file
8
docs/docs/documentation/getting-started/usage/ldap.md
Normal 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
@ -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"
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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" },
|
||||
];
|
||||
|
@ -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[];
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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"))
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
25
tests/fixtures/fixture_users.py
vendored
25
tests/fixtures/fixture_users.py
vendored
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user