Authenticating Members With Discord
Heads Up!
This article is several years old now, and much has happened since then, so please keep that in mind while reading it.
What and why
Discord is community tool supplying multi server (guilds) access for its users with chat and VoIP rooms with a lot of bells and whistles. It works very well for small and larger communities when the focus is to have conversations in the here and now. What it is not good at is organising information for the long term. So I thought that for certain communities it would be handy to have a flexible system linked to your discord permissions to enable certain community members to update information into this system.
Enter Umbraco with its build-in member system and its content management tools we all love.
What I will be showing you in this article is how to let end users create a member in an Umbraco installation if they belong to a certain server (guild). On creation and every log in, we will also sync permission group names (roles) to member groups in Umbraco. This allows the management of Umbraco members to be done from within Discord.
To check if everything works nicely, we will also be adding a dashboard with some useful information.
Requirements
- An Umbraco 9 installation ready to go (with Modelsbuilder models available in VS) with at least 1 member group setup (for testing)
- A Discord application with its client Id and secret known to you
- A bot user linked to the discord application in a server you belong to (preferably as admin to invite the bot). The bot token needs no extra permissions as we will only be reading data that a User can't.
- Filled in user secrets for the following keys
{
"Discord": {
"BotToken": "",
"ClientId": "",
"ClientSecret": ""
}
} - Flurl.Http installed (Nuget), we will be using this library to make HTTP calls to the Discord API
- (Optional) Usync installed to import the datatypes/document types
Disclaimer
Because this article is already going to be quite large, I have left some things as quick and dirty. I will try to mention the important ones trough out the article and have done my best to mark them in the code examples as well. This article and GitHub repository is by no means a drop in code or a package but will work out of the box except for some (unknown) edge cases. As always I welcome pull requests.
The plan
We will be creating 2 big components that share some common services
- The OAuth2 Login flow + permission sync
- Admin back-office to setup which discord guild permission groups (roles) should be synced to which Umbraco membership groups
The OAuth flow
Before we write any code or do Umbraco configuration, lets get a conceptual idea of what is needed to make the oath flow work.
- When a user want to login to the member area, we present them with a url to Discord to authenticate them.
- The user authenticates and discord sends the user back to us with a code
- We exchange the code for an OAuth token behind the scenes
- We get the user details from Discord and save them on an Umbraco member
- We login the member and display a page
Now we have a basic understanding of what we are going to do, lets flesh out the idea, alas nothing is a simple as it first appears and we have quite a lot of work ahead of us.
One of the things we learn from the Discord Oath documentation is that we need to pass a "state" value when we redirect an end-user to discord. When Discord then redirects back to our site, it will pass that state value back. This adds a layer of security so we can guarantee the origin of that authorization callback was our site.
Next is the issue that the majority of the Discord API endpoints are not accessible as a normal user. We will have to query some endpoints as a bot.
I have tried my best to represent all the needed steps (for a non blocking flow) into a nice diagram as it should be easier to follow along than reading walls of text.
We can see from this diagram that we will need to build the following components
- DiscordLoginController (to start the process)
- DiscordLoginRedirectHandlerController (to catch the authorization callback from Discord)
- DiscordAuthService
- DiscordRoleRepository
Things not in the diagram but are pretty typical for Umbraco
- LogoutController
- Doctypes for all the controllers
- Some basic views for the login and logout actions
The admin backoffice
For the back-office we will be creating a brand new section that will let us view and define sync rules.
When viewing the section it will show us all accessible guilds. When selecting a guild, it will show us all the roles on that guild. Finally when when we select a role, it will show us all the member groups that will be assigned to a member that has a user in that guild with that role. It will also let us add new groups to a role and remove old ones.
The logic to this will follow the standard pattern of HtmlView => AngularController => BackofficeController => ExternalApi + Repository
So we will be building/extending the following components
- Dashboard.html
- Dashboard.controller.js
- Package.manifest
- DiscordAdminController
- DiscordDashboard
- DiscordSection + SectionComposer
- DiscordRoleTableComponent + DiscordRoleComposer
- DiscordRoleRepository
Implementing the backoffice
Since the back-office is the most straightforward thing to build and easiest to implement, we will be tackling this first. When it is finished, we can be sure that the discord bot part of our architecture is working and we will have a way to manipulate the syncrules (which member groups a member should get based on their guild roles)
Getting things ready
The first thing to add is a section, it can be done in a package.manifest or C# code. I personally try to avoid the package.manifest as much as possible.
Next is adding a dashboard to that section. This defines in what section it should appear, where the view file is and what access (if any) needs to be set to be able to see this dashboard.
And lastly we tell the back-office need some files. When composing things like this I like to keep all of the classes in one file as they can't exist without each other.
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Dashboards;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Sections;
using Umbraco.Cms.Core.WebAssets;
using Umbraco.Cms.Web.BackOffice.Authorization;
using Umbraco.Cms.Web.Common.Authorization;
namespace UmbracoDiscord.Core.Components
{
public class DiscordSection : ISection
{
public string Alias => Constants.Backoffice.DiscordSection;
public string Name => "Discord";
}
public class DiscordDashboard : IDashboard
{
public string Alias => Constants.Backoffice.DiscordDashboard;
public string View => "/App_Plugins/Discord/Dashboard.html";
public string[] Sections => new[] { Constants.Backoffice.DiscordSection };
public IAccessRule[] AccessRules => new IAccessRule[]
{
new AccessRule {Type = AccessRuleType.Grant, Value = Umbraco.Cms.Core.Constants.Security.AdminGroupAlias}
};
}
// This will probably be changed in the future so it can be done more easily in the composer
// See https://github.com/umbraco/Umbraco-CMS/pull/11308
public class CustomPackageScript : JavaScriptFile
{
public CustomPackageScript() : base("/App_Plugins/Discord/Dashboard.controller.js") { }
}
public class DiscordSectionComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.Sections().Append<DiscordSection>();
builder.BackOfficeAssets().Append<CustomPackageScript>();
builder.Services.AddAuthorization(o => AddSecurityPolicies(o, Umbraco.Cms.Core.Constants.Security.BackOfficeAuthenticationType));
}
private void AddSecurityPolicies(AuthorizationOptions options, string backOfficeAuthenticationScheme)
{
options.AddPolicy(Constants.Backoffice.DiscordSectionAccessPolicy, policy =>
{
policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme);
policy.Requirements.Add(new SectionRequirement(Constants.Backoffice.DiscordSection));
});
}
}
}
Do note the last Line in the Compose function, this adds a new Backoffice Policy so we can add an Authorize attribute to our Backend controller so we don't have to check permissions in every method.
Another thing you might notice is the use of constants, this is to reduce the chance of mistyping. You can find all the constants in .core/Constants.
Before we add the view, build the solution and give your Umbraco user access to the section and add the following language file to the App_Plugins folder.
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<language alias="en" intName="English (US)" localName="English (US)" lcid="" culture="en-US">
<area alias="sections">
<key alias="discordSection">Discord</key>
</area>
</language>
Adding the Dashboard view
We should now be good to add the view and angular controller. The view uses as many Umbraco directives as possible with minimal custom styling (which is just defined at the bottom of the file). It is split up into 4 umb-boxes. Each box after the first is hidden until something is selected in the previous one and the last one overrides the third since they both handle the syncs (viewing/removing and adding). If somebody has tips on how to remove the last 2 styles, let me know.
<div ng-controller="Discord.Dashboard as vm">
<umb-box>
<umb-box-header title="Available guilds">
<umb-button action="vm.getGuilds()"
label="Refresh"
type="button"
button-style="info">
</umb-button>
</umb-box-header>
<umb-box-content>
<div ng-repeat="guild in vm.guilds"
class="discordDashboardItem"
ng-class="{selected : vm.selectedGuild === guild}"
ng-click="vm.selectGuild(guild)">
{{guild.name}}
</div>
</umb-box-content>
</umb-box>
<umb-box ng-if="vm.selectedGuild != null">
<umb-box-header title="Available roles">
<umb-button action="vm.getRoles()"
label="Refresh"
type="button"
button-style="info">
</umb-button>
</umb-box-header>
<umb-box-content>
<div ng-repeat="role in vm.roles"
class="discordDashboardItem"
ng-class="{selected : vm.selectedRole === role}"
ng-click="vm.selectRole(role)">
{{role.name}}
</div>
</umb-box-content>
</umb-box>
<umb-box ng-if="vm.selectedRole != null && vm.addingNewSync === false">
<umb-box-header title="Assigned membership groups">
<umb-button ng-if="vm.selectedSyncGroup != null"
action="vm.removeSync(true)"
label="Disable"
type="button"
button-style="warning">
</umb-button>
<umb-button ng-if="vm.selectedSyncGroup != null"
action="vm.removeSync(false)"
label="Remove"
type="button"
button-style="danger">
</umb-button>
<umb-button action="vm.startAddNewSync()"
label="New"
type="button"
button-style="success">
</umb-button>
<umb-button action="vm.getSyncGroups()"
label="Refresh"
type="button"
button-style="info">
</umb-button>
</umb-box-header>
<umb-box-content>
<div ng-repeat="group in vm.syncedGroups"
class="discordDashboardItem"
ng-class="{selected : vm.selectedSyncGroup === group, strike: group.syncRemoval}"
ng-click="vm.selectSyncedGroup(group)">
{{group.membershipGroupAlias}}
</div>
</umb-box-content>
</umb-box>
<umb-box ng-if="vm.addingNewSync === true">
<umb-box-header title="Add new synced membership group">
</umb-box-header>
<umb-box-content>
<div ng-repeat="group in vm.groups"
class="discordDashboardItem"
ng-class="{selected : vm.selectedGroup === group}"
ng-click="vm.selectGroup(group)">
{{group}}
</div>
<hr>
<umb-button ng-if="vm.selectedGroup != null"
action="vm.addSync()"
label="Save"
type="button"
button-style="success">
</umb-button>
<umb-button action="vm.cancelAddSync()"
label="Cancel"
type="button"
button-style="success">
</umb-button>
</umb-box-content>
</umb-box>
</div>
<style>
.discordDashboardItem {
padding: 3px;
}
.selected {
background-color: rgb(254,228,225);
}
</style>
Adding the dashboard controller
The real magic of this view happens in its controller. So lets have a look at the important bits as most of the code in here is for reactively manipulating the DOM. The functions that actually fetch data from our back-office API are grouped under Public get functions
- vm.getGuilds() which guilds our bot user can see
- vm.getRoles() which roles are defined on the selected guild
- vm.getGroups() the membership groups that are defined in Umbraco
- vm.getSyncGroups() which groups are synced to the selected role
Each of these uses the angular HTTP service and updates the controllers data when a successful response is received. It would probably be best to show some kind of error message when the API call fails.
The other functions deal with submitting data to the API and are grouped under Public Actions
- vm.addSync() Adds a new guild-role/membergroup sync rule.
- vm.removeSync(syncRemoval) Marks the sync as removed if the parameter is true [Disable], so that when a member logs in and has the membergroup assigned, it will be removed (if no other guild roles are linked to the same group). If false [Remove], no action will be taken when members log in(for this sync) because the sync will be removed from the database. Disabled sync rules appear crossed-out in the view.
//todo: handled error respones from $http calls
angular.module("umbraco")
.controller("Discord.Dashboard", function ($scope, $http) {
/* Data definitions */
var baseApiUrl = "/umbraco/backoffice/api/discordadmin/";
var vm = this;
vm.guilds = [];
vm.selectedGuild = null;
vm.roles = [];
vm.selectedRole = null;
vm.syncedGroups = [];
vm.selectedSyncGroup = null;
vm.groups = [];
vm.selectedGroup = null;
vm.addingNewSync = false;
/* Public get functions */
vm.getGuilds = function () {
resetSelectedGuild();
$http.get(baseApiUrl + "Guilds").then((response) => {
vm.guilds = response.data;
if (vm.guilds.length === 1) {
vm.selectedGuild = vm.guilds[0];
vm.getRoles();
}
});
}
vm.getRoles = function () {
resetSelectedRole();
$http.get(baseApiUrl + "Roles?guildId=" + vm.selectedGuild.id).then((response) => {
vm.roles = response.data;
});
}
vm.getSyncGroups = function () {
resetSelectedSync();
$http.get(baseApiUrl + "Syncs?guildId=" + vm.selectedGuild.id + "&roleId=" + vm.selectedRole.id).then((response) => {
vm.syncedGroups = response.data;
});
}
vm.getGetGroups = function () {
vm.groups = [];
vm.selectedGroup = null;
$http.get(baseApiUrl + "MemberShipGroups").then((response) => {
vm.groups = response.data;
});
}
/* Public select functions */
vm.selectGuild = function (guild) {
vm.selectedGuild = guild;
vm.getRoles();
}
vm.selectRole = function (role) {
vm.selectedRole = role;
vm.getSyncGroups();
}
vm.selectGroup = function (group) {
vm.selectedGroup = group;
}
vm.selectSyncedGroup = function(group) {
vm.selectedSyncGroup = group;
}
/* Public actions */
vm.startAddNewSync = function () {
resetSelectedGroup();
vm.getGetGroups();
vm.addingNewSync = true;
}
vm.addSync = function() {
$http.post(baseApiUrl + "RegisterRoleToMemberGroup", { guildId: vm.selectedGuild.id, roleId: vm.selectedRole.id, membershipGroupAlias:vm.selectedGroup}).then((response) => {
vm.getSyncGroups();
});
}
vm.cancelAddSync = function () {
vm.selectedGroup = false;
vm.addingNewSync = false;
}
vm.removeSync = function (syncRemoval) {
$http.post(baseApiUrl + "RemoveMemberGroupFromRole", { id: vm.selectedSyncGroup.id, syncRemoval: syncRemoval }).then((response) => {
vm.getSyncGroups();
});
}
//private functions
function resetSelectedGuild() {
vm.selectGuild = null;
vm.roles = [];
resetSelectedRole();
}
function resetSelectedRole() {
vm.selectedRole = null;
vm.syncedGroups = [];
resetSelectedSync();
}
function resetSelectedSync() {
vm.selectedSyncGroup = null;
resetAdding();
}
function resetAdding() {
resetSelectedGroup();
vm.addingNewSync = false;
}
function resetSelectedGroup() {
vm.selectedGroup = null;
}
//init
vm.getGuilds();
});
A repository intermezzo
That is the front-end of the back-end done. Before we move over to the back-end controller, lets quickly talk about the repository and the Discord service it is using.
Everybody has a preferred way of handling data persistence so I am not going over how I did this but will give a shout out to the amazing Kevin jump for supplying a nice Repositoy pattern (and more). The beauty of this pattern is that it uses the IUmbracoDatabase and the related IScopeAccessor to wrap everything up in transactrions. You can find the implementation of this in the .Core/Repositories folder and its registration is done with a composer. This composer also registers a Migration that creates the database if it doesn't exist on startup.
What is important is the Dto we will be using. Since Discord is multi server based, we need to map Guild+Role to a membership group. We also add an ID for easy selecting and a flag to know if we need to remove the membership group if the Rule is disabled.
using NPoco;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
namespace UmbracoDiscord.Core.Repositories.Dtos
{
[TableName(Constants.Database.SyncedDiscordRoleTableName)]
[PrimaryKey("Id", AutoIncrement = true)]
[ExplicitColumns]
public class SyncedDiscordRole
{
[PrimaryKeyColumn(AutoIncrement = true, IdentitySeed = 1)]
[Column("Id")]
public int Id { get; set; }
[Column("GuildId")]
public decimal GuildId { get; set; }
[Column("RoleId")]
public decimal RoleId { get; set; }
[Column("MembershipGroupAlias")]
public string MembershipGroupAlias { get; set; }
[Column("SyncRemoval")]
public bool SyncRemoval { get; set; }
}
}
The beginnings of our DiscordService
Onto the DiscordService, for this one I will show you parts of the full file as we will be adding to this service later when we do the actual login handling.
As you might remember, the controller we will build in just a second will have 4 get methods with 2 of them being data from Discord, so lets add those to our Discord service
We use the Flurl.Http library here to take a string (our endpoint), create a FlurlRequest by adding an authorization header that contains our bot token, send it as a get and read the response as json all nicely wrapped in async/await.
Couple things to note here
- By default Flurl throws an error if it does not receive a 200 response.
- For the GetAvailableGuilds method we are using the users/@me/guilds endpoint and are passing a token that represents our bot user, so this endpoint will return all guilds the bot is invited to.
- The models used here can be found in .core/Models
using Flurl.Http;
using Microsoft.Extensions.Configuration;
using System.Collections.Generic;
using System.Threading.Tasks;
using UmbracoDiscord.Core.Models.DiscordApi;
namespace UmbracoDiscord.Core.Partial
{
public class DiscordService
{
private readonly IConfiguration _configuration;
public DiscordService(IConfiguration configuration)
{
_configuration = configuration;
}
public async Task<List<GuildResult>> GetAvailableGuilds()
{
return await Constants.DiscordApi.GuildEndpoint.WithHeader("Authorization", "Bot " + _configuration["Discord:BotToken"])
.GetAsync().ReceiveJson<List<GuildResult>>().ConfigureAwait(false);
}
public async Task<List<GuildResult>> GetAvailableRolesForGuild(ulong guildId)
{
return await string.Format(Constants.DiscordApi.GuildRolesEndpoint, guildId).WithHeader("Authorization", "Bot " + _configuration["Discord:BotToken"])
.GetAsync().ReceiveJson<List<GuildResult>>().ConfigureAwait(false);
}
}
}
Onward to the last piece of the back end puzzle, the UmbracoApiController
The all important Back-end controller
Note the [Authorize(Policy = Constants.Backoffice.DiscordSectionAccessPolicy)] attribute, this uses the policy we declared in our DiscordSectionComposer so only users with the right permissions can call any of the methods in this controller. If we had not done this, we would have to check IBackOfficeSecurityAccessor.BackOfficeSecurity.UserHasSectionAccess() in every method.
- The first 2 get methods are pretty straight forward, they get the data from our Discord Service and hand it over as a list of objects.
- The next one gets the data from our repository and passes it along
- And the last one gets all the membership groups from the Umbraco IMemberGroupService
Next up are the post methods, these are pretty straight forward.
- The RegisterRoleToMemberGroup checks if an existing sync exists for guild+role+group and creates a new one if it it doesn't.
- The RemoveMemberGroupFromRole checks if the item to delete exists, if it does, it updates the flag if set and else deletes the sync.
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Attributes;
using Umbraco.Cms.Web.Common.Controllers;
using UmbracoDiscord.Core.Controllers.SubmitModels;
using UmbracoDiscord.Core.Models.DiscordDashboard;
using UmbracoDiscord.Core.Repositories;
using UmbracoDiscord.Core.Repositories.Dtos;
using UmbracoDiscord.Core.Services;
namespace UmbracoDiscord.Core.Controllers
{
[IsBackOffice]
[Authorize(Policy = Constants.Backoffice.DiscordSectionAccessPolicy)]
public class DiscordAdminController : UmbracoApiController
{
private readonly DiscordRoleRepository _discordRoleRepository;
private readonly IScopeProvider _scopeProvider;
private readonly IMemberGroupService _memberGroupService;
private readonly IDiscordService _discordAuthService;
public DiscordAdminController(DiscordRoleRepository discordRoleRepository,
IScopeProvider scopeProvider,
IMemberGroupService memberGroupService,
IDiscordService discordAuthService)
{
_discordRoleRepository = discordRoleRepository;
_scopeProvider = scopeProvider;
_memberGroupService = memberGroupService;
_discordAuthService = discordAuthService;
}
public async Task<IEnumerable<DiscordGuildInfo>> Guilds()
{
var availableGuilds = await _discordAuthService.GetAvailableGuilds();
return availableGuilds.Select(g => new DiscordGuildInfo { Id = g.Id.ToString(CultureInfo.InvariantCulture), Name = g.Name });
}
public async Task<IEnumerable<DiscordRoleInfo>> Roles(ulong guildId)
{
var roles = await _discordAuthService.GetAvailableRolesForGuild(guildId);
return roles.Select(r => new DiscordRoleInfo() {Id = r.Id.ToString(CultureInfo.InvariantCulture), Name = r.Name});
}
public IEnumerable<SyncedDiscordRole> Syncs(ulong guildId, ulong roleId)
{
using (_scopeProvider.CreateScope(autoComplete:true))
{
return _discordRoleRepository.GetAll().Where(s => s.GuildId == guildId && s.RoleId == roleId);
}
}
public IEnumerable<string> MemberShipGroups()
{
return _memberGroupService.GetAll().Select(g => g.Name);
}
[HttpPost]
public int RegisterRoleToMemberGroup([FromBody] AddSyncModel model)
{
using var scope = _scopeProvider.CreateScope();
// this is not optimal, but we don't expect to get enough items for this to be an issue
var existingItems = _discordRoleRepository.GetAll().Where(i => i.GuildId == model.GuildId && i.RoleId == model.RoleId).ToList();
if (!existingItems.Any())
{
var item = AddItem(model.GuildId, model.RoleId, model.MembershipGroupAlias);
scope.Complete();
return item;
}
return existingItems.First().Id;
}
private int AddItem(decimal guildId, decimal roleId, string membershipGroupAlias)
{
var newItem = new SyncedDiscordRole
{
GuildId = guildId,
RoleId = roleId,
MembershipGroupAlias = membershipGroupAlias,
};
_discordRoleRepository.Save(newItem);
return newItem.Id;
}
[HttpPost]
public bool RemoveMemberGroupFromRole(RemoveSyncModel model)
{
using var scope = _scopeProvider.CreateScope();
var existing = _discordRoleRepository.Get(model.Id);
if (existing == null)
{
return false;
}
if (model.SyncRemoval)
{
existing.SyncRemoval = true;
_discordRoleRepository.Save(existing);
}
else
{
_discordRoleRepository.Delete(existing.Id);
}
scope.Complete();
return true;
}
}
}
We should now have a working Discord dashboard that lets you configure synchronizations between guild roles and membership groups.
Implementing The Oath flow
I presume anyone reading this can setup doctypes and create content in Umbraco, if you are going to do this manually, we need the following structure with a single property on the Discord node (DiscordSection) called requiredGuildIds. You could get this from config instead or add the selection of which guilds to the Discord Dashboard. But for now, we fetch this from the Content
Alternatively, you can use Usync to import the doctypes/content I used in the repo
(Hack) The controllers
As discussed at the beginning of this article, we will need 3 controllers. We will be using the lovely named Route hijacking functionality of Umbraco to use our custom controller every time a page of a specific node type is visited
Lets start with the logout controller since it has the least logic in it as we only logout the member and redirect to the parent page
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;
namespace UmbracoDiscord.Core.Controllers
{
public class LogoutController : RenderController
{
private readonly IMemberSignInManager _memberSignInManager;
public LogoutController(ILogger<LogoutController> logger, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor,
IMemberSignInManager memberSignInManager) : base(logger, compositeViewEngine, umbracoContextAccessor)
{
_memberSignInManager = memberSignInManager;
}
public override IActionResult Index()
{
_memberSignInManager.SignOutAsync().GetAwaiter().GetResult();
return base.Redirect(CurrentPage.AncestorOrSelf(1).Url());
}
}
}
Next up is the login controller, this one has a bit more code but is pretty straightforward, we
- Check our config and Umbraco structure
- Get some values
- Redirect to the Umbraco Authorize endpoint which will call our CallBack endpoint when done
Do note the state variable that is passed along to Discord, as explained before, this is a security measure so we can only accept callbacks that originated from us.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Cms.Web.Common.PublishedModels;
using Umbraco.Extensions;
using UmbracoDiscord.Core.Constants;
using UmbracoDiscord.Core.Services;
namespace UmbracoDiscord.Core.Controllers
{
public class DiscordLoginController : RenderController
{
private readonly IDiscordService _discordAuthService;
private readonly IConfiguration _configuration;
public DiscordLoginController(ILogger<DiscordLoginController> logger, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor,
IDiscordService discordAuthService, IConfiguration configuration) : base(logger, compositeViewEngine, umbracoContextAccessor)
{
_discordAuthService = discordAuthService;
_configuration = configuration;
}
public override IActionResult Index()
{
var settings = (CurrentPage as DiscordLogin).Ancestor<DiscordSection>();
if (settings == null)
{
return base.Index();
}
if (_configuration["Discord:ClientId"].IsNullOrWhiteSpace() || _configuration["Discord:ClientSecret"].IsNullOrWhiteSpace())
{
return base.Index();
}
var state = _discordAuthService.GetState(HttpContext, true);
if (state == null)
{
return base.Index();
}
var redirectPage = settings.FirstChild<DiscordLoginRedirectHandler>();
return Redirect(
$"{DiscordApi.AuthorizeEndpoint}?response_type=code&client_id={_configuration["Discord:ClientId"]}&scope=identify%20email%20guilds&state={state}&redirect_uri={redirectPage.Url(mode:UrlMode.Absolute)}&prompt=none");
}
}
}
And lastly the callback controller, here we
- Check if the state passed back to us from Discord, is one that we actually handed to it in the first place (More on this in the next section)
- Let our DiscordService handle the callback
- Login the Member if allowed and redirect
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;
using Umbraco.Cms.Web.Common.PublishedModels;
using Umbraco.Cms.Web.Common.Security;
using Umbraco.Extensions;
using UmbracoDiscord.Core.Services;
namespace UmbracoDiscord.Core.Controllers
{
public class DiscordLoginRedirectHandlerController : RenderController
{
private readonly ILogger<DiscordLoginRedirectHandlerController> _logger;
private readonly IDiscordAuthService _discordAuthService;
private readonly IMemberManager _memberManager;
private readonly IMemberSignInManager _memberSignInManager;
public DiscordLoginRedirectHandlerController(ILogger<DiscordLoginRedirectHandlerController> logger, ICompositeViewEngine compositeViewEngine, IUmbracoContextAccessor umbracoContextAccessor,
IDiscordAuthService discordAuthService,
IMemberManager memberManager,
IMemberSignInManager memberSignInManager) : base(logger, compositeViewEngine, umbracoContextAccessor)
{
_logger = logger;
_discordAuthService = discordAuthService;
_memberManager = memberManager;
_memberSignInManager = memberSignInManager;
}
//todo figure out why override doesn't work with async
public override IActionResult Index()
{
if (_discordAuthService.IsValidState(HttpContext) == false)
{
_logger.LogInformation("Discord redirect handling failed: Invalid state");
return base.Index();
}
var handleRedirectResult = _discordAuthService
.HandleRedirect(HttpContext, CurrentPage.Ancestor<DiscordSection>()).GetAwaiter().GetResult();
if (handleRedirectResult.Success == false)
{
_logger.LogError(handleRedirectResult.Exception, "DiscordAuthService failed to handle redirect");
return base.Index();
}
var memberIdentity = _memberManager.FindByEmailAsync(handleRedirectResult.Result).GetAwaiter().GetResult();
_memberSignInManager.SignInAsync(memberIdentity, true, "discord");
return base.Redirect(CurrentPage.AncestorOrSelf(1).Url());
}
}
}
Expanding the Discord Service
Before we can test the controllers, we will have to expand our discord service with all the new functionality
- Adding state
- Checking valid state
- Handling the callback
Before we can do any of that, we will have to add a couple more namespaces and dependencies to it.
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.PublishedModels;
using Umbraco.Extensions;
using UmbracoDiscord.Core.Repositories;
private readonly ILogger<DiscordService> _logger;
private readonly IMemberService _memberService;
private readonly DiscordRoleRepository _discordRoleRepository;
private readonly IConfiguration _configuration;
private readonly IScopeProvider _scopeProvider;
public DiscordService(ILogger<DiscordService> logger,
IMemberService memberService,
DiscordRoleRepository discordRoleRepository,
IConfiguration configuration,
IScopeProvider scopeProvider)
{
_logger = logger;
_memberService = memberService;
_discordRoleRepository = discordRoleRepository;
_configuration = configuration;
_scopeProvider = scopeProvider;
}
Alright, lets add the State management. This is quick and dirty and needs some extra love and attention, but what we have here works for demo purposes. We will save 1 Guid for every unique IP in a Static dictionary. Again, never a good idea for production, but good enough to get started.
The code that follows could/should probably be in a separate class as well.
So lets declare the dictionary
private static Dictionary<string, Guid> _stateTracker = new();
Next we create our methods to get (and thus set if non-existent) a state, note the HttpContext we are passing to it to get the IP-address. If we ever change the internal logic on what data makes a unique client, we do not have to change the signature.
public Guid? GetState(HttpContext httpContext, bool renew)
{
if (httpContext?.Connection.RemoteIpAddress == null)
{
_logger.LogWarning("Could not issue State, httpContext or RemoteIpAddress unavailable");
return null;
}
var ip = httpContext.Connection.RemoteIpAddress.ToString();
if (renew || _stateTracker.ContainsKey(ip) == false)
{
return SetState(ip);
}
return _stateTracker[ip];
}
private Guid SetState(string ip)
{
var state = Guid.NewGuid();
_stateTracker[ip] = state;
return state;
}
And lastly our method to check if the passed httpContext contains a valid state
public bool IsValidState(HttpContext httpContext)
{
if (httpContext?.Connection.RemoteIpAddress == null)
{
_logger.LogWarning("Could not validate State, httpContext or RemoteIpAddress unavailable");
return false;
}
var ip = httpContext.Connection.RemoteIpAddress.ToString();
return _stateTracker.ContainsKey(ip) && _stateTracker[ip].ToString() == (string)httpContext.Request.Query["state"];
}
Now that we have finished the state management, we can work on the actual Callback logic.
First up lets have a look at our HandleRedirect Method, the code should be self documented enough to explain the intent and flow of the method. But there are a couple of extra things to note:
- We return an Attempt class so anything consuming this method has a clear idea when/why something failed without errors being thrown around
- a Fail with an EmailUnverifiedException is returned when the user that is fetched from Discord is not verified, this early returns (and thus not wastes extra resources) on bot/temp accounts.
public async Task<Attempt<string>> HandleRedirect(HttpContext httpContext, DiscordSection settings)
{
// get bearer token from redirect code
var bearerTokenResult = await ExchangeRedirectCode((string)httpContext.Request.Query["code"], settings);
// get user
var userResult = await GetUser(bearerTokenResult.AccessToken);
if (userResult.Verified == false)
{
return Attempt<string>.Fail(new EmailUnverifiedException());
}
// get guilds and check they are still a member of the guild specified
var guildResult = await GetUserGuilds(bearerTokenResult.AccessToken);
// if userId exists update them
var existingMember = _memberService.GetByEmail(userResult.Email);
if (existingMember != null)
{
var updateMemberResult = await UpdateMember(existingMember, userResult, guildResult, settings).ConfigureAwait(false);
if (updateMemberResult.Success == false)
{
return Attempt<string>.Fail(updateMemberResult.Exception);
}
return Attempt<string>.Succeed(userResult.Email);
}
// if no member exists create them
var newMemberResult = await CreateMember(userResult, guildResult, settings);
if (newMemberResult.Success == false)
{
return Attempt<string>.Fail(newMemberResult.Exception);
}
return Attempt<string>.Succeed(userResult.Email);
}
Before we head into all the private methods this one calls, lets have a quick look at some of the more deep down code, mainly the wrapper code for the Discord Api. We made 2 of these before and will be adding 2 more, this time from the context of the user. Note the WithOAuthBearerToken, this is a Flurl extension method that adds an Authentication header to the request.
private async Task<UserResult> GetUser(string accessToken)
{
return await Constants.DiscordApi.UserEndpoint.WithOAuthBearerToken(accessToken)
.GetAsync().ReceiveJson<UserResult>().ConfigureAwait(false);
}
private async Task<List<GuildResult>> GetUserGuilds(string accessToken)
{
return await Constants.DiscordApi.GuildEndpoint.
WithOAuthBearerToken(accessToken)
.GetAsync().ReceiveJson<List<GuildResult>>().ConfigureAwait(false);
}
To get that token, we use the following method which is the first one called in the HandleRedirect.
private async Task<BearerTokenResult> ExchangeRedirectCode(string code, DiscordSection settings)
{
return await Constants.DiscordApi.TokenEndpoint.PostUrlEncodedAsync(new
{
client_id = _configuration["Discord:ClientId"],
client_secret = _configuration["Discord:ClientSecret"],
grant_type = "authorization_code",
code = code,
redirect_uri = settings.FirstChild<DiscordLoginRedirectHandler>().Url(mode: UrlMode.Absolute)
}).ReceiveJson<BearerTokenResult>().ConfigureAwait(false);
}
Now we have all the logic done to get all the information we need to actually create/update a member, so let us do that next.
Before we can actually create/update a member we need to make sure their Discord user belongs to a guild we have access to.
private bool RequiredGuildsValidated(UserResult userResult, List<GuildResult> guilds, DiscordSection section)
{
if (section.RequiredGuildIds.IsNullOrWhiteSpace())
{
return true;
}
var requiredGuildStrings = section.RequiredGuildIds.Split(",");
requiredGuildStrings.RemoveAll(i => i.IsNullOrWhiteSpace());
var requiredGuildIds = requiredGuildStrings.Select(id => Convert.ToUInt64(id));
if (requiredGuildStrings.Any() == false)
{
return true;
}
return guilds.Any(g => requiredGuildIds.Any(gi => gi == g.Id));
}
And now we can create/disable/update members based on the data we have collected before.
Note that the member created is of type member, nothing is stopping you from create another member type just for discord logins.
private async Task<Attempt<bool>> CreateMember(UserResult userResult, List<GuildResult> guilds, DiscordSection settings)
{
if (RequiredGuildsValidated(userResult, guilds, settings) == false)
{
return Attempt<bool>.Fail(new FailedRequiredGuildsException());
}
var newMember = _memberService.CreateMember(userResult.Email, userResult.Email, userResult.Username, "member");
UpdateUserDetails(newMember, userResult);
_memberService.Save(newMember);
await SyncMemberGroups(newMember, userResult, guilds);
return Attempt<bool>.Succeed();
}
private async Task<Attempt<bool>> UpdateMember(IMember member, UserResult userResult, List<GuildResult> guilds, DiscordSection settings)
{
if (RequiredGuildsValidated(userResult, guilds, settings) == false)
{
member.IsApproved = false;
_memberService.Save(member);
return Attempt<bool>.Fail(new FailedRequiredGuildsException());
}
UpdateUserDetails(member, userResult);
_memberService.Save(member);
await SyncMemberGroups(member, userResult, guilds);
return Attempt<bool>.Succeed();
}
private void UpdateUserDetails(IMember member, UserResult userResult)
{
member.SetValue("discordId", userResult.Id);
member.SetValue("discordUserName", userResult.Username);
member.SetValue("discordDiscriminator", userResult.Discriminator);
}
As you might have noticed, there is 1 method still missing which does the actual sync between the discord user roles and the configuration we set up in the first half of this article.
private async Task SyncMemberGroups(IMember member, UserResult userResult, List<GuildResult> guilds)
{
using var scope = _scopeProvider.CreateScope(autoComplete: true);
var availableGuilds = await GetAvailableGuilds();
var syncRules = _discordRoleRepository.GetAll().ToList();
// we are not in the guild of the rule OR we no longer have access to the guild OR the rule has been marked as syncRemove
var groupsToRemove = syncRules.Where(r => guilds.Any(g => g.Id == r.GuildId) == false || availableGuilds.Any(g => g.Id == r.GuildId) || r.SyncRemoval)
.Select(r => r.MembershipGroupAlias).Distinct().ToList();
// we need to filter out unavailable guilds else fetching the discord information in the loop below will throw an error
var activeGuilds = syncRules.Where(r => r.SyncRemoval == false && availableGuilds.Any(g => g.Id == r.GuildId)).Select(r => r.GuildId).Distinct();
var groupsToAdd = new List<string>();
foreach (var guildId in activeGuilds)
{
var guildMember = await string.Format(Constants.DiscordApi.GuildPermissionsEndpoint, guildId, userResult.Id)
.WithHeader("Authorization", "Bot " + _configuration["Discord:BotToken"])
.GetAsync().ReceiveJson<GuildUserResult>().ConfigureAwait(false);
var validGroups = syncRules
.Where(s => s.SyncRemoval == false && s.GuildId == guildId &&
guildMember.Roles.Any(r => r == s.RoleId)).Select(s => s.MembershipGroupAlias)
.Distinct();
foreach (var validGroup in validGroups)
{
if (groupsToAdd.Contains(validGroup) == false)
{
groupsToAdd.Add(validGroup);
}
}
}
// no need to delete rolls we are going to add
foreach (var role in groupsToAdd)
{
if (groupsToRemove.Contains(role))
{
groupsToRemove.Remove(role);
}
}
if (groupsToRemove.Any())
{
_memberService.DissociateRoles(new[] { member.Id }, groupsToRemove.ToArray());
}
if (groupsToAdd.Any())
{
_memberService.AssignRoles(new[] { member.Id }, groupsToAdd.ToArray());
}
}
And that is our backend logic done, the only thing remaining now is to test it out and to be able to do that we need...
Some super basic views
Note the use of @inject this injects the dependency into the view just as you would do in the constructor if the controller.
Homepage
@using Umbraco.Cms.Web.Common.PublishedModels;
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<ContentModels.Homepage>
@using ContentModels = Umbraco.Cms.Web.Common.PublishedModels;
@using Umbraco.Cms.Core.Security
@using Umbraco.Cms.Core.Media.EmbedProviders
@inject IMemberManager MemberManager
@{
Layout = null;
}
<div>
@if (MemberManager.IsLoggedIn())
{
var logoutPage = Model.FirstChild<Logout>();
<span>Logged in as @MemberManager.GetCurrentMemberAsync().GetAwaiter().GetResult().Name</span>
if (logoutPage != null)
{
<a href="@logoutPage.Url()"> (Logout)</a>
}
}
else
{
var discordLoginPage = Model.FirstChild<DiscordSection>().FirstChild<DiscordLogin>();
<a href="@discordLoginPage.Url()">Login to discord</a>
}
</div>
DiscordLogin (For when the configuration/Umbraco setup is invalid)
@using Umbraco.Cms.Web.Common.PublishedModels;
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<ContentModels.DiscordLogin>
@using ContentModels = Umbraco.Cms.Web.Common.PublishedModels;
@{
Layout = null;
}
<div> Oops something went wrong, contact us if this keeps happening, try to <a href="@Model.Url()">reload the page</a> to try again.</div>
DiscordLoginRedirectHandler (For when something goes wrong)
@using Umbraco.Cms.Web.Common.PublishedModels;
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<ContentModels.DiscordLoginRedirectHandler>
@using ContentModels = Umbraco.Cms.Web.Common.PublishedModels;
@{
Layout = null;
}
<div> Oops something went wrong, contact us if this keeps happening, <a href="@(Model.Ancestor<DiscordSection>().FirstChild<DiscordLogin>().Url())">click here to try again.</a></div>
And with this, our journey into Discord Oauth land has come to an end.
Final words
I hope this article helps people who find themselves in the specific situation of having to integrate Umbraco with Discord or more broadly helps as a plan of attack for those having to write any kind of integration with an OAuth Provider into Umbraco members.
This article has plenty of links to the Umbraco documentation and a working Github repository to help you make sense of it all. If however something remains unclear, don't hesitate to ping me on Twitter or on the Umbraco Community Discord.
Sven Geusens
Sven is on Twitter as @migaroez