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:
parent
189cd0ee69
commit
a38828eb45
14 changed files with 184 additions and 17 deletions
|
@ -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:
|
||||
|
|
|
@ -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]),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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'),
|
||||
|
||||
|
|
16
backend/server/users/backends.py
Normal file
16
backend/server/users/backends.py
Normal 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
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
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)
|
||||
|
1
frontend/src/app.d.ts
vendored
1
frontend/src/app.d.ts
vendored
|
@ -16,6 +16,7 @@ declare global {
|
|||
uuid: string;
|
||||
public_profile: boolean;
|
||||
has_password: boolean;
|
||||
disable_password: boolean;
|
||||
} | null;
|
||||
locale: string;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ export type User = {
|
|||
uuid: string;
|
||||
public_profile: boolean;
|
||||
has_password: boolean;
|
||||
disable_password: boolean;
|
||||
};
|
||||
|
||||
export type Adventure = {
|
||||
|
|
|
@ -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!",
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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')}</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>
|
||||
</section>
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue