Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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())
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -36,7 +35,6 @@
| POSTGRES_PORT | 5432 | Postgres database port |
| POSTGRES_DB | mealie | Postgres database name |


### Email

| Variables | Default | Description |
Expand All @@ -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 |
Expand All @@ -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 |
Expand All @@ -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 |
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/documentation/getting-started/usage/ldap.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/docs/overrides/api.html

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 9 additions & 6 deletions frontend/components/global/AutoForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@
:label="inputField.label"
:name="inputField.varName"
:hint="inputField.hint || ''"
:disabled="updateMode && inputField.disableUpdate"
:disabled="(inputField.disableUpdate && updateMode) || (!updateMode && inputField.disableCreate)"
@change="emitBlur"
/>

<!-- Text Field -->
<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
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -75,6 +76,8 @@
:name="inputField.varName"
:items="inputField.options"
:return-object="false"
:hint="inputField.hint"
persistent-hint
lazy-validation
@blur="emitBlur"
>
Expand Down
8 changes: 8 additions & 0 deletions frontend/composables/use-users/user-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/lang/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions frontend/lib/api/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,6 +55,7 @@ export interface UserOut {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group: string;
advanced?: boolean;
Expand Down Expand Up @@ -99,6 +102,7 @@ export interface PrivateUser {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group: string;
advanced?: boolean;
Expand Down Expand Up @@ -150,6 +154,7 @@ export interface UserBase {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group?: string;
advanced?: boolean;
Expand All @@ -162,6 +167,7 @@ export interface UserFavorites {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group?: string;
advanced?: boolean;
Expand Down Expand Up @@ -214,6 +220,7 @@ export interface UserIn {
username?: string;
fullName?: string;
email: string;
authMethod?: AuthMethod & string;
admin?: boolean;
group?: string;
advanced?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions frontend/pages/admin/manage/users/create.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export default defineComponent({
canManage: false,
canOrganize: false,
password: "",
authMethod: "Mealie",
},
});

Expand All @@ -92,5 +93,4 @@ export default defineComponent({
});
</script>

<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>
1 change: 1 addition & 0 deletions frontend/pages/admin/manage/users/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
];
Expand Down
7 changes: 7 additions & 0 deletions frontend/types/auto-forms.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,6 +14,8 @@ export interface FormField {
type: FormFieldType;
rules?: string[];
disableUpdate?: boolean;
disableCreate?: boolean;
options?: FormSelectOption[];
}

export type AutoFormItems = FormField[];
4 changes: 3 additions & 1 deletion mealie/core/security/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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
Expand Down Expand Up @@ -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,
},
)

Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion mealie/db/models/users/users.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -32,13 +33,19 @@ def __init__(self, name, token, user_id, **_) -> None:
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)
full_name: Mapped[str | None] = mapped_column(String, index=True)
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)

Expand Down
5 changes: 3 additions & 2 deletions mealie/lang/messages/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions mealie/routes/users/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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
Expand Down Expand Up @@ -61,6 +62,10 @@ def get_logged_in_user(self):
@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"))
Expand Down
2 changes: 2 additions & 0 deletions mealie/schema/user/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions mealie/services/user_services/password_reset_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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
Expand All @@ -20,6 +21,9 @@ def generate_reset_token(self, email: str) -> SavePasswordResetToken | None:
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()
Expand Down
Loading