Fazendo o controle de acesso de uma API usando Go e Casbin
Quando pensamos em segurança, um dos conceitos mais básicos é o dos 3As: Authentication, Authorization e Accounting (autenticação, autorização e contabilização). Sendo:
- Authentication: provê formas de identificar um usuário, geralmente usando um nome de usuário (username) e senha. Além disso, podemos usar soluções como oAuth e tokens JWT para tornar o processo mais seguro.
- Authorization: foco deste post, o processo de autorização determina o que um usuário tem permissão de acessar no sistema.
- Accounting: mede o consumo dos recursos que o usuário acessou. Isso pode incluir desde estatísticas de consumo de memória, CPU, custos financeiros, logs de auditoria, etc. Devo falar sobre isso em um futuro post.
Neste post vamos ver como implementar o processo de authorization em uma API escrita em Go, usando o Casbin.
O Casbin é descrito como uma biblioteca de autorização que suporta modelos de acesso como ACL (Access Control List), RBAC (Role-Based Access Control), ABAC (Attribute-Based Access Control) e que possui bibliotecas para diversas linguagens de programação como Go, C#, Java, PHP, Elixir, etc.
Neste exemplo vamos implementar o controle de uma API usando o modelo de acesso RBAC. Nossa API vai ter as seguintes rotas e regras:
POST /login- acessível se o usuário não estiver logado- recebe
namecomo um parâmetro viaform-data- para simplificar não vou usar senhas neste exemplo, pois o foco é a autorização. - Usuários válidos:
AdminID:1, Role:adminSabineID:2, Role:memberSeppID:3, Role:member
- recebe
POST /logout- acessível se o usuário estiver logadoGET /member/current- acessível se o usuário que estiver logado possui a rolememberGET /member/role- acessível se o usuário que estiver logado possui a rolememberGET /admin/stuff- acessível se o usuário que estiver logado possui a roleadmin
O primeiro passo é definirmos qual vai ser o modelo de autorização que o Casbin vai usar. Para isso, criei o arquivo auth_model.conf na raiz do projeto com o seguinte conteúdo:
# Queremos que cada solicitação seja uma tupla de três itens,
# sendo o primeiro item associado ao sujeito (sub), o segundo ao objeto (obj) e o terceiro à ação (act).
# Um exemplo de um pedido válido com base nesta definição é
# `["alice, "blog_post", "read"]` (pode `alice` `read` `blog_post`?).
[request_definition]
r = sub, obj, act
# Cada definição de política deve ter uma chave e uma lista de atributos separados por um sinal de igual =. Todas as regras de política têm como resultado o atributo eft e este só pode assumir o valor "allow" ou "deny"
[policy_definition]
p = sub, obj, act
# O efeito da política define se o acesso deve ser aprovado ou negado
# Usamos o seguinte efeito de política em nosso sistema para significar que se houver qualquer regra de política
# correspondente do tipo allow (ou seja, eft == "allow"), o efeito final é allow.
# O que também significa que se não houver correspondência ou todas as correspondências forem do tipo deny, o efeito final será deny.
[policy_effect]
e = some(where (p.eft == allow))
# matchers é apenas uma expressão booleana usada para determinar se
# uma solicitação corresponde à regra de política fornecida.
[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")
Tentei documentar no arquivo o que cada trecho significa, mas no site do projeto existe um Editor que facilita a criação e validação dos arquivos de configuração.
O próximo passo é definirmos nossas políticas de acesso. Neste exemplo vamos usar a solução mais simples, que é a criação de um arquivo .csv dentro do projeto. Mas na documentação oficial é possível encontrarmos exemplos de outros formatos de armazenamento como bancos SQL e NoSQL, bem como outras opções mais complexas.
O arquivo policy.csv ficou desta forma:
p, admin, /*, *
p, anonymous, /login, POST
p, member, /logout, GET
p, member, /member/*, *
Ele descreve as regras de acesso as nossas rotas:
- usuários com a role
adminpodem acessar todas as rotas, com todos os métodos HTTP; - usuários com a role
anonymouspodem acessar apenas a rota/login, usando o métodoPOST; - usuários com a role
memberpodem acessar a rota/logoutapenas com o métodoGET; - usuários com a role
memberpodem acessar qualquer rota que inicie com/member, usando qualquer método.
Agora vamos aos códigos. Neste repositório é possível ver todo o exemplo, mas aqui quero ressaltar dois trechos main.go do projeto:
| |
Na função main criamos o authEnforcer, que lê os arquivos de configuração e de política de acesso. Ainda nesta função definimos que todas as rotas vão usar o middleware que fará a autorização: r.Use(authorization.Authorizer(authEnforcer, users)). Desta forma, o nosso router vai invocar o middleware antes de executar o handler correspondente.
A função loginHandler faz a autenticação e cria um token JWT com o ID e a Role do usuário. Este token vai ser usado pelo middleware de autorização para verificar as permissões do usuário.
O código do middleware de autorização ficou assim:
| |
O middleware usa o token que está no header Authorization, faz o parse e usa a informação da role do usuário para verificar se o acesso é permitido:
// casbin enforce
res, err := e.EnforceSafe(role, r.URL.Path, r.Method)
if err != nil {
writeError(http.StatusInternalServerError, "ERROR", w, err)
return
}
if !res {
writeError(http.StatusForbidden, "FORBIDDEN", w, errors.New("unauthorized"))
return
}
next.ServeHTTP(w, r)
Caso o acesso não seja permitido é gerado um erro do tipo http.StatusForbidden, caso contrário o handler correspondente é acessado com sucesso.
Com o Casbin é possível criar regras bem complexas com pouco esforço. Além deste exemplo, é possível usar o Casbin com o Go kit para implementar a autorização em microsserviços. Outra vantagem é o fato de existirem bibliotecas para várias linguagens, então é possível reutilizar as configurações e políticas em vários projetos.
O Casbin se mostrou uma solução bem simples e poderosa para implementarmos a camada de autorização em APIs, microsserviços e outras aplicações.