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
name
como um parâmetro viaform-data
- para simplificar não vou usar senhas neste exemplo, pois o foco é a autorização. - Usuários válidos:
Admin
ID:1
, Role:admin
Sabine
ID:2
, Role:member
Sepp
ID: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 rolemember
GET /member/role
- acessível se o usuário que estiver logado possui a rolemember
GET /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
admin
podem acessar todas as rotas, com todos os métodos HTTP; - usuários com a role
anonymous
podem acessar apenas a rota/login
, usando o métodoPOST
; - usuários com a role
member
podem acessar a rota/logout
apenas com o métodoGET
; - usuários com a role
member
podem 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.