Często WCF, mimo swoich możliwości w zakresie "interoperability", wcale nie musi być kompatybilny z komponentami zewnętrznymi. Nasz serwer, nasz klient, a WCF między nimi. I... tu zwykle zaczynają się problemy... (jak to pisał nie-ś.p. † Kurt Vonnegut, gówno wpada w szprychy:) ).
WCF jest tak rozbudowaną i skomplikowaną technologią, że odpowiednie dobranie zawartych w niej klocków do stworzenia budowli, której potrzebujemy, jest niekiedy żmudnym, trudnym i bardzo czasochłonnym zajęciem. Niby na MSDN jest masa materiału, niby mamy do dyspozycji wiele książek (w tym wyś-mie-ni-ta "Programming WCF Services" by Lowy Juval, polecam!)... Niby to jest takie "proste" że aż głupio przyznać się, jeśli spędza się nad czymś dużo czasu - bo przecież wystarczy kilka zmian w konfiguracji i mamy co trzeba.
Ale ja wyjdę z założenia, że prosty to jest kręgosłup programisty (zanim stanie się programistą) a nie WCF. I przyznam się, że kiedyś prawie trzy noce spędziłem nad realizacją takiego PODSTAWOWEGO scenariusza:
- serwer udostępniający funkcjonalność poprzez usługi WCF i klient korzystający z tych usług
- komunikacja kanałem net.tcp z binarną serializacją (klient i serwer są moje, nie potrzebuję protokołu HTTP ani serializacji SOAP)
- uwierzytelnianie poprzez login i hasło
Banalne...? Może i tak, ale próżno szukać omówienia takiego przykładu w, bardzo skądinąd interesującym, poradniku stworzonym przez zespół Patterns And Practices: Scenarios and Implementation Guidance for WCF. Znajdziemy tam zabezpieczenie username/password przez HTTP zintegrowane z ASP.NET, znajdziemy bezpieczną serializację binarną w środowisku intranetowym, znajdziemy wiele innych ciekawych zastosowań. Ale najprostszego, wydawałoby się, username/password po TCP bez żadnych dodatkowych mechanizmów - nie.
Ogólnie...
Przykładowe rozwiązanie (dostępne do ściągnięcia na stronie Samples) składa się z pięciu projektów. Jest to dość sensowne, dość logiczne i dość standardowe rozdzielenie odpowiedzialności pomiędzy dllki. A że jeden obraz wart tysiąca słów...:

Common
Projekty tu zawarte są współdzielone przez klientów i serwer. Zawierają tak banalne informacje jak zdefiniowane w aplikacji nazwy uprawnień czy klasy Identity i Principal. Znajdziemy tam też interfejsy kontraktów usług, co w przypadku, gdy implementujemy jednocześnie i klienta i serwer jest sensowniejszym rozwiązaniem niż generowanie kopii w VS na podstawie WSDL.
Serwer
Na serwerze znajduje się... logika serwerowa:). Czyli dostęp do danych, obiekty pseudo-biznesowe, implementacje usług... Oraz, co najważniejsze, cała machineria dostosowująca WCF do naszych potrzeb. Będzie tu zatem obsługa wyjątków zrealizowana w sposób opisany kiedyś ("Obsługa wyjątków w usługach WCF"), będzie też rozszerzona wersja własnych mechanizmów uwierzytelniania ("Własne mechanizmy uwierzytelniania w WCF"). I właśnie tymi rozszerzeniami zajmiemy się w tej chwili.
Po dłubaniu w internecie i własnych eksperymentach pojawiły sie następujące obserwacje:
- jeżeli i klient i serwer to .NET, wtedy najlepiej posłużyć się binarną serializacją i komunikacją poprzez protokół TCP, bez narzutu generowanego przez XML i HTTP; wybór jest zatem prosty: NetTcpBinding
- tryb bezpieczeństwa używany przez binding musi umożliwiać uwierzytelnianie za pomocą loginu i hasła, zatem z czterech opcji dostępnych w enumie SecurityMode zostają nam Message i TransportWithMessageCredential; z tych dwóch punktów uzyskujemy binding:
1: NetTcpBinding tcpBinding = new NetTcpBinding(SecurityMode.Message);
- przy definicji sposobu uwierzytelniania wybierzemy oczywiście opcję MessageCredentialType.UserName:
1: tcpBinding.Security.Message.ClientCredentialType = MessageCredentialType.UserName;
- sposobów weryfikacji poprawności loginu i hasła jest kilka, nas interesuje możliwość wpięcia własnej implementacji UserNamePasswordValidator...
1: host.Credentials.UserNameAuthentication.UserNamePasswordValidationMode = UserNamePasswordValidationMode.Custom;
2: host.Credentials.UserNameAuthentication.CustomUserNamePasswordValidator = _serverSecurity;
- ...natomiast autoryzację załatwiamy implementacją IAuthorizationPolicy:
1: host.Authorization.PrincipalPermissionMode = PrincipalPermissionMode.Custom;
2: host.Authorization.ExternalAuthorizationPolicies = new ReadOnlyCollection<IAuthorizationPolicy>(new[] { _serverSecurity });
Jeszcze raz link do posta z pokazanymi klasami: "Własne mechanizmy uwierzytelniania w WCF".
I takie coś wydawało się sensowne, jednak w momencie otwierania hosta pojawiał się wyjątek:
"The service certificate is not provided. Specify a service certificate in ServiceCredentials."
I jego wyeliminowanie zajęło mi dalszy kawał czasu.
Okazuje się, że włączenie SecurityMode na Message bądź TransportWithMessageCredential skutkuje wymaganiem posiadania certyfikatu w celu umożliwienia wiarygodnej identyfikacji serwera przez klienta. I z tego co sie naszukałem, NIE DA się tego wyłączyć. Natknąłem się nawet w internecie na jakąś własną implementację bindingu, która naokoło umożliwia uwierzytelnianie username/password bez dodatkowych zabezpieczeń, ale wydaje się to być rozwiązaniem bardzo na siłę.
Poszedłem więc inną drogą i wygenerowałem sobie certyfikat. Znalezienie odpowiedniej sekwencji komend wcale nie było proste, jeśli nie miało się z certyfikatami wcześniej do czynienia. Po kolei więc przedstawiam swoje kroki (może są głupie...?), których jedynym celem jest dostarczenie poprawnego certyfikatu pozwalającego na komunikację klient-serwer w kontrolowanym środowisku:
1) używamy narzędzie makecert w celu stworzenia pary publiczny/prywatny klucz; ustawiamy odpowiednie flagi pozwalające na dość frywolne obchodzenie się z kluczem prywatnym w kroku kolejnym, a w wyskakującym okienku możemy zaakceptować puste hasło:
makecert -sv WcfAuthSampleKey.pvk -n "CN=ProcentWcfSample" WcfAuthSampleKey.cer -pe -sky exchange
2) za pomocą narzędzia pvk2pfx generujemy plik PFX zawierający informacje o naszych kluczach:
pvk2pfx.exe -pvk WcfAuthSampleKey.pvk -spc WcfAuthSampleKey.cer -pfx WcfAuthSampleKey.pfx
3) tak uzyskany plik .pfx dodajemy do projektu ustawiając jego Build Action na Embedded Resource
Dzięki temu plik certyfikatu mamy wkompilowany w serwer i jest on samowystarczalny (UWAGA, takie rozwiązanie z pewnością nie jest oczywiście poprawnym rozwiązaniem kwestii certyfikatu w publicznie działającej usłudze:) )
Zaaplikowanie tak spreparowanego certyfikatu podczas uruchamiania hostów wygląda w kodzie następująco:
1: using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(
2: "Procent.Samples.WcfAuthStarter.Server.misc.WcfAuthSampleKey.pfx")
3: )
4: {
5: byte[] bytes = new byte[stream.Length];
6: stream.Read(bytes, 0, bytes.Length);
7: host.Credentials.ServiceCertificate.Certificate = new X509Certificate2(bytes, string.Empty);
8: }
Jasne jest, że taka procedura skutecznie uniemożliwia wykorzystanie na serwerze plików konfiguracyjnych w celu zmiany ustawień WCFa.
Cała metoda otwierająca hosta wygląda dzięki typom generycznym całkiem ładnie:
1: private static ServiceHost OpenHost<TContract, TImplementation>(string baseUri, string address) where TImplementation : TContract
2: {
3: ServiceHost host = new ServiceHost(typeof(TImplementation), new Uri(baseUri));
4:
5:
6: NetTcpBinding tcpBinding = new NetTcpBinding(SecurityMode.Message);
7:
8:
9: tcpBinding.Security.Message.ClientCredentialType = MessageCredentialType.UserName;
10:
11: host.Credentials.UserNameAuthentication.UserNamePasswordValidationMode = UserNamePasswordValidationMode.Custom;
12: host.Credentials.UserNameAuthentication.CustomUserNamePasswordValidator = _serverSecurity;
13:
14:
15: host.Authorization.PrincipalPermissionMode = PrincipalPermissionMode.Custom;
16:
17: host.Authorization.ExternalAuthorizationPolicies = new ReadOnlyCollection<IAuthorizationPolicy>(new[] { _serverSecurity });
18:
19:
20: using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream("Procent.Samples.WcfAuthStarter.Server.misc.WcfAuthSampleKey.pfx"))
21: {
22: byte[] bytes = new byte[stream.Length];
23: stream.Read(bytes, 0, bytes.Length);
24: host.Credentials.ServiceCertificate.Certificate = new X509Certificate2(bytes, string.Empty);
25: }
26:
27: host.AddServiceEndpoint(typeof(TContract), tcpBinding, address);
28:
29:
30: host.Description.Behaviors.Add(new ErrorHandlingBehavior());
31:
32: host.Open();
33:
34: Console.WriteLine("Opened service {0} implemented by {1} under address {2}", typeof(TContract).Name, typeof(TImplementation).Name, baseUri + address);
35:
36: return host;
37: }
A przykładowa deklaracja metody z usługi wygląda tak:
1: public class ProductsService : IProductsService
2: {
3: [PrincipalPermission(SecurityAction.Demand, Role = AppRoles.ProductsAdmin)]
4: public void AddProduct(string name, double price)
5: {
Klient
Logika kliencka podzielona została na dwie biblioteki.
Jedna z nich (Client.Core) ma na celu dostarczenie mechanizmów dla wszystkich implementacji klienta, niezależnie od wykorzystanej technologii. W przykładzie znajduje się tam jedynie opisywane przeze mnie już kiedyś WCF Proxy ("Własna implementacja WCF Proxy").
Druga biblioteka pisana jest pod konkretną technologię, w tym przypadku: Windows Forms. Znajdziemy tutaj między innymi kolejną warstwę komunikacji z WCF (także opisaną we wspomnianym wpisie). Mała różnica: tym razem przed wywołaniem każdej metody wypełniamy dane do uwierzytelnienia:
1: public static void Invoke(Action<TService> operation)
2: {
3: using (var proxy = new ServiceProxy<TService>())
4: {
5: proxy.ClientCredentials.UserName.UserName = CurrentData.Credentials.Username;
6: proxy.ClientCredentials.UserName.Password = CurrentData.Credentials.Password;
7:
8: operation(proxy.GetChannel());
9: }
10: }
Inny godny uwagi mechanizm to dynamiczne budowanie UI na podstawie uprawnień użytkownika. Swego czasu po opisaniu tego rozwiązania zostałem dość mocno na skrytykowany w komentarzach (post "Dynamiczne budowanie UI zależne od uprawnień użytkownika"). Obiecałem wówczas praktyczny przykład zastosowania. Trochę to potrwało, ale oto i on:). A o rolach danego użytkownika dowiemy się już na samym początku, w Main:
1: CurrentData.Credentials.Username = loginForm.UserName;
2: CurrentData.Credentials.Password = loginForm.Password;
3:
4: string[] roles = ServiceProxyProvider<ISecurityService>.Invoke(x => x.GetRoles());
5:
6: Thread.CurrentPrincipal = new ProcentPrincipal(new ProcentIdentity(-1, CurrentData.Credentials.Username), roles);
Błędne dane będą skutkowały wyjątkiem na serwerze.
Na specjalną uwagę zasługuje konfiguracja, którą na kliencie dla odmiany trzymam w configu. Jest kilka ważnych elementów:
1) w konfiguracji endpointu w elemencie identity/dns musimy podać wartość wybraną podczas generowania certyfikatu (-n "CN=..."):
1: <endpoint behaviorConfiguration="AnyCertEndpoint"
2: bindingConfiguration="UsernamePassword"
3: address="net.tcp://localhost:28736/Security"
4: binding="netTcpBinding"
5: contract="Procent.Samples.WcfAuthStarter.ServiceContracts.ISecurityService"
6: >
7: <identity>
8: <dns value="ProcentWcfSample" />
9: </identity>
10: </endpoint>
2) musimy utworzyć odpowiednią konfigurację bindingu dla netTcp:
1: <netTcpBinding>
2: <binding name="UsernamePassword">
3: <security mode="Message">
4: <message clientCredentialType="UserName"/>
5: </security>
6: </binding>
7: </netTcpBinding>
3) zakładamy, że nie potrzebujemy w żaden sposób weryfikować certyfikatu serwera:
1: <endpointBehaviors>
2: <behavior name="AnyCertEndpoint">
3: <clientCredentials>
4: <serviceCertificate>
5: <authentication certificateValidationMode="None"/>
6: </serviceCertificate>
7: </clientCredentials>
8: </behavior>
9: </endpointBehaviors>
W takiej konfiguracji aplikacja... po prostu działa.
Jak wspomniałem wcześniej, aplikacja dostępna na stronie Samples. Czy ktoś stosuje alternatywny sposób do osiągnięcia tego celu? Bardzo chętnie sobie o nim poczytam, proszę o komentarze!