Angular SPA基于Ocelot API网关与IdentityServer4的身份认证与授权(一)

By | 2020年3月1日

好吧,这个题目我也想了很久,不知道如何用最简单的几个字来概括这篇文章,原本打算取名《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网关同一协调,以从业务层面为前端提供各种服务。大致可以用下图表示:

image

在这个结构中,我没有将Identity Service放在API Gateway后端,因为考虑到Identity Service本身并没有承担任何业务功能。从它所能提供的端点(Endpoint)的角度,它也需要做负载均衡、熔断等保护,但我们暂时不讨论这些内容。

流程上其实也比较简单,在上图的数字标识中:

  1. Client向Identity Service发送认证请求,通常可以是用户名密码
  2. 如果验证通过,Identity Service会向Client返回认证的Token
  3. Client使用Token向API Gateway发送API调用请求
  4. API Gateway将Client发送过来的Token发送给Identity Service,以验证Token的有效性
  5. 如果验证成功,Identity Service会告知API Gateway认证成功
  6. API Gateway转发Client的请求到后端API Service
  7. API Service将结果返回给API Gateway
  8. 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的依赖包都加入项目,并且自动完成基础代码的搭建。

image

然后,通过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

效果如下:

image

打开SQL Server Management Studio,看到数据表都已成功创建:

image

由于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的开发基本完成:

image

小结

一篇文章实在是写不完,今天就暂且告一段落吧,下一讲我将介绍Weather API和基于Ocelot的API网关,整合Identity Service进行身份认证。

源代码

访问以下Github地址以获取源代码:

https://github.com/daxnet/identity-demo

Print Friendly, PDF & Email
(总访问量:205;当日访问量:1)

2 thoughts on “Angular SPA基于Ocelot API网关与IdentityServer4的身份认证与授权(一)

  1. Pingback: Angular SPA基于Ocelot API网关与IdentityServer4的身份认证与授权(二) – sunnycoding

  2. Pingback: Angular SPA基于Ocelot API网关与IdentityServer4的身份认证与授权(三) – sunnycoding

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据