diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index 9cf7532..571946e 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -4,7 +4,7 @@ from django.views.generic import RedirectView, TemplateView from django.conf import settings from django.conf.urls.static import static from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView -from .views import get_csrf_token +from .views import get_csrf_token, get_public_url from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -30,6 +30,7 @@ urlpatterns = [ path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'), path('csrf/', get_csrf_token, name='get_csrf_token'), + path('public-url/', get_public_url, name='get_public_url'), path('', TemplateView.as_view(template_name='home.html')), diff --git a/backend/server/main/views.py b/backend/server/main/views.py index 7a7507d..f21082b 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -1,6 +1,10 @@ from django.http import JsonResponse from django.middleware.csrf import get_token +from os import getenv def get_csrf_token(request): csrf_token = get_token(request) return JsonResponse({'csrfToken': csrf_token}) + +def get_public_url(request): + return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) \ No newline at end of file diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py index b85608c..0e0828f 100644 --- a/backend/server/users/serializers.py +++ b/backend/server/users/serializers.py @@ -36,7 +36,7 @@ import os class UserDetailsSerializer(serializers.ModelSerializer): """ - User model w/o password + User model without exposing the password. """ @staticmethod @@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer): return username class Meta: + model = CustomUser extra_fields = ['profile_pic', 'uuid', 'public_profile'] - profile_pic = serializers.ImageField(required=False) if hasattr(UserModel, 'USERNAME_FIELD'): extra_fields.append(UserModel.USERNAME_FIELD) @@ -64,19 +64,14 @@ class UserDetailsSerializer(serializers.ModelSerializer): extra_fields.append('date_joined') if hasattr(UserModel, 'is_staff'): extra_fields.append('is_staff') - if hasattr(UserModel, 'public_profile'): - extra_fields.append('public_profile') - class Meta: - model = CustomUser - fields = ('profile_pic', 'uuid', 'public_profile', 'email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') - - model = UserModel - fields = ('pk', *extra_fields) + fields = ['pk', *extra_fields] read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk') def handle_public_profile_change(self, instance, validated_data): - """Remove user from `shared_with` if public profile is set to False.""" + """ + Remove user from `shared_with` if public profile is set to False. + """ if 'public_profile' in validated_data and not validated_data['public_profile']: for collection in Collection.objects.filter(shared_with=instance): collection.shared_with.remove(instance) @@ -91,20 +86,37 @@ class UserDetailsSerializer(serializers.ModelSerializer): class CustomUserDetailsSerializer(UserDetailsSerializer): + """ + Custom serializer to add additional fields and logic for the user details. + """ + has_password = serializers.SerializerMethodField() class Meta(UserDetailsSerializer.Meta): model = CustomUser - fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile') - read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid',) + fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password'] + read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password') + + @staticmethod + def get_has_password(instance): + """ + Computes whether the user has a usable password set. + """ + return instance.has_usable_password() def to_representation(self, instance): + """ + Customizes the serialized output to modify `profile_pic` URL and add computed fields. + """ representation = super().to_representation(instance) + + # Construct profile picture URL if it exists if instance.profile_pic: public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') - #print(public_url) - # remove any ' from the url - public_url = public_url.replace("'", "") + public_url = public_url.replace("'", "") # Sanitize URL representation['profile_pic'] = f"{public_url}/media/{instance.profile_pic.name}" - del representation['pk'] # remove the pk field from the response + + # Remove `pk` field from the response + representation.pop('pk', None) + return representation diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index 4f1264c..7ec0abb 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -91,6 +91,28 @@ export default defineConfig({ text: "Immich Integration", link: "/docs/configuration/immich_integration", }, + { + text: "Social Auth", + link: "/docs/configuration/social_auth", + }, + { + text: "Authentication Providers", + collapsed: false, + items: [ + { + text: "Authentik", + link: "/docs/configuration/social_auth/authentik", + }, + { + text: "GitHub", + link: "/docs/configuration/social_auth/github", + }, + { + text: "Open ID Connect", + link: "/docs/configuration/social_auth/oidc", + }, + ], + }, { text: "Update App", link: "/docs/configuration/updating", diff --git a/documentation/docs/configuration/social_auth.md b/documentation/docs/configuration/social_auth.md new file mode 100644 index 0000000..9da70e1 --- /dev/null +++ b/documentation/docs/configuration/social_auth.md @@ -0,0 +1,15 @@ +# Social Authentication + +AdventureLog support autentication via 3rd party services and self-hosted identity providers. Once these services are enabled, users can log in to AdventureLog using their accounts from these services and link exising AdventureLog accounts to these services for easier access. + +The steps for each service varies so please refer to the specific service's documentation for more information. + +## Supported Services + +- [Authentik](social_auth/authentik.md) (self-hosted) +- [GitHub](social_auth/github.md) +- [Open ID Connect](social_auth/oidc.md) + +## Linking Existing Accounts + +If you already have an AdventureLog account and would like to link it to a 3rd party service, you can do so by logging in to AdventureLog and navigating to the `Account Settings` page. From there, scroll down to `Social and OIDC Authentication` and click the `Launch Account Connections` button. If identity providers have been enabled on your instance, you will see a list of available services to link to. diff --git a/documentation/docs/configuration/social_auth/authentik.md b/documentation/docs/configuration/social_auth/authentik.md new file mode 100644 index 0000000..bf9c5f4 --- /dev/null +++ b/documentation/docs/configuration/social_auth/authentik.md @@ -0,0 +1,52 @@ +# Authentik Social Authentication + +Authentik Logo + +Authentik is a self-hosted identity provider that supports OpenID Connect and OAuth2. AdventureLog can be configured to use Authentik as an identity provider for social authentication. Learn more about Authentik at [goauthentik.io](https://goauthentik.io/). + +Once Authentik is configured by the administrator, users can log in to AdventureLog using their Authentik account and link existing AdventureLog accounts to Authentik for easier access. + +# Configuration + +To enable Authentik as an identity provider, the administrator must first configure Authentik to allow AdventureLog to authenticate users. + +### Authentik Configuration + +1. Log in to Authentik and navigate to the `Providers` page and create a new provider. +2. Select `OAuth2/OpenID Provider` as the provider type. +3. Name it `AdventureLog` or any other name you prefer. +4. Set the `Redirect URI` of type `Regex` to `^http:///accounts/oidc/.*$` where `` is the URL of your AdventureLog Server service. +5. Copy the `Client ID` and `Client Secret` generated by Authentik, you will need these to configure AdventureLog. +6. Create an application in Authentik and assign the provider to it, name the `slug` `adventurelog` or any other name you prefer. + +### AdventureLog Configuration + +This configuration is done in the [Admin Panel](../../guides/admin_panel.md). You can either launch the pannel directly from the `Settings` page or navigate to `/admin` on your AdventureLog server. + +1. Login to AdventureLog as an administrator and navigate to the `Settings` page. +2. Scroll down to the `Administration Settings` and launch the admin panel. +3. In the admin panel, navigate to the `Social Accounts` section and click the add button next to `Social applications`. Fill in the following fields: + + - Provider: `OpenID Connect` + - Provider ID: Autnentik Client ID + - Name: `Authentik` + - Client ID: Authentik Client ID + - Secret Key: Authentik Client Secret + - Key: can be left blank + - Settings: (make sure http/https is set correctly) + + ```json + { + "server_url": "http:///application/o/[YOUR_SLUG]/" + } + ``` + + - Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites. + +#### What it Should Look Like + +![Authentik Social Auth Configuration](/authentik_settings.png) + +4. Save the configuration and restart the AdventureLog server. + +Ensure that the Authentik server is running and accessible by AdventureLog. Users should now be able to log in to AdventureLog using their Authentik account. diff --git a/documentation/docs/configuration/social_auth/github.md b/documentation/docs/configuration/social_auth/github.md new file mode 100644 index 0000000..e69de29 diff --git a/documentation/docs/configuration/social_auth/oidc.md b/documentation/docs/configuration/social_auth/oidc.md new file mode 100644 index 0000000..e69de29 diff --git a/documentation/public/authentik_settings.png b/documentation/public/authentik_settings.png new file mode 100644 index 0000000..d48e2c3 Binary files /dev/null and b/documentation/public/authentik_settings.png differ diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 166ff33..56c5f94 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -15,6 +15,7 @@ declare global { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; } | null; locale: string; } diff --git a/frontend/src/lib/components/Avatar.svelte b/frontend/src/lib/components/Avatar.svelte index e9c7033..a94a8eb 100644 --- a/frontend/src/lib/components/Avatar.svelte +++ b/frontend/src/lib/components/Avatar.svelte @@ -30,8 +30,9 @@

- {$t('navbar.greeting')}, {user.first_name} - {user.last_name} + {$t('navbar.greeting')}, {user.first_name + ? `${user.first_name} ${user.last_name}` + : user.username}

  • diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ea9e1fb..afac6a1 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -9,6 +9,7 @@ export type User = { profile_pic: string | null; uuid: string; public_profile: boolean; + has_password: boolean; }; export type Adventure = { diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 00139e5..43c32d2 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -17,7 +17,11 @@
    -

    {$t('dashboard.welcome_back')}, {user?.first_name}!

    +

    + {$t('dashboard.welcome_back')}, {user?.first_name + ? `${user.first_name} ${user.last_name}` + : user?.username}! +

    diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index ca29ee6..20bdb24 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -66,12 +66,22 @@ export const load: PageServerLoad = async (event) => { immichIntegration = await immichIntegrationsFetch.json(); } + let publicUrlFetch = await fetch(`${endpoint}/public-url/`); + let publicUrl = ''; + if (!publicUrlFetch.ok) { + return redirect(302, '/'); + } else { + let publicUrlJson = await publicUrlFetch.json(); + publicUrl = publicUrlJson.PUBLIC_URL; + } + return { props: { user, emails, authenticators, - immichIntegration + immichIntegration, + publicUrl } }; }; @@ -179,33 +189,52 @@ export const actions: Actions = { const password1 = formData.get('password1') as string | null | undefined; const password2 = formData.get('password2') as string | null | undefined; - const current_password = formData.get('current_password') as string | null | undefined; + let current_password = formData.get('current_password') as string | null | undefined; if (password1 !== password2) { return fail(400, { message: 'settings.password_does_not_match' }); } if (!current_password) { - return fail(400, { message: 'settings.password_is_required' }); + current_password = null; } let csrfToken = await fetchCSRFToken(); - let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { - method: 'POST', - headers: { - Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, - 'X-CSRFToken': csrfToken, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - current_password, - new_password: password1 - }) - }); - if (!res.ok) { - return fail(res.status, { message: 'settings.error_change_password' }); + if (current_password) { + let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + current_password, + new_password: password1 + }) + }); + if (!res.ok) { + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; + } else { + let res = await fetch(`${endpoint}/_allauth/browser/v1/account/password/change`, { + method: 'POST', + headers: { + Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`, + 'X-CSRFToken': csrfToken, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + new_password: password1 + }) + }); + if (!res.ok) { + console.log('Error:', await res.json()); + return fail(res.status, { message: 'settings.error_change_password' }); + } + return { success: true }; } - return { success: true }; }, changeEmail: async (event) => { if (!event.locals.user) { diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 6c2b97f..65b0905 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -9,6 +9,7 @@ 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,7 +21,7 @@ } let new_email: string = ''; - + let public_url: string = data.props.publicUrl; let immichIntegration = data.props.immichIntegration; let newImmichIntegration: ImmichIntegration = { @@ -307,17 +308,19 @@
    -
    - - -
    + {#if user.has_password} +
    + + +
    + {/if}