Skip to content

Commit 6dd08b2

Browse files
authored
V14 External login linking + Proposed error handling (#16052)
* Added mostly working linking methods to the backoffice controller Cleanup still required * Added proposed default error handling extionsion methods * Cleanup, clarification and PR feedback * More cleanup * Transformed the OAuthOptionsExtensions into a helper class this allows for proper DI for the dependencies --------- Co-authored-by: Sven Geusens <[email protected]>
1 parent 298f4de commit 6dd08b2

File tree

8 files changed

+254
-3
lines changed

8 files changed

+254
-3
lines changed

src/Umbraco.Cms.Api.Management/Controllers/Security/BackOfficeController.cs

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public class BackOfficeController : SecurityControllerBase
3939
private readonly ILogger<BackOfficeController> _logger;
4040
private readonly IBackOfficeTwoFactorOptions _backOfficeTwoFactorOptions;
4141
private readonly IUserTwoFactorLoginService _userTwoFactorLoginService;
42+
private readonly IBackOfficeExternalLoginProviders _backOfficeExternalLoginProviders;
4243

4344
public BackOfficeController(
4445
IHttpContextAccessor httpContextAccessor,
@@ -47,7 +48,8 @@ public BackOfficeController(
4748
IOptions<SecuritySettings> securitySettings,
4849
ILogger<BackOfficeController> logger,
4950
IBackOfficeTwoFactorOptions backOfficeTwoFactorOptions,
50-
IUserTwoFactorLoginService userTwoFactorLoginService)
51+
IUserTwoFactorLoginService userTwoFactorLoginService,
52+
IBackOfficeExternalLoginProviders backOfficeExternalLoginProviders)
5153
{
5254
_httpContextAccessor = httpContextAccessor;
5355
_backOfficeSignInManager = backOfficeSignInManager;
@@ -56,6 +58,7 @@ public BackOfficeController(
5658
_logger = logger;
5759
_backOfficeTwoFactorOptions = backOfficeTwoFactorOptions;
5860
_userTwoFactorLoginService = userTwoFactorLoginService;
61+
_backOfficeExternalLoginProviders = backOfficeExternalLoginProviders;
5962
}
6063

6164
[HttpPost("login")]
@@ -184,6 +187,145 @@ public async Task<IActionResult> Signout(CancellationToken cancellationToken)
184187
return SignOut(Constants.Security.BackOfficeAuthenticationType, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
185188
}
186189

190+
/// <summary>
191+
/// Called when a user links an external login provider in the back office
192+
/// </summary>
193+
/// <param name="provider"></param>
194+
/// <returns></returns>
195+
[HttpPost("link-login")]
196+
[MapToApiVersion("1.0")]
197+
public IActionResult LinkLogin(string provider)
198+
{
199+
// Request a redirect to the external login provider to link a login for the current user
200+
var redirectUrl = Url.Action(nameof(ExternalLinkLoginCallback), this.GetControllerName());
201+
202+
// Configures the redirect URL and user identifier for the specified external login including xsrf data
203+
AuthenticationProperties properties =
204+
_backOfficeSignInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _backOfficeUserManager.GetUserId(User));
205+
206+
return Challenge(properties, provider);
207+
}
208+
209+
/// <summary>
210+
/// Callback path when the user initiates a link login request from the back office to the external provider from the
211+
/// <see cref="LinkLogin(string)" /> action
212+
/// </summary>
213+
/// <remarks>
214+
/// An example of this is here
215+
/// https://github.com/dotnet/aspnetcore/blob/main/src/Identity/samples/IdentitySample.Mvc/Controllers/AccountController.cs#L155
216+
/// which this is based on
217+
/// </remarks>
218+
[HttpGet("ExternalLinkLoginCallback")]
219+
[AllowAnonymous]
220+
[MapToApiVersion("1.0")]
221+
public async Task<IActionResult> ExternalLinkLoginCallback()
222+
{
223+
var cookieAuthenticatedUserAttempt =
224+
await HttpContext.AuthenticateAsync(Constants.Security.BackOfficeAuthenticationType);
225+
226+
if (cookieAuthenticatedUserAttempt.Succeeded == false)
227+
{
228+
return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl(
229+
"flow=external-login-callback",
230+
"status=unauthorized"));
231+
}
232+
233+
BackOfficeIdentityUser? user = await _backOfficeUserManager.GetUserAsync(cookieAuthenticatedUserAttempt.Principal);
234+
if (user == null)
235+
{
236+
return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl(
237+
"flow=external-login-callback",
238+
"status=user-not-found"));
239+
}
240+
241+
ExternalLoginInfo? info =
242+
await _backOfficeSignInManager.GetExternalLoginInfoAsync();
243+
244+
if (info == null)
245+
{
246+
return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl(
247+
"flow=external-login-callback",
248+
"status=external-info-not-found"));
249+
}
250+
251+
IdentityResult addLoginResult = await _backOfficeUserManager.AddLoginAsync(user, info);
252+
if (addLoginResult.Succeeded)
253+
{
254+
// Update any authentication tokens if succeeded
255+
await _backOfficeSignInManager.UpdateExternalAuthenticationTokensAsync(info);
256+
return Redirect("/umbraco"); // todo shouldn't this come from configuration
257+
}
258+
259+
// Add errors and redirect for it to be displayed
260+
// TempData[ViewDataExtensions.TokenExternalSignInError] = addLoginResult.Errors;
261+
// return RedirectToLogin(new { flow = "external-login", status = "failed", logout = "true" });
262+
// todo
263+
return Redirect(_securitySettings.Value.AuthorizeCallbackErrorPathName.AppendQueryStringToUrl(
264+
"flow=external-login-callback",
265+
"status=failed"));
266+
}
267+
268+
// todo cleanup unhappy responses
269+
[HttpPost("unlink-login")]
270+
[MapToApiVersion("1.0")]
271+
public async Task<IActionResult> PostUnLinkLogin(UnLinkLoginRequestModel unlinkLoginRequestModel)
272+
{
273+
var userId = User.Identity?.GetUserId();
274+
if (userId is null)
275+
{
276+
throw new InvalidOperationException("Could not find userId");
277+
}
278+
279+
BackOfficeIdentityUser? user = await _backOfficeUserManager.FindByIdAsync(userId);
280+
if (user == null)
281+
{
282+
throw new InvalidOperationException("Could not find user");
283+
}
284+
285+
AuthenticationScheme? authType = (await _backOfficeSignInManager.GetExternalAuthenticationSchemesAsync())
286+
.FirstOrDefault(x => x.Name == unlinkLoginRequestModel.LoginProvider);
287+
288+
if (authType == null)
289+
{
290+
_logger.LogWarning("Could not find the supplied external authentication provider");
291+
}
292+
else
293+
{
294+
BackOfficeExternaLoginProviderScheme? opt = await _backOfficeExternalLoginProviders.GetAsync(authType.Name);
295+
if (opt == null)
296+
{
297+
return StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder()
298+
.WithTitle("Missing Authentication options")
299+
.WithDetail($"Could not find external authentication options registered for provider {authType.Name}")
300+
.Build());
301+
}
302+
303+
if (!opt.ExternalLoginProvider.Options.AutoLinkOptions.AllowManualLinking)
304+
{
305+
// If AllowManualLinking is disabled for this provider we cannot unlink
306+
return StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder()
307+
.WithTitle("Unlinking disabled")
308+
.WithDetail($"Manual linking is disabled for provider {authType.Name}")
309+
.Build());
310+
}
311+
}
312+
313+
IdentityResult result = await _backOfficeUserManager.RemoveLoginAsync(
314+
user,
315+
unlinkLoginRequestModel.LoginProvider,
316+
unlinkLoginRequestModel.ProviderKey);
317+
318+
if (result.Succeeded)
319+
{
320+
await _backOfficeSignInManager.SignInAsync(user, true);
321+
return Ok();
322+
}
323+
324+
return StatusCode(StatusCodes.Status400BadRequest, new ProblemDetailsBuilder()
325+
.WithTitle("Unlinking failed")
326+
.Build());
327+
}
328+
187329
/// <summary>
188330
/// Retrieve the user principal stored in the authentication cookie.
189331
/// </summary>

src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public static IUmbracoBuilder
2424
.AddConfiguration()
2525
.AddUmbracoCore()
2626
.AddWebComponents()
27+
.AddHelpers()
2728
.AddBackOfficeCore()
2829
.AddBackOfficeIdentity()
2930
.AddBackOfficeAuthentication()

src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,6 @@ private void MapMinimalBackOffice(IEndpointRouteBuilder endpoints)
8282
Controller = ControllerExtensions.GetControllerName<BackOfficeDefaultController>(),
8383
Action = nameof(BackOfficeDefaultController.Index),
8484
},
85-
constraints: new { slug = @"^(section.*|upgrade|install|logout)$" });
85+
constraints: new { slug = @"^(section.*|upgrade|install|logout|error)$" });
8686
}
8787
}

src/Umbraco.Cms.Api.Management/Security/BackOfficeAuthenticationBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public BackOfficeAuthenticationBuilder(
2020
: base(services)
2121
=> _loginProviderOptions = loginProviderOptions ?? (x => { });
2222

23-
public string? SchemeForBackOffice(string scheme)
23+
public static string? SchemeForBackOffice(string scheme)
2424
=> scheme?.EnsureStartsWith(Constants.Security.BackOfficeExternalAuthenticationTypePrefix);
2525

2626
/// <summary>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using System.Runtime.Serialization;
3+
4+
namespace Umbraco.Cms.Api.Management.ViewModels.Security;
5+
6+
public class UnLinkLoginRequestModel
7+
{
8+
[Required]
9+
public required string LoginProvider { get; set; }
10+
11+
[Required]
12+
public required string ProviderKey { get; set; }
13+
}

src/Umbraco.Core/Configuration/Models/SecuritySettings.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class SecuritySettings
2626
internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
2727
internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
2828
internal const string StaticAuthorizeCallbackPathName = "/umbraco";
29+
internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error";
2930

3031
/// <summary>
3132
/// Gets or sets a value indicating whether to keep the user logged in.
@@ -116,4 +117,7 @@ public class SecuritySettings
116117
/// </summary>
117118
[DefaultValue(StaticAuthorizeCallbackPathName)]
118119
public string AuthorizeCallbackPathName { get; set; } = StaticAuthorizeCallbackPathName;
120+
121+
[DefaultValue(StaticAuthorizeCallbackErrorPathName)]
122+
public string AuthorizeCallbackErrorPathName { get; set; } = StaticAuthorizeCallbackErrorPathName;
119123
}

src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
using Umbraco.Cms.Web.Common.Configuration;
4747
using Umbraco.Cms.Web.Common.DependencyInjection;
4848
using Umbraco.Cms.Web.Common.FileProviders;
49+
using Umbraco.Cms.Web.Common.Helpers;
4950
using Umbraco.Cms.Web.Common.Localization;
5051
using Umbraco.Cms.Web.Common.Middleware;
5152
using Umbraco.Cms.Web.Common.ModelBinders;
@@ -306,6 +307,13 @@ public static IUmbracoBuilder AddWebComponents(this IUmbracoBuilder builder)
306307
return builder;
307308
}
308309

310+
public static IUmbracoBuilder AddHelpers(this IUmbracoBuilder builder)
311+
{
312+
builder.Services.AddSingleton<OAuthOptionsHelper>();
313+
314+
return builder;
315+
}
316+
309317
// TODO: Does this need to exist and/or be public?
310318
public static IUmbracoBuilder AddWebServer(this IUmbracoBuilder builder)
311319
{
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using Microsoft.AspNetCore.Authentication;
2+
using Microsoft.AspNetCore.Authentication.OAuth;
3+
using Microsoft.Extensions.Options;
4+
using Umbraco.Cms.Core.Configuration.Models;
5+
using Umbraco.Extensions;
6+
7+
namespace Umbraco.Cms.Web.Common.Helpers;
8+
9+
public class OAuthOptionsHelper
10+
{
11+
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
12+
// we omit "state" and "error_uri" here as it hold no value in determining the message to display to the user
13+
private static readonly IReadOnlyCollection<string> _oathCallbackErrorParams = new string[] { "error", "error_description" };
14+
15+
private readonly IOptions<SecuritySettings> _securitySettings;
16+
17+
public OAuthOptionsHelper(IOptions<SecuritySettings> securitySettings)
18+
{
19+
_securitySettings = securitySettings;
20+
}
21+
22+
/// <summary>
23+
/// Applies SetUmbracoRedirectWithFilteredParams to both OnAccessDenied and OnRemoteFailure
24+
/// on the OAuthOptions so Umbraco can do its best to nicely display the error messages
25+
/// that are passed back from the external login provider on failure.
26+
/// </summary>
27+
public T SetDefaultErrorEventHandling<T>(T oAuthOptions, string providerFriendlyName) where T : OAuthOptions
28+
{
29+
oAuthOptions.Events.OnAccessDenied =
30+
context => HandleResponseWithDefaultUmbracoRedirect(context, providerFriendlyName, "OnAccessDenied");
31+
oAuthOptions.Events.OnRemoteFailure =
32+
context => HandleResponseWithDefaultUmbracoRedirect(context, providerFriendlyName, "OnRemoteFailure");
33+
34+
return oAuthOptions;
35+
}
36+
37+
private Task HandleResponseWithDefaultUmbracoRedirect(HandleRequestContext<RemoteAuthenticationOptions> context, string providerFriendlyName, string eventName)
38+
{
39+
SetUmbracoRedirectWithFilteredParams(context, providerFriendlyName, eventName)
40+
.HandleResponse();
41+
42+
return Task.FromResult(0);
43+
}
44+
45+
/// <summary>
46+
/// Sets the context to redirect to the <see cref="SecuritySettings.AuthorizeCallbackErrorPathName"/> path with all parameters, except state, that are passed to the initial server callback configured for the configured external login provider
47+
/// </summary>
48+
public T SetUmbracoRedirectWithFilteredParams<T>(T context, string providerFriendlyName, string eventName)
49+
where T : HandleRequestContext<RemoteAuthenticationOptions>
50+
{
51+
var callbackPath = _securitySettings.Value.AuthorizeCallbackErrorPathName;
52+
53+
callbackPath = callbackPath.AppendQueryStringToUrl("flow=external-login")
54+
.AppendQueryStringToUrl($"provider={providerFriendlyName}")
55+
.AppendQueryStringToUrl($"callback-event={eventName}");
56+
57+
foreach (var oathCallbackErrorParam in _oathCallbackErrorParams)
58+
{
59+
if (context.Request.Query.ContainsKey(oathCallbackErrorParam))
60+
{
61+
callbackPath = callbackPath.AppendQueryStringToUrl($"{oathCallbackErrorParam}={context.Request.Query[oathCallbackErrorParam]}");
62+
}
63+
}
64+
65+
context.Response.Redirect(callbackPath);
66+
return context;
67+
}
68+
69+
/// <summary>
70+
/// Sets the callbackPath for the RemoteAuthenticationOptions based on the configured Umbraco path and the path supplied.
71+
/// By default this will result in "/umbraco/your-supplied-path".
72+
/// </summary>
73+
/// <param name="options">The options object to set the path on.</param>
74+
/// <param name="path">The path that should go after the umbraco path, will add a leading slash if it's missing.</param>
75+
/// <returns></returns>
76+
public RemoteAuthenticationOptions SetUmbracoBasedCallbackPath(RemoteAuthenticationOptions options, string path)
77+
{
78+
var umbracoCallbackPath = _securitySettings.Value.AuthorizeCallbackPathName;
79+
80+
options.CallbackPath = umbracoCallbackPath + path.EnsureStartsWith("/");
81+
return options;
82+
}
83+
}

0 commit comments

Comments
 (0)