Genius DM

IdentityServer > Allow specific users in a client. 본문

.NET

IdentityServer > Allow specific users in a client.

Damon Jung 2018. 8. 11. 20:23

아이덴티티 서버 클라이언트에서 특정 유저만 접근 허용하기.


스택오버플로우에 이런 질문이 몇 일전에 올라왔는데, 유저나 클라이언트 세팅만으로 간단하게 해결할 수 있을 줄 알았는데, 그런 직접적이고 간단한 방법이 없어서 생각보다 긴 답변을 작성하게 되었다. 요청이 진행 중일 때 이런 접근 제어를 하기위한 가장 적합한 방법을 찾아보았으나, 결국 사용자 정의 벨리데이션을 직접 구현하는 방법 밖에는 없었다.




ICustomRequestValidator

the overview architecture of IdentityServer3 이 글에서 설명했 듯, 아이덴티티 서버는 추가적인 인증 프로세스를 요청 과정에 추가하기 위한 간단한 방법을 디자인 단계에서부터 고안하여 방법을 마련해두었다. 당연히 이 설계 방식에 맞게 구현해야 한다. 이제 할 일은 적당한 인터페이스를 찾아서 구현하고 해당 구현 클래스를 서비스 팩토리에 등록시켜주는 것이다. 코드를 보자.

public class UserRequestLimitor : ICustomRequestValidator { public Task<AuthorizeRequestValidationResult> ValidateAuthorizeRequestAsync(ValidatedAuthorizeRequest request) { var clientClaim = request.Client.claims.Where(x => x.Type == "AllowedUsers").FirstOrDefault(); // Check is this client has "AllowedUsers" claim. if(clientClaim != null) { var subClaim = request.Subject.Claims.Where(x => x.Type == "sub").FirstOrDefault() ?? new Claim(string.Empty, string.Empty); if(clientClaim.Value == subClaim.Value) { return Task.FromResult<AuthorizeRequestValidationResult>(new AuthorizeRequestValidationResult { IsError = false }); } else { return Task.FromResult<AuthorizeRequestValidationResult>(new AuthorizeRequestValidationResult { ErrorDescription = "This user doesn't have an authorization to request a token for this client.", IsError = true }); } } // This client has no access controls over users. else { return Task.FromResult<AuthorizeRequestValidationResult>(new AuthorizeRequestValidationResult { IsError = false }); } } public Task<TokenRequestValidationResult> ValidateTokenRequestAsync(ValidatedTokenRequest request) { return Task.FromResult<TokenRequestValidationResult>(new TokenRequestValidationResult { IsError = false }); } }


이 방식은 다소 유연성이 부족해 보이는데, 열거형으로 정의되었다거나, 프로토콜 네이밍이 아닌 AllowedUsers 를 키로하여 Subject Id 를 값으로 구성한 Claim 을 넣었기 때문이다. 해당 아이디는 추후에 요청 과정에서 받아볼 수 있다. sub 키네임은 스탠다드이다. 아무튼 AllowedUsers 키 네이밍은 특정 유저의 엑세스를 제한하기 위해 만든 것이다. 




제한 대상 유저 추가하기

유저 세팅은 여기서 진행한다. 아이덴티티 서버 기본 예제에서 볼 수 있는 전역 클래스 InMemoryUser 에서 아래처럼 유저를 추가할 수 있다.


static class Users { public static List<InMemoryUser> Get() { var users = new List<InMemoryUser> { new InMemoryUser{Subject = "818727", Username = "alice", Password = "alice", Claims = new Claim[] { new Claim(Constants.ClaimTypes.Name, "Alice Smith"), new Claim(Constants.ClaimTypes.GivenName, "Alice"), new Claim(Constants.ClaimTypes.FamilyName, "Smith"), new Claim(Constants.ClaimTypes.Email, "AliceSmith@email.com"), new Claim(Constants.ClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(Constants.ClaimTypes.Role, "Admin"), new Claim(Constants.ClaimTypes.Role, "Geek"), new Claim(Constants.ClaimTypes.WebSite, "http://alice.com"), new Claim(Constants.ClaimTypes.Address, @"{ ""street_address"": ""One Hacker Way"", ""locality"": ""Heidelberg"", ""postal_code"": 69118, ""country"": ""Germany"" }", Constants.ClaimValueTypes.Json) } }, new InMemoryUser{Subject = "88421113", Username = "bob", Password = "bob", Claims = new Claim[] { new Claim(Constants.ClaimTypes.Name, "Bob Smith"), new Claim(Constants.ClaimTypes.GivenName, "Bob"), new Claim(Constants.ClaimTypes.FamilyName, "Smith"), new Claim(Constants.ClaimTypes.Email, "BobSmith@email.com"), new Claim(Constants.ClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(Constants.ClaimTypes.Role, "Developer"), new Claim(Constants.ClaimTypes.Role, "Geek"), new Claim(Constants.ClaimTypes.WebSite, "http://bob.com"), new Claim(Constants.ClaimTypes.Address, @"{ ""street_address"": ""One Hacker Way"", ""locality"": ""Heidelberg"", ""postal_code"": 69118, ""country"": ""Germany"" }", Constants.ClaimValueTypes.Json) } },


// 제한 대상 유저 추가

new InMemoryUser{Subject = "870805", Username = "damon", Password = "damon", Claims = new Claim[] { new Claim(Constants.ClaimTypes.Name, "Damon Jeong"), new Claim(Constants.ClaimTypes.Email, "dmjeong@email.com"), new Claim(Constants.ClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean) } } };

return users; } }




대상 클라이언트에 클레임 추가하기.

클라이언트에 AllowedUsers 키로 제한 대상 유저의 Subject Id 인 "870805" 를 추가 할 차례다. "8708085" 값은 추후에 요청 과정에서 ValidatedAuthorizeRequest 에서 얻을 수 있는 값이다. 아래 예제 또한 기본 예제이며, 링크를 클릭하면 소스코드를 볼 수 있다.

public class Clients { public static List<Client> Get() { return new List<Client> { ///////////////////////////////////////////////////////////// // WPF WebView Client Sample ///////////////////////////////////////////////////////////// new Client { ClientName = "WPF WebView Client Sample", ClientId = "wpf.webview.client", Flow = Flows.Implicit, AllowedScopes = new List<string> { Constants.StandardScopes.OpenId, Constants.StandardScopes.Profile, Constants.StandardScopes.Email, Constants.StandardScopes.Roles, Constants.StandardScopes.Address, "read", "write" }, ClientUri = "https://identityserver.io", RequireConsent = true, AllowRememberConsent = true, RedirectUris = new List<string> { "oob://localhost/wpf.webview.client", },


// 클레임에 엑세스를 허용할 유저들을 추가한다.

Claims = new List<Claim>

{

new Claim("AllowedUsers", "870805"

} }, .

.

.

// Other clients. }; } }



구현한 클래스를 팩토리에 등록.

구현한 클래스가 아이덴티티 서버에서 활용되도록 하려면, IdentityServerServiceFactory 에 해당 클래스를 등록하여 Autofac IoC 컨테이너가 DI 할 수 있도록 해줘야 한다. 위에서 언급했지만, 아이덴티티 서버에서는 이러한 요구사항을 쉽게 반영할 수 있도록 확장 포인트를 제공해주고 있다.

var factory = new IdentityServerServiceFactory(); factory.Register(new Registration<ICustomRequestValidator>(resolver => new UserRequestLimitor()));




동작 확인

wpf.webview.client 아이디를 지닌 클라이언트에 클레임을 추가했다. 이 WPF 클라이언트 샘플은 여기서 찾을 수 있다. 위에서 제시된 설정으로 아이덴티티 서버를 구동시키고, 이 WPF 웹 뷰 클라이언트를 실행하자.




클라이언트 화면에서 Login only 버튼을 누르면, 웹 화면 팝업이 뜨게 되는데, 이 때 아이덴티티 서버로 로그인 요청이 날라간다. 그러면 이 요청인 _customValidator 라인에 디버깅이 걸린다. _customValidator 가 바로 ICustomRequestValidator 인터페이스이다.


Autofac IoC 에서 이미 구현 클래스를 인젝션했기 때문에 ValidateAuthorizeRequestAsync 가 수행되면 구현 코드로 디버거 브레이크 포인트가 넘어갈 것이다.




request.Client.Claims 에 원하는 속성 값이 존재할 것이다. 바로 이전에 클라이언트에 추가해둔 "AllowedUsers" : "870805" 를 지닌 클레임이다.




당연히 첫 번째 시도에서는 아무 문제 없이 인증이 통과된다. 왜냐하면 로그인 한 적이 없어서 request.Subject 에 유저 컨텍스트가 없기 때문이다. 로그인 화면에서 아이덴티티 서버 유저 리스트에 추가한 유저 damon 으로 로그인을 해보자.




damon 으로 로그인을 하면, 디버거가 다시 한 번 구현 코드로 들어올 것이다. 그러나 이번에는 유저가 인증된 상태이기 때문에 ValidatedAuthorizeRequest 객체에 해당 유저 컨텍스트가 존재할 것이다. 해당 유저의 "sub" 값도 볼 수 있을 것이다.





유저 damon 은 벨리데이션을 당연히 통과한다. 왜냐하면 sub 아이디가 870805 이고, 해당 아이디가 클라이언트에 AllowedUsers 로 등록되어있기 때문이다. 만약 다른 유저로 로그인을 한다면, 아래와 같은 에러 페이지를 보게될 것이다.








Allow specific users in a client.


I saw this question in stackoverflow a few days ago and thought that this could be easily done with User or Client settings when configuring up an identity server. But there was no direct way to do access control on users in a specific client request context. I've been trying to find the best way to control users when the request is in flight but, unfortunately, to no avail. As far as I know so far, implementing a custom validation is the only way to do that.




ICustomRequestValidator

As I explained the overview architecture of IdentityServer3, The identity server provides a simple way to add an additional validation process to the request context by design. You need to make the best of it. All you need to do is to implement a proper interface and register the implemented class to the service factory. Let's hit the road.

public class UserRequestLimitor : ICustomRequestValidator { public Task<AuthorizeRequestValidationResult> ValidateAuthorizeRequestAsync(ValidatedAuthorizeRequest request) { var clientClaim = request.Client.claims.Where(x => x.Type == "AllowedUsers").FirstOrDefault(); // Check is this client has "AllowedUsers" claim. if(clientClaim != null) { var subClaim = request.Subject.Claims.Where(x => x.Type == "sub").FirstOrDefault() ?? new Claim(string.Empty, string.Empty); if(clientClaim.Value == userClaim.Value) { return Task.FromResult<AuthorizeRequestValidationResult>(new AuthorizeRequestValidationResult { IsError = false }); } else { return Task.FromResult<AuthorizeRequestValidationResult>(new AuthorizeRequestValidationResult { ErrorDescription = "This user doesn't have an authorization to request a token for this client.", IsError = true }); } } // This client has no access controls over users. else { return Task.FromResult<AuthorizeRequestValidationResult>(new AuthorizeRequestValidationResult { IsError = false }); } } public Task<TokenRequestValidationResult> ValidateTokenRequestAsync(ValidatedTokenRequest request) { return Task.FromResult<TokenRequestValidationResult>(new TokenRequestValidationResult { IsError = false }); } }


This is rather a non-flexible way, it seems, because I added the "AllowedUsers" key name to the client object and the "sub" key name to retrieve the claim value. The "sub" key name is the default, so it doesn't matter anyway, but the "AllowedUsers" is what I've made to prevent the specified users. 




Adding a target user.

The configuration is done in here. This example is in Users, the static class for InMemoryUser in a basic identity server example. 


static class Users { public static List<InMemoryUser> Get() { var users = new List<InMemoryUser> { new InMemoryUser{Subject = "818727", Username = "alice", Password = "alice", Claims = new Claim[] { new Claim(Constants.ClaimTypes.Name, "Alice Smith"), new Claim(Constants.ClaimTypes.GivenName, "Alice"), new Claim(Constants.ClaimTypes.FamilyName, "Smith"), new Claim(Constants.ClaimTypes.Email, "AliceSmith@email.com"), new Claim(Constants.ClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(Constants.ClaimTypes.Role, "Admin"), new Claim(Constants.ClaimTypes.Role, "Geek"), new Claim(Constants.ClaimTypes.WebSite, "http://alice.com"), new Claim(Constants.ClaimTypes.Address, @"{ ""street_address"": ""One Hacker Way"", ""locality"": ""Heidelberg"", ""postal_code"": 69118, ""country"": ""Germany"" }", Constants.ClaimValueTypes.Json) } }, new InMemoryUser{Subject = "88421113", Username = "bob", Password = "bob", Claims = new Claim[] { new Claim(Constants.ClaimTypes.Name, "Bob Smith"), new Claim(Constants.ClaimTypes.GivenName, "Bob"), new Claim(Constants.ClaimTypes.FamilyName, "Smith"), new Claim(Constants.ClaimTypes.Email, "BobSmith@email.com"), new Claim(Constants.ClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean), new Claim(Constants.ClaimTypes.Role, "Developer"), new Claim(Constants.ClaimTypes.Role, "Geek"), new Claim(Constants.ClaimTypes.WebSite, "http://bob.com"), new Claim(Constants.ClaimTypes.Address, @"{ ""street_address"": ""One Hacker Way"", ""locality"": ""Heidelberg"", ""postal_code"": 69118, ""country"": ""Germany"" }", Constants.ClaimValueTypes.Json) } },


// Adding a target user here.

new InMemoryUser{Subject = "870805", Username = "damon", Password = "damon", Claims = new Claim[] { new Claim(Constants.ClaimTypes.Name, "Damon Jeong"), new Claim(Constants.ClaimTypes.Email, "dmjeong@email.com"), new Claim(Constants.ClaimTypes.EmailVerified, "true", ClaimValueTypes.Boolean) } } };

return users; } }




Adding a claim to a target client.

Now it's time to add a claim that has "AllowedUsers" as its key name and "870805" as its value. The "870805" value is what you can get in ValidatedAuthorizeRequest later in the request context. Below example is also the default example. You can find it here.

public class Clients { public static List<Client> Get() { return new List<Client> { ///////////////////////////////////////////////////////////// // WPF WebView Client Sample ///////////////////////////////////////////////////////////// new Client { ClientName = "WPF WebView Client Sample", ClientId = "wpf.webview.client", Flow = Flows.Implicit, AllowedScopes = new List<string> { Constants.StandardScopes.OpenId, Constants.StandardScopes.Profile, Constants.StandardScopes.Email, Constants.StandardScopes.Roles, Constants.StandardScopes.Address, "read", "write" }, ClientUri = "https://identityserver.io", RequireConsent = true, AllowRememberConsent = true, RedirectUris = new List<string> { "oob://localhost/wpf.webview.client", },


// Add users in this claim and its subject id.

Claims = new List<Claim>

{

new Claim("AllowedUsers", "870805"

} }, .

.

.

// Other clients. }; } }



Register your own implemented class to the factory.

To make your own implementation work, you need to register your class to the IdentityServerServiceFactory for Autofac to inject the dependency right at the moment. As I mentioned earlier, the authorization server has the exact extensibility point for you. 

var factory = new IdentityServerServiceFactory(); factory.Register(new Registration<ICustomRequestValidator>(resolver => new UserRequestLimitor()));




See if this works

I added the Claim in wpf.webview.client. You can find the client sample here. I started my identity server, configured as above, and run this WPF web view client.




Let's click the Login only first, then it pops up a web modal view requesting a login request to the identity server. Then it will hit this _customValidator line and _customValidator is the ICustomRequestValidator interface. 


As the Autofac IoC already injected our own implementation there, ValidateAuthorizeRequestAsync will be stepping into the implementation.




The request.Client.Claims will have the desired attribute; the previously added claim that has "AllowedUsers" : "870805" as its own value. 




Of course the first attempt will be fine because there's no user context. The user hasn't signed in yet. Let's sign in with the "damon" user and its password.




Again, after signing in with the user "damon", debugger will break at the implementation line again. But this time, the user is verified and the user context is included in the ValidatedAuthorizeRequest object. You can see "sub" value in the claims of the user.





The user damon will get through this validation because it has a "870805" subject id as the id is registered in the client's claim. If other users without "870805" id try to sign in, then the user will see this error page. 







































Comments