Desenvolvimento

15 dez, 2011

Vários clientes e formulários de autenticação

Publicidade

Cenários

Em cada um dos cenários abaixo, formulários de autenticação são usados para garantir o acesso seguro aos endpoints do MVC e aos serviços WCF do ASP.NET.

  • WPF acessando serviços WCF
  • WPF acessando MVC endpoints
  • Windows Phone 7 acessando WCF services
  • browsers de desktop e dispositivos móveis acessando páginas web ASP.NET MVC
  • jQuery acessando MVC endpoints

Mesmo eu não tendo incluído o código para o Windows Phone 7 para acessar os endpoints do MVC, este também é um cenário suportado.

Requisitos

  • Visual Studio 2010
  • SQL Express 2008
  • Ferramentas do Windows Phone 7

Se você não tem esses requisitos e não quer visualizar este projeto, você pode remover o projeto do Windows Phone 7 da solução.

Histórico

Eu tenho trabalhado em Stuff v2, uma aplicação de filmes, jogos e livros. Seu uso principal é para casos do tipo “Estou em uma loja e não consigo lembrar se tenho um filme, jogo ou livro específico. Preciso determinar se o tenho ou não; se não, então posso checar o preço online antes de fazer a compra”.

Considerando a variedade de clientes e de dispositivos, os formulários de autenticação ASP.NET parecem a escolha natural para a autenticação do website, MVC3 JSON endpoints, e WCF services.

A razão pela qual tenho um software para vários clientes e dispositivos é mais por uma experiência de aprendizado do que um requisito da aplicação. Tenho outras aplicações que quero escrever que precisarão acessar a aplicação de todos os meus dispositivos.

Quando eu comecei a programar o cliente WPF, encontrei uma parede de pedras com relação ao acesso do WPF aos serviços WCF, que são protegidos por formulários de autenticação. Este artigo é sobre ultrapassar essa parede.

Identificando o espaço do problema

No final das contas, o problema que precisa ser resolvido é o gerenciamento do cookie ou do ticket dos formulários de autenticação. Gerenciar significa que, depois da autenticação, o cliente deve ser capaz de recuperar o ticket retornado como resposta e incluí-lo em pedidos futuros para recursos seguros.

Como você verá, as APIs do cliente variam de acordo com o cenário, não apenas no que diz respeito a padrões de codificação, mas também a complexidade.

Independentemente de qual cliente está acessando os recursos que precisam de formulários de autenticação, os passos a seguir definem o fluxo de trabalho:

  • Log in
  • Coloque em cache o ticket retornado na resposta
  • Inclua o ticket em pedidos subsequentes

Configurando os formulários de autenticação

Quando eu criei a aplicação MVC3 ASP.NET, VariousClients.Web, eu usei o template MVC3 da Internet com o mecanismo de visualização Razor. Esse template configura os formulários de autenticação e fornece um ótimo sistema de adesão SQL Express out-of-box para você.

O fragmento abaixo do web.config mostra algumas mudanças necessárias.

<authentication mode="Forms">
<!-- cookieless="UseCookies" is required by non-browser clients
to authenticate using forms authentication-->

<!-- production applications, change to requiresSSL="true"-->
<forms timeout="2880" cookieless="UseCookies" loginUrl="~/Account/LogOn"
requireSSL="false" />
</authentication>

Configurando o AuthenticationService

O System.Web.ApplicationServices.AuthenticationService é um serviço incorporado que você pode expor como um serviço final para o seu website. Esse serviço expõe os métodos de log in e de log out para clientes que acessam os formulários de autenticação requeridos pelos WCF endpoints. Ele utiliza o provedor de associação definido no web.config. Após fazer o log in, o serviço retorna um ticket em resposta, da mesma maneira que o log in dos formulários de autenticação.

Adicionar o serviço é fácil. Primeiramente, adicione uma pasta na raiz do website chamada “Services”. Nessa pasta, adicione um serviço WCF chamado Authentication.svc. Delete o contrato do serviço gerado e os arquivos de código. Em seguida, substitua os conteúdos do arquivo Authentication.svc com o código abaixo:

<%@ ServiceHost Language="C#" Service="System.Web.ApplicationServices.AuthenticationService" %>

Agora adicione o seguinte a seu web.config:

<system.web.extensions>
<scripting>
<webServices>
<!-- for production applications, change to requiresSSL="true"-->
<authenticationService enabled="true" requireSSL="false"/>
</webServices>
</scripting>
</system.web.extensions>

Reconstrua sua aplicação web.

O Authentication.svc agora irá aparecer no diálogo Add Service Reference quando adicionar as referências do serviço no seu cliente de aplicações.

Browsers, web pages, e métodos do controller MCV3

Classes Controller ou métodos de ação controller podem ser decorados com o atributo Authorize para permitir que browsers clientes ou Javascripts que os acessarem possam ser autenticados.

Depois de fazer o log in, o browser automaticamente gerencia o ticket de autenticação e o inclui em todos os pedidos futuros do website.

O método GetTime abaixo precisa de autenticação:

using System;
using System.Web.Mvc;

namespace VariousClients.Web.Controllers {

public class CloudDataController : Controller {

[Authorize]
public JsonResult GetTime() {
return Json(DateTime.Now.ToLongDateString(), JsonRequestBehavior.AllowGet);
}
}
}

Métodos Controller do WPF e MVC3

A seção a seguir se aplica da mesma maneira para WPF, Windows Forms, consoles, e testes de projetos.

Server-Side

Eu adicionei um método RemoteLogOn ao AccountController, que é usado para clientes sem browsers que fazem o log in. A assinatura do método e sua implementação são levemente diferentes do método LogOn.

[HttpPost]
#if (!DEBUG)
[RequireHttps]
#endif
public Boolean RemoteLogOn(LogOnModel model) {
if(ModelState.IsValid) {
if(Membership.ValidateUser(model.UserName, model.Password)) {
FormsAuthentication.SetAuthCookie(model.UserName, model.RememberMe);
return true;
}
}
return false;
}

CookieAwareWebClient

Lembre-se de que o propósito do RemoteLogOn acima é retornar o ticket como resposta. Para simplificar a programação cliente, um CookieAwareWebClient pode ser usado para executar pedidos do WebClient ao mesmo tempo em que lida com a inclusão dos tickets nos pedidos.

using System;
using System.Net;

namespace VariousClients.Common.Net {

public class CookieAwareWebClient : WebClient {

public CookieContainer CookieContainer { get; private set; }

public CookieAwareWebClient()
: this(new CookieContainer()) {
}

public CookieAwareWebClient(CookieContainer cookieContainer) {
if (cookieContainer == null) throw new ArgumentNullException("cookieContainer");
this.CookieContainer = cookieContainer;
}

protected override WebRequest GetWebRequest(Uri address) {
if(this.CookieContainer == null) {
throw new InvalidOperationException("CookieContainer is null");
}
var request = base.GetWebRequest(address);
if (request is HttpWebRequest) {
(request as HttpWebRequest).CookieContainer = this.CookieContainer;
} return request;
}
}
}

Client-Side

void btnMvcLogIn_Click(Object sender, RoutedEventArgs e) {

_cookieJar = new CookieContainer();
var client = new CookieAwareWebClient(_cookieJar) { Encoding = Encoding.UTF8 };

client.UploadValuesCompleted += (s, args) => {
this.lblMvcResult.Content =
args.Error == null ? Encoding.UTF8.GetString(args.Result) : args.Error.Message;
};

var nvc = new NameValueCollection { { "UserName", Credentials.UserName },
{ "Password", Credentials.Password },
{ "RememberMe", "true" } };
client.UploadValuesAsync(
new Uri("http://localhost:1668/Account/RemoteLogOn"), "POST", nvc);
}

void btnMvcGetData_Click(Object sender, RoutedEventArgs e) {

this.lblMvcResult.Content = "calling cloud service...";
var client = new CookieAwareWebClient(_cookieJar);

client.DownloadStringCompleted += (s, args) => {
this.lblMvcResult.Content = args.Error == null ? args.Result : args.Error.Message;
};

client.DownloadStringAsync(new Uri("http://localhost:1668/CloudData/GetTime"));
}

No código acima, o _cookieJar está no escopo do nível de módulo. O conceito importante é usar a mesma instância do CookieAwareWebClient para todos os chamados, porque ele gerencia o ticket para você. Como alternativa, você poderia recuperar o valor do CookieContainer depois de fazer o log in, e passá-lo no construtor ao criar novas instâncias CookieAwareWebClient.

O código btnMvcGetData_Click se parece com um chamado típico WebClient. O CookieAwareWebClient deixa os endpoints do chamado protegidos por formulários de autenticação sem sofrimento.

Métodos de serviço WPF e WCF

A seção a seguir se aplica da mesma maneira para WPF, formulários Windows, consoles, e testes de projetos.

O WPF consumindo os serviços WCF protegidos com formulários de autenticação necessitam de um pouco mais de código para gerenciar o ticket, porque o WCF não fornece uma API simples para adicionar ou recuperá-lo ao fazer chamadas de serviço que usam proxies gerados.

Se você comparar os chamados do WPF e do Windows Phone 7 no mesmo serviço WCF, você entenderá o que estou dizendo. O proxy do Windows Phone 7 expõe um objeto CookieContainer facilitando a captura ou a inclusão do ticket em todos os chamadas de serviço.

Antes de poder chamar o AuthenticationService, você precisará adicionar uma referência de serviço ao serviço de Autenticação. Ao adicionar a referência do serviço, eu mudei o namespace para AuthenticationService como na imagem abaixo:

FormsAuthenticationAssistant

O FormsAuthenticationAssistant é uma fachada para clientes WPF que fazem chamadas protegidas por formulários de autenticação. Ele fornece o gerenciamento automático de tickets para fazer chamadas de serviço.

// Many thanks to Jonas Follesoe for this post:
// http://jonas.follesoe.no/2008/09/12/wcf-authentication-services-silverlight-and-smelly-cookies/
// I learned how to extract the forms authentication cookie from the AuthenticationService and how to
// reapply it subsequent service calls.

using System;
using System.Net;
using System.ServiceModel;
using System.ServiceModel.Channels;

namespace VariousClients.Common.ServiceModel {

public class FormsAuthenticationAssistant {

public String TicketCookie { get; private set; }

public FormsAuthenticationAssistant() { }

public FormsAuthenticationAssistant(String ticket) {
if (String.IsNullOrWhiteSpace(ticket)) throw new ArgumentNullException("ticket");
this.TicketCookie = ticket;
}

public Boolean Login(Func<Boolean> method, IContextChannel serviceInnerChannel) {
if (method == null) throw new ArgumentNullException("method");
if (serviceInnerChannel == null) throw new ArgumentNullException("serviceInnerChannel");

using (new OperationContextScope(serviceInnerChannel)) {
if (!method()) {
this.TicketCookie = null;
return false;
}
var properties = OperationContext.Current.IncomingMessageProperties;
var responseProperty =
(HttpResponseMessageProperty)properties[HttpResponseMessageProperty.Name];
this.TicketCookie = responseProperty.Headers[HttpResponseHeader.SetCookie];
return true;
}
}

public T Execute<T>(Func<T> method, IContextChannel serviceInnerChannel) {
if (method == null) throw new ArgumentNullException("method");
if (serviceInnerChannel == null) throw new ArgumentNullException("serviceInnerChannel");
if (String.IsNullOrWhiteSpace(this.TicketCookie)) {
throw new InvalidOperationException(
"Currently not logged in. Must Login before calling this method.");
}

using (new OperationContextScope(serviceInnerChannel)) {
var requestProperty = new HttpRequestMessageProperty();
OperationContext.Current.OutgoingMessageProperties.Add(
HttpRequestMessageProperty.Name, requestProperty);
requestProperty.Headers.Add(HttpRequestHeader.Cookie, this.TicketCookie);
return method();
}
}

public void Execute(Action method, IContextChannel serviceInnerChannel) {
if (method == null) throw new ArgumentNullException("method");
if (serviceInnerChannel == null) throw new ArgumentNullException("serviceInnerChannel");
if (String.IsNullOrWhiteSpace(this.TicketCookie)) {
throw new InvalidOperationException(
"Currently not logged in. Must Login before calling this method.");
}

using (new OperationContextScope(serviceInnerChannel)) {
var requestProperty = new HttpRequestMessageProperty();
OperationContext.Current.OutgoingMessageProperties.Add(
HttpRequestMessageProperty.Name, requestProperty);
requestProperty.Headers.Add(HttpRequestHeader.Cookie, this.TicketCookie);
method();
}
}
}
}

O método Login acima invoca o método passado no argumento do método. Se o log in for bem sucedido, o ticket será extraído da resposta e colocado em cache na propriedade do TicketCookie, retornando true.

Os dois métodos Execute acima invocam o método passado no argumento do método e adicionam o TicketCookie ao pedido.

Sua aplicação pode interagir com o FormsAuthenticationAssistant de duas maneiras. Uma delas é criando uma instância única do FormsAuthenticationAssistant e usando essa instância para todos os chamadas de serviço. É assim que a aplicação WPF demo é escrita.

A outra maneira é salvar o valor da propriedade TicketCookie depois de um log in de sucesso, e então criar uma nova instância do FormsAuthenticationAssistant para cada chamada e passar o valor salvo do TicketCookie dentro do construtor.

Uma vez que você estiver confortável com o roteamento dos proxies de chamada de serviço através do FormsAuthenticationAssistant, você perceberá muito poucas diferenças entre usar essa fachada e chamar os métodos de serviço proxy diretamente. Não se esqueça, em aplicações do mundo real, você quer fazer esses chamadas de serviço ou apenas chamados através de chamadas assíncronas, de modo que a linha UI não seja bloqueada.

Server-Side

O atributo PrincipalPermission pode ser usado em métodos de serviço WCF para restringir o acesso a usuários autenticados. Isso é parecido com usar o atributo Authorize nos métodos de ação do controller MCV3. Siga os três passos abaixo:

using System;
using System.Security.Permissions;
using System.ServiceModel.Activation;
using System.Threading;
using System.Web;

namespace VariousClients.Web.Services {

//STEP 1 - required for interop with ASP.NET
[AspNetCompatibilityRequirements(RequirementsMode = AspNetCompatibilityRequirementsMode.Required)]
public class CloudData : ICloudData {

public CloudData() {
//STEP 2 - pass caller principal to executing threads principal
Thread.CurrentPrincipal = HttpContext.Current.User;
}

//STEP 3 - verify caller is authenticated
[PrincipalPermission(SecurityAction.Demand, Authenticated = true)]
public String GetTime() {
return DateTime.Now.ToLongDateString();
}
}
}

Client-Side

No código abaixo, vamos primeiro no log contra o AuthenticationService, colocar o ticket em cache, e então chamar CloudData.GetTime.

void btnServiceLogIn_Click(Object sender, RoutedEventArgs e) {
this.lblServiceResult.Content = "calling cloud service...";
var authenticationService = new AuthenticationServiceClient();
if(_faa.Login(() => authenticationService.Login(
Credentials.UserName, Credentials.Password, String.Empty, true),
authenticationService.InnerChannel)) {
this.lblServiceResult.Content = "Log in sucessful.";
} else {
this.lblServiceResult.Content = "Bummer, log in not sucessful.";
}
}

void btnServiceGetData_Click(Object sender, RoutedEventArgs e) {
this.lblServiceResult.Content = "calling cloud service...";
var cloudDataService = new CloudDataClient();
try {
this.lblServiceResult.Content =
_faa.Execute<String>(cloudDataService.GetTime, cloudDataService.InnerChannel);
} catch(System.ServiceModel.Security.SecurityAccessDeniedException ex) {
// three conditions could cause this
// 1. client has never logged in
// 2. ticket is expired
// 3. instance of _faa was not the same instance used to log in
// when this happens in your client code, re-authenticate to get a ticket
this.lblServiceResult.Content = ex.Message;
} catch(Exception ex) {
this.lblServiceResult.Content = ex.Message;
}
}

O código acima é direto e bastante familiar aos desenvolvedores que fazem chamadas de serviços. A mudança de paradigma é o encaminhamento dos proxys dos chamadas de serviço WCF através da instância FormsAuthenticationAssistant (_faa) que gerencia o ticket.

Métodos de serviço Windows Phone 7 e WCF

Antes que você possa chamar o AuthenticationService, você terá que adicionar uma referência de serviço ao serviço de autenticação. Ao adicionar a referência do serviço, eu mudei o namespace para AuthenticationService como na imagem abaixo:

Para cada referência de serviço que você adicionar, você também deve editar o arquivo ServiceReferences.ClientConfig para cada entrada de ligação e configurar a propriedade
enableHttpCookieContainer property para true como feito abaixo.

A ferramenta do Visual Studio para adicionar referências de serviço engasga quando a propriedade enableHttpCookieContainer é configurada em uma ligação, e exibe uma mensagem bizarra de erro quando adiciona ou atualiza referências serviços.

Então… adicione todas as suas referências de serviço, e então edite o arquivo  ServiceReferences.ClientConfig.

Se você tiver que, subsequentemente, adicionar, deletar ou modificar uma referência de serviço, você precisará remover essas propriedades, usar a ferramenta e readicionar as propriedades.

<bindings>
<basicHttpBinding>
<binding name="BasicHttpBinding_AuthenticationService" maxBufferSize="2147483647"
maxReceivedMessageSize="2147483647" enableHttpCookieContainer="true">
<security mode="None" />
</binding>
<binding name="BasicHttpBinding_ICloudData" maxBufferSize="2147483647"
maxReceivedMessageSize="2147483647" enableHttpCookieContainer="true">
<security mode="None" />
</binding>
</basicHttpBinding>
</bindings>

Chamar o método AuthenticationService Login é o mesmo que chamar outros métodos de serviço WCF com uma pequena exceção; a propriedade CookieContainer deve ser configurada para uma nova instância da classe CookieContainer antes de a chamada ser feita. Se você se esquecer de popular essa propriedade, o ticket não será retornado como resposta.

void btnLogin_Click(Object sender, RoutedEventArgs e) {
this.tbResult.Text = "Attempting to log in...";
_cookieJar = new CookieContainer();
var authenticationService = new AuthenticationServiceClient();
authenticationService.CookieContainer = _cookieJar;
authenticationService.LoginCompleted += (s, args) => {
if(args.Error == null) {
this.tbResult.Text = "Login sucessful.";

// CookieJar can be persisted without an exception being thrown
// either when toomstonned or shutdown.
// Yes, it can be read from the below stores also.
// If required for your application, move this code to the appropriate location
// for launching, closing, activating, and deactivating.
//
// uncomment to test
// PhoneApplicationService.Current.State.Add("CookieJar", _cookieJar);
// IsolatedStorageSettings.ApplicationSettings.Add("CookieJar", _cookieJar);

} else {
this.tbResult.Text = "Login failed: " + args.Error.Message;
}
};
authenticationService.LoginAsync(Credentials.UserName, Credentials.Password, String.Empty, true);
}

No código acima, eu criei uma variável de módulo para conter o
CookieContainer (_cookieJar). A mesma instância CookieContainer deve ser passada em chamadas subsequentes para outros endpoints de serviço do WCF. 

A programação do Windows Phone 7 tem como principais conceitos o endereçamento do tombstoning e do lançamento. No fragmento de código acima, você verá uma seção de comentários mostrando que o CookieContainer persistiu até o armazenamento. Baseado nas necessidades e nas exigências de segurança da sua aplicação, você terá que determinar como e quando persistir o seu CookieContainer, de modo que seus usuários não tenham que fazer o log in repetidamente.

void btnGetServiceData_Click(Object sender, RoutedEventArgs e) {
this.tbResult.Text = "Calling service...";
var cloudDataService = new CloudDataClient { CookieContainer = _cookieJar };
cloudDataService.GetTimeCompleted += (s, args) => {
if(args.Error == null) {
this.tbResult.Text = args.Result;
} else {
if(args.Error is System.ServiceModel.Security.SecurityAccessDeniedException) {
// three conditions could cause this
// 1. client has never logged in
// 2. ticket is expired
// 3. the _cookieJar instance is not the same instance used when logging in
// when this happens in your client code, re-authenticate to get a ticket
this.tbResult.Text = args.Error.Message;
} else {
this.tbResult.Text = "Error: " + args.Error.Message;
}
}
};
cloudDataService.GetTimeAsync();
}

No código acima, o _cookieJar está atribuído à propriedade CookieContainer. Compare essa API com a WPF API para o mesmo chamado: esta API é muito mais simples. Seria legal ter isso exposto na API do desktop também.

Executando a aplicação

  • Configure o projeto VariousClients.Web como o projeto startup.
  • Execute a aplicação (isso irá criar sua database de adesão do SQL Express.)
  • Quando a página abaixo for exibida, clique no link Register.

  • Ao completar a página de registro, você terá que utilizar o nome do usuário e a senha especificados na classe Credentials abaixo. Essa classe fornece o nome do usuário e a senha para os projetos WPF e Windows Phone 7 ao fazer chamadas de serviço. 

  • Depois de completar a página de registro, você será redirecionado para a página que demonstra como fazer chamadas jQuery AJAX para um endpoint MVC protegido.
  • Para executar as aplicações WPF and Windows Phone 7, configure-as como projetos iniciais. 

Download

A solução demo pode ser baixada a partir do meu Sky Drive: Various Clients Solution (1.7 MB)

Encerramento

Espero que isso ajude quando você estiver escrevendo suas próprias aplicações para desktop, celular e web que utilizem formulários de autenticação.

?

Texto original disponível em http://karlshifflett.wordpress.com/2011/04/25/various-clients-and-forms-authentication/