1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-24 15:29:36 +02:00

feat: Implement disable password authentication for users with social accounts

This commit is contained in:
Sean Morley 2025-03-16 21:49:00 -04:00
parent 189cd0ee69
commit a38828eb45
14 changed files with 184 additions and 17 deletions

View file

@ -71,7 +71,7 @@ class CustomUserAdmin(UserAdmin):
readonly_fields = ('uuid',) readonly_fields = ('uuid',)
search_fields = ('username',) search_fields = ('username',)
fieldsets = UserAdmin.fieldsets + ( fieldsets = UserAdmin.fieldsets + (
(None, {'fields': ('profile_pic', 'uuid', 'public_profile')}), (None, {'fields': ('profile_pic', 'uuid', 'public_profile', 'disable_password')}),
) )
def image_display(self, obj): def image_display(self, obj):
if obj.profile_pic: if obj.profile_pic:

View file

@ -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]),
),
]

View file

@ -227,6 +227,10 @@ HEADLESS_FRONTEND_URLS = {
"socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback", "socialaccount_login_error": f"{FRONTEND_URL}/account/provider/callback",
} }
AUTHENTICATION_BACKENDS = [
'users.backends.NoPasswordAuthBackend',
]
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
SITE_ID = 1 SITE_ID = 1
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True

View file

@ -1,7 +1,7 @@
from django.urls import include, re_path, path from django.urls import include, re_path, path
from django.contrib import admin from django.contrib import admin
from django.views.generic import RedirectView, TemplateView 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 .views import get_csrf_token, get_public_url, serve_protected_media
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
from drf_yasg import openapi from drf_yasg import openapi
@ -29,6 +29,8 @@ urlpatterns = [
path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), 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('csrf/', get_csrf_token, name='get_csrf_token'),
path('public-url/', get_public_url, name='get_public_url'), path('public-url/', get_public_url, name='get_public_url'),

View file

@ -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

View file

@ -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),
),
]

View file

@ -8,6 +8,7 @@ class CustomUser(AbstractUser):
profile_pic = ResizedImageField(force_format="WEBP", quality=75, null=True, blank=True, upload_to='profile-pics/') 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) uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
public_profile = models.BooleanField(default=False) public_profile = models.BooleanField(default=False)
disable_password = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.username return self.username

View file

@ -64,9 +64,11 @@ class UserDetailsSerializer(serializers.ModelSerializer):
extra_fields.append('date_joined') extra_fields.append('date_joined')
if hasattr(UserModel, 'is_staff'): if hasattr(UserModel, 'is_staff'):
extra_fields.append('is_staff') extra_fields.append('is_staff')
if hasattr(UserModel, 'disable_password'):
extra_fields.append('disable_password')
fields = ['pk', *extra_fields] 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): def handle_public_profile_change(self, instance, validated_data):
""" """
@ -95,7 +97,7 @@ class CustomUserDetailsSerializer(UserDetailsSerializer):
class Meta(UserDetailsSerializer.Meta): class Meta(UserDetailsSerializer.Meta):
model = CustomUser model = CustomUser
fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password'] 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 @staticmethod
def get_has_password(instance): def get_has_password(instance):

View file

@ -13,6 +13,7 @@ from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
from allauth.socialaccount.models import SocialApp from allauth.socialaccount.models import SocialApp
from adventures.serializers import AdventureSerializer, CollectionSerializer from adventures.serializers import AdventureSerializer, CollectionSerializer
from adventures.models import Adventure, Collection from adventures.models import Adventure, Collection
from allauth.socialaccount.models import SocialAccount
User = get_user_model() User = get_user_model()
@ -171,4 +172,35 @@ class EnabledSocialProvidersView(APIView):
'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/", 'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/",
'name': provider.name 'name': provider.name
}) })
return Response(providers, status=status.HTTP_200_OK) 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)

View file

@ -16,6 +16,7 @@ declare global {
uuid: string; uuid: string;
public_profile: boolean; public_profile: boolean;
has_password: boolean; has_password: boolean;
disable_password: boolean;
} | null; } | null;
locale: string; locale: string;
} }

View file

@ -9,6 +9,7 @@ export type User = {
uuid: string; uuid: string;
public_profile: boolean; public_profile: boolean;
has_password: boolean; has_password: boolean;
disable_password: boolean;
}; };
export type Adventure = { export type Adventure = {

View file

@ -428,7 +428,15 @@
"documentation_link": "Documentation Link", "documentation_link": "Documentation Link",
"launch_account_connections": "Launch Account Connections", "launch_account_connections": "Launch Account Connections",
"password_too_short": "Password must be at least 6 characters", "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": {
"collection_created": "Collection created successfully!", "collection_created": "Collection created successfully!",

View file

@ -6,7 +6,7 @@ import { json } from '@sveltejs/kit';
/** @type {import('./$types').RequestHandler} */ /** @type {import('./$types').RequestHandler} */
export async function GET(event) { export async function GET(event) {
const { url, params, request, fetch, cookies } = 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); 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 }) { export async function PATCH({ url, params, request, fetch, cookies }) {
const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; const searchParam = url.search ? `${url.search}` : '';
return handleRequest(url, params, request, fetch, cookies, searchParam, true); return handleRequest(url, params, request, fetch, cookies, searchParam, false);
} }
export async function PUT({ url, params, request, fetch, cookies }) { export async function PUT({ url, params, request, fetch, cookies }) {
const searchParam = url.search ? `${url.search}&format=json` : '?format=json'; const searchParam = url.search ? `${url.search}` : '';
return handleRequest(url, params, request, fetch, cookies, searchParam, true); return handleRequest(url, params, request, fetch, cookies, searchParam, false);
} }
export async function DELETE({ url, params, request, fetch, cookies }) { export async function DELETE({ url, params, request, fetch, cookies }) {
@ -43,13 +43,15 @@ async function handleRequest(
const path = params.path; const path = params.path;
let targetUrl = `${endpoint}/auth/${path}`; let targetUrl = `${endpoint}/auth/${path}`;
const add_trailing_slash_list = ['disable-password'];
// Ensure the path ends with a trailing slash // Ensure the path ends with a trailing slash
if (requreTrailingSlash && !targetUrl.endsWith('/')) { if ((requreTrailingSlash && !targetUrl.endsWith('/')) || add_trailing_slash_list.includes(path)) {
targetUrl += '/'; targetUrl += '/';
} }
// Append query parameters to the path correctly // 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); const headers = new Headers(request.headers);

View file

@ -9,7 +9,6 @@
import TotpModal from '$lib/components/TOTPModal.svelte'; import TotpModal from '$lib/components/TOTPModal.svelte';
import { appTitle, appVersion } from '$lib/config.js'; import { appTitle, appVersion } from '$lib/config.js';
import ImmichLogo from '$lib/assets/immich.svg'; import ImmichLogo from '$lib/assets/immich.svg';
import { goto } from '$app/navigation';
export let data; export let data;
console.log(data); console.log(data);
@ -20,6 +19,8 @@
emails = data.props.emails; emails = data.props.emails;
} }
let new_password_disable_setting: boolean = false;
let new_email: string = ''; let new_email: string = '';
let public_url: string = data.props.publicUrl; let public_url: string = data.props.publicUrl;
let immichIntegration = data.props.immichIntegration; 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 }) { 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', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -103,7 +132,7 @@
} }
async function addEmail() { async function addEmail() {
let res = await fetch('/auth/browser/v1/account/email/', { let res = await fetch('/auth/browser/v1/account/email', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -122,7 +151,7 @@
} }
async function primaryEmail(email: { email: any; verified?: boolean; primary?: boolean }) { 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', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -490,6 +519,38 @@
href={`${public_url}/accounts/social/connections/`} href={`${public_url}/accounts/social/connections/`}
target="_blank">{$t('settings.launch_account_connections')}</a target="_blank">{$t('settings.launch_account_connections')}</a
> >
<div class="mt-8">
<h2 class="text-2xl font-semibold text-center">{$t('settings.password_disable')}</h2>
<p>{$t('settings.password_disable_desc')}</p>
<div class="flex flex-col items-center mt-4">
<input
type="checkbox"
id="disable_password"
name="disable_password"
bind:checked={user.disable_password}
class="checkbox checkbox-primary"
/>
<label for="disable_password" class="ml-2 text-sm text-neutral-content"
>{$t('settings.disable_password')}</label
>
<button class="btn btn-primary mt-4" on:click={disablePassword}
>{$t('settings.update')}</button
>
{#if user.disable_password}
<div class="badge badge-error mt-2">{$t('settings.password_disabled')}</div>
{/if}
{#if !user.disable_password}
<div class="badge badge-success mt-2">{$t('settings.password_enabled')}</div>
{/if}
{#if user.disable_password}
<div class="alert alert-warning mt-4">
{$t('settings.password_disable_warning')}
</div>
{/if}
</div>
</div>
</div> </div>
</section> </section>