Meu nome é Elton Minetto

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 via form-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
  • POST /logout - acessível se o usuário estiver logado
  • GET /member/current - acessível se o usuário que estiver logado possui a role member
  • GET /member/role - acessível se o usuário que estiver logado possui a role member
  • GET /admin/stuff - acessível se o usuário que estiver logado possui a role admin

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étodo POST;
  • usuários com a role member podem acessar a rota /logout apenas com o método GET;
  • 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:

15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
func main() {
	// setup casbin auth rules
	authEnforcer, err := casbin.NewEnforcerSafe("./auth_model.conf", "./policy.csv")
	if err != nil {
		log.Fatal(err)
	}

	users := createUsers()

	// setup routes
	r := mux.NewRouter()
	r.HandleFunc("/login", loginHandler(users)).Methods("POST")
	r.HandleFunc("/logout", logoutHandler())
	r.HandleFunc("/member/current", currentMemberHandler())
	r.HandleFunc("/member/role", memberRoleHandler())
	r.HandleFunc("/admin/stuff", adminHandler())
	r.Use(authorization.Authorizer(authEnforcer, users))

	log.Print("Server started on localhost:8080")
	log.Fatal(http.ListenAndServe(":8080", r))
}

func loginHandler(users model.Users) http.HandlerFunc {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		name := r.FormValue("name")
		user, err := users.FindByName(name)
		if err != nil {
			writeError(http.StatusBadRequest, "WRONG_CREDENTIALS", w, err)
			return
		}
		var result struct {
			Token string `json:"token"`
		}
		result.Token, err = security.NewToken(user.ID, user.Role)
		if err != nil {
			writeError(http.StatusInternalServerError, "ERROR", w, err)
			return
		}

		if err := json.NewEncoder(w).Encode(result); err != nil {
			writeError(http.StatusInternalServerError, "ERROR", w, err)
			return
		}
	})
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
package authorization

import (
	"errors"
	"log"
	"net/http"

	"github.com/casbin/casbin"
	"github.com/eminetto/casbin-http-role-example/model"
	"github.com/eminetto/casbin-http-role-example/security"
)

// Authorizer is a middleware for authorization
func Authorizer(e *casbin.Enforcer, users model.Users) func(next http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		fn := func(w http.ResponseWriter, r *http.Request) {
			role := "anonymous"
			tokenString := r.Header.Get("Authorization")
			var uid int
			var err error
			if tokenString != "" {
				uid, role, err = parseToken(tokenString)
				if err != nil {
					writeError(http.StatusInternalServerError, "ERROR", w, err)
					return
				}
			}
			// check if the user still exists
			if role != "anonymous" {
				exists := users.Exists(uid)
				if !exists {
					writeError(http.StatusForbidden, "FORBIDDEN", w, errors.New("user does not exist"))
					return
				}
			}
			// 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)
		}

		return http.HandlerFunc(fn)
	}
}

func parseToken(token string) (int, string, error) {
	t, err := security.ParseToken(token)
	if err != nil {
		return 0, "", nil
	}
	tData, err := security.GetClaims(t)
	if err != nil {
		return 0, "", nil
	}
	userID := tData["userID"].(float64)
	role := tData["role"].(string)
	return int(userID), role, nil
}

func writeError(status int, message string, w http.ResponseWriter, err error) {
	log.Print("ERROR: ", err.Error())
	w.WriteHeader(status)
	w.Write([]byte(message))
}

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.