PiranhaCMS / piranha.core

Piranha CMS is the friendly editor-focused CMS for .NET that can be used both as an integrated CMS or as a headless API.
http://piranhacms.org
MIT License
1.97k stars 553 forks source link

Two Authentication Schemes - Or Cookie Authentication outside Piranha. #627

Closed zakwillis closed 2 years ago

zakwillis commented 5 years ago

My requirement is to let admin manage the site using the manager login and to have separate users logging into customer facing site. I don't think this is possible, having looked into it.

In other words, customer site users have nothing to do with dbo.Piranha_Users and [dbo].[Piranha_RoleClaims] . This is because I have build my own authentication protocol. You will wonder why I want to do this, but I do not want to use external providers. My users should be anonymous (almost). Independently, I have written my own authentication protocol. The first version works by not needing a password. I don't even store user names and emails.

My plan for Piranha was to use the CMS for significant elements of site content, have some standalone pages, and have recently been plugging in my own view components which have knockout.js within them. This works quite well. I realised, at some point I would have to see if the Piranha Manager could have users cookie authenticate through their world, and my users cookie authenticate through my world.

I have tried;

What does work;

tidyui commented 5 years ago

Hi there! Let me start off my saying the security implementations is NOT my field of expertise, this is why I've added as little security features into Piranha as possible :) I can however describe how everything works, and maybe you can draw some conclusions from that.

ISecurity

The ISecurity interface is only used when clicking Login on the manager login view. After the user has been authenticated it's no longer used by any part of Piranha.

Manager login

The login of the manager user the currently registered ISecurity service. You can however override the Login view in your local implementation and post the login anywhere you like if you'd like to use a completely different approach to authenticate users.

Manager permissions

The manager uses the basic feature set of MVC to check for claims & policies, i.e. the attribute

[Authorize(Policy = ...)]

How the claims actually get set on the current user is of no importance, for example, the development service SimpleSecurity has a very simple implementation on how this is done.

Last resort

Like you wrote, if nothing else works there's nothing that prevents you from deploying the manager & client to different sites/applications, this way they can use completely different setups. The only thing you should keep in mind is that you will need to use a distributed cache, like for Redis for example, since the applications won't share the same memory and items needs to be invalidated from a centralized cache.

Regards

zakwillis commented 5 years ago

Hi @tidyui . Thanks for replying and trying to understand it :). First off, you may not be aware that instead of using object and casting it to an httpcontext, you can use this? IHttpContextAccessor.

I have supplied background info later in this thread, but my architectural thinking goes along the line of this. Solution??? WebsiteProjectA has all Piranha Manager and functionality set up. This is where I edit content etc within the manager. Website Project B only references enough libraries to do with requesting content and I can add my own pages in this project. There becomes zero need to worry about the authentication side of things.

Example as such... Keep Nuget Packages

Remove Nuget Packages

Strangely enough. When I kept Piranha.Manager in the Nuget Package and disabled site and commented out app.UsePiranhaManager(); it still worked...

I am going to run a test to see if I can remove Piranha Manager on a site copy.

Background info My feeling is, there is too much going on to understand this but it is something to do with the way in which the pipeline can only accept your http authentication cookie process. This means, trying to override your manager will not make any difference (only a guess).

I independently tested the creating of a cookie from adapting a really simple .net core example from Microsoft, so I know that my code works outside Piranha okay.

I realised immediately after raising this issue, the first ever case of using DI for years, where I may actually be able to benefit from DI, would not work (slight joke).

Here is a guy who put out a blog concerning two authentication cookies. https://odetocode.com/blogs/scott/archive/2019/01/02/experimenting-with-asp-net-core-authentication-schemes.aspx I can bet $100 (virtual) he never checked the HttpContext.User.Identity.IsAuthenticated property.

zakwillis commented 5 years ago

Hi, so... I have solved the issue. Kind of...

By using Nuget Manager and undertaking; Uninstall-Package Piranha.Manager Uninstall-Package Piranha.AspNetCore.Identity.SQLServer And disabling a few lines refering to piranha manager and bindings. The website works with content. When I then apply signin manager within my page and my cookies. Cookie Authentication works and the user is signed in.

Obviously, this is not ideal, but it should mean, I can maintain one repository for Piranha through one site portal and have my website use the CMS functionality through another site. I am happy to write a blog/post on this for you once I am confident it works as I think my use case is a realistic use case scenario.

I did consider going down the bearer authentication side of things as you guys aren't using that but for now I can get by.

I am not closing the issue but I guess you can.

tidyui commented 5 years ago

Yes, a blog post would be awesome! We would be more than happy to have guest writers on the official Piranha CMS site as well if you would be interested to get it published there as well!

As a side note, when publishing as two different application, besides using a distributed cache, my recommendation is that only one of the applications uses the AttributeBuilder and is in charge of keeping the content types in sync. As you will be using the models in your client application preferably that would be the application controlling the models.

As the manager also only works with dynamic models it doesn't need to have access to the model classes at all, it will just use the current meta-data available in the database when loading/saving content.

Regards

zakwillis commented 5 years ago

No problem. I am probably a way off getting this project separation working but I get it. I will write something up and take a look at it once I get a bit further. It will describe the problem I was trying to solve with Piranha and how I solved the security issue so it can help others. Many thanks.


From: Håkan Edling notifications@github.com Sent: 24 May 2019 09:29 To: PiranhaCMS/piranha.core Cc: zakwillis; Author Subject: Re: [PiranhaCMS/piranha.core] Two Authentication Schemes - Or Cookie Authentication outside Piranha. (#627)

Yes, a blog post would be awesome! We would be more than happy to have guest writers on the official Piranha CMS site as well if you would be interested to get it published there as well!

As a side note, when publishing as two different application, besides using a distributed cache, my recommendation is that only one of the applications uses the AttributeBuilder and is in charge of keeping the content types in sync. As you will be using the models in your client application preferably that would be the application controlling the models.

As the manager also only works with dynamic models it doesn't need to have access to the model files at all, it will just use the current meta-data available in the database when loading/saving content.

Regards

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHubhttps://eur01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2FPiranhaCMS%2Fpiranha.core%2Fissues%2F627%3Femail_source%3Dnotifications%26email_token%3DAC5CL4HJ2CJBMNHRYDFUO5DPW6RORA5CNFSM4HOPFEW2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGODWEQ5UQ%23issuecomment-495521490&data=02%7C01%7C%7C25b3b0a83cc94dcd2f8b08d6e021f0ae%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C636942833700550986&sdata=ui%2FY51EWZn7BBh%2FpV8cBgDgIhxOK09eQ5vcYAjLenmU%3D&reserved=0, or mute the threadhttps://eur01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fnotifications%2Funsubscribe-auth%2FAC5CL4CF6KQ67LCAMN6KCYLPW6RORANCNFSM4HOPFEWQ&data=02%7C01%7C%7C25b3b0a83cc94dcd2f8b08d6e021f0ae%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C636942833700560997&sdata=MGPqGoTuLXfBkgF6P7oiigJkqsqob3NcEZXpTAXpX8E%3D&reserved=0.

afetter commented 3 years ago

Hello Guys,

I'm joint to this because I'm facing the same problem. Well. I was able to have 2 identities on my web site. One for my clients and another one for Piranha Manager. The problem with that was doesn't matter my settings It is overwritten by Manager settings. Like, Expire date for cookies.

That was fine. But, somehow with a cookie with 2 days to expire, It expires always after 15min.

Part of the job was completed. Until I received a requirement to have a much longer session/cookie.

This was the way I found to have My Identity and Manager working.

services.AddIdentityCore() .AddRoles() .AddClaimsPrincipalFactory<UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>() .AddEntityFrameworkStores() .AddDefaultTokenProviders();

tidyui commented 3 years ago

@afetter Did you setup the cookie settings when adding Piranha.AspNet.Identity, because otherwise it's likely that the default settings will override the ones you've added.

https://piranhacms.org/docs/architecture/authentication/identity

Regards

afetter commented 3 years ago

hello @tidyui ,

What I recently understood was. If I add app.UseAuthentication(); it will make my session expire in about 15min. Doesn't matter what I have in my settings.

Why I need app.UseAuthentication(); ? because in the site I'm working, I would like to save in the requests witch user is logged, to make it easy to debug.

Am I doing something wrong?

martijntakken commented 2 years ago

Hello Guys,

I'm joint to this because I'm facing the same problem. Well. I was able to have 2 identities on my web site. One for my clients and another one for Piranha Manager. The problem with that was doesn't matter my settings It is overwritten by Manager settings. Like, Expire date for cookies.

That was fine. But, somehow with a cookie with 2 days to expire, It expires always after 15min.

Part of the job was completed. Until I received a requirement to have a much longer session/cookie.

This was the way I found to have My Identity and Manager working.

services.AddIdentityCore() .AddRoles() .AddClaimsPrincipalFactory<UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>() .AddEntityFrameworkStores() .AddDefaultTokenProviders();

Hi @afetter,

I've have also a requirement for 2 identities on my website, I didn't get it working yet. Can you share the relevant parts of your startup.cs?

tidyui commented 2 years ago

You can always setup your applications on different servers or applications as long as you use a distributed cache. The manager application doesn't have to include the front-end and the other way around.

zakwillis commented 2 years ago

Hello. I think we need to look at separating the application from piranha and working on storage based authentication via a separate api. I have done a lot of work on building I dependent functionality into the platform, and plugins. I separated pirannha manager from the website to allow separate authentication for users. My suggestion and strategy will be to have a separate api.

Sent from my Huawei phone

-------- Original message -------- From: Håkan Edling @.> Date: Thu, 21 Oct 2021, 21:00 To: "PiranhaCMS/piranha.core" @.> Cc: zakwillis @.>, Author @.> Subject: Re: [PiranhaCMS/piranha.core] Two Authentication Schemes - Or Cookie Authentication outside Piranha. (#627)

You can always setup your applications on different servers or applications as long as you use a distributed cache. The manager application doesn't have to include the front-end and the other way around.

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHubhttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2FPiranhaCMS%2Fpiranha.core%2Fissues%2F627%23issuecomment-948958718&data=04%7C01%7C%7C6148077530954a4de1d208d994cd6875%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704432216254445%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=UXLhZ6KC6055nkFcH%2BNhNfFxTosfBb9U5CE8zziZye0%3D&reserved=0, or unsubscribehttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fnotifications%2Funsubscribe-auth%2FAC5CL4HBFQC6TFRYAY2ICMLUIBWNFANCNFSM4HOPFEWQ&data=04%7C01%7C%7C6148077530954a4de1d208d994cd6875%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704432216264445%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=CgqcRSaq0RKkP8WdG%2FYLY4Ul9AN3dBtJUCsPiA1QfFE%3D&reserved=0. Triage notifications on the go with GitHub Mobile for iOShttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fapps.apple.com%2Fapp%2Fapple-store%2Fid1477376905%3Fct%3Dnotification-email%26mt%3D8%26pt%3D524675&data=04%7C01%7C%7C6148077530954a4de1d208d994cd6875%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704432216264445%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=61TMQOYqYaU2Nj4iaNVP7y%2BVow2oNEewt2Kdlkb%2F%2FBk%3D&reserved=0 or Androidhttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.github.android%26referrer%3Dutm_campaign%253Dnotification-email%2526utm_medium%253Demail%2526utm_source%253Dgithub&data=04%7C01%7C%7C6148077530954a4de1d208d994cd6875%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704432216274436%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=ur8rBhSzCVhxRP1%2BL8uw6UJb4NhVF3v4se9nmAMuyBM%3D&reserved=0.

afetter commented 2 years ago

I can suggest to you the same approach I had. I wasn't able to fix this problem by code.

The solution I implemented was to have an Admin server (Piranha.Manager) were using a flag in the setting I enable the Piranha.Manager and another server for Content Delivery (Production).

I think that was good practice as well. I host my servers on Azure, I can have a small server to run Piranha.Manager and it is not required to be scalable. The side effect of this approach. It is very important to understand. When I make changes to mine Piranha.Manager instance I need to restart my Content Delivery instance to get the latest version of the pages. Again it was good for me because I can double-check the pages before it goes to production.

Another good thing about this approach, the Piranha.Manager is a nom public URL. It team, the public URL has no administration area. I saw a lot of attacks on my site but the only way to access the admin is in a different URL that only a few people know.

Code wasn't too complex:


public class Startup
    {
        /// <summary>
        /// The application config.
        /// </summary>
        public static IConfiguration Configuration { get; set; }

        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
            var credentials = new StorageCredentials(Configuration["ConnectionStrings:storageName"],
                                                     Configuration["ConnectionStrings:storageKey"]);

            var appSettingsSection = Configuration.GetSection("AppSettings");
            var appSettings = appSettingsSection.Get<AppSettings>();

            services.Configure<AppSettings>(appSettingsSection);

            services.AddDefaultCorrelationId();
            //services.AddResponseCaching();
            services.AddPiranha();
            //services.AddPiranhaApplication();
            services.AddPiranhaImageSharp();

            if (!appSettings.EnablePiranhaManager)
            {
                services.AddIdentity<ApplicationUser, IdentityRole>()
                        .AddEntityFrameworkStores<ApplicationDbContext>()
                        .AddDefaultTokenProviders();

                services.ConfigureApplicationCookie(x =>
                {
                    x.ExpireTimeSpan = TimeSpan.FromDays(10);
                    x.Cookie.Name = "MyOffer.Identity.Application";
                    x.SlidingExpiration = true;
                    x.LoginPath = "/entrar";
                    x.AccessDeniedPath = "/entrar";
                });

                services.Configure<IdentityOptions>(options =>
                {
                    options.Password.RequiredLength = 6;
                    options.Password.RequireUppercase = false;
                    options.Password.RequireLowercase = false;
                    options.Password.RequireDigit = false;
                    options.Password.RequireNonAlphanumeric = false;
                });

            }
            else
            {

                services.AddPiranhaManager();

                //This enables on Piranha Manager

                services.AddIdentityCore<ApplicationUser>()
                    .AddRoles<IdentityRole>()
                    .AddClaimsPrincipalFactory<UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>()
                    .AddEntityFrameworkStores<ApplicationDbContext>()
                    .AddDefaultTokenProviders();

            }

           ...
                if (appSettings.EnablePiranhaManager)
                {

                    options.UseIdentityWithSeed<IdentitySQLServerDb>(db =>
                        db.UseSqlServer(Configuration.GetConnectionString("piranha")), cookieOptions: cookieOptions =>
                        {
                            cookieOptions.ExpireTimeSpan = TimeSpan.FromDays(1);
                            cookieOptions.SlidingExpiration = true;
                            cookieOptions.LoginPath = "/manager/login";
                            cookieOptions.AccessDeniedPath = "/manager/login";
                        });
                }

            });
             ...
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApi api)
        {
            var IsPiranhaManagerEnabled = Convert.ToBoolean(Configuration.GetSection("AppSettings:EnablePiranhaManager").Value);
            app.UseResponseCompression();
            app.UseCorrelationId();
            app.UseResponseCaching();

            ...
            if (IsPiranhaManagerEnabled)
            {

                #region Manager Menu
                Menu.Items.Insert(2, new MenuItem
                {
                    InternalId = "MyOffer",
                    Name = "My Offer Web",
                    Css = "fas fa-home"
                });

                Menu.Items["MyOffer"].Items.Add(new MenuItem
                {
                    InternalId = "BackOfficeId",
                    Name = "Back Office",
                    Route = "~/manager/backoffice",
                    Css = "fas fa-briefcase"
                });
                #endregion
            }
            ....
}
zakwillis commented 2 years ago

Yes. In the end, I just run a manager version and a client version. Like you say, it keeps it separate and makes the client facing website more secure. So. I have two separate projects/solutions.

You could share models with the same namespace. I have nearly finished the release management automation, which means this will be a better approach for separating different client websites.

Sent from my Huawei phone

-------- Original message -------- From: Anderson Fetter @.> Date: Thu, 21 Oct 2021, 22:51 To: "PiranhaCMS/piranha.core" @.> Cc: zakwillis @.>, Author @.> Subject: Re: [PiranhaCMS/piranha.core] Two Authentication Schemes - Or Cookie Authentication outside Piranha. (#627)

I can suggest to you the same approach I had. I wasn't able to fix this problem by code.

The solution I implemented was to have an Admin server (Piranha.Manager) were using a flag in the setting I enable the Piranha.Manager and another server for Content Delivery (Production).

I think that was good practice as well. I host my servers on Azure, I can have a small server to run Piranha.Manager and it is not required to be scalable. The side effect of this approach. It is very important to understand. When I make changes to mine Piranha.Manager instance I need to restart my Content Delivery instance to get the latest version of the pages. Again it was good for me because I can double-check the pages before it goes to production.

Another good thing about this approach, the Piranha.Manager is a nom public URL. It team, the public URL has no administration area. I saw a lot of attacks on my site but the only way to access the admin is in a different URL that only a few people know.

Code wasn't too complex:

public class Startup { ///

/// The application config. /// public static IConfiguration Configuration { get; set; }

    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    // This method gets called by the runtime. Use this method to add services to the container.
    // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
    public void ConfigureServices(IServiceCollection services)
    {
        var credentials = new StorageCredentials(Configuration["ConnectionStrings:storageName"],
                                                 Configuration["ConnectionStrings:storageKey"]);

        var appSettingsSection = Configuration.GetSection("AppSettings");
        var appSettings = appSettingsSection.Get<AppSettings>();

        services.Configure<AppSettings>(appSettingsSection);

        services.AddDefaultCorrelationId();
        //services.AddResponseCaching();
        services.AddPiranha();
        //services.AddPiranhaApplication();
        services.AddPiranhaImageSharp();

        if (!appSettings.EnablePiranhaManager)
        {
            services.AddIdentity<ApplicationUser, IdentityRole>()
                    .AddEntityFrameworkStores<ApplicationDbContext>()
                    .AddDefaultTokenProviders();

            services.ConfigureApplicationCookie(x =>
            {
                x.ExpireTimeSpan = TimeSpan.FromDays(10);
                x.Cookie.Name = "MyOffer.Identity.Application";
                x.SlidingExpiration = true;
                x.LoginPath = "/entrar";
                x.AccessDeniedPath = "/entrar";
            });

            services.Configure<IdentityOptions>(options =>
            {
                options.Password.RequiredLength = 6;
                options.Password.RequireUppercase = false;
                options.Password.RequireLowercase = false;
                options.Password.RequireDigit = false;
                options.Password.RequireNonAlphanumeric = false;
            });

        }
        else
        {

            services.AddPiranhaManager();

            //This enables on Piranha Manager

            services.AddIdentityCore<ApplicationUser>()
                .AddRoles<IdentityRole>()
                .AddClaimsPrincipalFactory<UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();

        }

       ...
            if (appSettings.EnablePiranhaManager)
            {

                options.UseIdentityWithSeed<IdentitySQLServerDb>(db =>
                    db.UseSqlServer(Configuration.GetConnectionString("piranha")), cookieOptions: cookieOptions =>
                    {
                        cookieOptions.ExpireTimeSpan = TimeSpan.FromDays(1);
                        cookieOptions.SlidingExpiration = true;
                        cookieOptions.LoginPath = "/manager/login";
                        cookieOptions.AccessDeniedPath = "/manager/login";
                    });
            }

        });
         ...
    }

    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IApi api)
    {
        var IsPiranhaManagerEnabled = Convert.ToBoolean(Configuration.GetSection("AppSettings:EnablePiranhaManager").Value);
        app.UseResponseCompression();
        app.UseCorrelationId();
        app.UseResponseCaching();

        ...
        if (IsPiranhaManagerEnabled)
        {

            #region Manager Menu
            Menu.Items.Insert(2, new MenuItem
            {
                InternalId = "MyOffer",
                Name = "My Offer Web",
                Css = "fas fa-home"
            });

            Menu.Items["MyOffer"].Items.Add(new MenuItem
            {
                InternalId = "BackOfficeId",
                Name = "Back Office",
                Route = "~/manager/backoffice",
                Css = "fas fa-briefcase"
            });
            #endregion
        }
        ....

}

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHubhttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2FPiranhaCMS%2Fpiranha.core%2Fissues%2F627%23issuecomment-949029972&data=04%7C01%7C%7Cf94f38823ac845fd557208d994dced2f%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704498886066014%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=OE%2B0SaSAlQ7kNAqEBZULsliIQIaePN0MSFm01xgaRRU%3D&reserved=0, or unsubscribehttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fgithub.com%2Fnotifications%2Funsubscribe-auth%2FAC5CL4D4D6S2DVKO2PGLN2DUICDNXANCNFSM4HOPFEWQ&data=04%7C01%7C%7Cf94f38823ac845fd557208d994dced2f%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704498886076009%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=mIxya8pILFOeSnGrL5TT0DxnLfM0eIgVHMEpO9OotQo%3D&reserved=0. Triage notifications on the go with GitHub Mobile for iOShttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fapps.apple.com%2Fapp%2Fapple-store%2Fid1477376905%3Fct%3Dnotification-email%26mt%3D8%26pt%3D524675&data=04%7C01%7C%7Cf94f38823ac845fd557208d994dced2f%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704498886076009%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=DoweApDET4uWxKPh4AtCqeamKSTeOeI5AJ7zRRfG%2F0c%3D&reserved=0 or Androidhttps://emea01.safelinks.protection.outlook.com/?url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.github.android%26referrer%3Dutm_campaign%253Dnotification-email%2526utm_medium%253Demail%2526utm_source%253Dgithub&data=04%7C01%7C%7Cf94f38823ac845fd557208d994dced2f%7C84df9e7fe9f640afb435aaaaaaaaaaaa%7C1%7C0%7C637704498886086008%7CUnknown%7CTWFpbGZsb3d8eyJWIjoiMC4wLjAwMDAiLCJQIjoiV2luMzIiLCJBTiI6Ik1haWwiLCJXVCI6Mn0%3D%7C1000&sdata=1p4gzQ5hda9XLHGZC7Rk%2F5PSt37%2F9Um3HdJeBr2JPsc%3D&reserved=0.