locked
Identity Server 4 with custom logic RRS feed

  • Question

  • User320513238 posted

    Hello,

    I have been tasked with implementing Identity Server 4; I thought this would be a simple endeavor. I have a .NET Core 2.1 web application where I've written all the code to connect to our database and do the verification process to determine if a user is valid however, I'm unsure of how everything is supposed to be wired up from the Identity Server 4 side of things. Currently my login method looks like this:

            public async Task<IActionResult> Login(LoginModel model)
            {
                Shared.OperationResult result = await _lazyUserService.Value.LoginAsync(model.ToDomainModel()).ConfigureAwait(false);
                if (result.ApplicationErrors.Count > 0)
                    return RedirectToAction("Index", "Error");
                if (result.ValidationErrors.Count > 0)
                {
                    ViewData["Errors"] = result.ValidationErrors;
                    return View(model);
                }
                ClaimsIdentity claimsIdentity = new ClaimsIdentity(new List<Claim>()
                {
                    new Claim(JwtClaimTypes.Subject, "something"),
                    new Claim(ClaimTypes.NameIdentifier, "guid"),
                    new Claim(ClaimTypes.Email, model.Username),
                    new Claim(ClaimTypes.Role, "role")
                });
                await HttpContext.SignInAsync(new ClaimsPrincipal(claimsIdentity));
                return RedirectToAction("Index", "Home");
            }

    However, when it redirects to Home/Index, the Authorize attribute is redirecting back to the login page as the user isn't logged in. I have read that I need two classes, one that implements IProfileService and one that implements IResourceOwnerPasswordValidator. I've created those and wired them up in Startup.cs.

            public void ConfigureServices(IServiceCollection services)
            {
                services.Configure<CookiePolicyOptions>(options =>
                {
                    options.CheckConsentNeeded = context => true;
                    options.MinimumSameSitePolicy = SameSiteMode.None;
                });
    
                services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
    
                services.AddIdentityServer()
                    .AddInMemoryIdentityResources(new List<IdentityResource>()
                    {
                        new IdentityResources.OpenId(),
                        new IdentityResources.Profile(),
                        new IdentityResources.Email(),
                        new IdentityResource()
                        {
                            Name = "role",
                            UserClaims = new List<string> { "role" }
                        }
                    })
                    .AddInMemoryClients(new List<Client>()
                    {
                        new Client()
                        {
                            AllowedGrantTypes = GrantTypes.ClientCredentials,
                            ClientId = "ClientId",
                            ClientName = "My Client Name",
                            ClientSecrets = new List<Secret>()
                            {
                                new Secret("super secret password".Sha512())
                            }
                        }
                    })
                    .AddProfileService<UserProfileService>()
                    .AddResourceOwnerValidator<UserResourceStore>()
                    .AddDeveloperSigningCredential();
            }
    
            public void Configure(IApplicationBuilder app, IHostingEnvironment env)
            {
                if (env.IsDevelopment())
                    app.UseDeveloperExceptionPage();
                else
                    app.UseExceptionHandler("/Home/Error");
    
                app.UseStaticFiles()
                    .UseIdentityServer()
                    .UseAuthentication()
                    .UseMvc(routes =>
                    {
                        routes.MapRoute(
                            name: "default",
                            template: "{controller=Account}/{action=Login}/{id?}");
                    });
            }

    Still, nothing is working. I guess I'm just lost on what I need to do to get things up and running. Also, there seems to be a difference in how my project is setup vs how I've seen other Identity Server examples. I do not have a separate API project. I have one project for Identity Server, the web project, and that's it.

    Also, please note that we are not using entity framework.  We have our own database to store user account information and our own wrappers around ADO.NET that we use for database access.  I say this because it seems that most people are taking the default implementation of Identity Server, which uses EF, to get up and going quickly.  I assume that because we aren't using EF, this will add some complication to what I'm trying to accomplish?

    Any help on this would be greatly appreciated.

    Thursday, November 29, 2018 9:06 PM

Answers

  • User475983607 posted

    da.3vil.coder

    I will check out the link but I feel I'm being misunderstood.  My application IS THE IDENTITY SERVER.  The client application is being created by another team.  So, when everyone keeps saying that you are redirected to Identity Server and that's where you login, that's the piece I'm working on....the identity server.  Seeing as how my component is the identity server and how the other team is working on the client side application, we have 2 separate applications.

    First, I use Identity Server where there is a login logic that calls a SOAP service.  It is very similar to what you're doing in that it's custom authentication.  My user roles and claims are stored in another data store not connected to the SOAP service.  I understand what you are trying to do.

    Most importantly, how can you configure Identity Server without a test client?  

    However, your code looks nothing like what's in the reference docs and you have not explained what scopes you are using which is the most important bit.  Nor have you shown the configuration which is how the defines how your clients interact with the Identity Server. 

    I strongly recommend that you take the time to go through the Quick Start Tutorials and take a look at the Quick Start code as it has everything you need.  This code below comes from the quick start source.  The highlighted bit is where to add the authentication and where principal is added.  If you need to pass back custom claims then implement an IProfileService.  But first go through the Quick start tutorials.

    /// <summary>
            /// Entry point into the login workflow
            /// </summary>
            [HttpGet]
            public async Task<IActionResult> Login(string returnUrl)
            {
                // build a model so we know what to show on the login page
                var vm = await BuildLoginViewModelAsync(returnUrl);
    
                if (vm.IsExternalLoginOnly)
                {
                    // we only have one option for logging in and it's an external provider
                    return RedirectToAction("Challenge", "External", new { provider = vm.ExternalLoginScheme, returnUrl });
                }
    
                return View(vm);
            }
    
            /// <summary>
            /// Handle postback from username/password login
            /// </summary>
            [HttpPost]
            [ValidateAntiForgeryToken]
            public async Task<IActionResult> Login(LoginInputModel model, string button)
            {
                // check if we are in the context of an authorization request
                var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);
    
                // the user clicked the "cancel" button
                if (button != "login")
                {
                    if (context != null)
                    {
                        // if the user cancels, send a result back into IdentityServer as if they 
                        // denied the consent (even if this client does not require consent).
                        // this will send back an access denied OIDC error response to the client.
                        await _interaction.GrantConsentAsync(context, ConsentResponse.Denied);
    
                        // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                        if (await _clientStore.IsPkceClientAsync(context.ClientId))
                        {
                            // if the client is PKCE then we assume it's native, so this change in how to
                            // return the response is for better UX for the end user.
                            return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
                        }
    
                        return Redirect(model.ReturnUrl);
                    }
                    else
                    {
                        // since we don't have a valid context, then we just go back to the home page
                        return Redirect("~/");
                    }
                }
    
                if (ModelState.IsValid)
                {
                    // validate username/password against in-memory store
                    if (_users.ValidateCredentials(model.Username, model.Password))
                    {
                        var user = _users.FindByUsername(model.Username);
                        await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username));
    
                        // only set explicit expiration here if user chooses "remember me". 
                        // otherwise we rely upon expiration configured in cookie middleware.
                        AuthenticationProperties props = null;
                        if (AccountOptions.AllowRememberLogin && model.RememberLogin)
                        {
                            props = new AuthenticationProperties
                            {
                                IsPersistent = true,
                                ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
                            };
                        };
    
                        // issue authentication cookie with subject ID and username
                        await HttpContext.SignInAsync(user.SubjectId, user.Username, props);
    
                        if (context != null)
                        {
                            if (await _clientStore.IsPkceClientAsync(context.ClientId))
                            {
                                // if the client is PKCE then we assume it's native, so this change in how to
                                // return the response is for better UX for the end user.
                                return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
                            }
    
                            // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                            return Redirect(model.ReturnUrl);
                        }
    
                        // request for a local page
                        if (Url.IsLocalUrl(model.ReturnUrl))
                        {
                            return Redirect(model.ReturnUrl);
                        }
                        else if (string.IsNullOrEmpty(model.ReturnUrl))
                        {
                            return Redirect("~/");
                        }
                        else
                        {
                            // user might have clicked on a malicious link - should be logged
                            throw new Exception("invalid return URL");
                        }
                    }
    
                    await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials"));
                    ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
                }
    
                // something went wrong, show form with error
                var vm = await BuildLoginViewModelAsync(model);
                return View(vm);
            }

    • Marked as answer by Anonymous Thursday, October 7, 2021 12:00 AM
    Friday, November 30, 2018 4:56 PM
  • User320513238 posted

    After looking over Microsoft's example, everything that I have is correct.  All configuration, all settings, etc.  Turns out that the one thing that was missing was the following:

    using Microsoft.AspNetCore.Http;

    That one using statement gives you access to 11 other overloads for:

    HttpContext.SignInAsync

    So, instead of doing:

    await HttpContext.SignInAsync(new ClaimsPrincipal(claimsIdentity));

    I now do this:

    await HttpContext.SignInAsync("SubjectId, "Username", authenticationProprties);

    And with that, now the Authorize attribute works and the user is able to sign in.

    • Marked as answer by Anonymous Thursday, October 7, 2021 12:00 AM
    Friday, November 30, 2018 7:26 PM

All replies

  • User1724605321 posted

    Hi da.3vil.coder,

    At first , you need a identity server 4 application  which is used to config the allowed clients /resources/users, and issue the access token/ID token . Then in your application , you could config the OpenID Connect middleware to redirect user to identity server's login page and sign in ,  in your application , if using your own database , you need to get the claims from JWT token and add to your database , then sign in the user . Something like :

                    var result1 = await HttpContext.AuthenticateAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
                    // Get the information about the user from the external login provider
                    // var info = await _signInManager.GetExternalLoginInfoAsync();
                    if (result1 == null)
                    {
                        throw new ApplicationException("Error loading external login information during confirmation.");
                    }
                    var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
                    var result = await _userManager.CreateAsync(user);
                    if (result.Succeeded)
                    {
                        // retrieve claims of the external user
                        var externalUser = result1.Principal;
                        if (externalUser == null)
                        {
                            throw new Exception("External authentication error");
                        }
    
                        // retrieve claims of the external user
                        var claims = externalUser.Claims.ToList();
    
                        result = await _userManager.AddLoginAsync(user, new UserLoginInfo("AAD", claims.FirstOrDefault(x => x.Type == "http://schemas.microsoft.com/identity/claims/objectidentifier").Value, claims.FirstOrDefault(x => x.Type == ClaimTypes.Upn).Value));
                        if (result.Succeeded)
                        {
                            await _signInManager.SignInAsync(user, isPersistent: false);
                            _logger.LogInformation("User created an account using {Name} provider.", "AAD");
                            return RedirectToLocal(returnUrl);
                        }

    Best Regards,

    Nan Yu

    Friday, November 30, 2018 3:26 AM
  • User320513238 posted

    Nan Yu,

    Thank you for the reply.  Can you please tell me what exactly is an 'identity server 4 application'?  I see no project templates for this.  I have added the nuget packages for Identity Server 4, is that what you mean?

    Also, please note that this web application that I'm creating is the identity server application.  The client applications that will interact with this are the job of another team.

    Friday, November 30, 2018 2:21 PM
  • User475983607 posted

    IdentityServer4 is a separate web application used to authenticate and authorize access to remote services and applications.  Basically, the client logs into Identity Server, receives a token, then uses the token to access secured resources. The IdentityServer4 documentation explains the problem space Identity Server solves.  

    http://docs.identityserver.io/en/latest/

    Keep in mind that the code shown in the first post is conceptually incorrect.  You'll want to start with the docs to make sure IdentityServer4 is appropriate for your design.

    Friday, November 30, 2018 3:20 PM
  • User320513238 posted

    Right, that is my understanding as well and that's what I'm trying to create with my application.  However, I can't get anything authenticating.  My call:

    await HttpContext.SignInAsync(new ClaimsPrincipal(claimsIdentity));

    Isn't registering as authorized as I keep getting redirected back to the login page.  And when I say the login page, I mean the login page on Identity Server.

    Friday, November 30, 2018 3:53 PM
  • User475983607 posted

    Right, that is my understanding as well and that's what I'm trying to create with my application.  However, I can't get anything authenticating.  My call:
    await HttpContext.SignInAsync(new ClaimsPrincipal(claimsIdentity));
    Isn't registering as authorized as I keep getting redirected back to the login page.
    

    Your code is way off... You should have at least 2 web applications.  Your web application and Identity Server.  Your application redirect to Identity Server if the client is not authenticated.  The user logs into Identity Server.  Identity Server redirects back to the your application where the token is parsed and added to an auth cookie.  This is all done through configuration and the Identity API.

    The first step is reading the Identity Server documentation.  The Identity Server docs have templates you can download or copy.  The only thing you need to change is the authentication piece.  The template uses an in memory user table.  Simply replace the in-memory login with your login.

    Anyway, I recommend that go through the Quick Start tutorials.  That should give you base understanding then you can go from there.  Adding your authentication should be very simple.

    http://docs.identityserver.io/en/latest/quickstarts/1_client_credentials.html#defining-the-api

    Friday, November 30, 2018 4:09 PM
  • User320513238 posted

    I will check out the link but I feel I'm being misunderstood.  My application IS THE IDENTITY SERVER.  The client application is being created by another team.  So, when everyone keeps saying that you are redirected to Identity Server and that's where you login, that's the piece I'm working on....the identity server.  Seeing as how my component is the identity server and how the other team is working on the client side application, we have 2 separate applications.

    Friday, November 30, 2018 4:21 PM
  • User475983607 posted

    da.3vil.coder

    I will check out the link but I feel I'm being misunderstood.  My application IS THE IDENTITY SERVER.  The client application is being created by another team.  So, when everyone keeps saying that you are redirected to Identity Server and that's where you login, that's the piece I'm working on....the identity server.  Seeing as how my component is the identity server and how the other team is working on the client side application, we have 2 separate applications.

    First, I use Identity Server where there is a login logic that calls a SOAP service.  It is very similar to what you're doing in that it's custom authentication.  My user roles and claims are stored in another data store not connected to the SOAP service.  I understand what you are trying to do.

    Most importantly, how can you configure Identity Server without a test client?  

    However, your code looks nothing like what's in the reference docs and you have not explained what scopes you are using which is the most important bit.  Nor have you shown the configuration which is how the defines how your clients interact with the Identity Server. 

    I strongly recommend that you take the time to go through the Quick Start Tutorials and take a look at the Quick Start code as it has everything you need.  This code below comes from the quick start source.  The highlighted bit is where to add the authentication and where principal is added.  If you need to pass back custom claims then implement an IProfileService.  But first go through the Quick start tutorials.

    /// <summary>
            /// Entry point into the login workflow
            /// </summary>
            [HttpGet]
            public async Task<IActionResult> Login(string returnUrl)
            {
                // build a model so we know what to show on the login page
                var vm = await BuildLoginViewModelAsync(returnUrl);
    
                if (vm.IsExternalLoginOnly)
                {
                    // we only have one option for logging in and it's an external provider
                    return RedirectToAction("Challenge", "External", new { provider = vm.ExternalLoginScheme, returnUrl });
                }
    
                return View(vm);
            }
    
            /// <summary>
            /// Handle postback from username/password login
            /// </summary>
            [HttpPost]
            [ValidateAntiForgeryToken]
            public async Task<IActionResult> Login(LoginInputModel model, string button)
            {
                // check if we are in the context of an authorization request
                var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);
    
                // the user clicked the "cancel" button
                if (button != "login")
                {
                    if (context != null)
                    {
                        // if the user cancels, send a result back into IdentityServer as if they 
                        // denied the consent (even if this client does not require consent).
                        // this will send back an access denied OIDC error response to the client.
                        await _interaction.GrantConsentAsync(context, ConsentResponse.Denied);
    
                        // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                        if (await _clientStore.IsPkceClientAsync(context.ClientId))
                        {
                            // if the client is PKCE then we assume it's native, so this change in how to
                            // return the response is for better UX for the end user.
                            return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
                        }
    
                        return Redirect(model.ReturnUrl);
                    }
                    else
                    {
                        // since we don't have a valid context, then we just go back to the home page
                        return Redirect("~/");
                    }
                }
    
                if (ModelState.IsValid)
                {
                    // validate username/password against in-memory store
                    if (_users.ValidateCredentials(model.Username, model.Password))
                    {
                        var user = _users.FindByUsername(model.Username);
                        await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username));
    
                        // only set explicit expiration here if user chooses "remember me". 
                        // otherwise we rely upon expiration configured in cookie middleware.
                        AuthenticationProperties props = null;
                        if (AccountOptions.AllowRememberLogin && model.RememberLogin)
                        {
                            props = new AuthenticationProperties
                            {
                                IsPersistent = true,
                                ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
                            };
                        };
    
                        // issue authentication cookie with subject ID and username
                        await HttpContext.SignInAsync(user.SubjectId, user.Username, props);
    
                        if (context != null)
                        {
                            if (await _clientStore.IsPkceClientAsync(context.ClientId))
                            {
                                // if the client is PKCE then we assume it's native, so this change in how to
                                // return the response is for better UX for the end user.
                                return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
                            }
    
                            // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                            return Redirect(model.ReturnUrl);
                        }
    
                        // request for a local page
                        if (Url.IsLocalUrl(model.ReturnUrl))
                        {
                            return Redirect(model.ReturnUrl);
                        }
                        else if (string.IsNullOrEmpty(model.ReturnUrl))
                        {
                            return Redirect("~/");
                        }
                        else
                        {
                            // user might have clicked on a malicious link - should be logged
                            throw new Exception("invalid return URL");
                        }
                    }
    
                    await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials"));
                    ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
                }
    
                // something went wrong, show form with error
                var vm = await BuildLoginViewModelAsync(model);
                return View(vm);
            }

    • Marked as answer by Anonymous Thursday, October 7, 2021 12:00 AM
    Friday, November 30, 2018 4:56 PM
  • User320513238 posted

    After looking over Microsoft's example, everything that I have is correct.  All configuration, all settings, etc.  Turns out that the one thing that was missing was the following:

    using Microsoft.AspNetCore.Http;

    That one using statement gives you access to 11 other overloads for:

    HttpContext.SignInAsync

    So, instead of doing:

    await HttpContext.SignInAsync(new ClaimsPrincipal(claimsIdentity));

    I now do this:

    await HttpContext.SignInAsync("SubjectId, "Username", authenticationProprties);

    And with that, now the Authorize attribute works and the user is able to sign in.

    • Marked as answer by Anonymous Thursday, October 7, 2021 12:00 AM
    Friday, November 30, 2018 7:26 PM