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',)
|
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:
|
||||||
|
|
|
@ -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",
|
"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
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
||||||
|
|
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/')
|
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
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
1
frontend/src/app.d.ts
vendored
1
frontend/src/app.d.ts
vendored
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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!",
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue