146

Basic Role-Based HTTP Authorization in Go with Casbin - zupzup

 4 years ago
source link: https://zupzup.org/casbin-http-role-auth/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Basic Role-Based HTTP Authorization in Go with Casbin

Authentication and Authorization are essential parts of any secured Web Application. I recently finished writing my first serious web application in Go and this is one part in a series of posts of things about what I learned during that experience.

In this post, we will look at HTTP Authorization in Go using the great casbin library. We will also use scs for session management in the code example.

The following example is very basic, but I hope it shows how implementing authorization can be achieved in a Go web application. The focus is on the usage of casbin, so the other parts of the application will be very minimal and abstract (e.g.: a login without password).

So, let’s take a look!

Disclaimer: Please don’t use the following example code as a template for a production-grade application, the code focuses on clarity, not on security.

Setup

First, we create a User model, with some utility functions:

type User struct {
    ID   int
    Name string
    Role string
}

type Users []User

func (u Users) Exists(id int) bool {
    ...
}

func (u Users) FindByName(name string) (User, error) {
    ...
}

Then we come to the casbin configuration. There are two files we will create for this purpose - a configuration file and a policy file. The configuration file uses the PERM metamodel. PERM stands for Policy, Effect, Request, Matchers.

Our auth_model.conf has the following contents:

[request_definition]
r = sub, obj, act

[policy_definition]
p = sub, obj, act

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = r.sub == p.sub && keyMatch(r.obj, p.obj) && (r.act == p.act || p.act == "*")

This defines the request and policy to contain subject, object and action. In the HTTP case, this means that the subject is the user role, the object is the path the user wants to access and the action is the request method (e.g.: GET, POST etc.).

The matchers define how the policy parts are matched, either directly like the subject or with a helper method like keyMatch, which can also match wildcards. casbin is of course a lot more powerful than this simple example lets on. You can define all sorts of custom functionality in a declarative way, making it easy to switch and maintain authorization configurations.

When security is concerned, I usually opt for the simplest solution possible, because when things get complex and unmaintainable, errors start to happen.

The policy file, in this example, is a simple csv file describing which role can access which paths etc.

The policy.csv file looks like this:

p, admin, /*, *
p, anonymous, /login, *
p, member, /logout, *
p, member, /member/*, *

This is, of course, also very simple. In this case, we simply define that the admin role can access everything, the member role can access everything after /member/ as well as logout and all unauthenticated users can use the login.

What I like about this format is that it stays maintainable, even if there are many rules and user roles.

Implementation

Let’s start with the main function, which sets everything up and starts the HTTP server:

func main() {
    // setup casbin auth rules
    authEnforcer, err := casbin.NewEnforcerSafe("./auth_model.conf", "./policy.csv")
    if err != nil {
        log.Fatal(err)
    }

    // setup session store
    engine := memstore.New(30 * time.Minute)
    sessionManager := session.Manage(engine, session.IdleTimeout(30*time.Minute), session.Persist(true), session.Secure(true))

    // setup users
    users := createUsers()

    // setup routes
    mux := http.NewServeMux()
    mux.HandleFunc("/login", loginHandler(users))
    mux.HandleFunc("/logout", logoutHandler())
    mux.HandleFunc("/member/current", currentMemberHandler())
    mux.HandleFunc("/member/role", memberRoleHandler())
    mux.HandleFunc("/admin/stuff", adminHandler())

    log.Print("Server started on localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", sessionManager(authorization.Authorizer(authEnforcer, users)(mux))))

}

There are several things going on here. Basically, we set up the authorization rules, session management, users, HTTP handlers and start the HTTP server with the router wrapped by an authorization middleware and the session manager.

Let’s go through it one by one.

First, we create a casbin enforcer with the above mentioned auth_model.conf and policy.csv files. If this fails, we shut down, as there is likely something wrong with the authorization rules.

The next step is to set up the sessionManager. We create an in-memory session store with a 30-minute timeout and a session manager with secure cookie storage.

The createUsers function simply creates three users with their user roles as seen below:

func createUsers() model.Users {
    users := model.Users{}
    users = append(users, model.User{ID: 1, Name: "Admin", Role: "admin"})
    users = append(users, model.User{ID: 2, Name: "Sabine", Role: "member"})
    users = append(users, model.User{ID: 3, Name: "Sepp", Role: "member"})
    return users
}

In a real-world application, we would probably have a database containing these users - in this example we will just use this list.

Next up are the handlers for login and logout:

func loginHandler(users model.Users) http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        name := r.PostFormValue("name")
        user, err := users.FindByName(name)
        if err != nil {
            writeError(http.StatusBadRequest, "WRONG_CREDENTIALS", w, err)
            return
        }
        // setup session
        if err := session.RegenerateToken(r); err != nil {
            writeError(http.StatusInternalServerError, "ERROR", w, err)
            return
        }
        session.PutInt(r, "userID", user.ID)
        session.PutString(r, "role", user.Role)
        writeSuccess("SUCCESS", w)
    })
}

func logoutHandler() http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if err := session.Renew(r); err != nil {
            writeError(http.StatusInternalServerError, "ERROR", w, err)
            return
        }
        writeSuccess("SUCCESS", w)
    })
}

For login, we get the name from the request payload, check if there is a user with that name and if that is the case, we create a new session and put the user role and ID into it.

The logout handler simply creates a new, empty session for the user, deleting the old session from the session store, logging out the user.

Then we have a few handlers, which test the implementation by returning the userID and user role. The endpoints for these handlers are secured by casbin as seen above in the policy.csv file.

func currentMemberHandler() http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        uid, err := session.GetInt(r, "userID")
        if err != nil {
            writeError(http.StatusInternalServerError, "ERROR", w, err)
            return
        }
        writeSuccess(fmt.Sprintf("User with ID: %d", uid), w)
    })
}

func memberRoleHandler() http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        role, err := session.GetString(r, "role")
        if err != nil {
            writeError(http.StatusInternalServerError, "ERROR", w, err)
            return
        }
        writeSuccess(fmt.Sprintf("User with Role: %s", role), w)
    })
}

func adminHandler() http.HandlerFunc {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        writeSuccess("I'm an Admin!", w)
    })
}

With session.GetInt and session.GetString we can fetch values stored in the current session.

For these handlers to be actually secured by the authorization mechanism, we need to implement the Authorizer middleware, which wraps the router.

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, err := session.GetString(r, "role")
            if err != nil {
                writeError(http.StatusInternalServerError, "ERROR", w, err)
                return
            }

            if role == "" {
                role = "anonymous"
            }

            // if it's a member, check if the user still exists
            if role == "member" {
                uid, err := session.GetInt(r, "userID")
                if err != nil {
                    writeError(http.StatusInternalServerError, "ERROR", w, err)
                    return
                }
                exists := users.Exists(uid)
                if !exists {
                    writeError(http.StatusForbidden, "FORBIDDEN", w, errors.New("user does not exist"))
                    return
                }
            }

            // casbin rule enforcing
            res, err := e.EnforceSafe(role, r.URL.Path, r.Method)
            if err != nil {
                writeError(http.StatusInternalServerError, "ERROR", w, err)
                return
            }
            if res {
                next.ServeHTTP(w, r)
            } else {
                writeError(http.StatusForbidden, "FORBIDDEN", w, errors.New("unauthorized"))
                return
            }
        }

        return http.HandlerFunc(fn)
    }
}

The Authorizer middleware receives the casbin rule enforcer and the users as parameters. First, it tries to fetch the role of the requesting user from the session.

If the user has no user role, we set it to anonymous, otherwise, if the user is a member we check if it’s a valid account that still exists by fetching the userID from the session and checking it against the users list.

After these preliminary checks, we can give the user role, request path and the request method to the casbin enforcer, which decides, if the user with the given role (subject) is allowed to access the action specified by the request method (action) and the path (object).

If the check fails, we return a 403 error, otherwise we simply call the wrapped HTTP handler, authorizing the user to execute this action.

As mentioned above in the main method, the sessionManager and Authorizer wrap our mux, so that every request needs to go through this middleware, making sure that all requests are secured with our authorization mechanism.

You can test this by logging in with the different users and trying out the above-mentioned handlers using a tool like curl or postman.

That’s it. You can find the full code here.

Conclusion

I have used casbin in production in a medium-sized web application and was pleased with the maintainability and stability of the library. Looking over its documentation, casbin seems like a very powerful tool for authorization, featuring a staggering amount of access control models in a declarative way.

I hope this example could showcase the power of casbin and scs and, again, to demonstrate the conciseness and clarity of Go for web applications and in general.

Have fun authorizing. ;)

Resources


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK