可以将文章内容翻译成中文,广告屏蔽插件可能会导致该功能失效(如失效,请关闭广告屏蔽插件后再试):
问题:
I am using the 'new' project templates for angular SPA applications in dotnet core 2.1 as written in the article Use the Angular project template with ASP.NET Core.
But this article doesn't mention anything about securing the SPA itself.
All information i find is about securing a WEBAPI but first of all i am interested in securing the SPA.
That means: When i open up my SPA e.g. https://localhost:44329/ i would like to be redirected to the authorization server immediatly instead of clicking some button that will do the authentication.
Background:
- I have to ensure that only a authenticated users are allowed to see the SPA.
- I want to use Authorization Code Grant to get refresh tokens from my authorization server.
- I cannot use Implicit Grant because refresh tokens cannot be kept private on the browser
Current approach is to enforce a MVC policy that requires a authenticated user. But this can only be applied to a MVC Controller. That's why i added HomeController to serve the first request.
See project structure:
My Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "CustomScheme";
})
.AddCookie()
.AddOAuth("CustomScheme", options =>
{
// Removed for brevity
});
services.AddMvc(config =>
{
// Require a authenticated user
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
config.Filters.Add(new AuthorizeFilter(policy));
});
// In production, the Angular files will be served from this directory
services.AddSpaStaticFiles(configuration =>
{
configuration.RootPath = "ClientApp/dist";
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseAuthentication();
app.UseStaticFiles();
app.UseSpaStaticFiles();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
}
Current behaviour: When i spin up my SPA i'm immediately redirected to my authorization server because of the MVC policy. After successful authentication i see the Index method of the home controller but not my SPA.
So the question is how should i serve my SPA after i have been redirected from authentication server?
回答1:
I have something that seems to work.
In my researches I stumbbled apon this post suggesting to use a middleware instead of the Authorize attribute.
Now, the method used in that post authService does not seem to work in my case (no clue why, I'll continue the investigation and post whaterver I find later on).
So I decided to go with a simpler solution. Here is my config
app.Use(async (context, next) =>
{
if (!context.User.Identity.IsAuthenticated)
{
await context.ChallengeAsync("oidc");
}
else
{
await next();
}
});
In this case, oidc kicks in BEFORE the Spa app and the flow is working properly. No need for a Controller at all.
HTH
回答2:
Using @George's middlware will require authentication on all requests. If you want to run this only for localhost add it under UseSpa wrapped in an env.IsDevelopment() block.
Another option that also works well for deployed environments is to return the index.html from your spa fallback route.
Startup:
if (!env.IsDevelopment())
{
builder.UseMvc(routes =>
{
routes.MapSpaFallbackRoute(
name: "spa-fallback",
defaults: new { controller = "Home", action = "AuthorizedSpaFallBack" });
});
}
HomeController:
[Authorize]
public IActionResult AuthorizedSpaFallBack()
{
var file = _env.ContentRootFileProvider.GetFileInfo("ClientApp/dist/index.html");
return PhysicalFile(file.PhysicalPath, "text/html");
}
If you need the base.href to match the browser request url (For example a cookie that that has a Path value) you can template that with a regex (or use a razor view like the other examples).
[Authorize]
public IActionResult SpaFallback()
{
var fileInfo = _env.ContentRootFileProvider.GetFileInfo("ClientApp/dist/index.html");
using (var reader = new StreamReader(fileInfo.CreateReadStream()))
{
var fileContent = reader.ReadToEnd();
var basePath = !string.IsNullOrWhiteSpace(Url.Content("~")) ? Url.Content("~") + "/" : "/";
//Note: basePath needs to match request path, because cookie.path is case sensitive
fileContent = Regex.Replace(fileContent, "<base.*", $"<base href=\"{basePath}\">");
return Content(fileContent, "text/html");
}
}
回答3:
Make this change to your startup.cs:
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
spa.Options.DefaultPage = "/home/index";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
Then put the reference to the angular app in the index.cshtml:
<app-root></app-root>
and make sure you include all of the needed files in the index.cshtml file or your layout:
<link href="~/styles.bundle.css" rel="stylesheet" />
<script type="text/javascript" src="~/inline.bundle.js" asp-append-version="true"></script>
<script type="text/javascript" src="~/polyfills.bundle.js" asp-append-version="true"></script>
<script type="text/javascript" src="~/vendor.bundle.js" asp-append-version="true"></script>
<script type="text/javascript" src="~/main.bundle.js" asp-append-version="true"></script>
We are still working out the kinks with all of our referenced packages, but this will get the basic SPA running behind asp.net auth.
回答4:
Based on the Georges Legros I've managed to get this working for .Net Core 3 with Identity Server 4 (the out-of-the-box VS project) so that the app.UseSpa pipeline is not hit if the user is not authenticated via the identity server first. This is much nicer because you don't have to wait for the SPA to load only to then get redirected to the login.
You have to make sure you have authorization/roles working correctly or the User.Identity.IsAuthenticated will always be false.
public void ConfigureServices(IServiceCollection services)
{
...
//Change the following pre-fab lines from
//services.AddDefaultIdentity<ApplicationUser>()
// .AddEntityFrameworkStores<ApplicationDbContext>();
//To
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddRoles<IdentityRole>()
//You might not need the following two settings
.AddDefaultUI()
.AddEntityFrameworkStores<ApplicationDbContext>();
services.AddIdentityServer()
.AddApiAuthorization<ApplicationUser, ApplicationDbContext>();
...
}
Then add the following the set up the following pipe:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller}/{action=Index}/{id?}");
});
//Added this to redirect to Identity Server auth prior to loading SPA
app.Use(async (context, next) =>
{
if (!context.User.Identity.IsAuthenticated)
{
await context.ChallengeAsync("Identity.Application");
}
else
{
await next();
}
});
app.UseSpa(spa =>
{
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
}
回答5:
Seems like there is no REAL solution when speaking of SPAs.
In order to execute some logic in the SPA the SPA has to be initially loaded.
But there is some kind of trick: in the RouterModule
you can prevent the initialNavigation as shown:
const routes: Routes = [
{
path: '',
redirectTo: 'about',
pathMatch: 'full'
},
{
path: '**',
redirectTo: 'about'
}
];
@NgModule({
imports: [RouterModule.forRoot(routes, { initialNavigation: false })],
exports: [RouterModule]
})
export class AppRoutingModule {}
Then in you app.component.ts
you can take care about your authentication:
@Component({
selector: 'flight-app',
templateUrl: './app.component.html'
})
export class AppComponent {
constructor(private router: Router, private oauthService: OAuthService) {
if (this.oauthService.isAuthenticated()) {
this.router.navigate(['/home']);
} else {
// login Logic
}
}
}