diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py index 67b3d2e..3210f7a 100644 --- a/backend/server/adventures/admin.py +++ b/backend/server/adventures/admin.py @@ -71,7 +71,7 @@ class CustomUserAdmin(UserAdmin): readonly_fields = ('uuid',) search_fields = ('username',) fieldsets = UserAdmin.fieldsets + ( - (None, {'fields': ('profile_pic', 'uuid', 'public_profile')}), + (None, {'fields': ('profile_pic', 'uuid', 'public_profile', 'disable_password')}), ) def image_display(self, obj): if obj.profile_pic: diff --git a/backend/server/adventures/migrations/0024_alter_attachment_file.py b/backend/server/adventures/migrations/0024_alter_attachment_file.py new file mode 100644 index 0000000..e63bb0e --- /dev/null +++ b/backend/server/adventures/migrations/0024_alter_attachment_file.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.8 on 2025-03-17 01:15 + +import adventures.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0023_lodging_delete_hotel'), + ] + + operations = [ + migrations.AlterField( + model_name='attachment', + name='file', + field=models.FileField(upload_to=adventures.models.PathAndRename('attachments/'), validators=[adventures.models.validate_file_extension]), + ), + ] diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py index 539106f..406e37a 100644 --- a/backend/server/main/settings.py +++ b/backend/server/main/settings.py @@ -227,6 +227,10 @@ HEADLESS_FRONTEND_URLS = { "socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback", } +AUTHENTICATION_BACKENDS = [ + 'users.backends.NoPasswordAuthBackend', +] + EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' SITE_ID = 1 ACCOUNT_EMAIL_REQUIRED = True diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 2007662..3b8415e 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -1,7 +1,7 @@ from django.urls import include, re_path, path from django.contrib import admin from django.views.generic import RedirectView, TemplateView -from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView +from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView from .views import get_csrf_token, get_public_url, serve_protected_media from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -29,6 +29,8 @@ urlpatterns = [ path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), + path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'), + path('csrf/', get_csrf_token, name='get_csrf_token'), path('public-url/', get_public_url, name='get_public_url'), diff --git a/backend/server/users/backends.py b/backend/server/users/backends.py new file mode 100644 index 0000000..a099f11 --- /dev/null +++ b/backend/server/users/backends.py @@ -0,0 +1,16 @@ +from django.contrib.auth.backends import ModelBackend +from allauth.socialaccount.models import SocialAccount + +class NoPasswordAuthBackend(ModelBackend): + def authenticate(self, request, username=None, password=None, **kwargs): + print("NoPasswordAuthBackend") + # First, attempt normal authentication + user = super().authenticate(request, username=username, password=password, **kwargs) + if user is None: + return None + + if SocialAccount.objects.filter(user=user).exists() and user.disable_password: + # If yes, disable login via password + return None + + return user diff --git a/backend/server/users/migrations/0004_customuser_disable_password.py b/backend/server/users/migrations/0004_customuser_disable_password.py new file mode 100644 index 0000000..3b11ad4 --- /dev/null +++ b/backend/server/users/migrations/0004_customuser_disable_password.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.8 on 2025-03-17 01:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_alter_customuser_email'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='disable_password', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/server/users/models.py b/backend/server/users/models.py index 670d6e3..bec028f 100644 --- a/backend/server/users/models.py +++ b/backend/server/users/models.py @@ -8,6 +8,7 @@ class CustomUser(AbstractUser): profile_pic = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='profile-pics/') uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) public_profile = models.BooleanField(default=False) + disable_password = models.BooleanField(default=False) def __str__(self): return self.username \ No newline at end of file diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index 81bacd6..f358713 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -64,9 +64,11 @@ class UserDetailsSerializer(serializers.ModelSerializer): extra_fields.append('date_joined') if hasattr(UserModel, 'is_staff'): extra_fields.append('is_staff') + if hasattr(UserModel, 'disable_password'): + extra_fields.append('disable_password') fields = ['pk', *extra_fields] - read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') + read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk', 'disable_password') def handle_public_profile_change(self, instance, validated_data): """ @@ -95,7 +97,7 @@ class CustomUserDetailsSerializer(UserDetailsSerializer): class Meta(UserDetailsSerializer.Meta): model = CustomUser fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password'] - read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password') + read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password', 'disable_password') @staticmethod def get_has_password(instance): diff --git a/backend/server/users/views.py b/backend/server/users/views.py index 428c5b8..be7dda4 100644 --- a/backend/server/users/views.py +++ b/backend/server/users/views.py @@ -13,6 +13,7 @@ from .serializers import CustomUserDetailsSerializer as PublicUserSerializer from allauth.socialaccount.models import SocialApp from adventures.serializers import AdventureSerializer, CollectionSerializer from adventures.models import Adventure, Collection +from allauth.socialaccount.models import SocialAccount User = get_user_model() @@ -171,4 +172,35 @@ class EnabledSocialProvidersView(APIView): 'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/", 'name': provider.name }) - return Response(providers, status=status.HTTP_200_OK) \ No newline at end of file + return Response(providers, status=status.HTTP_200_OK) + + +class DisablePasswordAuthenticationView(APIView): + """ + Disable password authentication for a user. This is used when a user signs up with a social provider. + """ + +# Allows the user to set the disable_password field to True if they have a social account linked + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + responses={ + 200: openapi.Response('Password authentication disabled'), + 400: 'Bad Request' + }, + operation_description="Disable password authentication." + ) + def post(self, request): + user = request.user + if SocialAccount.objects.filter(user=user).exists(): + user.disable_password = True + user.save() + return Response({"detail": "Password authentication disabled."}, status=status.HTTP_200_OK) + return Response({"detail": "No social account linked."}, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request): + user = request.user + user.disable_password = False + user.save() + return Response({"detail": "Password authentication enabled."}, status=status.HTTP_200_OK) + \ No newline at end of file diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 56c5f94..cfe51ed 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -16,6 +16,7 @@ declare global { uuid: string; public_profile: boolean; has_password: boolean; + disable_password: boolean; } | null; locale: string; } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 15ada33..23a9975 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -9,6 +9,7 @@ export type User = { uuid: string; public_profile: boolean; has_password: boolean; + disable_password: boolean; }; export type Adventure = { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 6355ce0..25a79de 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -428,7 +428,15 @@ "documentation_link": "Documentation Link", "launch_account_connections": "Launch Account Connections", "password_too_short": "Password must be at least 6 characters", - "add_email": "Add Email" + "add_email": "Add Email", + "password_disable": "Disable Password Authentication", + "password_disable_desc": "Disabling password authentication will prevent you from logging in with a password. You will need to use a social or OIDC provider to log in. Should your social provider be unlinked, password authentication will be automatically re-enabled even if this setting is disabled.", + "disable_password": "Disable Password", + "password_enabled": "Password authentication enabled", + "password_disabled": "Password authentication disabled", + "password_disable_warning": "Currently, password authentication is disabled. Login via a social or OIDC provider is required.", + "password_disabled_error": "Error disabling password authentication. Make sure a social or OIDC provider is linked to your account.", + "password_enabled_error": "Error enabling password authentication." }, "collection": { "collection_created": "Collection created successfully!", diff --git a/frontend/src/routes/auth/[...path]/+server.ts b/frontend/src/routes/auth/[...path]/+server.ts index 6a1efef..dc608a1 100644 --- a/frontend/src/routes/auth/[...path]/+server.ts +++ b/frontend/src/routes/auth/[...path]/+server.ts @@ -6,7 +6,7 @@ import { json } from '@sveltejs/kit'; /** @type {import('./$types').RequestHandler} */ export async function GET(event) { const { url, params, request, fetch, cookies } = event; - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; + const searchParam = url.search ? `${url.search}` : ''; return handleRequest(url, params, request, fetch, cookies, searchParam); } @@ -17,13 +17,13 @@ export async function POST({ url, params, request, fetch, cookies }) { } export async function PATCH({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; - return handleRequest(url, params, request, fetch, cookies, searchParam, true); + const searchParam = url.search ? `${url.search}` : ''; + return handleRequest(url, params, request, fetch, cookies, searchParam, false); } export async function PUT({ url, params, request, fetch, cookies }) { - const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; - return handleRequest(url, params, request, fetch, cookies, searchParam, true); + const searchParam = url.search ? `${url.search}` : ''; + return handleRequest(url, params, request, fetch, cookies, searchParam, false); } export async function DELETE({ url, params, request, fetch, cookies }) { @@ -43,13 +43,15 @@ async function handleRequest( const path = params.path; let targetUrl = `${endpoint}/auth/${path}`; + const add_trailing_slash_list = ['disable-password']; + // Ensure the path ends with a trailing slash - if (requreTrailingSlash && !targetUrl.endsWith('/')) { + if ((requreTrailingSlash && !targetUrl.endsWith('/')) || add_trailing_slash_list.includes(path)) { targetUrl += '/'; } // Append query parameters to the path correctly - targetUrl += searchParam; // This will add ?format=json or &format=json to the URL + targetUrl += searchParam; // This will add or to the URL const headers = new Headers(request.headers); diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 362c08f..349aca6 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -9,7 +9,6 @@ import TotpModal from '$lib/components/TOTPModal.svelte'; import { appTitle, appVersion } from '$lib/config.js'; import ImmichLogo from '$lib/assets/immich.svg'; - import { goto } from '$app/navigation'; export let data; console.log(data); @@ -20,6 +19,8 @@ emails = data.props.emails; } + let new_password_disable_setting: boolean = false; + let new_email: string = ''; let public_url: string = data.props.publicUrl; let immichIntegration = data.props.immichIntegration; @@ -87,8 +88,36 @@ } } + async function disablePassword() { + if (user.disable_password) { + let res = await fetch('/auth/disable-password/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + if (res.ok) { + addToast('success', $t('settings.password_disabled')); + } else { + addToast('error', $t('settings.password_disabled_error')); + } + } else { + let res = await fetch('/auth/disable-password/', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }); + if (res.ok) { + addToast('success', $t('settings.password_enabled')); + } else { + addToast('error', $t('settings.password_enabled_error')); + } + } + } + async function verifyEmail(email: { email: any; verified?: boolean; primary?: boolean }) { - let res = await fetch('/auth/browser/v1/account/email/', { + let res = await fetch('/auth/browser/v1/account/email', { method: 'PUT', headers: { 'Content-Type': 'application/json' @@ -103,7 +132,7 @@ } async function addEmail() { - let res = await fetch('/auth/browser/v1/account/email/', { + let res = await fetch('/auth/browser/v1/account/email', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -122,7 +151,7 @@ } async function primaryEmail(email: { email: any; verified?: boolean; primary?: boolean }) { - let res = await fetch('/auth/browser/v1/account/email/', { + let res = await fetch('/auth/browser/v1/account/email', { method: 'PATCH', headers: { 'Content-Type': 'application/json' @@ -490,6 +519,38 @@ href={`${public_url}/accounts/social/connections/`} target="_blank">{$t('settings.launch_account_connections')} + +
{$t('settings.password_disable_desc')}
+ +