Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map Discord roles to NextCloud groups #395

Closed
wants to merge 1 commit into from

Conversation

kousu
Copy link

@kousu kousu commented Feb 14, 2023

Fixes #390

@kousu
Copy link
Author

kousu commented Feb 14, 2023

Currently this only reads Discord role IDs, not their names, which makes them very verbose in the UI:

Screenshott

Obviously I would like it to read their names instead. For that, I believe it needs to call [GET/guilds/{guild.id}/roles, but for that I fear that it probably requires a. creating a Bot User, b. inviting that Bot User to the target guilds, c. giving the Bot User's token to this plugin d. using the Bot User's token to call that API, all of which is quite a bit more complicated than the status quo.

It might be good enough to just impose a group mapping like @pktiuk suggested; it'll be annoying for the admin to set it up the first time and every time they add a new role ((I have no idea how to find out the role IDs from the Discord UI, they just don't appear anywhere)), but it'll be stable at least.


Currently this reads ALL roles from ALL guilds someone is in. It should probably be restricted to just the guilds in the $allowedGuilds list.

Also, I'm not sure if roles have globally unique IDs across Discord or not? What happens if there's an ID collision between two guilds? Do we need to prefix the roles with the guild ID to make sure they're unique? That sounds...even worse.


I think, also, this patch belongs better in HybridAuth. But I tried patching the vendored copy of it under 3rdparty and couldn't get it to work, until I learned from ef5e24c that lib/Service/ProviderService.php overrides a lot of its settings so that patching it wouldn't work anyway. I still think that's a good idea, but it will require some coordination and deeper thought -- and like, say, not everyone is going to want to use it, which means the guilds and guilds.members.read scopes need to be optional.

@kousu
Copy link
Author

kousu commented Feb 15, 2023

I've got group mapping protoyped. But I didn't take the time to build a whole UI for it, so so far all I have is this patch, which I'm not going to attach to the branch because it's too specific to my use case, but I want to record it here anyway because it shows what needs doing:

diff --git a/lib/Service/ProviderService.php b/lib/Service/ProviderService.php
index 5e737f3..011332e 100644
--- a/lib/Service/ProviderService.php
+++ b/lib/Service/ProviderService.php
@@ -423,6 +423,15 @@ class ProviderService
                 // TODO: /member returns roles as their ID; to get their name requires an extra API call
                 //       (and perhaps extra permissions?)
             }
+
+           // HACK: because there's no UI for mapped groups under the Discord connector like there is under OIDC or Discourse
+           //       code in our mapping here
+           $profile->data['group_mapping'] = array(
+                   '5916019825232292399' => "admin",
+                   '3908310574390159257' => "Stars",
+                   '1647820633687053577' => "Coordinators",
+
+           );
         }
 
         if (!empty($config['logout_url'])) {

This works! I can assign/unassign people roles in Discord, and the next time they log in (which I can force with NextCloud's "Wipe all devices" option in the /settings/users page) their groups will be updated. And with "Restrict login for users without mapped groups" turned on this means that only the roles listed there are allowed to log in, giving me some peace of mind.

Some important points:

  • the keys in "group_mapping" are the strings as given by Discord; they're not "discord-XXXXXXXX" which
  • nextcloud-social-login still creates groups named "discord-XXXXXXXX"; I wish I could turn that off
  • the values are the names of the NextCloud groups; and you have to be careful here, these are the original names of the NextCloud groups; if you "rename" a NextCloud group it doesn't actually rename it, it just adds a label overtop that masks the internal name

@zorn-v
Copy link
Owner

zorn-v commented Feb 16, 2023

the keys in "group_mapping" are the strings as given by Discord; they're not "discord-XXXXXXXX" which

Yep, mapping assumes "exact foreign group -> exact NC group".

nextcloud-social-login still creates groups named "discord-XXXXXXXX"; I wish I could turn that off

Just disable auto_create_groups option

the values are the names of the NextCloud groups; and you have to be careful here, these are the original names of the NextCloud groups; if you "rename" a NextCloud group it doesn't actually rename it, it just adds a label overtop that masks the internal name

There is gid and displayname for groups. Long time displayname just not used. Seems it is changed.

@pktiuk
Copy link
Contributor

pktiuk commented Feb 16, 2023

@kousu
Maybe I will wait with this testing until you will apply suggestions of zorn-v

@zorn-v
Copy link
Owner

zorn-v commented Feb 17, 2023

@pktiuk I did not add any useful information (almost), soo )

@kousu
Copy link
Author

kousu commented Feb 17, 2023

Just disable auto_create_groups option

This is useful! And obvious in retrospect but I missed it. I assumed mapped groups would count as their target names.

@kousu
Copy link
Author

kousu commented Feb 17, 2023

@pktiuk

Maybe I will wait with this testing until you will apply suggestions of zorn-v

There's no code changes to make from this yet 😵‍💫 I haven't added UI to set up the group mappings yet. For my team, I edited group_mapping in directly into the code just to see if I understood how it worked, but that's not part of the branch you can use.

@kousu
Copy link
Author

kousu commented Feb 17, 2023

Looking ahead, I'm stumped on how to do the workflow for adding new mapped groups. With "Custom Discourse" the UI looks like this empty free-form box:

Screenshot 2023-02-17 at 09-50-39 Social login

Which is maybe fine for Discourse where the group names are obvious, but with Discord the group names are ID numbers, and as far as I can, the only way to get those numbers are to use the API:

The former works with the plugin's current basic OAuth2 setup, but the latter, I think, requires additionally creating a bot user and inviting it to each guild you might want to use it with. Which just seems like it greatly complicates the setup process.

Currently I have been relying on auto_create_groups and, tbh, tailing my logs, to find out what the Discord role IDs I should be looking for are. It's not been a good time.

@SDS1234
Copy link

SDS1234 commented Mar 19, 2023

When will the feature be integrated into the official version?

@FabiChan99
Copy link

Yeah, I want this too

@kousu
Copy link
Author

kousu commented Aug 7, 2023

When will the feature be integrated into the official version?

Unfortunately there's no timeline :( . It has no UI yet and what I do have written needs a lot more testing. If you want to speed it along, cheer me on, help me test, and if you want to learn a bit of PHP or Angular, read https://nextcloud.com/developer/ and give me a hand getting the code over the finish line.

@kousu
Copy link
Author

kousu commented Aug 7, 2023

Obviously I would like it to read their names instead.

Note to self: I bet the giant username migration is going to either simplify or complexify this whole bit.

no, I confused myself; this PR is about groups and their names, not users and their names. And mapping role IDs to named groups ( #395 (comment) ) is a 99% better solution than trying to put strings from the Discord API into NextCloud.

@FabiChan99
Copy link

When will the feature be integrated into the official version?

Unfortunately there's no timeline :( . It has no UI yet and what I do have written needs a lot more testing. If you want to speed it along, cheer me on, help me test, and if you want to learn a bit of PHP or Angular, read https://nextcloud.com/developer/ and give me a hand getting the code over the finish line.

I would help test too if you need. I need this for my project

@zorn-v
Copy link
Owner

zorn-v commented Aug 7, 2023

or Angular

angular sucks, learn vue 😄

@kousu
Copy link
Author

kousu commented Aug 7, 2023

Currently I have been relying on auto_create_groups and, tbh, tailing my logs, to find out what the Discord role IDs I should be looking for are. It's not been a good time.

@FabiChan99 taught me something! Discord has a Developer Mode under User -> Settings -> Advanced

Screenshot_20230807_135954

and with this enabled, you can get your Role IDs out by right clicking on them under Server -> Settings -> Roles

Screenshot_20230807_140120

That also gives me a good idea to at least get this to a usable state: the "Custom OIDC" and "Custom OAuth2" backends have this "Add Group Mapping" button which expects you to know the OIDC/OAuth2 Group ID strings:

Screenshot_20230807_134645

If I can teach people how to turn on Discord Developer Mode then I can copy this UI and have them paste in the Role IDs the same way. 🎉 Once I get that working I'll undraft this and ask it to be merged.

I would really like to have a button that pulls the Discord roles from their API, but that requires bouncing through their OAuth flow so it's more complicated than I want to think about right now. That can be a follow up.

@kousu kousu changed the title [WIP] Read Discord roles on login Read Discord roles on login Aug 13, 2023
@kousu kousu marked this pull request as ready for review August 13, 2023 01:57
@kousu
Copy link
Author

kousu commented Aug 13, 2023

I've got it working! @SDS1234, @FabiChan99, @pktiuk, please test and let me know what you think. It shouldn't break anything -- it should be safe to switch between this one and the official release at any time.

Testing

To try this out (from scratch, to give you a complete picture):

  1. If not yet done, install sociallogin from your NextCloud instance's app manager. You need to be a NextCloud admin to do this.

  2. Log in to a shell on your server (over ssh, but maybe another way if you roll that way)

  3. cd path/to/nextcloud/; to make sure you're in the right place, check that ls -l apps/sociallogin/ shows you files -- they should be the same as you can see at https://github.com/zorn-v/nextcloud-social-login/

  4. Replace the official release with mine: mv sociallogin sociallogin.official.disabled && git clone -b discord-roles https://github.com/kousu/nextcloud-social-login sociallogin

  5. php occ upgrade -- I'm not sure this is always necessary but it was for me when NextCloud noticed sociallogin had been changed (this is to let the plugin run new migrations. But I haven't added any migrations so it shouldn't change anything.)

  6. If not yet done, connect Discord to NextCloud as described in the README. You must be an admin on your Discord server to do this.

  7. Go to Discord, User -> Settings -> Advanced, and turn on Developer Mode to enable the next step

    Discord Developer Mode Screenshot

  8. For each Discord role you want to map in

    1. Create a group in your NextCloud server

    2. Go to Discord -> Server -> Settings -> Roles -> find the role -> Copy Role ID

      Copy Role ID Screenshot

    3. Go to NextCloud -> Settings -> Admin Settings -> Social Login -> Discord -> Add Group Mapping. Paste in the Role ID and pick the matching NextCloud group

      Discord auth provider settings screenshot

    4. Click Save so that you see

      Settings for social login successfully saved

      save

  9. Test it out by

    1. logging in with Discord

      login

      oauth

    2. checking Settings -> Personal Settings to see if your groups got picked up. With the default settings only mapped groups will be saved; all others will be forgotten each time you log in.

      personal settings

To switch back to the official release:

  1. mv sociallogin sociallogin.discord-roles && mv sociallogin.official.disabled sociallogin
  2. php occ upgrade
  3. Go to Settings > Admin > Social Login and click "Save" once (make sure you have a non-Discord admin account handy in case this locks you out!)

@kousu kousu changed the title Read Discord roles on login Map Discord roles to NextCloud groups Aug 13, 2023
@kousu kousu force-pushed the discord-roles branch 2 times, most recently from 519640c to eb0ea74 Compare August 13, 2023 04:05
@@ -79,6 +79,7 @@ class ProviderService
'id' => 'appid',
'secret' => 'secret',
],
'group_mapping' => 'groupMapping',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what $configMapping is about. We control the schema, can't we just force all the providers to use the same one?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hybridauth is external lib

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It just works as it should. I think with the little Tutorial, everyone can set this up!

Copy link
Author

@kousu kousu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still needs:

  • Massage the "Copy Role ID" install instructions into README.md

I especially struggled with the distinction between "custom" auth providers

$customProviders = json_decode($this->config->getAppValue($this->appName, 'custom_providers'), true);

custom_oidc: {
title: t(appName, 'Custom OpenID Connect'),
hasGroupMapping: true,
fields: {
name: {
title: t(appName, 'Internal name'),
type: 'text',
required: true,
},
title: {
title: t(appName, 'Title'),
type: 'text',
required: true,
},
authorizeUrl: {
title: t(appName, 'Authorize url'),
type: 'url',
required: true,
},
tokenUrl: {
title: t(appName, 'Token url'),
type: 'url',
required: true,
},
displayNameClaim: {
title: t(appName, 'Display name claim (optional)'),
type: 'text',
},
userInfoUrl: {
title: t(appName, 'User info URL (optional)'),
type: 'url',
required: false,
},
logoutUrl: {
title: t(appName, 'Logout URL (optional)'),
type: 'url',
required: false,
},
clientId: {
title: t(appName, 'Client Id'),
type: 'text',
required: true,
},
clientSecret: {
title: t(appName, 'Client Secret'),
type: 'password',
required: true,
},
scope: {
title: t(appName, 'Scope'),
type: 'text',
required: true,
},
groupsClaim: {
title: t(appName, 'Groups claim (optional)'),
type: 'text',
required: false,
},
}
},
openid: {
title: 'OpenID',
fields: {
name: {
title: t(appName, 'Internal name'),
type: 'text',
required: true,
},
title: {
title: t(appName, 'Title'),
type: 'text',
required: true,
},
url: {
title: t(appName, 'Identifier url'),
type: 'url',
required: true,
},
}
},
custom_oauth2: {
title: t(appName, 'Custom OAuth2'),
hasGroupMapping: true,
fields: {
name: {
title: t(appName, 'Internal name'),
type: 'text',
required: true,
},
title: {
title: t(appName, 'Title'),
type: 'text',
required: true,
},
apiBaseUrl: {
title: t(appName, 'API Base URL'),
type: 'url',
required: true,
},
authorizeUrl: {
title: t(appName, 'Authorize url (can be relative to base URL)'),
type: 'text',
required: true,
},
tokenUrl: {
title: t(appName, 'Token url (can be relative to base URL)'),
type: 'text',
required: true,
},
profileUrl: {
title: t(appName, 'Profile url (can be relative to base URL)'),
type: 'text',
required: true,
},
logoutUrl: {
title: t(appName, 'Logout URL (optional)'),
type: 'url',
required: false,
},
clientId: {
title: t(appName, 'Client Id'),
type: 'text',
required: true,
},
clientSecret: {
title: t(appName, 'Client Secret'),
type: 'password',
required: true,
},
scope: {
title: t(appName, 'Scope (optional)'),
type: 'text',
required: false,
},
profileFields: {
title: t(appName, 'Profile Fields (optional, comma-separated)'),
type: 'text',
required: false,
},
displayNameClaim: {
title: t(appName, 'Display name claim (optional)'),
type: 'text',
},
groupsClaim: {
title: t(appName, 'Groups claim (optional)'),
type: 'text',
required: false,
},
}
},
custom_oauth1: {
title: t(appName, 'Custom OAuth1'),
fields: {
name: {
title: t(appName, 'Internal name'),
type: 'text',
required: true,
},
title: {
title: t(appName, 'Title'),
type: 'text',
required: true,
},
authorizeUrl: {
title: t(appName, 'Authorize url'),
type: 'text',
required: true,
},
tokenUrl: {
title: t(appName, 'Token url'),
type: 'text',
required: true,
},
profileUrl: {
title: t(appName, 'Profile url'),
type: 'text',
required: true,
},
logoutUrl: {
title: t(appName, 'Logout URL (optional)'),
type: 'url',
required: false,
},
clientId: {
title: t(appName, 'Consumer key'),
type: 'text',
required: true,
},
clientSecret: {
title: t(appName, 'Consumer Secret'),
type: 'password',
required: true,
},
}
},
custom_discourse: {
title: t(appName, 'Custom Discourse'),
hasGroupMapping: true,
fields: {
name: {
title: t(appName, 'Internal name'),
type: 'text',
required: true,
},
title: {
title: t(appName, 'Title'),
type: 'text',
required: true,
},
baseUrl: {
title: t(appName, 'Base url'),
type: 'text',
required: true,
},
logoutUrl: {
title: t(appName, 'Logout URL (optional)'),
type: 'url',
required: false,
},
ssoSecret: {
title: t(appName, 'SSO Secret'),
type: 'password',
required: true,
},
}
},

import providerTypes from './settings/provider-types'

data.providerTypes = providerTypes

which render at the top of the settings page:

Custom Provider UI

Custom Provider UI Screenshot

and can have many instances of themselves,

and "providers" like Discord.com, which only have one of each and have custom code to support their proprietary APIs scattered throughout the plugin

$providers = [];
$savedProviders = json_decode($this->config->getAppValue($this->appName, 'oauth_providers'), true) ?: [];
foreach (ProviderService::DEFAULT_PROVIDERS as $provider) {
if (array_key_exists($provider, $savedProviders)) {
$providers[$provider] = $savedProviders[$provider];
} else {
$providers[$provider] = [
'appid' => '',
'secret' => '',
];
}

Provider UI

Provider UI Screenshot

They are both stored in the database as JSON, but in separate objects with similar but subtly different schemas -- particularly, because customs have to be nested in an Array because there can be many of them. This split causes a lot of special cases in the code. I got it working but I wonder if it could be more elegant.

@@ -120,6 +120,20 @@
<input type="text" :name="'providers['+name+'][guilds]'" v-model="provider.guilds"/>
</label>
</template>
<template v-if="provider.hasGroupMapping">
<button class="group-mapping-add" type="button" @click="provider.groupMapping.push({foreign: '', local: ''})">
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason the existence of the Add Group Mapping button widens the Discord box and breaks the layout. I do not understand. I've already spent 40 minutes looking at it.

Seriously what is this BS

srs

Fix the CSS here.

Comment on lines +193 to +197
var groupMappingArr = []
var groupMapping = data.providers[provType].groupMapping
if (groupMapping) {
for (var foreignGroup in groupMapping) {
groupMappingArr.push({foreign: foreignGroup, local: groupMapping[foreignGroup]})
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is copy-pasted!! 💢

Comment on lines +123 to +136
<template v-if="provider.hasGroupMapping">
<button class="group-mapping-add" type="button" @click="provider.groupMapping.push({foreign: '', local: ''})">
{{ t(appName, 'Add group mapping') }}
</button>
<div v-for="(mapping, mappingIdx) in provider.groupMapping" :key="mapping">
<input type="text" class="foreign-group" v-model="mapping.foreign" />
<select class="local-group" :name="mapping.foreign ? 'providers['+name+'][groupMapping]['+mapping.foreign+']' : ''">
<option v-for="group in groups" :key="group" :value="group" :selected="mapping.local === group">
{{ group }}
</option>
</select>
<span class="group-mapping-remove" @click="provider.groupMapping.splice(mappingIdx, 1)">x</span>
</div>
</template>
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is also copy-pasted!!

@@ -422,6 +441,7 @@ private function auth($class, array $config, $provider, $providerType = null)
}

$profile->data['default_group'] = $config['default_group'];
$profile->data['group_mapping'] = $config['group_mapping'];
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be wrapped in an isset()? My PHP is kind of weak.

@kousu kousu mentioned this pull request Aug 13, 2023
@zorn-v
Copy link
Owner

zorn-v commented Aug 16, 2023

I'll take a look later (and maybe make some refactoring). In vacations now 😃

@zorn-v
Copy link
Owner

zorn-v commented Aug 30, 2023

Implemented in eb1ab88

@zorn-v zorn-v closed this Aug 30, 2023
@pktiuk
Copy link
Contributor

pktiuk commented Aug 30, 2023

I have one question linked with implementation of this functionality.
Are these groups assigned only after the first time login, or are they updated after every login?

@kousu
Copy link
Author

kousu commented Aug 30, 2023

Are these groups assigned only after the first time login, or are they updated after every login?

They're updated every login. There's a setting to tune it, but by default it does what you want:

2023-08-30-082757_366x77_scrot

@pktiuk
Copy link
Contributor

pktiuk commented Aug 30, 2023

I will update today and test it with my organisation

@kousu
Copy link
Author

kousu commented Sep 14, 2023

I updated my own today finally (replacing the one from my git branch with the one from the app store) and it's looking good!! Thanks for fixing the CSS on that button @zorn-v.

Screenshot 2023-09-13 at 20-48-08 Social login - Administration settings - Your Organic Techno Adventure

I also appreciate that the default providers are now all hidden by default unless in use!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Read Discord Groups
5 participants