Radiofisik

my knowledge base

Identity

IdentityServer4 - это реализация протокола OpenId. Она предоставляет следующие ендпойнты:

К регистрации пользователей и хранению учетных данных IdentityServer4 отношения не имеет, для этого обычно используется Identity.

Реализуем простейшую реализацию авторизации через OpenId.

Создадим проект который будет реализовывать Resource Owner Paasword Flow OpenId. Создадим проект, добавим логирование (Serilog, ElasticSearch), Autofac. Далее установим пакеты

dotnet add package IdentityServer4

В простейшем случае для того чтобы заработал сервер аутентификации небходимо добавить (код взят из примеров IdentityServer4)

services.AddIdentityServer()
                .AddInMemoryIdentityResources(Config.GetIdentityResources())
                .AddInMemoryApiResources(Config.GetApis())
                .AddInMemoryClients(Config.GetClients())
                .AddTestUsers(Config.GetUsers())
                .AddDeveloperSigningCredential();


app.UseIdentityServer();

где config

 public static class Config
    {
        public static List<TestUser> GetUsers()
        {
            return new List<TestUser>
            {
                new TestUser
                {
                    SubjectId = "1",
                    Username = "alice",
                    Password = "password"
                },
                new TestUser
                {
                    SubjectId = "2",
                    Username = "bob",
                    Password = "password"
                }
            };
        }

        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new IdentityResource[]
            {
                new IdentityResources.OpenId()
            };
        }

        public static IEnumerable<ApiResource> GetApis()
        {
            return new List<ApiResource>
            {
                new ApiResource("api1", "My API")
            };
        }

        public static IEnumerable<Client> GetClients()
        {
            return new List<Client>
            {
                new Client
                {
                    ClientId = "client",

                    // no interactive user, use the clientid/secret for authentication
                    AllowedGrantTypes = GrantTypes.ClientCredentials,

                    // secret for authentication
                    ClientSecrets =
                    {
                        new Secret("secret".Sha256())
                    },

                    // scopes that client has access to
                    AllowedScopes = {  IdentityServerConstants.StandardScopes.OpenId, "api1" }
                },
                // resource owner password grant client
                new Client
                {
                    ClientId = "ro.client",
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,

                    ClientSecrets =
                    {
                        new Secret("secret".Sha256())
                    },
                    AllowedScopes = { "api1" }
                }
            };
        }
    }

В принципе это уже работоспособный сервер, на нем уже можно аутентифицироваться. Но надо спрятать его за прокси.

location ~ ^/auth/ {
    rewrite ^/auth/(.*)$ /$1 break;
    proxy_pass  http://debughost:5005;
}

таким образом сервер будет доступен по адресу http://docker/auth/.well-known/openid-configuration но в ответ он выдает адреса как будто он доступен напрямую. Для того чтобы с этим что-то сделать надо знать на какой url шел запрос на первую прокси от польлзователя. Добавим в прокси конфиг который кладет этот адрес в заголовок.

 	set $url $http_X_real_base_url;

    if ($http_X_real_base_url = "") {
        set $url $scheme://$http_host/auth;
    }

    proxy_set_header X-real-base-url $url;

Чтобы IdentityServer понимал этот url добавим MiddleWare

 public class BaseUrlMiddleWare
    {
        private readonly RequestDelegate _next;

        public BaseUrlMiddleWare(RequestDelegate next)
        {
            _next = next;
        }

        public async Task Invoke(HttpContext context)
        {
            var realBaseUrl = context.Request.Headers["X-real-base-url"];
            var url = realBaseUrl.FirstOrDefault();

            if (url != null)
            {
                Uri uriAddress = new Uri(url, UriKind.Absolute);

                context.Request.Scheme = uriAddress.Scheme;
                context.Request.PathBase = new PathString(uriAddress.LocalPath);
                context.Request.Host = new HostString(uriAddress.Host, uriAddress.Port);
                context.SetIdentityServerBasePath(uriAddress.LocalPath);
            }

            await _next(context);
        }
    }

теперь запрос discovery http://docker/auth/.well-known/openid-configuration выдает приличны результат

{"issuer":"http://docker:80/auth","authorization_endpoint":"http://docker:80/auth/connect/authorize","token_endpoint":"http://docker:80/auth/connect/token","userinfo_endpoint":"http://docker:80/auth/connect/userinfo","end_session_endpoint":"http://docker:80/auth/connect/endsession","check_session_iframe":"http://docker:80/auth/connect/checksession","revocation_endpoint":"http://docker:80/auth/connect/revocation","introspection_endpoint":"http://docker:80/auth/connect/introspect","device_authorization_endpoint":"http://docker:80/auth/connect/deviceauthorization","frontchannel_logout_supported":true,"frontchannel_logout_session_supported":true,"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"scopes_supported":["openid","api1","offline_access"],"claims_supported":["sub"],"grant_types_supported":["authorization_code","client_credentials","refresh_token","implicit","password","urn:ietf:params:oauth:grant-type:device_code"],"response_types_supported":["code","token","id_token","id_token token","code id_token","code token","code id_token token"],"response_modes_supported":["form_post","query","fragment"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"code_challenge_methods_supported":["plain","S256"],"request_parameter_supported":true}

Проверка токена

Приложение сейчас доступно напрямую по ссылке http://docker:8081/swagger/index.html и внутри докера по http://app/swagger/index.html Вынесем его за прокси чтобы адрес был http://docker/api/app/swagger/index.html и внутри докера по http://app/swagger/index.html

location ~ ^/api/(?<service>[\.a-zA-Z0-9_-]+)/(.*)$ {
    set $upstream_endpoint http://${service}:80;
    rewrite ^/api/([\.a-zA-Z0-9_-]+)/(.*) /$2 break;
    proxy_pass $upstream_endpoint;
}

для отладки переопределим конфиг менее общим, который редиректит на приложение запущенное в Visual Studio и проверим аутентификацию

location ~ ^/api/app/(.*)$ {
	rewrite ^/api/app/(.*) /$1 break;
	proxy_pass http://debughost:5000;
	 access_by_lua '
	 local opts = {
		 discovery = "http://192.168.1.103:5005/.well-known/openid-configuration"
	 }

	  	-- call bearer_jwt_verify for OAuth 2.0 JWT validation
          local res, err = require("resty.openidc").bearer_jwt_verify(opts)

           if err or not res then
            ngx.status = 403
            ngx.say(err and err or "no access_token provided")
            ngx.exit(ngx.HTTP_FORBIDDEN)
          end
	 ';
}

Для удобства использования вынесем работу с аутентификацией через lua в отдельный файл

local module = {};

module.opts = {
    discovery = "http://192.168.1.103:5005/.well-known/openid-configuration"
}

function module.authorize(ngx)
    local res, err = require("resty.openidc").bearer_jwt_verify(module.opts);

    if err or not res then
        ngx.status = 403
        ngx.say(err and err or "no access_token provided")
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end

    ngx.req.set_header("X-TOKEN-VERIFIED", tostring(true));

    for name, value in pairs(res) do
        ngx.req.set_header("X-USER-"..name, value);
    end
end

function module.fillClaims(ngx)
    local res, err = require("resty.openidc").bearer_jwt_verify(module.opts);

    if err or not res then
        ngx.req.set_header("X-TOKEN-VERIFIED", tostring(false));
        ngx.req.set_header("X-USER-ISADMIN", tostring(false));
    else
        ngx.req.set_header("X-TOKEN-VERIFIED", tostring(true));

        for name, value in pairs(res) do
            ngx.req.set_header("X-USER-"..name, value);
        end
    end
end

return module;

теперь использование в конфиге упростится до

location ~ ^/api/app/(.*)$ {
    set $upstream_endpoint http://debughost:5000;
	rewrite ^/api/app/(.*) /$1 break;
	access_by_lua 'require("auth").fillClaims(ngx);';
	#access_by_lua 'require("auth").authorize(ngx);';
    proxy_pass $upstream_endpoint;
}

Настройка авторизации в Swagger

Swagger поддерживает аутентификацию, для того чтобы ее добавить надо добавить

 services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new Info { Title = "My API", Version = "v1" });

                c.AddSecurityDefinition("oauth2", new OAuth2Scheme
                {
                    Type = "oauth2",
                    Flow = "password",
                    TokenUrl = "http://docker:80/auth/connect/token",
                    Scopes = new Dictionary<string, string>{}
                });
                c.AddSecurityRequirement(new Dictionary<string, IEnumerable<string>>
                {
                    { "oauth2", new string[] { } }
                });
            });

и

 var baseUrl = "/api/app";

            app.UseSwagger(c=>c.PreSerializeFilters.Add((doc, req) =>
            {
                doc.BasePath = baseUrl;
            }));
         
            // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), 
            // specifying the Swagger JSON endpoint.
            app.UseSwaggerUI(c =>
            {
                c.SwaggerEndpoint($"{baseUrl}/swagger/v1/swagger.json", "My API V1");
                c.OAuthClientId("client");
                c.OAuthClientSecret("secret");
                c.OAuthRealm("test-realm");
                c.OAuthAppName("test-app");
                c.OAuthScopeSeparator(" ");
                c.OAuthUseBasicAuthenticationWithAccessCodeGrant();
            });

ASP Net Identity

IdentityServer 4 не имеет встроенного функционала по поддержанию базы данных пользователей. Для этого используется AspNet Identity. Создадим классы пользователя, роли, контекста

public class ApplicationUser: IdentityUser<Guid>
    {
    }
    
  public class ApplicationRole: IdentityRole<Guid>
    {
    }
    
public class ApplicationDbContext: IdentityDbContext<ApplicationUser, ApplicationRole, Guid>
    {
        private readonly Connections _connections;

        public ApplicationDbContext(Connections connections)
        {
            _connections = connections;
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
            => optionsBuilder.UseNpgsql(_connections.DBConnectionString, options=>options.MigrationsHistoryTable("__EFMigrationsHistory", "auth"));

        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.HasDefaultSchema("auth");
            base.OnModelCreating(builder);
        }
    }

Зарегстрируем Identity и контекст БД

  services.AddIdentity<ApplicationUser, ApplicationRole>()
                .AddEntityFrameworkStores<ApplicationDbContext>()
                .AddDefaultTokenProviders();
  services.AddDbContext<ApplicationDbContext>();

Добавим миграции и смигрируем БД. Теперь сделаем простейший контроллер для регистрации пользователя

[Route("account")]
    public class AccountController: BaseController
    {
        private readonly UserManager<ApplicationUser> _userManager;

        public AccountController(UserManager<ApplicationUser> userManager)
        {
            _userManager = userManager;
        }

            [HttpGet("do-something")]
            public async Task<IActionResult> DoSomething(string email, string password)
            {
                var user = new ApplicationUser()
                {
                    Email = email,
                    UserName = email
                };

                var result = await _userManager.CreateAsync(user, password);

                return Result(new Success<bool>(result.Succeeded));
            }
    }

Осталось подключить IdentityServer4 к Identity. Для этого надо установить пакет IdentityServer4.AspNetIdentity и добавить

  .AddAspNetIdentity<ApplicationUser>()

Claim Based аутентификация

В Asp.Net Identity включена возможность аутентификации по Claim’ам. для того чтобы добавить пользователю Claim можно выполнить следующий код

await  _userManager.AddClaimAsync(user, new Claim("Admin", "true"));

Для того чтобы внедрить в токен этот Claim добавим специального клиента IdentityServer4

Создадим клиента, в скопах которого добавим ресурс administration

 public static Client AdministrationClient(int lifetime) => new Client
        {
            ClientName = "administration_client",
            ClientId = "administration_client",
            ClientSecrets =
            {
                new Secret("administration_client-secret".Sha256())
            },

            AllowedGrantTypes = { GrantType.ResourceOwnerPassword },
            AccessTokenType = AccessTokenType.Jwt,

            AllowOfflineAccess = true,
            AccessTokenLifetime = lifetime,
            AllowAccessTokensViaBrowser = true,
            RequireConsent = false,
            AllowedCorsOrigins = Origins.Urls,

            RefreshTokenExpiration = TokenExpiration.Absolute,
            RefreshTokenUsage = TokenUsage.OneTimeOnly,

            AllowedScopes = new List<string>
            {
                IdentityServerConstants.StandardScopes.OpenId,
                IdentityServerConstants.StandardScopes.Email,
                IdentityServerConstants.StandardScopes.OfflineAccess,
                "administration"
            }
        };

Определим ресурс в скопе ` .AddInMemoryApiResources(GetApiResources())`

public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource()
                {
                    Name = "administration_api",
                    ApiSecrets = { new Secret("administration_api_secret".Sha256()) },
                    UserClaims = {
                       "IsAdmin"
                    },
                    Description = "Administration API",
                    DisplayName = "Administration API",
                    Enabled = true,
                    Scopes = { new Scope("administration") }
                }
            };
        }

Для получения информации о пользователях из Identity для IdentityServer4 надо реализовать интерфейс IProfileService который включает два метода. один для проверки активен ли пользовтель

 public async Task IsActiveAsync(IsActiveContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await _userManager.FindByIdAsync(sub);
            context.IsActive = user != null;
        }

Второй для получения информации о нем. В него при запросе Claim прилитит что мы просим в context.RequestedClaimTypes

   public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await _userManager.FindByIdAsync(sub);
            var principal = await _claimsFactory.CreateAsync(user);

            var claims = principal.Claims
                                  .Where(claim => context.RequestedClaimTypes.Contains(claim.Type))
                                  .ToList();

            claims.Add(new Claim(IdentityServerConstants.StandardScopes.Email, user.Email.ToLower()));

            context.IssuedClaims = claims;
        }

Для проверки создадим простой скрипт на python который получает токен с Claim и обращается к ресурсу

import requests
import jwt
import json

headers = {}
payload = {
    'client_id': 'administration_client',
    'grant_type': 'password',
    'client_secret': 'administration_client-secret',
    'scope': 'openid email offline_access administration',
    'username': 'admin3@radiofisik.ru',
    'password': 'password'
     }

response = requests.post("http://base-url/connect/token", data=payload, headers=headers)

# print(response.content)
token = response.json()['access_token']
# print(token)

jwtContent = jwt.decode(token, 'secret',  verify=False)
# print(jwtContent)

print(json.dumps(jwtContent, indent=4, sort_keys=True))


headers = {'Authorization': 'Bearer '+token,
             'Content-Type':'application/json',
             'Accept': 'text/plain',
             'Content-Encoding': 'utf-8'}
payload = {
    'testParam': 'fe67b4ab-4d6d-4cbe-ac3f-213b37dc740d',
     }

response = requests.post("http://api-url", data=json.dumps(payload), headers=headers)
print(response.content)

Репозиторий https://github.com/Radiofisik/AppTemplate tag identity