OpenIddict身份验证与授权之: 二. 1(密码模式)验证服务器

密码模式(最简化示例)

为了简化步骤便于理解, 在密码模式下,我只生成两个项目,一个是验证服务器,一个是资源服务器,客户端用postman或者自带的swagger, 详情请看系列文章,此文章为密码模式中的验证服务器篇

OpenIddict 密码模式流程图

一. Nuget

 <ItemGroup>
   <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.31" />
   <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.31" />
   <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.31" />
   <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.31" />
   <PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.6.0" />
   <PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.6.0" />
   <PackageReference Include="OpenIddict.Server.AspNetCore" Version="5.6.0" />
   <PackageReference Include="OpenIddict.Validation.AspNetCore" Version="5.6.0" />
   <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
   <PackageReference Include="System.Linq.Async" Version="6.0.1" />
 </ItemGroup>

二. 实体类与数据库上下文

1.自定义的用户实体类,继承自IdentityUser

public class ApplicationUser : IdentityUser
{
}

2.自定义的角色实体类,继承自IdentityRole

 public class ApplicationRole : IdentityRole
 {
     public ApplicationRole() : base() { }
     public ApplicationRole(string roleName) : base(roleName)
     {
     }
 }

3.应用程序数据库上下文

//string:指定了用户ID和角色ID的数据类型。在这个例子中,ID被指定为字符串类型。
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, string>
{
    public DbSet<ApplicationRole>? ApplicationRoles { get; set; }
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        // 配置OpenIddict实体
        builder.UseOpenIddict();
    }
}

三. OpenIddict拓展

拓展源码

public static class OpenIddictExtension
{
    public static IServiceCollection AddOpenIddictServices(this IServiceCollection services, IConfiguration Configuration)
    {
        services.AddDbContext<ApplicationDbContext>(options =>
        {
            // 配置数据库提供程序
            options.UseSqlite(Configuration.GetConnectionString("DefaultConnection"));
            options.UseOpenIddict();
        });

        // 添加ASP.NET Core Identity配置
        services.AddIdentity<ApplicationUser, ApplicationRole>()
            .AddEntityFrameworkStores<ApplicationDbContext>()
            .AddDefaultTokenProviders();

        // 配置OpenIddict
        services.AddOpenIddict()
            // 配置 OpenIddict 核心组件
            .AddCore(options =>
            {
                options.UseEntityFrameworkCore()
                       .UseDbContext<ApplicationDbContext>();
            })
            // 配置 OpenIddict 服务器
            .AddServer(options =>
            {
                #region 模式
                //1.授权码模式
                options.AllowAuthorizationCodeFlow();
                //2. 简化模式
                //     适用于无法进行客户端认证的客户端,如纯前端应用。由于安全性较低,这种模式已不再推荐使用。
                //3. 密码模式
                options.AllowPasswordFlow();
                //4. 客户端凭据模式
                //options.AllowClientCredentialsFlow();
                #endregion
                
                #region 证书
                // 签名证书, 仅限开发环境使用
                //options.AddDevelopmentSigningCertificate();
                //options.AddDevelopmentEncryptionCertificate();

                // 使用自己的证书进行签名
                options.AddSigningCertificate(new X509Certificate2(Configuration.GetValue<string>("Certificate:Path"), Configuration.GetValue<string>("Certificate:PassWord")));
                options.AddEncryptionCertificate(new X509Certificate2(Configuration.GetValue<string>("Certificate:Path"), Configuration.GetValue<string>("Certificate:PassWord")));

                // 禁用 访问令牌加密
                //options.DisableAccessTokenEncryption();
                #endregion
                
                #region 其他必要的配置
                //token过期时间
                options.SetAccessTokenLifetime(TimeSpan.FromHours(6));

                // 设置令牌和授权端点
                options.SetTokenEndpointUris("/connect/token")
                       .SetAuthorizationEndpointUris("/connect/authorize")
                       .SetUserinfoEndpointUris("/connect/userinfo")
                       ;

                // 重要:配置与 ASP.NET Core 的集成
                options.UseAspNetCore()
                       .EnableAuthorizationEndpointPassthrough()//这个调用配置 OpenIddict 对于授权请求不要自己处理,而是直接传递给 ASP.NET Core。这意味着你需要在你的 ASP.NET Core 应用中自己实现授权端点的逻辑。
                       .EnableTokenEndpointPassthrough()//允许 OpenIddict 将令牌请求直接传递给 ASP.NET Core,你需要自己处理这些请求。
                       //.EnableLogoutEndpointPassthrough()//启用此选项可以让 OpenIddict 将注销请求直接传递给 ASP.NET Core,需要你自己实现注销逻辑。
                       ;
                #endregion
            })
            .AddValidation(options =>
            {
                // 配置受众验证
                options.AddAudiences("api");
            });
        return services;
    }

    //自动创建表
    public static IApplicationBuilder UseOpenIddict(this IApplicationBuilder app)
    {
        using (IServiceScope serviceScope = app.ApplicationServices.CreateScope())
        {
            //自动创建表
            var context = serviceScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

            // 先删除数据库
            context.Database.EnsureDeleted();

            // 再重新创建数据库
            context.Database.EnsureCreated();

            // 调用种子数据方法
            SeedEssentialsAsync(serviceScope.ServiceProvider).Wait();

            // 调用OpenIddict种子数据方法
            SeedOpenIddictAsync(serviceScope.ServiceProvider).Wait();
        }
        return app;
    }

    // 生成种子数据
    private static async Task SeedEssentialsAsync(IServiceProvider serviceProvider)
    {
        // 获取数据库上下文
        var context = serviceProvider.GetRequiredService<ApplicationDbContext>();
        // 获取用户和角色管理器
        var userManager = serviceProvider.GetRequiredService<UserManager<ApplicationUser>>();
        var roleManager = serviceProvider.GetRequiredService<RoleManager<ApplicationRole>>();

        // 添加角色种子数据
        if (!await roleManager.RoleExistsAsync("Admin"))
        {
            await roleManager.CreateAsync(new ApplicationRole { Name = "Admin" });
        }

        // 添加用户种子数据
        if (await userManager.FindByNameAsync("admin") == null)
        {
            var user = new ApplicationUser { UserName = "admin", Email = "123@qq.com" };
            await userManager.CreateAsync(user, "QWE@qwe123");
            await userManager.AddToRoleAsync(user, "Admin");
        }
        
    }

    // 生成种子api
    public static async Task SeedOpenIddictAsync(IServiceProvider serviceProvider)
    {
        // 获取OpenIddict的应用、作用域和授权管理器
        var applicationManager = serviceProvider.GetRequiredService<IOpenIddictApplicationManager>();
        var scopeManager = serviceProvider.GetRequiredService<IOpenIddictScopeManager>();


        // 检查并添加作用域
        if (await scopeManager.FindByNameAsync("api") == null)
        {
            await scopeManager.CreateAsync(new OpenIddictScopeDescriptor
            {
                Name = "api",
                DisplayName = "Access to API",
                Resources = { "resource_api_1" },
            });
        }

        #region 检查并添加API资源 (只需要token就可访问的客户端)
        if (await applicationManager.FindByClientIdAsync("clientid") == null)
        {
            await applicationManager.CreateAsync(new OpenIddictApplicationDescriptor
            {
                ClientId = "clientid",
                DisplayName = "Demo.Resources.WebApi",
                Permissions =
                {
                    OpenIddictConstants.Permissions.Endpoints.Token,
                    OpenIddictConstants.Permissions.GrantTypes.Password,
                    OpenIddictConstants.Permissions.GrantTypes.RefreshToken,

                    // 这里添加允许的作用域
                    OpenIddictConstants.Permissions.Prefixes.Scope + "api",
                    // 添加offline_access作用域
                    OpenIddictConstants.Permissions.Prefixes.Scope + OpenIddictConstants.Scopes.OfflineAccess, 
                },

            });
        }
        #endregion
    }

}

使用拓展

builder.Services.AddOpenIddictServices(builder.Configuration);

app.UseHttpsRedirection();

//种子数据
app.UseOpenIddict();

//下面是需要的,以便于访问端点
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
});

四.控制器

1.登录Dto

public class LoginDto
{
    public string username { get; set; } = "admin";
    public string password { get; set; } = "123546";
    public string grant_type { get; set; } = "password";
    public string client_id { get; set; } = "clientid";
    public string scope { get; set; } = "api";
}

2.控制器(以下视情况自主选择二选一)

2.1 token未加密

资源webapi未启用JWT加密, 在验证服务器的拓展中增加以下

// 禁用 访问令牌加密
options.DisableAccessTokenEncryption();

此种此情况,只能手动从数据库中获取 Resources ,详见官网介绍https://documentation.openiddict.com/configuration/claim-destinations.html

[ApiController]
[Route("connect")]
public class TokenController : ControllerBase
{
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly IOpenIddictScopeManager _scopeManager;
    private readonly ApplicationDbContext _context;
    public TokenController( SignInManager<ApplicationUser> signInManager,
        UserManager<ApplicationUser> userManager,
        IOpenIddictScopeManager scopeManager,
        ApplicationDbContext context)
    {
        _signInManager = signInManager;
        _userManager = userManager;
        _scopeManager = scopeManager;
        _context = context;
    }

    // 资源webapi未启用JWT加密,只能手动从数据库中获取 Resources https://documentation.openiddict.com/configuration/claim-destinations.html
    [Consumes("application/x-www-form-urlencoded")]
    [HttpPost("token1")]
    public async Task<IActionResult> GetToken_NoEncryption([FromForm] LoginDto loginDto)
    {
        var user = await _userManager.FindByNameAsync(loginDto.username);
        if (user == null || !await _userManager.CheckPasswordAsync(user, loginDto.password))
        {
            return BadRequest(new { message = "Username or password is incorrect." });
        }

        // 创建用户主体
        var principal = await _signInManager.CreateUserPrincipalAsync(user);

        // 确保添加了 sub 声明
        var identity = principal.Identity as ClaimsIdentity;
        identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, user.Id));

        var ticket = new AuthenticationTicket(principal,
            new AuthenticationProperties(),
            OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

        // 设置请求的作用域
        var requestedScopes = new[] { OpenIddictConstants.Scopes.OfflineAccess };
        requestedScopes = requestedScopes.Concat(loginDto.scope.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)).Distinct().ToArray();

        ticket.Principal.SetScopes(requestedScopes);

        // 根据请求的作用域动态设置资源(aud)
        foreach (var scope in requestedScopes)
        {
            var resources = await GetResourcesForScopeAsync(scope);
            foreach (var resource in resources)
            {
                // 这里我们直接将资源作为audience添加到声明中
                identity.AddClaim(new Claim(JwtRegisteredClaimNames.Aud, resource, ClaimValueTypes.String, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme));
            }
        }

        // 为所有声明设置目的地为访问令牌
        foreach (var claim in identity.Claims)
        {
            // 防止ASP.NET Core Identity的角色和策略声明被添加到访问令牌中
            if (claim.Type != ClaimTypes.Name && claim.Type != ClaimTypes.NameIdentifier)
            {
                claim.SetDestinations(OpenIddictConstants.Destinations.AccessToken);
            }
        }

        return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
    }

    private async Task<IEnumerable<string>> GetResourcesForScopeAsync(string scope)
    {
        // 从数据库查询与指定作用域相关联的资源列表
        var resources = await _context.Set<OpenIddictEntityFrameworkCoreScope>()
            .Where(s => s.Name == scope)
            .Select(s => s.Resources)
            .FirstOrDefaultAsync();

        if (!string.IsNullOrEmpty(resources))
        {
            var resourceList = System.Text.Json.JsonSerializer.Deserialize<List<string>>(resources);
            return resourceList ?? Enumerable.Empty<string>();
        }

        return Enumerable.Empty<string>();
    }
}

2.2 token加密(证书加密)

此种情况会自动增加aud

[ApiController]
[Route("connect")]
public class TokenController : ControllerBase
{
    private readonly SignInManager<ApplicationUser> _signInManager;
    private readonly UserManager<ApplicationUser> _userManager;
    private readonly IOpenIddictScopeManager _scopeManager;
    private readonly ApplicationDbContext _context;
    public TokenController( SignInManager<ApplicationUser> signInManager,
        UserManager<ApplicationUser> userManager,
        IOpenIddictScopeManager scopeManager,
        ApplicationDbContext context)
    {
        _signInManager = signInManager;
        _userManager = userManager;
        _scopeManager = scopeManager;
        _context = context;
    }
    
    [Consumes("application/x-www-form-urlencoded")]
    [HttpPost("token")]
    public async Task<IActionResult> GetToken_Encryption([FromForm] LoginDto model)
    {
        var user = await _userManager.FindByNameAsync(model.username);
        if (user == null || !await _userManager.CheckPasswordAsync(user, model.password))
        {
            return BadRequest(new { message = "Username or password is incorrect." });
        }

        // 创建用户主体
        var principal = await _signInManager.CreateUserPrincipalAsync(user);

        // 确保添加了 sub 声明
        var identity = principal.Identity as ClaimsIdentity;
        identity.AddClaim(new Claim(OpenIddictConstants.Claims.Subject, user.Id));

        // 设置作用域
        principal.SetScopes(new[]
        {
            OpenIddictConstants.Scopes.OpenId,
            OpenIddictConstants.Scopes.Profile,
            OpenIddictConstants.Scopes.OfflineAccess,
            "api"
        });

        // 设置资源
        principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());

        // 设置声明目的地
        principal.SetDestinations(claim =>
        {
            switch (claim.Type)
            {
                case Claims.Name when claim.Subject.HasScope(OpenIddictConstants.Scopes.Profile):
                    return new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken };

                case "secret_value":
                    return Array.Empty<string>();

                default:
                    return new[] { OpenIddictConstants.Destinations.AccessToken };
            }
        });

        var ticket = new AuthenticationTicket(principal,
            new AuthenticationProperties(),
            OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

        return SignIn(ticket.Principal, ticket.Properties, ticket.AuthenticationScheme);
    }
}

五.客户端发送请求

根据扩展中的种子数据, 根据自身编程语言或者用postman发送如下ajx请求

curl -X 'POST' \
  'https://localhost:7097/connect/token' \
  -H 'accept: */*' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'username=admin&password=QWE%40qwe123&grant_type=password&client_id=clientid&scope=api'

返回结果如下(就是很长….很长), 此时就可以用access_token访问资源服务器了

{
  "access_token": "eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZDQkMtSFM1MTIiLCJraWQiOiJEODFDODgyQ0RDNTg3RkQ5RjlCQjNGQzg3OEY4RDg0MEZCMTlBMjQ4IiwidHlwIjoiYXQrand0IiwiY3R5IjoiSldUIn0.CijmUD9Wxosv1aaQjgEosG6GliK1s9lgBdZVl60Nx4F4W9HQ_8Wr1P8klQVS-NG9k9P7hcyjSJY64CXnpN57ZvTXdc2F7dKvYeNsKgaQRn7yGPwlcOUOgks02BuizIsw9Pl6_lDVHwJcecqcpRh3ec2MWB-aB21N0UA7fabFi4YHV7pzr_Mr0A8Krdxu8Xe0UC8f51Tk5tawAQA3rqwXm3ZXpDN8EzJxWkeiPmVpYEBKuoI6aouK4GgprMYJqL3-DBPbt5cT8-mPGlEfyQRhTrq5uxd-y6arbVXRbuJIhmTZIixdAC-gab8zSplwcc-IfTEUKHEk66YV7i1AzveQqQ._TEuhLFw7LInL7u6twY8Zg.FOaBEVwRNp3JKkc4rwSu7Ggm_vLkXYYQmWrY6RFBeE5SFDwOxxdcIdx-vBdwmBrIvCBVlKV5DEEhyjuI4p2XHANNn7ObIpvJnKYwZJm5_dM4AGE1-ZdYgL8o0v0O0ZnufU07zav9zcOtsXcujBV_sdYEEdDvW2-x0xc4so_z3dh4Z4PwsvYf2TiTqKPqjKF9lzcjjEzIGEMIDM-lEP7hLwd1Z200-Tqs8PeaFy1yXdx0jN5cOzCO_ehSTKo4IAxpuw5GUMJjjqQI25F5JaHEhctHbLQpc90ghrDYqGTeEDuVC-hgLz58b-ctw-iINtBJKFFQSCjyIFje4nz7dq2Ek_9pjPnC7RVk6MTg7utGEaI3altvUUJHnPznkBaFazOFZbWrVDC0Uy097WrNbk-GS1rij3YjvWQ5780BtpF8O7MDE_gnJRI_fUNQHO9AKU9Pi-GCIT8DRQmeNEBzGXcSh-NdF6rmHEJQuUq57Awan-aYBmVtDZRSm_zb9PbmccqZKcf9y-ahvlEXwnWU6QLU_herm3mDEmknexLw8X8y6M1pCPhqGaUE-flVk3aIU6mmJ2o9wvG_mQsxF7BAPA2AUVfiBCsCgK2rnAfuo-PU4A1qres6GXFU0TXkWvyZkLZ3O0cPW21nQLHwUAH1rfCMwMIg5vptUOts2CpcqKLNcFm3U5lbMGB-o15v7VDW_ioF88VKDzLi0kU_k89GVmmD4tzlhxEqu9fOhOHU49aBR7b7zV0eUlfI0ubNRZuox6ODPYLKor_7zn-JhPz4b_D32RhdyvTiXNzMotdahqjCWAkUKOWhRXQbZIbi0Bo3VnlQsg3HhIToZpLsfQOByynJpY4knH_A2k5BfOIw0CliVGkkM_QETJT5051lyfzixeV2_-bEHRj9MzHJqSlyK5ldZZDtcP8T456da2kp9TAc6s_wUYeiha7NbtbGOutFHdhGxHxiwja0QkVYAtGx3Gq3KXxrTeWb_VPSJz27RQk_hCH2RJQdfdR_UzEvKe-M4ncbDNPjCeJRHV7WGTeQDKYSnSqg6ZYVsXL5OD1QQxx7BYFXh681XAXOwpLAWu4OOa9brF2PTGC86DdyzmgCTsG8teju3N5gOepngEjgBz6XtV7zPUjBoCH6uegExgLgbjFZqbJcaRpzcb5RYGHis0mpdT9VzrExVANNaRqYTBfNdzynTsZmszkrB0SQKp4sPyg_NDupeNl9SGZRsPW8WhvItxAWE5g4fkuU0vVdsO6BxMG-qN473wwOPJVqS0kP0rLp0CLg3adPZtuSja0SItoXhBXiNf94ehDS4n90PrjL93L7huVZ4_NaMHOGk7CJ73H9hLRMKhRdcqFXVWDbxoseo-fBXLqLXMvNmk9_FSZMzaOKvMZmZddMk8nCAxwlAoHntSw_yU7-JUoNszk1qZofHYBqCJDwQFcTEMBPd18csYhXQiumRQ0HMQInVsVFBPNqPeR3sMpqPXW2xxfcbLnwBqTpbQrkvLDwFpfXcZ14RPnyTb-Ho5Pg-DR9p06cvfYq77SENayRaDhgMehhKlF0MIPycw2ob0e6S0cAxMxRzOgvMzm7wR0D4Y-RA9ZrqR8_s2tQPLJM8mdsTGRemsTsYkhwljw8ye-8zIHA3YeMahhCBUgHvDAW8QXDZPa5myxWnWC7gCL3q_fv3QAgFbgFGcWjQqg1dvxgQwHap14BjvWpa_RL3CmP7zr1fNoZdUi8thrSYOc6RG2zd1_wB-SuD_Sv4sofzOND0FG8r4ziq1V-IfS8Irio-HaOUdUYjye01Y0OhlgZDHesL0ZszWO1jReIQF76S9JtrVlAJTZTnWGEquJ7x7loZplD1tuxnvnBE5ZhBnuJEfLlWj8TXeAUQmDzYYIfKyw1jA9fAL1W7s_fQpJtYmrgEbY-kdToK-HOnxKJfJIZBoL7Vf-EQdWGDsDYL0EbULrSGWPmlCEb9dEZxPzffbRzY2Fi1O7DDp46C7fR33U7V5mkHycLWOvoG6_WQFTiA3j8HY_oIGLHE8ikDETGbtEMo0UBX5Cb14HQ.FIagVnKoIoUCO0qWtZf7mYGLOACWbSG2O0OcvR_xF5E",
  "token_type": "Bearer",
  "expires_in": 21599,
  "scope": "openid profile offline_access api",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkQ4MUM4ODJDREM1ODdGRDlGOUJCM0ZDODc4RjhEODQwRkIxOUEyNDgiLCJ4NXQiOiIyQnlJTE54WWY5bjV1el9JZVBqWVFQc1pva2ciLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDk3LyIsImV4cCI6MTcxNzQ5NDM2OCwiaWF0IjoxNzE3NDkzMTY4LCJhdWQiOiJjbGllbnRpZCIsInN1YiI6IjQ0ZTE3ZDE2LTgxOGUtNDJlMi04OTllLTAxODIzZWVhZmE0YiIsIm9pX2F1X2lkIjoiOGYwZDliNDEtMTQ3Ny00Zjk0LWIxOGUtOTZhNTdiNDgzYWE3IiwiYXpwIjoiY2xpZW50aWQiLCJhdF9oYXNoIjoiNy14amd0Y3dHM1NwbUhBYXhkNzFXQSIsIm9pX3Rrbl9pZCI6IjU5MjhmNjJkLTE2YzItNGUwNS1hODc2LTM3MDY2NjZlZTIxMSJ9.rH1MATGfGmy-k9l_W4TNIjpbKjkcTON2j_oGWQhaKpCvhP723FZ8b7ahBkX8ITByBadV_WEXb8y04NXNDAELh0e15S5tllKkUNMSkrgcQK3l5bh5LXb9nuKUtO2HrQfg3Wkavgu2caK7nzUj6fmh0vahnBOdoTXny1xFYLJP79UAdLKwNQ1Vpnvk6RttEG69SbAySAelE0XajocshmuAK2TVwVOBBNB4V16evjzD-alzDmr312r8RlCnul6AzSkpMf_TZ4-aYbhpdt6CZA8wqvWT-SioLlGB89PEN6-LKrSD0NtKN3wWMUTv-fVyXtFpJyd-SIGv9hYUHM1qgttTAA",
  "refresh_token": "eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZDQkMtSFM1MTIiLCJraWQiOiJEODFDODgyQ0RDNTg3RkQ5RjlCQjNGQzg3OEY4RDg0MEZCMTlBMjQ4IiwidHlwIjoib2lfcmVmdCtqd3QiLCJjdHkiOiJKV1QifQ.EUXrtSTDQ3NstYhpAYaugHs73gW2LZeuqRwGftuxVScLMm6u8VL1MSkXgaOYNclyPeeaaopfja8tIv6-ms9K2Kj-8M9EcOfPia_-daLWJM8fgH9_mOb_5AY_khLRWSnT5vV3sTvokDY6glSd7Egqlvlkrt-lMRRBxKJa9zh8Fswp68IYImtjnlHwMqC_BXkHPKMTVNGsRBAGVBd1p_FhPQjes_p9tR_jcf1s3u3ekQhaYo9-qg-WzfImDXbsLoW9o70zgT_I0k605NhxBxiEf_3kLHYogWsg5pdicZy8yPUwuOhtNu1sY-2QWPzmxq7lOWGteclThjGlh4ptzubeAw.vmNtqW7nQ95FuTHrkQnGSQ.jFiSeMwY8GxZjugGo_69H-S63F2zj2iwdvzQd_5MEZZudvRpF3OHV9n3tVcA_FKSXmo3H7Tl7jOdLE6oMWL5EjCM5ik0MU7hBN4IB-HOCJhEBC7VTmEF68HdqC7xbJX55rPNK2F4fna1cFAc0wr0w5eGaIccITpSoeqOPh2rmapjQw53lb_EY15D2XgRHiXX7qiw6-fBUBFjqI3J3eOcNWQC2Jg1JsN1okhvD5AVSRHjhzb4AwUXGTrV2xanm_p0YfQMohaObMQsOCZLazLCCWIYyA9LFJnyy6kV8pOpoyMh2CRiCB0ahBWf9cKYqpBbPMlls8g6_KUA_6TGgXwxxGMYL7RCu_B4NZtne6wnMCSAztO8Fu9swTREJOhygiAlW-eTTICuX3dyNC7QSjCk4ISdZvGXaK5UeBxMvVD85_QikAO2jqDxFk2O_xQEvHAZ9mXRiPtrcGedvUipo49q30abMCKW572lucahUTXQF_v_Y-OQ7Y0OVZr6KtkOpu71Ot-_8OBpKxMscGx3RqugQITLgShjLIrkCwSxykoCADKtu-ySIadjMViYW0sl4Xfdk4a-IFWeYfCYfCbXFzBxfNafuK9kRWZT2eo5poT-o17wmhI747s75FY6E7c0p9HFDbcxZ_U3KYzMlr6WrexVJqqb4qHO9CNPwxGlhP0WM3F0_JF44Drdcm2nDzyHurE2PmhagTaAAt-0qb2vz0jwdEZDj7vY93pShMoIQW3W_e_2NDAnBLMcZgANs5Oh8pHEbsaU7TgghTwqMgwnyS5uYqcEyqfRPCpoHXGbvrmxeiN1ODlsw0To7qk9NfAOPAV7z_CP7MCEHkpTJgI9cIYt9PEK_fxN2Bq8D0tyiah3Sc2TFVqvH5DbcgdaIG5wpV_S6pc55q-iU7lSgBzJziQiURHEJj1v8j_hP0n4HK9IADw6umEqNsz2KlpKWGMubDoh1ZwibNiweqwzH7C09kHMhlHifgjP7oLe5LGLagZyYD6BV9Z0io5vgl7imZrUiIxhA3vWZrrrZGi9j9KQQrkDuZxNuiUSW66-o13mJ_Ki3Z5kwA8gBssL27oAitL3l_rlhOhjcsACIU9AdA2eVu5xeijf_a4QqQEx8l2sbAzgNLJr4mOaYuUFQlPjFUaw30LXhf9VEa8ks8AVMhjRVtrfEekArrCBvUIrza5fzj-ofICtLoPRbd7K3fxT2NVB57zLra4YYchM5Cqgdqg26ETOpvUDQM8_G55Bw-zYWRQe6JmSpjj8_UBE1EE9L7pWy_6xFxk1Hci1G1dITdthPFlanQnzpX71fHLI69C_I71zrr_On5XIyjZHeGB7Fo2GPV_uurKuB_mmjMKuofpJ-fxuNPbDNUVO2qNg_mfC_aJIP8gCvetsrV-Zpv090hBewMnBrg6nh1rfRatFxKgCv7ZF-SWAb7MqHM4ngoZBoUdVy1gLFfQmmMPWsEB3P4A5tnOEornWAeEsYE92HAtD6AY0W6nSyAIdfHQURq_5DgVxtjqmrBDkYhc0DZmdcSSB4CuCQN7EQKrADAyw48jlldbKMZEDnhcUW855Hru37nf53z5JCp13b8extVI9Fb2ZNkxWhm3vvqOrxPjHGlONO7MesGzxYbmK76XtJzOcDStDydYR9nX7V4OR6zbgjTN517FSxcD9VMOc1r-DYo28K5a9sDOvoyasHIsvKDZlx5j472Vtm1q9JvcG-j1djbkfu462ymD0CjVZss_aGHl8Wn2t3ZFk44s5A1ceRY39jfC4iYzBl3ysTbmJF3uPRc-fZEeAMa239hBhWDOgi8f4udbLcHxbz3QEQBDMgsxTLpJKLfXL4ofJZy6BKdHQtMqokRKvTYTTIPtzoKJNgXycdD88o-C-I8MK8orLTN2reLjSevvg8fKR81rlWWoT1JN3pMzbmsns3Bi4AD1sA-5ZqWtyZiG1_KarZCwhvfe3iH-5PQaMVqOZoluwW9Y0CK9khfoB2gGDEUM7fqAB6ES_3oOA_CbXR6xh39BBQS--Qa1sOU_xaE7HTeL0S_QswHgUb8OJzvybaD19jE8eCFlPb1xkHIN7rDeqjWhtYs_RMmJUHkTzOjhCT6icaPZD8Q7aw0E0PW37K8f25lRNvwzlt-MI8K37aypoxwk0q4A2KKd5EWnPG-tNNQtcxRdIzQtZ7s3_G_TVMeJRiv86JKNXk_MrMFMJ6Ys7N_QcO4dtjFQTWdwag-tnm8j1fsB8AZFtUgLzsSyIKj2WfJQ9cw00UJggx14e5EggbvOYnMdMn7EC56dLyP_Ew1r4rciCyx4RPsk8r0djTXzU5HJ-Af3YM1yvn0-NMcsTky73E2LigXwdEIF4ohQjEzJEbuIGhbPCd8S0C2WZc7XRd6-ki-SHMCSbn87OMI5jsVHGX_8gFALaEqT02PHnMHHo5Z3XHjGck2TfI2kpBQ9q3UPomI0R6U7YR1w0CoPwFzKNosFh6y1d6T-ulpMIMOM3LoyZo3AvWb9c0K6Tnixky1S_5ALsIeg01dOVUSPYwTOI8Jw493cGdQ6SA6NvvZMCw20nAS2jRc_0e3QU6EAtN8srnVw7pzXysE939uVxbzhMyzStmWYH_KtV0tXC7uGam6x88V04ee-Um2oejVz6HbTSQQZJlE76ZUB5MC9cggk_DME1r1otWU2NuxPKtZZkeDpA1Xv6pDYarVLrI5QEXJB8xeh-upWepsQlJ3j9HPTC2NK5RQwp64c7DHqfGZ8WYY2N6-d3XLT7X6rpxxUT20ZeAkKyZ6Se9KljJWa4X8l5LsaO3ZidHBI.kQ_u2mD3-p2GUlKi3S4MxylC-tJhj1MmBf-70PPb35I"
}