diff --git a/Oqtane.Client/Modules/Admin/Site/Index.razor b/Oqtane.Client/Modules/Admin/Site/Index.razor index b034ae76..2ac49aab 100644 --- a/Oqtane.Client/Modules/Admin/Site/Index.razor +++ b/Oqtane.Client/Modules/Admin/Site/Index.razor @@ -127,11 +127,12 @@
- +
@@ -424,7 +425,7 @@ private string _themetype = ""; private string _containertype = ""; private string _admincontainertype = ""; - private string _cookieconsent = "False"; + private string _cookieconsent = ""; private Dictionary _textEditors = new Dictionary(); private string _textEditor = ""; @@ -515,7 +516,7 @@ _containers = ThemeService.GetContainerControls(PageState.Site.Themes, _themetype); _containertype = (!string.IsNullOrEmpty(site.DefaultContainerType)) ? site.DefaultContainerType : Constants.DefaultContainer; _admincontainertype = (!string.IsNullOrEmpty(site.AdminContainerType)) ? site.AdminContainerType : Constants.DefaultAdminContainer; - _cookieconsent = SettingService.GetSetting(settings, "CookieConsent", "False"); + _cookieconsent = SettingService.GetSetting(settings, "CookieConsent", string.Empty); // functionality var textEditors = ServiceProvider.GetServices(); diff --git a/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx index 3e371f76..dfdaf953 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Site/Index.resx @@ -427,9 +427,15 @@ System - Specify if cookie consent is enabled on this site + Specify if cookie consent is enabled on this site. Please make sure your using theme supports Cookie Consent when enable this option. Cookie Consent: + + Opt-In + + + Opt-Out + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Themes/Controls/CookieConsent.resx b/Oqtane.Client/Resources/Themes/Controls/CookieConsent.resx index 33700f00..6beb4805 100644 --- a/Oqtane.Client/Resources/Themes/Controls/CookieConsent.resx +++ b/Oqtane.Client/Resources/Themes/Controls/CookieConsent.resx @@ -117,11 +117,11 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Apply + + Confirm - By clicking "Accept", you agree us to use cookies to ensure you get the best experience on our website. + I agree to using cookies to provide the best user experience for this site. Privacy diff --git a/Oqtane.Client/Services/CookieConsentService.cs b/Oqtane.Client/Services/CookieConsentService.cs index 53e7d124..83c2abc7 100644 --- a/Oqtane.Client/Services/CookieConsentService.cs +++ b/Oqtane.Client/Services/CookieConsentService.cs @@ -16,10 +16,20 @@ namespace Oqtane.Services private string ApiUrl => CreateApiUrl("CookieConsent"); - /// - public async Task CanTrackAsync() + public async Task IsActionedAsync() { - return await GetJsonAsync($"{ApiUrl}/CanTrack"); + return await GetJsonAsync($"{ApiUrl}/IsActioned"); + } + + public async Task CanTrackAsync(bool optOut) + { + return await GetJsonAsync($"{ApiUrl}/CanTrack?optout=" + optOut); + } + + public async Task CreateActionedCookieAsync() + { + var cookie = await GetStringAsync($"{ApiUrl}/CreateActionedCookie"); + return cookie ?? string.Empty; } public async Task CreateConsentCookieAsync() @@ -27,5 +37,11 @@ namespace Oqtane.Services var cookie = await GetStringAsync($"{ApiUrl}/CreateConsentCookie"); return cookie ?? string.Empty; } + + public async Task WithdrawConsentCookieAsync() + { + var cookie = await GetStringAsync($"{ApiUrl}/WithdrawConsentCookie"); + return cookie ?? string.Empty; + } } } diff --git a/Oqtane.Client/Services/Interfaces/ICookieConsentService.cs b/Oqtane.Client/Services/Interfaces/ICookieConsentService.cs index 833d68a3..71580d7e 100644 --- a/Oqtane.Client/Services/Interfaces/ICookieConsentService.cs +++ b/Oqtane.Client/Services/Interfaces/ICookieConsentService.cs @@ -9,16 +9,34 @@ namespace Oqtane.Services /// public interface ICookieConsentService { + /// + /// Get cookie consent bar actioned status + /// + /// + Task IsActionedAsync(); + /// /// Get cookie consent status /// /// - Task CanTrackAsync(); + Task CanTrackAsync(bool optOut); /// - /// Grant cookie consent + /// create actioned cookie + /// + /// + Task CreateActionedCookieAsync(); + + /// + /// create consent cookie /// /// Task CreateConsentCookieAsync(); + + /// + /// widhdraw consent cookie + /// + /// + Task WithdrawConsentCookieAsync(); } } diff --git a/Oqtane.Client/Themes/Controls/Theme/CookieConsent.razor b/Oqtane.Client/Themes/Controls/Theme/CookieConsent.razor index 40e0f643..1b94a2a8 100644 --- a/Oqtane.Client/Themes/Controls/Theme/CookieConsent.razor +++ b/Oqtane.Client/Themes/Controls/Theme/CookieConsent.razor @@ -5,30 +5,64 @@ @inject IJSRuntime JSRuntime @inject IStringLocalizer Localizer -@if (_enabled && !Hidden && showBanner) +@if (_enabled && !Hidden) { -
- - -
+ } @code { - private bool showBanner; + private bool _showBanner; private bool _enabled; + private bool _optout; + private bool _actioned; + private bool _canTrack; + private bool _consentPostback; + private bool _togglePostback; [Parameter] public bool Hidden { get; set; } @@ -36,21 +70,87 @@ [Parameter] public bool ShowPrivacyLink { get; set; } = true; + [SupplyParameterFromForm(FormName = "CookieConsentToggleForm")] + public string ShowBanner { + get => ""; + set + { + _showBanner = bool.Parse(value); + _togglePostback = true; + } + } + + [SupplyParameterFromForm(FormName = "CookieConsentForm")] + public string CanTrack + { + get => ""; + set + { + _canTrack = !string.IsNullOrEmpty(value); + _consentPostback = true; + } + } + protected override async Task OnInitializedAsync() { - showBanner = !(await CookieConsentService.CanTrackAsync()); - _enabled = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "CookieConsent", "False")); + var cookieConsentSetting = SettingService.GetSetting(PageState.Site.Settings, "CookieConsent", string.Empty); + _enabled = !string.IsNullOrEmpty(cookieConsentSetting); + _optout = cookieConsentSetting == "optout"; + _actioned = await CookieConsentService.IsActionedAsync(); + + if (!_consentPostback) + { + _canTrack = await CookieConsentService.CanTrackAsync(_optout); + } + + if(!_togglePostback) + { + _showBanner = !_actioned; + } } private async Task AcceptPolicy() { - var cookieString = await CookieConsentService.CreateConsentCookieAsync(); + var cookieString = string.Empty; + if(_optout) + { + cookieString = _canTrack ? await CookieConsentService.WithdrawConsentCookieAsync() : await CookieConsentService.CreateConsentCookieAsync(); + } + else + { + cookieString = _canTrack ? await CookieConsentService.CreateConsentCookieAsync() : await CookieConsentService.WithdrawConsentCookieAsync(); + } + if (!string.IsNullOrEmpty(cookieString)) { var interop = new Interop(JSRuntime); await interop.SetCookieString(cookieString); - showBanner = false; + _actioned = true; + _showBanner = false; + + StateHasChanged(); + } + } + + private async Task ToggleBanner() + { + if (!_actioned) + { + var cookieString = await CookieConsentService.CreateActionedCookieAsync(); + if (!string.IsNullOrEmpty(cookieString)) + { + var interop = new Interop(JSRuntime); + await interop.SetCookieString(cookieString); + + _actioned = true; + } + } + + if(PageState.RenderMode == RenderModes.Interactive) + { + _showBanner = !_showBanner; + StateHasChanged(); } } } \ No newline at end of file diff --git a/Oqtane.Client/UI/PageState.cs b/Oqtane.Client/UI/PageState.cs index 0c17c530..a038a81e 100644 --- a/Oqtane.Client/UI/PageState.cs +++ b/Oqtane.Client/UI/PageState.cs @@ -27,6 +27,7 @@ namespace Oqtane.UI public bool IsInternalNavigation { get; set; } public Guid RenderId { get; set; } public bool Refresh { get; set; } + public bool AllowCookies { get; set; } public List Pages { diff --git a/Oqtane.Server/Components/App.razor b/Oqtane.Server/Components/App.razor index 712f7145..868755aa 100644 --- a/Oqtane.Server/Components/App.razor +++ b/Oqtane.Server/Components/App.razor @@ -31,6 +31,8 @@ @inject IUrlMappingRepository UrlMappingRepository @inject IVisitorRepository VisitorRepository @inject IJwtManager JwtManager +@inject ICookieConsentService CookieConsentService +@inject ISettingService SettingService @if (_initialized) { @@ -107,6 +109,7 @@ private string _styleSheets = ""; private string _scripts = ""; private string _message = ""; + private bool _allowCookies; private PageState _pageState; // CascadingParameter is required to access HttpContext @@ -140,6 +143,9 @@ _prerender = site.Prerender; _fingerprint = site.Fingerprint; + var cookieConsentSettings = SettingService.GetSetting(site.Settings, "CookieConsent", string.Empty); + _allowCookies = string.IsNullOrEmpty(cookieConsentSettings) || await CookieConsentService.CanTrackAsync(cookieConsentSettings == "optout"); + var modules = new List(); Route route = new Route(url, alias.Path); @@ -170,7 +176,7 @@ modules = await SiteService.GetModulesAsync(site.SiteId, page.PageId); } - if (site.VisitorTracking) + if (site.VisitorTracking && _allowCookies) { TrackVisitor(site.SiteId); } @@ -245,7 +251,8 @@ ReturnUrl = "", IsInternalNavigation = false, RenderId = Guid.NewGuid(), - Refresh = true + Refresh = true, + AllowCookies = _allowCookies }; } else diff --git a/Oqtane.Server/Controllers/CookieConsentController.cs b/Oqtane.Server/Controllers/CookieConsentController.cs index 7483c0b3..7cb0a577 100644 --- a/Oqtane.Server/Controllers/CookieConsentController.cs +++ b/Oqtane.Server/Controllers/CookieConsentController.cs @@ -19,10 +19,22 @@ namespace Oqtane.Controllers _cookieConsentService = cookieConsentService; } - [HttpGet("CanTrack")] - public async Task CanTrack() + [HttpGet("IsActioned")] + public async Task IsActioned() { - return await _cookieConsentService.CanTrackAsync(); + return await _cookieConsentService.IsActionedAsync(); + } + + [HttpGet("CanTrack")] + public async Task CanTrack(string optout) + { + return await _cookieConsentService.CanTrackAsync(bool.Parse(optout)); + } + + [HttpGet("CreateActionedCookie")] + public async Task CreateActionedCookie() + { + return await _cookieConsentService.CreateActionedCookieAsync(); } [HttpGet("CreateConsentCookie")] @@ -30,5 +42,11 @@ namespace Oqtane.Controllers { return await _cookieConsentService.CreateConsentCookieAsync(); } + + [HttpGet("WithdrawConsentCookie")] + public async Task WithdrawConsentCookie() + { + return await _cookieConsentService.WithdrawConsentCookieAsync(); + } } } diff --git a/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs b/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs index 355c23a2..3e31ac60 100644 --- a/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs +++ b/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs @@ -55,8 +55,5 @@ namespace Oqtane.Extensions public static IApplicationBuilder UseExceptionMiddleWare(this IApplicationBuilder builder) => builder.UseMiddleware(); - - public static IApplicationBuilder UseCookieConsent(this IApplicationBuilder builder) - => builder.UseMiddleware(); } } diff --git a/Oqtane.Server/Infrastructure/Middleware/CookieConsentMiddleware.cs b/Oqtane.Server/Infrastructure/Middleware/CookieConsentMiddleware.cs deleted file mode 100644 index 5e784e6d..00000000 --- a/Oqtane.Server/Infrastructure/Middleware/CookieConsentMiddleware.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Oqtane.Services; -using Oqtane.Shared; - -namespace Oqtane.Infrastructure -{ - internal class CookieConsentMiddleware - { - private readonly IList _defaultEssentialCookies = new List - { - ".AspNetCore.Culture", - "X-XSRF-TOKEN-COOKIE", - ".AspNetCore.Identity.Application" - }; - - private readonly RequestDelegate _next; - - public CookieConsentMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task Invoke(HttpContext context) - { - // check if framework is installed - var config = context.RequestServices.GetService(typeof(IConfigManager)) as IConfigManager; - var settingService = context.RequestServices.GetService(typeof(ISettingService)) as ISettingService; - var cookieConsentService = context.RequestServices.GetService(typeof(ICookieConsentService)) as ICookieConsentService; - string path = context.Request.Path.ToString(); - - if (config.IsInstalled()) - { - try - { - var settings = (Dictionary)context.Items[Constants.HttpContextSiteSettingsKey]; - if (settings != null) - { - var cookieConsentEnabled = bool.Parse(settingService.GetSetting(settings, "CookieConsent", "False")); - if (cookieConsentEnabled && !await cookieConsentService.CanTrackAsync()) - { - //only allow essential cookies when consent is not granted - var loginCookieName = settingService.GetSetting(settings, "LoginOptions:CookieName", ".AspNetCore.Identity.Application"); - var cookiesSetting = settingService.GetSetting(settings, "EssentialCookies", string.Empty); - - var essentialCookies = !string.IsNullOrEmpty(cookiesSetting) ? cookiesSetting.Split(",").ToList() : _defaultEssentialCookies; - - foreach (var cookie in context.Request.Cookies) - { - if (cookie.Key != loginCookieName && !essentialCookies.Contains(cookie.Key)) - { - context.Response.Cookies.Delete(cookie.Key); - } - } - } - } - } - catch(Exception ex) - { - - } - } - - // continue processing - if (_next != null) await _next(context); - } - } -} diff --git a/Oqtane.Server/Services/CookieConsentService.cs b/Oqtane.Server/Services/CookieConsentService.cs index afc1dbe1..dd2ce21d 100644 --- a/Oqtane.Server/Services/CookieConsentService.cs +++ b/Oqtane.Server/Services/CookieConsentService.cs @@ -1,10 +1,13 @@ using System; using System.Diagnostics.Contracts; using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Options; using Oqtane.Documentation; +using Oqtane.Shared; namespace Oqtane.Services { @@ -12,27 +15,106 @@ namespace Oqtane.Services public class ServerCookieConsentService : ICookieConsentService { private readonly IHttpContextAccessor _accessor; + private readonly CookiePolicyOptions _cookiePolicyOptions; - public ServerCookieConsentService(IHttpContextAccessor accessor) + public ServerCookieConsentService(IHttpContextAccessor accessor, IOptions cookiePolicyOptions) { _accessor = accessor; + _cookiePolicyOptions = cookiePolicyOptions.Value; } - public Task CanTrackAsync() + public Task IsActionedAsync() { - var consentFeature = _accessor.HttpContext?.Features.Get(); - var canTrack = consentFeature?.CanTrack ?? true; + var actioned = false; + if (_accessor.HttpContext != null) + { + var cookieValue = GetCookieValue("actioned"); + actioned = cookieValue == Constants.CookieConsentActionCookieValue; + } + return Task.FromResult(actioned); + } + + public Task CanTrackAsync(bool optOut) + { + var canTrack = true; + if (_accessor.HttpContext != null) + { + var cookieValue = GetCookieValue("consent"); + var saved = cookieValue == Constants.CookieConsentCookieValue; + if (optOut) + { + canTrack = string.IsNullOrEmpty(cookieValue) || !saved; + } + else + { + canTrack = cookieValue == Constants.CookieConsentCookieValue; + } + } return Task.FromResult(canTrack); } + public Task CreateActionedCookieAsync() + { + var cookieString = CreateCookieString(false, string.Empty); + return Task.FromResult(cookieString); + } + public Task CreateConsentCookieAsync() { - var consentFeature = _accessor.HttpContext?.Features.Get(); - consentFeature?.GrantConsent(); - var cookie = consentFeature?.CreateConsentCookie() ?? string.Empty; + var cookieString = CreateCookieString(true, Constants.CookieConsentCookieValue); + return Task.FromResult(cookieString); + } - return Task.FromResult(cookie); + public Task WithdrawConsentCookieAsync() + { + var cookieString = CreateCookieString(true, string.Empty); + return Task.FromResult(cookieString); + } + + private string GetCookieValue(string type) + { + var cookieValue = string.Empty; + if (_accessor.HttpContext != null) + { + var value = _accessor.HttpContext.Request.Cookies[Constants.CookieConsentCookieName]; + var index = type == "actioned" ? 1 : 0; + cookieValue = !string.IsNullOrEmpty(value) && value.Contains("|") ? value.Split('|')[index] : string.Empty; + } + + return cookieValue; + } + + private string CreateCookieString(bool saved, string savedValue) + { + var cookieString = string.Empty; + if (_accessor.HttpContext != null) + { + var savedCookie = saved ? savedValue : GetCookieValue("consent"); + var actionedCookie = Constants.CookieConsentActionCookieValue; + var cookieValue = $"{savedCookie}|{actionedCookie}"; + var options = _cookiePolicyOptions.ConsentCookie.Build(_accessor.HttpContext); + + if (!_accessor.HttpContext.Response.HasStarted) + { + _accessor.HttpContext.Response.Cookies.Append( + Constants.CookieConsentCookieName, + cookieValue, + new CookieOptions() + { + Expires = options.Expires, + IsEssential = true, + SameSite = options.SameSite, + Secure = options.Secure + } + ); + } + + //get the cookie string from response header + cookieString = options.CreateCookieHeader(Constants.CookieConsentCookieName, Uri.EscapeDataString(cookieValue)).ToString(); + } + + return cookieString; } } } diff --git a/Oqtane.Server/Startup.cs b/Oqtane.Server/Startup.cs index baa320de..8ab42dd8 100644 --- a/Oqtane.Server/Startup.cs +++ b/Oqtane.Server/Startup.cs @@ -169,13 +169,6 @@ namespace Oqtane options.CustomSchemaIds(type => type.ToString()); // Handle SchemaId already used for different type }); services.TryAddSwagger(_useSwagger); - - //add cookie consent policy - services.Configure(options => - { - options.CheckConsentNeeded = context => true; - options.ConsentCookieValue = Constants.CookieConsentCookieValue; - }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -232,8 +225,6 @@ namespace Oqtane app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); - app.UseCookiePolicy(); - app.UseCookieConsent(); if (_useSwagger) { diff --git a/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css b/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css index 99020c83..0544c0b9 100644 --- a/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css +++ b/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.BlazorTheme/Theme.css @@ -121,6 +121,16 @@ color: white; } +/* cookie consent */ +.gdpr-consent-bar .btn-show{ + bottom: 0; + right: 5px; +} +.gdpr-consent-bar .btn-hide{ + top: 0; + right: 5px; +} + @media (max-width: 767.98px) { .main .top-row { display: none; diff --git a/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.OqtaneTheme/Theme.css b/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.OqtaneTheme/Theme.css index 6bb1aacb..f11c3094 100644 --- a/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.OqtaneTheme/Theme.css +++ b/Oqtane.Server/wwwroot/Themes/Oqtane.Themes.OqtaneTheme/Theme.css @@ -100,6 +100,16 @@ div.app-moduleactions a.dropdown-toggle, div.app-moduleactions div.dropdown-menu z-index: 1000; } +/* cookie consent */ +.gdpr-consent-bar .btn-show{ + bottom: 0; + right: 5px; +} +.gdpr-consent-bar .btn-hide{ + top: 0; + right: 5px; +} + @media (max-width: 767.98px) { .app-menu { diff --git a/Oqtane.Shared/Shared/Constants.cs b/Oqtane.Shared/Shared/Constants.cs index 00d0dc6a..7e99421a 100644 --- a/Oqtane.Shared/Shared/Constants.cs +++ b/Oqtane.Shared/Shared/Constants.cs @@ -91,7 +91,9 @@ namespace Oqtane.Shared public const string BootstrapStylesheetUrl = "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"; public const string BootstrapStylesheetIntegrity = "sha512-jnSuA4Ss2PkkikSOLtYs8BlYIeeIK1h99ty4YfvRPAlzr377vr3CXDb7sb7eEEBYjDtcYj+AjBH3FLv5uSJuXg=="; - public const string CookieConsentCookieValue = "true"; + public const string CookieConsentCookieName = "Oqtane.CookieConsent"; + public const string CookieConsentCookieValue = "yes"; + public const string CookieConsentActionCookieValue = "yes"; // Obsolete constants const string RoleObsoleteMessage = "Use the corresponding member from Oqtane.Shared.RoleNames";