// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschränkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using System.Security; using System.Security.Claims; using System.Text; using System.Threading.Tasks; using IdentityModel; using IdentityServer4; using IdentityServer4.Extensions; using IdentityServer4.Models; using IdentityServer4.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Squidex.Config; using Squidex.Domain.Users; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Tasks; using Squidex.Shared; using Squidex.Shared.Identity; using Squidex.Shared.Users; using Squidex.Web; namespace Squidex.Areas.IdentityServer.Controllers.Account { public sealed class AccountController : IdentityServerController { private readonly SignInManager signInManager; private readonly UserManager userManager; private readonly IUserFactory userFactory; private readonly IUserEvents userEvents; private readonly MyIdentityOptions identityOptions; private readonly ISemanticLog log; private readonly IIdentityServerInteractionService interactions; public AccountController( SignInManager signInManager, UserManager userManager, IUserFactory userFactory, IUserEvents userEvents, IOptions identityOptions, ISemanticLog log, IIdentityServerInteractionService interactions) { this.log = log; this.userEvents = userEvents; this.userManager = userManager; this.userFactory = userFactory; this.interactions = interactions; this.identityOptions = identityOptions.Value; this.signInManager = signInManager; } [HttpGet] [Route("account/error/")] public IActionResult LoginError() { throw new InvalidOperationException(); } [HttpGet] [Route("account/forbidden/")] public IActionResult Forbidden() { throw new SecurityException("User is not allowed to login."); } [HttpGet] [Route("account/lockedout/")] public IActionResult LockedOut() { return View(); } [HttpGet] [Route("account/accessdenied/")] public IActionResult AccessDenied() { return View(); } [HttpGet] [Route("account/logout-completed/")] public IActionResult LogoutCompleted() { return View(); } [HttpGet] [Route("account/consent/")] public IActionResult Consent(string? returnUrl = null) { return View(new ConsentVM { PrivacyUrl = identityOptions.PrivacyUrl, ReturnUrl = returnUrl }); } [HttpPost] [Route("account/consent/")] public async Task Consent(ConsentModel model, string? returnUrl = null) { if (!model.ConsentToCookies) { ModelState.AddModelError(nameof(model.ConsentToCookies), "You have to give consent."); } if (!model.ConsentToPersonalInformation) { ModelState.AddModelError(nameof(model.ConsentToPersonalInformation), "You have to give consent."); } if (!ModelState.IsValid) { var vm = new ConsentVM { PrivacyUrl = identityOptions.PrivacyUrl, ReturnUrl = returnUrl }; return View(vm); } var user = await userManager.GetUserWithClaimsAsync(User); if (user == null) { throw new DomainException("Cannot find user."); } var update = new UserValues { Consent = true, ConsentForEmails = model.ConsentToAutomatedEmails }; await userManager.UpdateAsync(user.Id, update); userEvents.OnConsentGiven(user); return RedirectToReturnUrl(returnUrl); } [HttpGet] [Route("account/logout/")] public async Task Logout(string logoutId) { await signInManager.SignOutAsync(); if (User?.Identity.IsAuthenticated == true) { var provider = User.FindFirst(JwtClaimTypes.IdentityProvider)?.Value; if (provider != null && provider != IdentityServerConstants.LocalIdentityProvider) { var providerSupportsSignout = await HttpContext.GetSchemeSupportsSignOutAsync(provider); if (providerSupportsSignout) { return SignOut(provider); } } } var context = await interactions.GetLogoutContextAsync(logoutId); return RedirectToLogoutUrl(context); } [HttpGet] [Route("account/logout-redirect/")] public async Task LogoutRedirect() { await signInManager.SignOutAsync(); return RedirectToAction(nameof(LogoutCompleted)); } [HttpGet] [Route("account/signup/")] public Task Signup(string? returnUrl = null) { return LoginViewAsync(returnUrl, false, false); } [HttpGet] [Route("account/login/")] [ClearCookies] public Task Login(string? returnUrl = null) { return LoginViewAsync(returnUrl, true, false); } [HttpPost] [Route("account/login/")] public async Task Login(LoginModel model, string? returnUrl = null) { if (!ModelState.IsValid) { return await LoginViewAsync(returnUrl, true, true); } var result = await signInManager.PasswordSignInAsync(model.Email, model.Password, true, true); if (!result.Succeeded) { return await LoginViewAsync(returnUrl, true, true); } else { return RedirectToReturnUrl(returnUrl); } } private async Task LoginViewAsync(string? returnUrl, bool isLogin, bool isFailed) { var allowPasswordAuth = identityOptions.AllowPasswordAuth; var externalProviders = await signInManager.GetExternalProvidersAsync(); if (externalProviders.Count == 1 && !allowPasswordAuth) { var provider = externalProviders[0].AuthenticationScheme; var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, Url.Action(nameof(ExternalCallback), new { ReturnUrl = returnUrl })); return Challenge(properties, provider); } var vm = new LoginVM { ExternalProviders = externalProviders, IsLogin = isLogin, IsFailed = isFailed, HasPasswordAuth = allowPasswordAuth, HasPasswordAndExternal = allowPasswordAuth && externalProviders.Any(), ReturnUrl = returnUrl }; return View(nameof(Login), vm); } [HttpPost] [Route("account/external/")] public IActionResult External(string provider, string? returnUrl = null) { var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, Url.Action(nameof(ExternalCallback), new { ReturnUrl = returnUrl })); return Challenge(properties, provider); } [HttpGet] [Route("account/external-callback/")] public async Task ExternalCallback(string? returnUrl = null) { var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(); if (externalLogin == null) { return RedirectToAction(nameof(Login)); } var result = await signInManager.ExternalLoginSignInAsync(externalLogin.LoginProvider, externalLogin.ProviderKey, true); if (!result.Succeeded && result.IsLockedOut) { return View(nameof(LockedOut)); } var isLoggedIn = result.Succeeded; UserWithClaims? user; if (isLoggedIn) { user = await userManager.FindByLoginWithClaimsAsync(externalLogin.LoginProvider, externalLogin.ProviderKey); } else { var email = externalLogin.Principal.FindFirst(ClaimTypes.Email).Value; user = await userManager.FindByEmailWithClaimsAsync(email); if (user != null) { isLoggedIn = await AddLoginAsync(user, externalLogin) && await AddClaimsAsync(user, externalLogin, email) && await LoginAsync(externalLogin); } else { user = new UserWithClaims(userFactory.Create(email), new List()); var isFirst = userManager.Users.LongCount() == 0; isLoggedIn = await AddUserAsync(user) && await AddLoginAsync(user, externalLogin) && await AddClaimsAsync(user, externalLogin, email, isFirst) && await LockAsync(user, isFirst) && await LoginAsync(externalLogin); userEvents.OnUserRegistered(user); if (await userManager.IsLockedOutAsync(user.Identity)) { return View(nameof(LockedOut)); } } } if (!isLoggedIn) { return RedirectToAction(nameof(Login)); } else if (user != null && !user.HasConsent() && !identityOptions.NoConsent) { return RedirectToAction(nameof(Consent), new { returnUrl }); } else { return RedirectToReturnUrl(returnUrl); } } private Task AddLoginAsync(UserWithClaims user, UserLoginInfo externalLogin) { return MakeIdentityOperation(() => userManager.AddLoginAsync(user.Identity, externalLogin)); } private Task AddUserAsync(UserWithClaims user) { return MakeIdentityOperation(() => userManager.CreateAsync(user.Identity)); } private async Task LoginAsync(UserLoginInfo externalLogin) { var result = await signInManager.ExternalLoginSignInAsync(externalLogin.LoginProvider, externalLogin.ProviderKey, true); return result.Succeeded; } private Task LockAsync(UserWithClaims user, bool isFirst) { if (isFirst || !identityOptions.LockAutomatically) { return TaskHelper.True; } return MakeIdentityOperation(() => userManager.SetLockoutEndDateAsync(user.Identity, DateTimeOffset.UtcNow.AddYears(100))); } private Task AddClaimsAsync(UserWithClaims user, ExternalLoginInfo externalLogin, string email, bool isFirst = false) { var newClaims = new List(); void AddClaim(Claim claim) { newClaims.Add(claim); user.Claims.Add(claim); } foreach (var squidexClaim in externalLogin.Principal.GetSquidexClaims()) { AddClaim(squidexClaim); } if (!user.HasPictureUrl()) { AddClaim(new Claim(SquidexClaimTypes.PictureUrl, GravatarHelper.CreatePictureUrl(email))); } if (!user.HasDisplayName()) { AddClaim(new Claim(SquidexClaimTypes.DisplayName, email)); } if (isFirst) { AddClaim(new Claim(SquidexClaimTypes.Permissions, Permissions.Admin)); } return MakeIdentityOperation(() => userManager.SyncClaimsAsync(user.Identity, newClaims)); } private IActionResult RedirectToLogoutUrl(LogoutRequest context) { if (!string.IsNullOrWhiteSpace(context.PostLogoutRedirectUri)) { return Redirect(context.PostLogoutRedirectUri); } else { return Redirect("~/../"); } } private IActionResult RedirectToReturnUrl(string? returnUrl) { if (!string.IsNullOrWhiteSpace(returnUrl)) { return Redirect(returnUrl); } else { return Redirect("~/../"); } } private async Task MakeIdentityOperation(Func> action, [CallerMemberName] string? operationName = null) { try { var result = await action(); if (!result.Succeeded) { var errorMessageBuilder = new StringBuilder(); foreach (var error in result.Errors) { errorMessageBuilder.Append(error.Code); errorMessageBuilder.Append(": "); errorMessageBuilder.AppendLine(error.Description); } var errorMessage = errorMessageBuilder.ToString(); log.LogError((operationName, errorMessage), (ctx, w) => w .WriteProperty("action", ctx.operationName) .WriteProperty("status", "Failed") .WriteProperty("message", ctx.errorMessage)); } return result.Succeeded; } catch (Exception ex) { log.LogError(ex, operationName, (logOperationName, w) => w .WriteProperty("action", logOperationName) .WriteProperty("status", "Failed")); return false; } } } }