Skip to content

Commit

Permalink
Feature: Add "Authentication Method" to allow existing users to sign …
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
cmintey authored Feb 26, 2023
1 parent 39012ad commit 2e6ad5d
Show file tree
Hide file tree
Showing 24 changed files with 213 additions and 24 deletions.
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

0 comments on commit 2e6ad5d

Please sign in to comment.