OWIN AuthenticationOptions updating at runtime in

2019-04-12 17:41发布

Hi!

Here is the situation:
I have one MVC5 application with Identity2 on iis7 wich serves multiple web sites. host name is the key for certain web site.
site.com, anothersite.com and so on

i've decided to use external login with google on all my sites and every site should be google client with personal clientid and clientsecret.
for example:
site.com - clientid=123123, clientsecret=xxxaaabbb
anothersite.com - clientid=890890, clientsecret=zzzqqqeee

but there is a little problem -- AuthenticationOptions are set at the start of application and i did'n find any way to replace it at runtime.

so, after reading Creating Custom OAuth Middleware for MVC 5 and Writing an Owin Authentication Middleware i've realized that i should override AuthenticationHandler.ApplyResponseChallengeAsync() and put this piece of code in the begining of this method:

    Options.ClientId = OAuth2Helper.GetProviderAppId("google");
    Options.ClientSecret = OAuth2Helper.GetProviderAppSecret("google");

i've decided to use only google, so we will talk about google middleware.

  1. AuthenticationHandler are returned by AuthenticationMiddleWare.CreateHandler() and in my case they are GoogleOAuth2AuthenticationHandler and GoogleOAuth2AuthenticationMiddleware.
    I've found GoogleOAuth2AuthenticationMiddleware at the http://katanaproject.codeplex.com/ and take it in my project like this

    public class GoogleAuthenticationMiddlewareExtended : GoogleOAuth2AuthenticationMiddleware
    {
        private readonly ILogger _logger;
        private readonly HttpClient _httpClient;
    
        public GoogleAuthenticationMiddlewareExtended(
            OwinMiddleware next,
            IAppBuilder app,
            GoogleOAuth2AuthenticationOptions options)
            : base(next, app, options)
        {
            _logger = app.CreateLogger<GoogleOAuth2AuthenticationMiddleware>();
            _httpClient = new HttpClient(ResolveHttpMessageHandler(Options));
            _httpClient.Timeout = Options.BackchannelTimeout;
            _httpClient.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB
        }
    
        protected override AuthenticationHandler<GoogleOAuth2AuthenticationOptions> CreateHandler()
        {
            return new GoogleOAuth2AuthenticationHandlerExtended(_httpClient, _logger);
        }
    
        private static HttpMessageHandler ResolveHttpMessageHandler(GoogleOAuth2AuthenticationOptions options)
        {
            HttpMessageHandler handler = options.BackchannelHttpHandler ?? new WebRequestHandler();
    
            // If they provided a validator, apply it or fail.
            if (options.BackchannelCertificateValidator != null)
            {
                // Set the cert validate callback
                var webRequestHandler = handler as WebRequestHandler;
                if (webRequestHandler == null)
                {
                    throw new InvalidOperationException("Exception_ValidatorHandlerMismatch");
                }
                webRequestHandler.ServerCertificateValidationCallback = options.BackchannelCertificateValidator.Validate;
            }
            return handler;
        }
    }
    
  2. then i've create my own Handler with modified ApplyResponseChallengeAsync. i've got a bad news at this point - GoogleOAuth2AuthenticationHandler is internal and i had to take it entirely and put in my project like this (again katanaproject.codeplex.com)

    public class GoogleOAuth2AuthenticationHandlerExtended : AuthenticationHandler<GoogleOAuth2AuthenticationOptions>
    {
        private const string TokenEndpoint = "https://accounts.google.com/o/oauth2/token";
        private const string UserInfoEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo?access_token=";
        private const string AuthorizeEndpoint = "https://accounts.google.com/o/oauth2/auth";
    
        private readonly ILogger _logger;
        private readonly HttpClient _httpClient;
    
        public GoogleOAuth2AuthenticationHandlerExtended(HttpClient httpClient, ILogger logger)
        {
            _httpClient = httpClient;
            _logger = logger;
        }
    
        // i've got some surpises here
        protected override async Task<AuthenticationTicket> AuthenticateCoreAsync()
        {
            AuthenticationProperties properties = null;
    
            try
            {
                string code = null;
                string state = null;
    
                IReadableStringCollection query = Request.Query;
                IList<string> values = query.GetValues("code");
                if (values != null && values.Count == 1)
                {
                    code = values[0];
                }
                values = query.GetValues("state");
                if (values != null && values.Count == 1)
                {
                    state = values[0];
                }
    
                properties = Options.StateDataFormat.Unprotect(state);
                if (properties == null)
                {
                    return null;
                }
    
                // OAuth2 10.12 CSRF
                if (!ValidateCorrelationId(properties, _logger))
                {
                    return new AuthenticationTicket(null, properties);
                }
    
                string requestPrefix = Request.Scheme + "://" + Request.Host;
                string redirectUri = requestPrefix + Request.PathBase + Options.CallbackPath;
    
                // Build up the body for the token request
                var body = new List<KeyValuePair<string, string>>();
                body.Add(new KeyValuePair<string, string>("grant_type", "authorization_code"));
                body.Add(new KeyValuePair<string, string>("code", code));
                body.Add(new KeyValuePair<string, string>("redirect_uri", redirectUri));
                body.Add(new KeyValuePair<string, string>("client_id", Options.ClientId));
                body.Add(new KeyValuePair<string, string>("client_secret", Options.ClientSecret));
    
                // Request the token
                HttpResponseMessage tokenResponse =
                await _httpClient.PostAsync(TokenEndpoint, new FormUrlEncodedContent(body));
                tokenResponse.EnsureSuccessStatusCode();
                string text = await tokenResponse.Content.ReadAsStringAsync();
    
                // Deserializes the token response
                JObject response = JObject.Parse(text);
                string accessToken = response.Value<string>("access_token");
                string expires = response.Value<string>("expires_in");
                string refreshToken = response.Value<string>("refresh_token");
    
                if (string.IsNullOrWhiteSpace(accessToken))
                {
                    _logger.WriteWarning("Access token was not found");
                    return new AuthenticationTicket(null, properties);
                }
    
                // Get the Google user
                HttpResponseMessage graphResponse = await _httpClient.GetAsync(
                    UserInfoEndpoint + Uri.EscapeDataString(accessToken), Request.CallCancelled);
                graphResponse.EnsureSuccessStatusCode();
    
                // i will show content of this var later
                text = await graphResponse.Content.ReadAsStringAsync();
                JObject user = JObject.Parse(text);
    
    
                //because of permanent exception in GoogleOAuth2AuthenticatedContext constructor i prepare user data with my extension
                JObject correctUser = OAuth2Helper.PrepareGoogleUserInfo(user);
    
                // i've replaced this with selfprepared user2
                //var context = new GoogleOAuth2AuthenticatedContext(Context, user, accessToken, refreshToken, expires);
                var context = new GoogleOAuth2AuthenticatedContext(Context, correctUser, accessToken, refreshToken, expires);
                context.Identity = new ClaimsIdentity(
                    Options.AuthenticationType,
                    ClaimsIdentity.DefaultNameClaimType,
                    ClaimsIdentity.DefaultRoleClaimType);
                if (!string.IsNullOrEmpty(context.Id))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, context.Id,
                    ClaimValueTypes.String, Options.AuthenticationType));
                }
                if (!string.IsNullOrEmpty(context.GivenName))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.GivenName, context.GivenName,
                    ClaimValueTypes.String, Options.AuthenticationType));
                }
                if (!string.IsNullOrEmpty(context.FamilyName))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.Surname, context.FamilyName,
                    ClaimValueTypes.String, Options.AuthenticationType));
                }
                if (!string.IsNullOrEmpty(context.Name))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.Name, context.Name, ClaimValueTypes.String,
                    Options.AuthenticationType));
                }
                if (!string.IsNullOrEmpty(context.Email))
                {
                    context.Identity.AddClaim(new Claim(ClaimTypes.Email, context.Email, ClaimValueTypes.String,
                    Options.AuthenticationType));
                }
    
                if (!string.IsNullOrEmpty(context.Profile))
                {
                    context.Identity.AddClaim(new Claim("urn:google:profile", context.Profile, ClaimValueTypes.String,
                    Options.AuthenticationType));
                }
                context.Properties = properties;
    
                await Options.Provider.Authenticated(context);
    
                return new AuthenticationTicket(context.Identity, context.Properties);
            }
            catch (Exception ex)
            {
                _logger.WriteError("Authentication failed", ex);
                return new AuthenticationTicket(null, properties);
            }
        }
    
        protected override Task ApplyResponseChallengeAsync()
        {
    
            // finaly! here it is. i just want to put this two lines here. thats all
            Options.ClientId = OAuth2Helper.GetProviderAppId("google");
            Options.ClientSecret = OAuth2Helper.GetProviderAppSecret("google");
    
            /* default code ot the method */
        }
    
        // no changes
        public override async Task<bool> InvokeAsync()
        {
        /* default code here */
        }
    
        // no changes
        private async Task<bool> InvokeReplyPathAsync()
        {
        /* default code here */
        }
    
        //  no changes
        private static void AddQueryString(IDictionary<string, string> queryStrings, AuthenticationProperties properties,
        string name, string defaultValue = null)
        {
        /* default code here */
        }   
    }
    

After all i get some surprises.

  1. after myhost/signin-google i get myhost/Account/ExternalLoginCallback?error=access_denied and 302 redirect back to login page with no success.
    that is because of few Exception in internal methods of GoogleOAuth2AuthenticatedContext constructor.

    GivenName = TryGetValue(user, "name", "givenName");
    FamilyName = TryGetValue(user, "name", "familyName");
    

and

    Email = TryGetFirstValue(user, "emails", "value");

and here is the google response which we translate to JObject user

        {
        "sub": "XXXXXXXXXXXXXXXXXX",
        "name": "John Smith",
        "given_name": "John",
        "family_name": "Smith",
        "profile": "https://plus.google.com/XXXXXXXXXXXXXXXXXX",
        "picture": "https://lh5.googleusercontent.com/url-to-the-picture/photo.jpg",
        "email": "usermail@domain.com",
        "email_verified": true,
        "gender": "male",
        "locale": "ru",
        "hd": "google application website"
        }

name is string and TryGetValue(user, "name", "givenName") will fail as TryGetValue(user, "name", "familyName")
emails is missed

thats why i used helper wich translate user to correct correctUser

  1. correctUser is ok but i still have no success. why? after myhost/signin-google i get myhost/Account/ExternalLoginCallback and 302 redirect back to login page with no success.

id in google response is actualy sub so
• Id property of AuthenticatedContext is not filled
ClaimTypes.NameIdentifier never created
• AccountController.ExternalLoginCallback(string returnUrl) will always redirect us because of loginInfo is null

GetExternalLoginInfo takes AuthenticateResult wich should not be null and it checks result.Identity for ClaimTypes.NameIdentifier existence

renaming sub into id do the work. now everything is ok.

it seems that microsoft implementation of katana differs from katana source because if i use default everything is work without any magic.

if you can correct me, if you know more easiest way to make owin work with AuthenticationOptions determined at runtime based on host name, please tell me

1条回答
放荡不羁爱自由
2楼-- · 2019-04-12 17:48

I've recently battled with trying to get multi-tennancy working with the same OAuth provider but with different accounts. I know you wanted to update the options dynamically at runtime but you might not need to do that, hopefully this helps...

I think the reason that you don't have this working, even with overriding all of those classes is because each configured google OAuth account needs to have a unique CallbackPath. This is what determines which registered provider and options will execute on the callback.

Instead of trying to do this dynamically, you can declare each OAuth provider at startup and ensure they have unique AuthenticationType and unique CallbackPath, for example:

//Provider #1
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions
{
    AuthenticationType = "Google-Site.Com",
    ClientId = "abcdef...",
    ClientSecret = "zyxwv....",
    CallbackPath = new PathString("/sitecom-signin-google")
});

//Provider #2
app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions
{
    AuthenticationType = "Google-AnotherSite.com",
    ClientId = "abcdef...",
    ClientSecret = "zyxwv....",
    CallbackPath = new PathString("/anothersitecom-signin-google")
});

Then where you are calling IOwinContext.Authentication.Challenge you make sure to pass it your correctly named AuthenticationType for the current tenant you want to authenticate. Example: HttpContext.GetOwinContext().Authentication.Challenge(properties, "Google-AnotherSite.com");

The next step is to update your callback path in Google's Developers Console to match your custom callback paths. By default it is "signin-google" but each of these needs to be unique among your declared providers so that the provider knows it needs to handle the specific callback on that path.

I actually just blogged about all of this here in more detail: http://shazwazza.com/post/configuring-aspnet-identity-oauth-login-providers-for-multi-tenancy/

查看更多
登录 后发表回答