好吧,这个题目我也想了很久,不知道如何用最简单的几个字来概括这篇文章,原本打算取名《Angular单页面应用基于Ocelot API网关与IdentityServer4+ASP.NET Identity实现身份认证与授权》,然而如你所见,这样的名字实在是太长了。所以,我不得不缩写“单页面应用”几个字,然后去掉ASP.NET Identity的描述,最后形成目前的标题。
不过,这也就意味着这篇文章会涵盖很多内容和技术,我会利用这些技术来走通一个完整的流程,这个流程也代表着在微服务架构中单点登录的一种实现模式。在此过程中,我们会使用到如下技术或框架:
- Angular 8
- Ocelot API Gateway
- IdentityServer4
- ASP.NET Identity
- Entity Framework Core
- SQL Server
本文假设读者具有上述技术框架的基础知识。由于内容比较多,我还是将这篇文章分几个部分进行讲解和讨论。
场景描述
在微服务架构下的一种比较流行的设计,就是基于前后端分离,前端只做呈现和用户操作流的管理,后端服务由API网关同一协调,以从业务层面为前端提供各种服务。大致可以用下图表示:
在这个结构中,我没有将Identity Service放在API Gateway后端,因为考虑到Identity Service本身并没有承担任何业务功能。从它所能提供的端点(Endpoint)的角度,它也需要做负载均衡、熔断等保护,但我们暂时不讨论这些内容。
流程上其实也比较简单,在上图的数字标识中:
- Client向Identity Service发送认证请求,通常可以是用户名密码
- 如果验证通过,Identity Service会向Client返回认证的Token
- Client使用Token向API Gateway发送API调用请求
- API Gateway将Client发送过来的Token发送给Identity Service,以验证Token的有效性
- 如果验证成功,Identity Service会告知API Gateway认证成功
- API Gateway转发Client的请求到后端API Service
- API Service将结果返回给API Gateway
- API Gateway将API Service返回的结果转发到Client
只是在这些步骤中,我们有很多技术选择,比如Identity Service的实现方式、认证方式等等。接下来,我就在ASP.NET Core的基础上使用IdentityServer4、Entity Framework Core和Ocelot来完成这一流程。在完成整个流程的演练之前,需要确保机器满足以下条件:
- 安装Visual Studio 2019 Community Edition。使用Visual Studio Code也是可以的,根据自己的需要选择
- 安装Visual Studio Code
- 安装Angular 8
IdentityServer4结合ASP.NET Identity实现Identity Service
创建新项目
首先第一步就是实现Identity Service。在Visual Studio 2019 Community Edition中,新建一个ASP.NET Core Web Application,模板选择Web Application (Model-View-Controller),然后点击Authentication下的Change按钮,再选择Individual User Accounts选项,以便将ASP.NET Identity的依赖包都加入项目,并且自动完成基础代码的搭建。
然后,通过NuGet添加IdentityServer4.AspNetIdentity以及IdentityServer4.EntityFramework的引用,IdentityServer4也随之会被添加进来。接下来,在该项目的目录下,执行以下命令安装IdentityServer4的模板,并将IdentityServer4的GUI加入到当前项目:
dotnet new -i identityserver4.templates
dotnet new is4ui --force
然后调整一下项目结构,将原本的Controllers目录删除,同时删除Models目录下的ErrorViewModel类,然后将Quickstart目录重命名为Controllers,编译代码,代码应该可以编译通过,接下来就是实现我们自己的Identity。
定制Identity Service
为了能够展现一个标准的应用场景,我自己定义了User和Role对象,它们分别继承于IdentityUser和IdentityRole类:
public class AppUser : IdentityUser
{
public string DisplayName { get; set; }
} public class AppRole : IdentityRole
{
public string Description { get; set; }
}
当然,Data目录下的ApplicationDbContext也要做相应调整,它应该继承于IdentityDbContext<AppUser, AppRole, string>类,这是因为我们使用了自定义的IdentityUser和IdentityRole的实现:
public class ApplicationDbContext : IdentityDbContext<AppUser, AppRole, string>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
}
之后修改Startup.cs里的ConfigureServices方法,通过调用AddIdentity、AddIdentityServer以及AddDbContext,将ASP.NET Identity、IdentityServer4以及存储认证数据所使用的Entity Framework Core的依赖全部注册进来。为了测试方便,目前我们还是使用Developer Signing Credential,对于Identity Resource、API Resource以及Clients,我们也是暂时先写死(hard code):
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection")));
services.AddIdentity<AppUser, AppRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddIdentityServer().AddDeveloperSigningCredential()
.AddOperationalStore(options =>
{
options.ConfigureDbContext = builder => builder.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"),
sqlServerDbContextOptionsBuilder =>
sqlServerDbContextOptionsBuilder.MigrationsAssembly(typeof(Startup).Assembly.GetName().Name));
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 30; // interval in seconds
})
.AddInMemoryIdentityResources(Config.GetIdentityResources())
.AddInMemoryApiResources(Config.GetApiResources())
.AddInMemoryClients(Config.GetClients())
.AddAspNetIdentity<AppUser>(); services.AddCors(options => options.AddPolicy("AllowAll", p => p.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader())); services.AddControllersWithViews();
services.AddRazorPages();
services.AddControllers();
}
然后,调整Configure方法的实现,将IdentityServer加入进来,同时配置CORS使得站点能够被跨域访问:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseDatabaseErrorPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
} app.UseCors("AllowAll");
app.UseHttpsRedirection();
app.UseStaticFiles(); app.UseRouting();
app.UseIdentityServer(); app.UseAuthentication();
app.UseAuthorization(); app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
endpoints.MapRazorPages();
});
}
完成这部分代码调整后,编译是通不过的,因为我们还没有定义IdentityServer4的IdentityResource、API Resource和Clients。在项目中新建一个Config类,代码如下:
public static class Config
{
public static IEnumerable<IdentityResource> GetIdentityResources() =>
new IdentityResource[]
{
new IdentityResources.OpenId(),
new IdentityResources.Email(),
new IdentityResources.Profile()
}; public static IEnumerable<ApiResource> GetApiResources() =>
new[]
{
new ApiResource("api.weather", "Weather API")
{
Scopes =
{
new Scope("api.weather.full_access", "Full access to Weather API")
},
UserClaims =
{
ClaimTypes.NameIdentifier,
ClaimTypes.Name,
ClaimTypes.Email,
ClaimTypes.Role
}
}
}; public static IEnumerable<Client> GetClients() =>
new[]
{
new Client
{
RequireConsent = false,
ClientId = "angular",
ClientName = "Angular SPA",
AllowedGrantTypes = GrantTypes.Implicit,
AllowedScopes = { "openid", "profile", "email", "api.weather.full_access" },
RedirectUris = {"http://localhost:4200/auth-callback"},
PostLogoutRedirectUris = {"http://localhost:4200/"},
AllowedCorsOrigins = {"http://localhost:4200"},
AllowAccessTokensViaBrowser = true,
AccessTokenLifetime = 3600
},
new Client
{
ClientId = "webapi",
AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
ClientSecrets =
{
new Secret("mysecret".Sha256())
},
AlwaysSendClientClaims = true,
AllowedScopes = { "api.weather.full_access" }
}
};
}
大致说明一下上面的代码。通俗地讲,IdentityResource是指允许应用程序访问用户的哪些身份认证资源,比如,用户的电子邮件或者其它用户账户信息,在Open ID Connect规范中,这些信息会被转换成Claims,保存在User Identity的对象里;ApiResource用来指定被IdentityServer4所保护的资源,比如这里新建了一个ApiResource,用来保护Weather API,它定义了自己的Scope和UserClaims。Scope其实是一种关联关系,它关联着Client与ApiResource,用来表示什么样的Client对于什么样的ApiResource具有怎样的访问权限,比如在这里,我定义了两个Client:angular和webapi,它们对Weather API都可以访问;UserClaims定义了当认证通过之后,IdentityServer4应该向请求方返回哪些Claim。至于Client,就比较容易理解了,它定义了客户端能够以哪几种方式来向IdentityServer4提交请求。
至此,我们的源代码就可以编译通过了,成功编译之后,还需要使用Entity Framework Core所提供的命令行工具或者Powershell Cmdlet来初始化数据库。我这里选择使用Visual Studio 2019 Community中的Package Manager Console,在执行数据库更新之前,确保appsettings.json文件里设置了正确的SQL Server连接字符串。当然,你也可以选择使用其它类型的数据库,只要对ConfigureServices方法做些相应的修改即可。在Package Manager Console中,依次执行下面的命令:
Add-Migration ModifiedUserAndRole -Context ApplicationDbContext
Add-Migration ModifiedUserAndRole –Context PersistedGrantDbContext
Update-Database -Context ApplicationDbContext
Update-Database -Context PersistedGrantDbContext
效果如下:
打开SQL Server Management Studio,看到数据表都已成功创建:
由于IdentityServer4的模板所产生的代码使用的是mock user,也就是IdentityServer4里默认的TestUser,因此,相关部分的代码需要被替换掉,最主要的部分就是AccountController的Login方法,将该方法中的相关代码替换为:
if (ModelState.IsValid)
{
var user = await _userManager.FindByNameAsync(model.Username); if (user != null && await _userManager.CheckPasswordAsync(user, model.Password))
{
await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.DisplayName)); // 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.Id, 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", clientId: context?.ClientId));
ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
}
这样才能通过注入的userManager和EntityFramework Core来访问SQL Server,以完成登录逻辑。
新用户注册API
由IdentityServer4所提供的默认UI模板中没有包括新用户注册的页面,开发者可以根据自己的需要向Identity Service中增加View来提供注册界面。不过为了快速演示,我打算先增加两个API,然后使用curl来新建一些用于测试的角色(Role)和用户(User)。下面的代码为客户端提供了注册角色和注册用户的API:
public class RegisterRoleRequestViewModel
{
[Required]
public string Name { get; set; } public string Description { get; set; }
} public class RegisterRoleResponseViewModel
{
public RegisterRoleResponseViewModel(AppRole role)
{
Id = role.Id;
Name = role.Name;
Description = role.Description;
} public string Id { get; } public string Name { get; } public string Description { get; }
} public class RegisterUserRequestViewModel
{
[Required]
[StringLength(50, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 2)]
[Display(Name = "DisplayName")]
public string DisplayName { get; set; } public string Email { get; set; } [Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; } [Required]
[StringLength(20)]
[Display(Name = "UserName")]
public string UserName { get; set; } public List<string> RoleNames { get; set; }
} public class RegisterUserResponseViewModel
{
public string Id { get; set; }
public string UserName { get; set; }
public string DisplayName { get; set; }
public string Email { get; set; } public RegisterUserResponseViewModel(AppUser user)
{
Id = user.Id;
UserName = user.UserName;
DisplayName = user.DisplayName;
Email = user.Email;
}
} // Controllers\Account\AccountController.cs
[HttpPost]
[Route("api/[controller]/register-account")]
public async Task<IActionResult> RegisterAccount([FromBody] RegisterUserRequestViewModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
} var user = new AppUser { UserName = model.UserName, DisplayName = model.DisplayName, Email = model.Email }; var result = await _userManager.CreateAsync(user, model.Password); if (!result.Succeeded) return BadRequest(result.Errors); await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.NameIdentifier, user.UserName));
await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Name, user.DisplayName));
await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Email, user.Email)); if (model.RoleNames?.Count > 0)
{
var validRoleNames = new List<string>();
foreach(var roleName in model.RoleNames)
{
var trimmedRoleName = roleName.Trim();
if (await _roleManager.RoleExistsAsync(trimmedRoleName))
{
validRoleNames.Add(trimmedRoleName);
await _userManager.AddToRoleAsync(user, trimmedRoleName);
}
} await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Role, string.Join(',', validRoleNames)));
} return Ok(new RegisterUserResponseViewModel(user));
} // Controllers\Account\AccountController.cs
[HttpPost]
[Route("api/[controller]/register-role")]
public async Task<IActionResult> RegisterRole([FromBody] RegisterRoleRequestViewModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
} var appRole = new AppRole { Name = model.Name, Description = model.Description };
var result = await _roleManager.CreateAsync(appRole);
if (!result.Succeeded) return BadRequest(result.Errors); return Ok(new RegisterRoleResponseViewModel(appRole));
}
在上面的代码中,值得关注的就是register-account API中的几行AddClaimAsync调用,我们将一些用户信息数据加入到User Identity的Claims中,比如,将用户的角色信息,通过逗号分隔的字符串保存为Claim,在后续进行用户授权的时候,会用到这些数据。
创建一些基础数据
运行我们已经搭建好的Identity Service,然后使用下面的curl命令创建一些基础数据:
curl -X POST https://localhost:7890/api/account/register-role \
-d '{"name":"admin","description":"Administrator"}' \
-H 'Content-Type:application/json' --insecure
curl -X POST https://localhost:7890/api/account/register-account \
-d '{"userName":"daxnet","password":"P@ssw0rd123","displayName":"Sunny Chen","email":"daxnet@163.com","roleNames":["admin"]}' \
-H 'Content-Type:application/json' --insecure
curl -X POST https://localhost:7890/api/account/register-account \
-d '{"userName":"acqy","password":"P@ssw0rd123","displayName":"Qingyang Chen","email":"qychen@163.com"}' \
-H 'Content-Type:application/json' --insecure
完成这些命令后,系统中会创建一个admin的角色,并且会创建daxnet和acqy两个用户,daxnet具有admin角色,而acqy则没有该角色。
使用浏览器访问https://localhost:7890,点击主页的链接进入登录界面,用已创建的用户名和密码登录,可以看到如下的界面,表示Identity Service的开发基本完成:
小结
一篇文章实在是写不完,今天就暂且告一段落吧,下一讲我将介绍Weather API和基于Ocelot的API网关,整合Identity Service进行身份认证。
源代码
访问以下Github地址以获取源代码: