diff --git a/src/Squidex.Domain.Users.MongoDb/MongoUser.cs b/src/Squidex.Domain.Users.MongoDb/MongoUser.cs index dff095d15..57e7528ca 100644 --- a/src/Squidex.Domain.Users.MongoDb/MongoUser.cs +++ b/src/Squidex.Domain.Users.MongoDb/MongoUser.cs @@ -111,7 +111,7 @@ namespace Squidex.Domain.Users.MongoDb Id = ObjectId.GenerateNewId().ToString(); } - public void UpdateEmail(string email) + public void SetEmail(string email) { Email = UserName = email; } diff --git a/src/Squidex.Domain.Users/UserExtensions.cs b/src/Squidex.Domain.Users/UserExtensions.cs index 065eb605f..cc87e88c7 100644 --- a/src/Squidex.Domain.Users/UserExtensions.cs +++ b/src/Squidex.Domain.Users/UserExtensions.cs @@ -35,19 +35,64 @@ namespace Squidex.Domain.Users user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email)); } + public static void SetConsent(this IUser user) + { + user.SetClaim(SquidexClaimTypes.SquidexConsent, "true"); + } + + public static void SetConsentForEmails(this IUser user, bool value) + { + user.SetClaim(SquidexClaimTypes.SquidexConsentForEmails, value.ToString()); + } + + public static bool HasConsent(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexConsent, "true"); + } + + public static bool HasConsentForEmails(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexConsentForEmails, "true"); + } + + public static bool HasDisplayName(this IUser user) + { + return user.HasClaim(SquidexClaimTypes.SquidexDisplayName); + } + + public static bool HasPictureUrl(this IUser user) + { + return user.HasClaim(SquidexClaimTypes.SquidexPictureUrl); + } + public static bool IsPictureUrlStored(this IUser user) { - return string.Equals(user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)?.Value, "store", StringComparison.OrdinalIgnoreCase); + return user.HasClaimValue(SquidexClaimTypes.SquidexPictureUrl, "store"); } public static string PictureUrl(this IUser user) { - return user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)?.Value; + return user.GetClaimValue(SquidexClaimTypes.SquidexPictureUrl); } public static string DisplayName(this IUser user) { - return user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexDisplayName)?.Value; + return user.GetClaimValue(SquidexClaimTypes.SquidexDisplayName); + } + + public static string GetClaimValue(this IUser user, string claim) + { + return user.Claims.FirstOrDefault(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase))?.Value; + } + + public static bool HasClaim(this IUser user, string claim) + { + return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase)); + } + + public static bool HasClaimValue(this IUser user, string claim, string value) + { + return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Value, value, StringComparison.OrdinalIgnoreCase)); } public static string PictureNormalizedUrl(this IUser user) diff --git a/src/Squidex.Domain.Users/UserManagerExtensions.cs b/src/Squidex.Domain.Users/UserManagerExtensions.cs index 525823d32..302b025d1 100644 --- a/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -73,7 +73,7 @@ namespace Squidex.Domain.Users public static Task UpdateAsync(this UserManager userManager, IUser user, string email, string displayName) { - user.UpdateEmail(email); + user.SetEmail(email); user.SetDisplayName(displayName); return userManager.UpdateAsync(user); diff --git a/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs b/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs new file mode 100644 index 000000000..32ad369d8 --- /dev/null +++ b/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; + +namespace Squidex.Shared.Identity +{ + public static class ClaimsPrincipalExtensions + { + public static void SetDisplayName(this ClaimsIdentity identity, string displayName) + { + identity.AddClaim(new Claim(SquidexClaimTypes.SquidexDisplayName, displayName)); + } + + public static void SetPictureUrl(this ClaimsIdentity identity, string pictureUrl) + { + identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl)); + } + + public static IEnumerable GetSquidexClaims(this ClaimsPrincipal principal) + { + return principal.Claims.Where(c => c.Type.StartsWith(SquidexClaimTypes.Prefix, StringComparison.Ordinal)); + } + } +} diff --git a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index f622d5bfa..b456cfd21 100644 --- a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs @@ -13,6 +13,10 @@ namespace Squidex.Shared.Identity public static readonly string SquidexPictureUrl = "urn:squidex:picture"; + public static readonly string SquidexConsent = "urn:squidex:consent"; + + public static readonly string SquidexConsentForEmails = "urn:squidex:consent:emails"; + public static readonly string Prefix = "urn:squidex:"; } } diff --git a/src/Squidex.Shared/Users/IUser.cs b/src/Squidex.Shared/Users/IUser.cs index 14f607177..14065247f 100644 --- a/src/Squidex.Shared/Users/IUser.cs +++ b/src/Squidex.Shared/Users/IUser.cs @@ -24,10 +24,10 @@ namespace Squidex.Shared.Users IReadOnlyList Logins { get; } - void UpdateEmail(string email); - - void AddClaim(Claim claim); + void SetEmail(string email); void SetClaim(string type, string value); + + void AddClaim(Claim claim); } } diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs b/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs index b2bdf20c0..4ee48c856 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs @@ -12,6 +12,7 @@ using System.Security; using System.Security.Claims; using System.Text; using System.Threading.Tasks; +using IdentityServer4.Models; using IdentityServer4.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -88,21 +89,52 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account } [HttpGet] - [Route("account/logout/")] - public async Task Logout(string logoutId) + [Route("account/consent/")] + public IActionResult Consent(string returnUrl = null) { - var context = await interactions.GetLogoutContextAsync(logoutId); + return View(new ConsentVM { PrivacyUrl = identityOptions.Value.PrivacyUrl, ReturnUrl = returnUrl }); + } - await signInManager.SignOutAsync(); + [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."); + } - var logoutUrl = context.PostLogoutRedirectUri; + if (!model.ConsentToPersonalInformation) + { + ModelState.AddModelError(nameof(model.ConsentToPersonalInformation), "You have to give consent."); + } - if (string.IsNullOrWhiteSpace(logoutUrl)) + if (!ModelState.IsValid) { - logoutUrl = urlOptions.Value.BuildUrl("logout/"); + var vm = new ConsentVM { PrivacyUrl = identityOptions.Value.PrivacyUrl, ReturnUrl = returnUrl }; + + return View(vm); } - return Redirect(logoutUrl); + var user = await userManager.GetUserAsync(User); + + user.SetConsentForEmails(model.ConsentToAutomatedEmails); + user.SetConsent(); + + await userManager.UpdateAsync(user); + + return RedirectToReturnUrl(returnUrl); + } + + [HttpGet] + [Route("account/logout/")] + public async Task Logout(string logoutId) + { + var context = await interactions.GetLogoutContextAsync(logoutId); + + await signInManager.SignOutAsync(); + + return RedirectToLogoutUrl(context); } [HttpGet] @@ -143,13 +175,9 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { return await LoginViewAsync(returnUrl, true, true); } - else if (!string.IsNullOrWhiteSpace(returnUrl)) - { - return Redirect(returnUrl); - } else { - return Redirect("~/../"); + return RedirectToReturnUrl(returnUrl); } } @@ -203,11 +231,13 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account var isLoggedIn = result.Succeeded; + IUser user = null; + if (!isLoggedIn) { var email = externalLogin.Principal.FindFirst(ClaimTypes.Email).Value; - var user = await userManager.FindByEmailAsync(email); + user = await userManager.FindByEmailAsync(email); if (user != null) { @@ -241,13 +271,13 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { return RedirectToAction(nameof(Login)); } - else if (!string.IsNullOrWhiteSpace(returnUrl)) + else if (user != null && !user.HasConsent()) { - return Redirect(returnUrl); + return RedirectToAction(nameof(Consent), new { returnUrl }); } else { - return Redirect("~/../"); + return RedirectToReturnUrl(returnUrl); } } @@ -292,24 +322,48 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { var user = userFactory.Create(email); - if (!externalLogin.Principal.HasClaim(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)) + foreach (var squidexClaim in externalLogin.Principal.GetSquidexClaims()) { - user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email)); + user.AddClaim(squidexClaim); } - if (!externalLogin.Principal.HasClaim(x => x.Type == SquidexClaimTypes.SquidexDisplayName)) + if (!user.HasPictureUrl()) { - user.SetClaim(SquidexClaimTypes.SquidexDisplayName, email); + user.SetPictureUrl(GravatarHelper.CreatePictureUrl(email)); } - foreach (var squidexClaim in externalLogin.Principal.Claims.Where(c => c.Type.StartsWith(SquidexClaimTypes.Prefix, StringComparison.Ordinal))) + if (!user.HasDisplayName()) { - user.AddClaim(squidexClaim); + user.SetDisplayName(email); } return user; } + 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 diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentModel.cs b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentModel.cs new file mode 100644 index 000000000..bcc77c7e5 --- /dev/null +++ b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentModel.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.IdentityServer.Controllers.Account +{ + public sealed class ConsentModel + { + public bool ConsentToPersonalInformation { get; set; } + + public bool ConsentToAutomatedEmails { get; set; } + + public bool ConsentToCookies { get; set; } + } +} diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentVM.cs b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentVM.cs new file mode 100644 index 000000000..a8764ff13 --- /dev/null +++ b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentVM.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.IdentityServer.Controllers.Account +{ + public sealed class ConsentVM + { + public string ReturnUrl { get; set; } + + public string PrivacyUrl { get; set; } + } +} diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index 3b0713066..90ea54e4f 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -75,12 +75,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/login-add-callback/")] public Task AddLoginCallback() { - return MakeChangeAsync(async user => - { - var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(userManager.GetUserId(User)); - - return await userManager.AddLoginAsync(user, externalLogin); - }, "Login added successfully."); + return MakeChangeAsync(user => AddLoginAsync(user), + "Login added successfully."); } [HttpPost] @@ -119,31 +115,41 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/upload-picture/")] public Task UploadPicture(List file) { - return MakeChangeAsync(async user => + return MakeChangeAsync(user => UpdatePictureAsync(file, user), + "Picture uploaded successfully."); + } + + private async Task AddLoginAsync(IUser user) + { + var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(userManager.GetUserId(User)); + + return await userManager.AddLoginAsync(user, externalLogin); + } + + private async Task UpdatePictureAsync(List file, IUser user) + { + if (file.Count != 1) { - if (file.Count != 1) - { - return IdentityResult.Failed(new IdentityError { Description = "Please upload a single file." }); - } + return IdentityResult.Failed(new IdentityError { Description = "Please upload a single file." }); + } - var thumbnailStream = new MemoryStream(); - try - { - await assetThumbnailGenerator.CreateThumbnailAsync(file[0].OpenReadStream(), thumbnailStream, 128, 128, "Crop"); + var thumbnailStream = new MemoryStream(); + try + { + await assetThumbnailGenerator.CreateThumbnailAsync(file[0].OpenReadStream(), thumbnailStream, 128, 128, "Crop"); - thumbnailStream.Position = 0; - } - catch - { - return IdentityResult.Failed(new IdentityError { Description = "Picture is not a valid image." }); - } + thumbnailStream.Position = 0; + } + catch + { + return IdentityResult.Failed(new IdentityError { Description = "Picture is not a valid image." }); + } - await userPictureStore.UploadAsync(user.Id, thumbnailStream); + await userPictureStore.UploadAsync(user.Id, thumbnailStream); - user.SetPictureUrlToStore(); + user.SetPictureUrlToStore(); - return await userManager.UpdateAsync(user); - }, "Picture uploaded successfully."); + return await userManager.UpdateAsync(user); } private async Task MakeChangeAsync(Func> action, string successMessage, ChangeProfileModel model = null) diff --git a/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml b/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml new file mode 100644 index 000000000..083e78ffe --- /dev/null +++ b/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml @@ -0,0 +1,91 @@ +@model Squidex.Areas.IdentityServer.Controllers.Account.ConsentVM + +@{ + ViewBag.Theme = "white"; + ViewBag.Title = "Consent"; +} + +@functions { + public string ErrorClass(string error) + { + return ViewData.ModelState[error]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid ? "border-danger" : ""; + } +} + +
+
+ + +

We need your consent

+ +
+
+

Automated E-Mails

+ +
+
+ +
+
+ I understand and agree that Squidex sends Emails to imform me about new features, breaking changes and downtimes. +
+
+
+
+ +
+
+

Cookies & Analytics

+ +
+
+ +
+
+

+ I understand and agree that Squidex uses cookies to ensure you get the best experience on our platform and to store your login status. +

+

+ I understand and agree that Squidex has integrated Google Analytics (with the anonymizer function). Google Analytics is a web analytics service to gather and analyse data about the behavior of users. +

+

+ I have read the Privacy Policies. +

+
+
+
+
+ +
+
+

Personal Information

+ +
+
+ +
+
+ I understand and agree that Squidex collects the following private information that are retrieved from external authentication providers such as Google, Microsoft or Github. + +
    +
  • + Basic personal information (e-mail address, name and picture) are provided to all other users so that they can add you to their working space. +
  • +
  • + At anytime you have the option to change these information to anonymize your account. +
  • +
  • + Your user account has an unique identifier and for all your changes we track, that you made these changes and provide this information to other users. +
  • +
+ +
+
+
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/src/Squidex/Config/Authentication/GoogleHandler.cs b/src/Squidex/Config/Authentication/GoogleHandler.cs index a0500b80a..ded513599 100644 --- a/src/Squidex/Config/Authentication/GoogleHandler.cs +++ b/src/Squidex/Config/Authentication/GoogleHandler.cs @@ -29,7 +29,7 @@ namespace Squidex.Config.Authentication var displayNameClaim = context.Identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Name); if (displayNameClaim != null) { - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexDisplayName, displayNameClaim.Value)); + context.Identity.SetDisplayName(displayNameClaim.Value); } var pictureUrl = context.User?.Value("picture"); @@ -46,7 +46,7 @@ namespace Squidex.Config.Authentication if (!string.IsNullOrWhiteSpace(pictureUrl)) { - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl)); + context.Identity.SetPictureUrl(pictureUrl); } return base.CreatingTicket(context); diff --git a/src/Squidex/Config/Authentication/MicrosoftHandler.cs b/src/Squidex/Config/Authentication/MicrosoftHandler.cs index 1b06bdcd3..c308814af 100644 --- a/src/Squidex/Config/Authentication/MicrosoftHandler.cs +++ b/src/Squidex/Config/Authentication/MicrosoftHandler.cs @@ -20,7 +20,7 @@ namespace Squidex.Config.Authentication if (!string.IsNullOrEmpty(displayName)) { - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexDisplayName, displayName)); + context.Identity.SetDisplayName(displayName); } var id = context.User.Value("id"); @@ -29,7 +29,7 @@ namespace Squidex.Config.Authentication { var pictureUrl = $"https://apis.live.net/v5.0/{id}/picture"; - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl)); + context.Identity.SetPictureUrl(pictureUrl); } return base.CreatingTicket(context); diff --git a/src/Squidex/Config/MyIdentityOptions.cs b/src/Squidex/Config/MyIdentityOptions.cs index 8dade2b71..8ab795f53 100644 --- a/src/Squidex/Config/MyIdentityOptions.cs +++ b/src/Squidex/Config/MyIdentityOptions.cs @@ -23,6 +23,8 @@ namespace Squidex.Config public string AuthorityUrl { get; set; } + public string PrivacyUrl { get; set; } + public bool RequiresHttps { get; set; } public bool AllowPasswordAuth { get; set; } diff --git a/src/Squidex/Squidex.csproj b/src/Squidex/Squidex.csproj index 367d22302..6a2adcef5 100644 --- a/src/Squidex/Squidex.csproj +++ b/src/Squidex/Squidex.csproj @@ -63,10 +63,15 @@ + + + + + diff --git a/src/Squidex/app/theme/_static.scss b/src/Squidex/app/theme/_static.scss index ddb92fbee..2a724c867 100644 --- a/src/Squidex/app/theme/_static.scss +++ b/src/Squidex/app/theme/_static.scss @@ -44,6 +44,10 @@ noscript { margin-top: 2rem; } + &-section-sm { + margin-top: 1rem; + } + &-picture-col { max-width: 7rem; } @@ -111,6 +115,20 @@ noscript { width: 1.6rem; } } + + .personal-information { + margin: 1rem 0; + } + + p { + & { + margin-bottom: .5rem; + } + + &:last-child { + margin-bottom: 0; + } + } } // diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index f3d1ee074..7edf55f44 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -210,6 +210,10 @@ /* * Lock new users automatically, the administrator must unlock them. */ - "lockAutomatically": false + "lockAutomatically": false, + /* + * The url to you privacy statements, if you host squidex by yourself. + */ + "privacyUrl": "https://squidex.io/privacy" } } \ No newline at end of file