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

Add social login support to Boilerplate (#7525) #7653

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="8.0.1" />
<PackageReference Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="8.0.1" />
<PackageReference Include="AspNetCore.HealthChecks.Network" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.6" />
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="8.0.0" />
<PackageReference Include="Riok.Mapperly" Version="3.5.1" />
<PackageReference Include="Twilio" Version="7.1.0" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public partial class IdentityController : AppControllerBase, IIdentityController

[AutoInject] private EmailService emailService = default!;

[AutoInject] private ILogger<IdentityController> logger = default!;

//#if (captcha == "reCaptcha")
[AutoInject] private GoogleRecaptchaHttpClient googleRecaptchaHttpClient = default!;
//#endif
Expand Down Expand Up @@ -114,6 +116,10 @@ public async Task ConfirmEmail(ConfirmEmailRequestDto request, CancellationToken
var result = await userManager.UpdateAsync(user);
if (result.Succeeded is false)
throw new ResourceValidationException(result.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());

var updateSecurityStampResult = await userManager.UpdateSecurityStampAsync(user); // invalidates email token
if (updateSecurityStampResult.Succeeded is false)
throw new ResourceValidationException(updateSecurityStampResult.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());
}

[HttpPost]
Expand Down Expand Up @@ -150,6 +156,10 @@ public async Task ConfirmPhone(ConfirmPhoneRequestDto request, CancellationToken
var result = await userManager.UpdateAsync(user);
if (result.Succeeded is false)
throw new ResourceValidationException(result.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());

var updateSecurityStampResult = await userManager.UpdateSecurityStampAsync(user); // invalidates phone token
if (updateSecurityStampResult.Succeeded is false)
throw new ResourceValidationException(updateSecurityStampResult.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());
}

[HttpPost]
Expand Down Expand Up @@ -194,7 +204,9 @@ public async Task<ActionResult<SignInResponseDto>> SignIn(SignInRequestDto reque

if (string.IsNullOrEmpty(request.Otp) is false)
{
await userManager.UpdateSecurityStampAsync(user); // invalidates the OTP.
var updateSecurityStampResult = await userManager.UpdateSecurityStampAsync(user); // invalidates the OTP.
if (updateSecurityStampResult.Succeeded is false)
throw new ResourceValidationException(updateSecurityStampResult.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());
}

return Empty;
Expand Down Expand Up @@ -265,7 +277,7 @@ async Task SendSms()
/// For either otp or magic link
/// </summary>
[HttpPost]
public async Task SendOtp(IdentityRequestDto request, CancellationToken cancellationToken)
public async Task SendOtp(IdentityRequestDto request, string? returnUrl = null, CancellationToken cancellationToken = default)
{
var user = await userManager.FindUser(request)
?? throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserNotFound)]);
Expand All @@ -285,10 +297,8 @@ public async Task SendOtp(IdentityRequestDto request, CancellationToken cancella
if (result.Succeeded is false)
throw new ResourceValidationException(result.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());

var token = await userManager.GenerateUserTokenAsync(user, TokenOptions.DefaultPhoneProvider, $"Otp,Date:{user.OtpRequestedOn}");
var isEmail = string.IsNullOrEmpty(request.Email) is false;
var qs = $"{(isEmail ? "email" : "phoneNumber")}={Uri.EscapeDataString(isEmail ? request.Email! : request.PhoneNumber!)}";
var url = $"sign-in?otp={Uri.EscapeDataString(token)}&{qs}";
var (token, url) = await GenerateOtpTokenData(user, returnUrl);

var link = new Uri(HttpContext.Request.GetBaseUrl(), url);

async Task SendEmail()
Expand Down Expand Up @@ -328,6 +338,10 @@ public async Task ResetPassword(ResetPasswordRequestDto request, CancellationTok

if (result.Succeeded is false)
throw new ResourceValidationException(result.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());

var updateSecurityStampResult = await userManager.UpdateSecurityStampAsync(user); // invalidates reset password token
if (updateSecurityStampResult.Succeeded is false)
throw new ResourceValidationException(updateSecurityStampResult.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());
}

[HttpPost]
Expand Down Expand Up @@ -366,6 +380,111 @@ async Task SendSms()
await Task.WhenAll(SendEmail(), SendSms());
}

[HttpGet]
public async Task<string> GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null)
{
var uri = Url.Action(nameof(SocialSignIn), new { provider, returnUrl, localHttpPort })!;
return new Uri(Request.GetBaseUrl(), uri).ToString();
}

[HttpGet]
public async Task<ActionResult> SocialSignIn(string provider, string? returnUrl = null, int? localHttpPort = null)
{
var redirectUrl = Url.Action(nameof(SocialSignInCallback), "Identity", new { returnUrl, localHttpPort });
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return new ChallengeResult(provider, properties);
}

[HttpGet]
public async Task<ActionResult> SocialSignInCallback(string? returnUrl = null, int? localHttpPort = null, CancellationToken cancellationToken = default)
{
string? url;

var info = await signInManager.GetExternalLoginInfoAsync() ?? throw new BadRequestException();

try
{
var email = info.Principal.GetEmail();
var phoneNumber = info.Principal.Claims.FirstOrDefault(c => c.Type is ClaimTypes.HomePhone or ClaimTypes.MobilePhone or ClaimTypes.OtherPhone)?.Value;

if (string.IsNullOrEmpty(email) && string.IsNullOrEmpty(phoneNumber))
throw new InvalidOperationException(); // The app requires users to have at least one communication channel: phone or email.

var user = await userManager.FindUser(new() { Email = email, PhoneNumber = phoneNumber });

if (user is null)
{
// Instead of automatically creating a user here, you can navigate to the sign-up page and pass the email and phone number in the query string.

user = new() { LockoutEnabled = true };

await userStore.SetUserNameAsync(user, Guid.NewGuid().ToString(), cancellationToken);

if (string.IsNullOrEmpty(email) is false)
{
await ((IUserEmailStore<User>)userStore).SetEmailAsync(user, email, cancellationToken);
}

if (string.IsNullOrEmpty(phoneNumber) is false)
{
await ((IUserPhoneNumberStore<User>)userStore).SetPhoneNumberAsync(user, phoneNumber!, cancellationToken);
}

var result = await userManager.CreateAsync(user, password: Guid.NewGuid().ToString("N") /* Users can reset their password later. */);

if (result.Succeeded is false)
{
throw new BadRequestException(string.Join(", ", result.Errors.Select(e => new LocalizedString(e.Code, e.Description))));
}

await userManager.AddLoginAsync(user, info);
}

if (string.IsNullOrEmpty(email) is false && email == user.Email && await userManager.IsEmailConfirmedAsync(user) is false)
{
await ((IUserEmailStore<User>)userStore).SetEmailConfirmedAsync(user, true, cancellationToken);
await userManager.UpdateAsync(user);
}

if (string.IsNullOrEmpty(phoneNumber) is false && phoneNumber == user.PhoneNumber && await userManager.IsPhoneNumberConfirmedAsync(user) is false)
{
await ((IUserPhoneNumberStore<User>)userStore).SetPhoneNumberConfirmedAsync(user, true, cancellationToken);
await userManager.UpdateAsync(user);
}

(_, url) = await GenerateOtpTokenData(user, returnUrl); // Sign in with a magic link, and 2FA will be prompted if already enabled.
}
catch (Exception exp)
{
LogSocialSignInCallbackFailed(logger, exp, info.LoginProvider, info.Principal.GetDisplayName());
url = $"sign-in?error={Uri.EscapeDataString(exp is KnownException ? Localizer[exp.Message] : Localizer[nameof(AppStrings.UnknownException)])}";
}

if (localHttpPort is null) return LocalRedirect($"~/{url}");

return Redirect(new Uri(new Uri($"http://localhost:{localHttpPort}/"), url).ToString());
}

[LoggerMessage(Level = LogLevel.Error, Message = "Failed to perform {loginProvider} social sign in for {principal}")]
private static partial void LogSocialSignInCallbackFailed(ILogger logger, Exception exp, string loginProvider, string principal);

private async Task<(string token, string url)> GenerateOtpTokenData(User user, string? returnUrl)
{
var token = await userManager.GenerateUserTokenAsync(user, TokenOptions.DefaultPhoneProvider, $"Otp,Date:{user.OtpRequestedOn}");

var isEmail = string.IsNullOrEmpty(user.Email) is false;
var qs = $"{(isEmail ? "email" : "phoneNumber")}={Uri.EscapeDataString(isEmail ? user.Email! : user.PhoneNumber!)}";

if (string.IsNullOrEmpty(returnUrl) is false)
{
qs += $"&return-url={Uri.EscapeDataString(returnUrl)}";
}

var url = $"sign-in?otp={Uri.EscapeDataString(token)}&{qs}";

return (token, url);
}

private async Task SendConfirmEmailToken(User user, CancellationToken cancellationToken)
{
var resendDelay = (DateTimeOffset.Now - user.EmailTokenRequestedOn) - AppSettings.IdentitySettings.EmailTokenRequestResendDelay;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ public async Task ChangeEmail(ChangeEmailRequestDto request, CancellationToken c

if (result.Succeeded is false)
throw new ResourceValidationException(result.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());

var updateSecurityStampResult = await userManager.UpdateSecurityStampAsync(user); // invalidates email token
if (updateSecurityStampResult.Succeeded is false)
throw new ResourceValidationException(updateSecurityStampResult.Errors.Select(e => new LocalizedString(e.Code, e.Description)).ToArray());
}

[HttpPost]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,8 @@ private static void Configure_401_403_404_Pages(WebApplication app)

var qs = HttpUtility.ParseQueryString(httpContext.Request.QueryString.Value ?? string.Empty);
qs.Remove("try_refreshing_token");
var redirectUrl = UriHelper.BuildRelative(httpContext.Request.PathBase, httpContext.Request.Path, new QueryString(qs.ToString()));
httpContext.Response.Redirect($"/not-authorized?redirect-url={redirectUrl}&isForbidden={(is403 ? "true" : "false")}");
var returnUrl = UriHelper.BuildRelative(httpContext.Request.PathBase, httpContext.Request.Path, new QueryString(qs.ToString()));
httpContext.Response.Redirect($"/not-authorized?return-url={returnUrl}&isForbidden={(is403 ? "true" : "false")}");
}
else if (httpContext.Response.StatusCode is 404 &&
httpContext.GetEndpoint() is null /* Please be aware that certain endpoints, particularly those associated with web API actions, may intentionally return a 404 error. */)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ private static void AddIdentity(WebApplicationBuilder builder)
.AddErrorDescriber<AppIdentityErrorDescriber>()
.AddApiEndpoints();

services.AddAuthentication(options =>
var authenticationBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = IdentityConstants.BearerScheme;
options.DefaultChallengeScheme = IdentityConstants.BearerScheme;
Expand Down Expand Up @@ -272,6 +272,26 @@ private static void AddIdentity(WebApplicationBuilder builder)
};
});

if (configuration["Authentication:Google:ClientId"] is not { Length: 0 })
{
authenticationBuilder.AddGoogle(options =>
{
options.ClientId = configuration["Authentication:Google:ClientId"]!;
options.ClientSecret = configuration["Authentication:Google:ClientSecret"]!;
options.SignInScheme = IdentityConstants.ExternalScheme;
});
}

if (configuration["Authentication:GitHub:ClientId"] is not { Length: 0 })
{
authenticationBuilder.AddGitHub(options =>
{
options.ClientId = configuration["Authentication:GitHub:ClientId"]!;
options.ClientSecret = configuration["Authentication:GitHub:ClientSecret"]!;
options.SignInScheme = IdentityConstants.ExternalScheme;
});
}

services.AddAuthorization();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{

"ForwardedHeadersOptions": {
"AllowedHosts": "bp.bitplatform.dev" // If the list is empty then all hosts are allowed.
// Failing to restrict this these values may allow an attacker to spoof links generated for reset password etc.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,17 @@
"GoogleRecaptchaSecretKey": "6LdMKr4pAAAAANvngWNam_nlHzEDJ2t6SfV6L_DS"
//#endif
},
"Authentication": {
"Google": {
"ClientId": null,
"ClientSecret": null
}
},
//#endif
"AllowedHosts": "*",
"ForwardedHeadersOptions": {
"ForwardedHostHeaderName": "X-Forwarded-Host", // Behind Cloudflare CDN, use X-Host instead of X-Forwarded-Host
"ForwardedHeaders": "All"
"ForwardedHeadersOptions": { // These values apply only if your backend is hosted behind a CDN (such as Cloudflare).
"ForwardedHostHeaderName": "X-Forwarded-Host", // For Cloudflare, use X-Host instead of X-Forwarded-Host.
"ForwardedHeaders": "All",
"AllowedHosts": "*" // Configure this in production with your backend URL host address (See appsettings.Production.json).
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ private async void LogAuthenticationState(Task<AuthenticationState> task)

var (userId, userName, email, isUserAuthenticated) = user.IsAuthenticated() ? (user.GetUserId().ToString(), user.GetUserName(), user.GetEmail(), user.IsAuthenticated()) : default;

authLogger.LogInformation("Authentication State: {UserId}, {UserName}, {Email}, {IsUserAuthenticated}", userId, userName, email, isUserAuthenticated);
LogAuthenticationState(authLogger, userId, userName, email, isUserAuthenticated);
}
catch (Exception exp)
{
ExceptionHandler.Handle(exp);
}
}

[LoggerMessage(Level = LogLevel.Information, Message = "Authentication State: {UserId}, {UserName}, {Email}, {IsUserAuthenticated}")]
private static partial void LogAuthenticationState(ILogger logger, string userId, string userName, string? email, bool isUserAuthenticated);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ public partial class SignInPage
[AutoInject] private IIdentityController identityController = default!;


[Parameter, SupplyParameterFromQuery(Name = "redirect-url")]
public string? RedirectUrlQueryString { get; set; }
[Parameter, SupplyParameterFromQuery(Name = "return-url")]
public string? ReturnUrlQueryString { get; set; }

[Parameter, SupplyParameterFromQuery(Name = "email")]
public string? EmailQueryString { get; set; }
Expand Down Expand Up @@ -78,7 +78,7 @@ private async Task DoSignIn()

if (requiresTwoFactor is false)
{
NavigationManager.NavigateTo(RedirectUrlQueryString ?? "/");
NavigationManager.NavigateTo(ReturnUrlQueryString ?? "/");
}
}
catch (KnownException e)
Expand All @@ -103,7 +103,7 @@ private async Task SendOtp()
try
{
var request = new IdentityRequestDto { UserName = model.UserName, Email = model.Email, PhoneNumber = model.PhoneNumber };
await identityController.SendOtp(request, CurrentCancellationToken);
await identityController.SendOtp(request, ReturnUrlQueryString, CurrentCancellationToken);

message = Localizer[nameof(AppStrings.OtpSentMessage)];
messageSeverity = BitSeverity.Success;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public partial class NotAuthorizedPage
{
private ClaimsPrincipal user = default!;

[SupplyParameterFromQuery(Name = "redirect-url"), Parameter] public string? RedirectUrl { get; set; }
[SupplyParameterFromQuery(Name = "return-url"), Parameter] public string? ReturnUrl { get; set; }

protected override async Task OnParamsSetAsync()
{
Expand All @@ -21,16 +21,16 @@ protected override async Task OnAfterFirstRenderAsync()
// Following this procedure, the newly acquired access token may now include the necessary roles or claims.
// To prevent infinitie redirect loop, let's append refresh_token=false to the url, so we only redirect in case no refresh_token=false is present

if (string.IsNullOrEmpty(refresh_token) is false && RedirectUrl?.Contains("try_refreshing_token=false", StringComparison.InvariantCulture) is null or false)
if (string.IsNullOrEmpty(refresh_token) is false && ReturnUrl?.Contains("try_refreshing_token=false", StringComparison.InvariantCulture) is null or false)
{
await AuthenticationManager.RefreshToken();

if ((await AuthenticationStateTask).User.IsAuthenticated())
{
if (RedirectUrl is not null)
if (ReturnUrl is not null)
{
var @char = RedirectUrl.Contains('?') ? '&' : '?'; // The RedirectUrl may already include a query string.
NavigationManager.NavigateTo($"{RedirectUrl}{@char}try_refreshing_token=false");
var @char = ReturnUrl.Contains('?') ? '&' : '?'; // The RedirectUrl may already include a query string.
NavigationManager.NavigateTo($"{ReturnUrl}{@char}try_refreshing_token=false");
}
}
}
Expand All @@ -53,7 +53,7 @@ private async Task SignIn()

private void RedirectToSignInPage()
{
var redirectUrl = RedirectUrl ?? NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.NavigateTo($"/sign-in{(string.IsNullOrEmpty(redirectUrl) ? "" : $"?redirect-url={redirectUrl}")}");
var returnUrl = ReturnUrl ?? NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.NavigateTo($"/sign-in{(string.IsNullOrEmpty(returnUrl) ? "" : $"?return-url={returnUrl}")}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public interface IIdentityController : IAppController
[HttpPost]
Task SendTwoFactorToken(IdentityRequestDto request, CancellationToken cancellationToken);

[HttpPost]
Task SendOtp(IdentityRequestDto request, CancellationToken cancellationToken);
[HttpPost("{?returnUrl}")]
Task SendOtp(IdentityRequestDto request, string? returnUrl = null, CancellationToken cancellationToken = default);

[HttpGet("{?provider,returnUrl,localHttpPort}")]
Task<string> GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null);
}
Loading
Loading