I'm implementing OpenIdConnect into a .Net Core app, which associates an external login with a user stored in an internal (default Identity) database, and attempts to log in. The log in method reports success, but the User object isn't populated.
The login process was scaffolded in, so it's all boilerplate, including the external login process.
I'd appreciate any help in understanding what I'm doing wrong.
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = "oidc";
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = oauthSettings.Location.AbsoluteUri;
options.RequireHttpsMetadata = false;
options.ClientId = oauthSettings.ClientID;
options.ClientSecret = oauthSettings.ClientSecret;
options.ResponseType = "id_token token";
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
});
//In Login.cshtml.cs
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl = returnUrl ?? Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
if (result.RequiresTwoFactor)
{
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
}
if (result.IsLockedOut)
{
_logger.LogWarning("User account locked out.");
return RedirectToPage("./Lockout");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
}
// If we got this far, something failed, redisplay form
return Page();
}
EDIT: I've found that when I remove the AddAuthentication method in Startup the login works, but obviously that disabled the external auth. Something about adding OpenIdConnect is breaking the login process.
To fix this I did the following in the startup:
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = "oidc";
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = oauthSettings.Location.AbsoluteUri;
options.RequireHttpsMetadata = false;
options.ClientId = oauthSettings.ClientID;
options.ClientSecret = oauthSettings.ClientSecret;
options.ResponseType = "id_token token";
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
});
So I left it to its defaults and it worked. I'm not 100% why.
Related
Context
I have one web application seperated in two "utilities".
Web API
Razor Pages
So my app is both an API and a ASP.NET Razor Pages app, to authenticate on the Web API side I use JWT Bearer and on Web App side a simple Cookie.
Problem
When using Cookie Authentication, I followed Microsoft's Use cookie authentication without ASP.NET Core Identity and it's absolutely not working AT ALL.
I use a custom AuthManager with a SignInAsync method. The cookie is indeed created BUT the ClaimsPrincipal in my HttpContext is empty
I didn't find ANY SOLUTION AT ALL on internet, the only solutions that seemed viable were by using custom Middlewares but I don't even know where to start.
If anybody encountered the same problem as me.
Thanks
public async Task<bool> LogInAsync(string email, string password, bool rememberMe)
{
UserModel user = _userService.UserLogin(email, password).MapFromBLL();
if (user is null) return false;
user.Roles = _roleService.GetUserRoles(user.Id).Select(r => r.MapFromBLL());
if (user.Roles is null || user.Roles.Count() == 0) return false;
List<Claim> claims = new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Email),
new Claim("Stamp", user.SecurityStamp.ToString())
};
IEnumerable<Claim> roleClaims = user.Roles.Select(ur => new Claim(ClaimTypes.Role, ur.Name));
claims.AddRange(roleClaims);
ClaimsIdentity claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
AuthenticationProperties authProperties = new AuthenticationProperties
{
AllowRefresh = true,
ExpiresUtc = DateTime.Now.AddDays(_jwtModel.ExpirationInDays),
IsPersistent = true,
IssuedUtc = DateTime.Now,
};
ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
try
{
await _httpAccessor.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal, authProperties);
return true;
}
catch (Exception e)
{
Debug.WriteLine(e.Message);
return false;
}
}
Startup.cs
services.AddAuthentication(options =>
{
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.None;
options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Forbidden";
options.EventsType = typeof(SecurityStampUpdatedCookieAuthenticationEvent);
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidIssuer = jwtModel.Issuer,
ValidAudience = jwtModel.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtModel.Secret)),
ClockSkew = TimeSpan.Zero
};
});
EDIT 10-02-2022
I seperated the post in two chapters : "Context" and "Problem"
My solution
Ok, so I found a solution to my problem. It was, as usual after 10 hours of research, dumb. In my Startup.cs I used JwtBearerDefaults.AuthenticationScheme as DefaultAuthenticateScheme. So I changed it with CookieAuthenticationDefaults.AuthenticationScheme, and now by miracle IT WORKS. After logging in, my ClaimsPrincipal User is full and it retrieve correctly the cookie.
My new services.AddAuthentication() in Startup.cs
services.AddAuthentication(options =>
{
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
I don't really know if I should keep options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;. If anyone could enlight me.
I didn't change anything in my method public async Task<bool> SignInAsync() I just added await _httpAccessor.HttpContext.SignOutAsync() at the beginning just to be sure.
public async Task<bool> LogInAsync(string email, string password, bool rememberMe)
{
await LogOutAsync();
// The same code as before
}
The LogOutAsync() method
public async Task LogOutAsync()
{
try
{
await _httpAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
catch (Exception e)
{
Debug.WriteLine(e.Message);
}
}
Problems encountered
Authorization
After fixing this problem, I had another one. Now that my default scheme is Cookie based, I can't just use [Authorize] and except it to work with both Cookie or Jwt. To fix that I just added this code in Startup.cs after services.AddAuthentication()
AuthorizationPolicy multiSchemePolicy = new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
services.AddAuthorization(options =>
{
options.DefaultPolicy = multiSchemePolicy;
});
Code that I found here
API Unauthorize return Login Page instead of 401 or 403 status codes
This one is kind of strange, now the cookie and the JwtBearer worked fine, when I tried to access an [Authorize] route, sometimes it returned me the HTML page to Login instead of 401 Status Code and sometimes not.
To avoid this problem, I found a solution here
using services.ConfigureApplicationCookie didn't work for me, instead I adapted my services.AddCookie().
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Forbidden";
options.Events = new CookieAuthenticationEvents()
{
OnRedirectToLogin = (ctx) =>
{
if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
{
ctx.Response.StatusCode = 401;
}
return Task.CompletedTask;
},
OnRedirectToAccessDenied = (ctx) =>
{
if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
{
ctx.Response.StatusCode = 403;
}
return Task.CompletedTask;
}
};
options.EventsType = typeof(SecurityStampUpdatedCookieAuthenticationEvent);
});
VoilĂ , I hope I have helped at least one of you. If there is anything to change, just let me know !
Complete code
Startup.cs
services.AddAuthentication(options =>
{
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidIssuer = jwtModel.Issuer,
ValidAudience = jwtModel.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtModel.Secret)),
ClockSkew = TimeSpan.Zero
};
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.LoginPath = "/Account/Login";
options.AccessDeniedPath = "/Forbidden";
options.Events = new CookieAuthenticationEvents()
{
OnRedirectToLogin = (ctx) =>
{
if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
{
ctx.Response.StatusCode = 401;
}
return Task.CompletedTask;
},
OnRedirectToAccessDenied = (ctx) =>
{
if (ctx.Request.Path.StartsWithSegments("/api") && ctx.Response.StatusCode == 200)
{
ctx.Response.StatusCode = 403;
}
return Task.CompletedTask;
}
};
options.EventsType = typeof(SecurityStampUpdatedCookieAuthenticationEvent);
});
AuthorizationPolicy multiSchemePolicy = new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
services.AddAuthorization(options =>
{
options.DefaultPolicy = multiSchemePolicy;
});
AuthManager.cs
public async Task<bool> LogInAsync(string email, string password, bool rememberMe)
{
await LogOutAsync();
UserModel user = _userService.UserLogin(email, password).MapFromBLL();
if (user is null) return false;
user.Roles = _roleService.GetUserRoles(user.Id).Select(r => r.MapFromBLL());
if (user.Roles is null || user.Roles.Count() == 0) return false;
List<Claim> claims = new List<Claim>()
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Email),
new Claim("Stamp", user.SecurityStamp.ToString())
};
IEnumerable<Claim> roleClaims = user.Roles.Select(ur => new Claim(ClaimTypes.Role, ur.Name));
claims.AddRange(roleClaims);
ClaimsIdentity claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
AuthenticationProperties authProperties = new AuthenticationProperties
{
AllowRefresh = true,
ExpiresUtc = DateTime.Now.AddDays(_jwtModel.ExpirationInDays),
IsPersistent = true,
IssuedUtc = DateTime.Now,
};
ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
try
{
await _httpAccessor.HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, claimsPrincipal, authProperties);
return true;
}
catch (Exception e)
{
Debug.WriteLine(e.Message);
return false;
}
}
public async Task LogOutAsync()
{
try
{
await _httpAccessor.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
catch (Exception e)
{
Debug.WriteLine(e.Message);
}
}
I'm try to add oAuth to my .net core application. I have that code in Startup.cs
services
.AddAuthentication( options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = MicrosoftAccountDefaults.AuthenticationScheme;
} )
.AddCookie()
.AddMicrosoftAccount( microsoftOptions =>
{
microsoftOptions.ClientId = "...";
microsoftOptions.ClientSecret = "...";
microsoftOptions.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
} );
And then in callback handler I try to authorize user like this
await _signInManager.SignInAsync( user, new AuthenticationProperties { IsPersistent = true, AllowRefresh = true } );
After that, try to call signInManager.IsSignedIn(HttpContext.User) but it's return false.
I think problem in the signInManager, because his AuthorizationScheme use another cookie state or something like that
I am building a application where registration page is only available to admin user so I can give registration page to admin user. But I need to create an admin user which can create those users. I can not do it by any Api.
Problem
I am using JWT Token based Authention. In JWT Token based Authention it needed certain procedure to create user by C# code.
Is there any way I can create user from backhand without API call?
[HttpPost]
[Route("register")]
public async Task<IActionResult> Register(RegisterModel model)
{
var userExists = await userManager.FindByNameAsync(model.Username);
if (userExists != null)
return StatusCode(StatusCodes.Status500InternalServerError, new Response { Status = "Error", Message = "User already exists!" });
ApplicationUser user = new ApplicationUser()
{
Email = model.Email,
SecurityStamp = Guid.NewGuid().ToString(),
UserName = model.Username
};
var result = await userManager.CreateAsync(user, model.Password);
if (!result.Succeeded)
return StatusCode(StatusCodes.Status500InternalServerError, new Response { Status = "Error", Message = "User creation failed! Please check user details and try again." });
return Ok(new Response { Status = "Success", Message = "User created successfully!" });
}
[HttpPost]
[Route("register-admin")]
public async Task<IActionResult> RegisterAdmin(RegisterModel model)
{
var userExists = await userManager.FindByNameAsync(model.Username);
if (userExists != null)
return StatusCode(StatusCodes.Status500InternalServerError, new Response { Status = "Error", Message = "User already exists!" });
ApplicationUser user = new ApplicationUser()
{
Email = model.Email,
SecurityStamp = Guid.NewGuid().ToString(),
UserName = model.Username
};
var result = await userManager.CreateAsync(user, model.Password);
if (!result.Succeeded)
return StatusCode(StatusCodes.Status500InternalServerError, new Response { Status = "Error", Message = "User creation failed! Please check user details and try again." });
if (!await roleManager.RoleExistsAsync(UserRoles.Admin))
await roleManager.CreateAsync(new IdentityRole(UserRoles.Admin));
if (!await roleManager.RoleExistsAsync(UserRoles.User))
await roleManager.CreateAsync(new IdentityRole(UserRoles.User));
if (await roleManager.RoleExistsAsync(UserRoles.Admin))
{
await userManager.AddToRoleAsync(user, UserRoles.Admin);
}
return Ok(new Response { Status = "Success", Message = "User created successfully!" });
}
public void ConfigureServices(IServiceCollection services)
{
// Allow Cross for Centain Network
services.AddCors(options =>
{
options.AddPolicy(name: MyAllowSpecificOrigins,
builder =>
{
builder.WithOrigins(Configuration["JWT:ValidAudience"]);
});
});
services.AddControllers();
// For Entity Framework
services.AddDbContext<DentalDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
// For Identity
services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequiredLength = 6;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireDigit = false;
})
.AddEntityFrameworkStores<DentalDbContext>()
.AddDefaultTokenProviders();
// Adding Authentication
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
})
// Adding Jwt Bearer
.AddJwtBearer(options =>
{
options.SaveToken = true;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = Configuration["JWT:ValidAudience"],
ValidIssuer = Configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JWT:Secret"]))
};
});
}
Yes, If you use Identity of Microsoft. There is way out for this.
In Program.cs
public static void Main(string[] args)
{
var host = CreateHostBuilder(args).Build();
try
{
using var scope = host.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
context.Database.EnsureCreated();
if (!context.Users.Any())
{
var superAdminUser= new IdentityUser
{
UserName = "SuperAdmin"
};
userManager.CreateAsync(superAdminUser, "SuperAdminPassword").GetAwaiter().GetResult();
//Roles contains user, user contains claims
var adminClaim = new Claim("Role", "SuperAdminUser");
userManager.AddClaimAsync(SuperAdminUser, adminClaim).GetAwaiter().GetResult();
}
}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
host.Run();
}
In Startup use authorization.
services.AddAuthorization(options =>
{
options.AddPolicy("SuperAdmin", policy => policy.RequireClaim("Role", "SuperAdmin"));
});
Add Authorize in Controller Specially for SuperAdmin Role
[Authorize(Policy = "SuperAdmin ")]
public class SecretController : Controller
{}
I have an ASP.NET Core 2.2 application with the following configuration:
And this is my Startup.cs class
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.SignInScheme = "Cookies";
options.Authority = $"{ Configuration.GetValue<string>("AzureAdB2C:Instance") }/{ Configuration.GetValue<string>("AzureAdB2C:Tenant") }/{ Configuration.GetValue<string>("AzureAdB2C:SignUpSignInPolicyId") }/v2.0";
options.ClientId = Configuration.GetValue<string>("AzureAdB2C:ClientId");
options.ResponseType = "code id_token";
options.SaveTokens = true;
options.ClientSecret = Configuration.GetValue<string>("AzureAdB2C:ClientSecret");
});
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
When I run this the B2C login page pops-up, however once I enter the credentials I get the error below. URL is redirected to https://localhost:xxxx/signin-oidc
How can I solve this problem?
This is the similar issue, the same error occurred when external login is canceled and redirects to the source application. You could use OnRemoteFailure to handle the exception.
AddOpenIdConnect("oidc", options => {
// ...
options.Events = new OpenIdConnectEvents
{
OnRemoteFailure = ctx =>
{
ctx.Response.Redirect("/error?FailureMessage=" + UrlEncoder.Default.Encode(ctx.Failure.Message));
ctx.HandleResponse();
return Task.FromResult(0);
}
};
});
How to regenerate open id connect custom claims from asp.net core project?
I've setup manually mapping to claim type name, but some times i need to update other claim from outside of the event OnTicketRecieved, namely from controller, so at that stage i do need to regenerate somehow claims. I've set up openIdConnect in the following way:
_services
.AddAuthentication(options =>
{
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddOpenIdConnect(options =>
{
options.ClientId = clientId;
options.ClientSecret = clientSecret;
options.Authority = $"{baseAuthorityUrl}/{tenantId}";
options.CallbackPath = new PathString(callBackPath);
options.Scope.Add("email");
options.Scope.Add("profile");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name"
};
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = e =>
{
return Task.CompletedTask;
},
OnTicketReceived = e =>
{
e.Principal.Identities.First().AddClaim(new Claim(ClaimTypes.Name, e.Principal.FindFirstValue("name")));
return Task.CompletedTask;
}
};
})
How i can to regenerate claims from controller?
I'm thinking just somehow override signInManager.RefreshSignInAsync(user).
If you want to add claims in controller after init login , you should make the authentication manager to use the new identity :
if (HttpContext.User.Identity is ClaimsIdentity identity)
{
identity.AddClaim(new Claim("userId", "1234"));
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(HttpContext.User.Identity));
}
This is how you can update claims outside of the login event. Update is a controller method.
public async Task Update()
{
AuthenticateResult authenticateResult = await HttpContext.AuthenticateAsync();
// Make a copy of the principal so we can modify it's claims
ClaimsPrincipal newPrincipal = new ClaimsPrincipal(User.Identity)
ClaimsIdentity claimsIdentity = (ClaimsIdentity)newPrincipal.Identity;
// Add/remove claims
claimsIdentity.AddClaim(new Claim("name", "value"));
Claim toRemove = claimsIdentity.Claims.FirstOrDefault(c => string.Equals(c.Type, "claimnametoremove", StringComparison.Ordinal));
if (toRemove != null)
claimsIdentity.RemoveClaim(toRemove);
// If these aren't updated, calls to "User" will pull the old principal value
HttpContext.User = newPrincipal;
Thread.CurrentPrincipal = newPrincipal;
// Sign in the user with the new principal to "refresh" our logged-in user
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, newPrincipal, authenticateResult.Properties);
}