DEV Community

Cover image for Securing a Go-Backed Scrappy Twitter API with Magic
Maricris Bonzo for Magic Labs

Posted on • Edited on

Securing a Go-Backed Scrappy Twitter API with Magic

Hi there 🙋🏻‍♀️. The Scrappy Twitter API is a Go-backend project that is secured by the Magic Admin SDK for Go. This SDK makes it super easy to leverage Decentralized ID (DID) Tokens to authenticate your users for your app.

Table of Contents

Demo

To test out our Live demo, click the button below to import the Magic-secured Scrappy Twitter API Postman collection.

Run in Postman

Alternatively, you could also manually import this static snapshot of the collection:

https://www.getpostman.com/collections/595abf685418eeb96401

Postman Collection

This Postman collection has the following routes:

You’ll only be able to make requests with the Get All Tweets and Get a Single Tweet endpoints because they’re unprotected. To post or delete a tweet, you’ll need to pass in a DID token to the request Header.

💁🏻‍♀️ Create an account here to get a DID token.

Great! Now that you’ve got a DID token, you can pass it into the Postman Collection’s HTTP Authorization request header as a Bearer Token and be able to send a Create a Tweet or Delete a Tweet request.

Postman Collection

Keep reading if you want to learn how we secured this Go-backed Scrappy Twitter API with the Magic Admin SDK for Go. 🪄🔐

A High-level View

Here are the building blocks of this project and how each part connects to one another.

  1. Client

    This Next.js app authenticates the user and generates the DID token required to make POST or DELETE requests with the Scrappy Twitter API.

    Noteworthy Package Dependencies:

    • Magic SDK: Allows users to sign up or log in.
    • SWR: Lets us get user info using a hook.
    • @hapi/iron: Lets us encrypt the login cookie for more security.
  2. Server

    This Go server is where all of the Scrappy Twitter API requests are handled. Once the user has generated a DID token from the client side, they can pass it into their Request Header as a Bearer token to hit protected endpoints.

    API routes:

    Noteworthy Packages:

In this article, we’ll only be focusing on the Server’s code to show you how we secured the Go Rest API.

Getting Started

Prerequisites ✅

Magic 🦄

  1. Sign up for an account on Magic.
  2. Create an app.
  3. Keep this Magic tab open. You’ll need both of your app’s Test Publishable and Secret keys soon.

Note: Test API keys are always allowed to be used on localhost. For added security, you can specify the URLs that are allowed to use your live API keys in Magic's Dashboard Settings. Doing so will block your Live API keys from working anywhere except the URLs in your whitelisted domains.

Server 💾

  1. git clone https://github.com/magiclabs/scrappy-twitter-api-server
  2. cd scrappy-twitter-api-server
  3. mv .env.example .env
  4. Go back to the Magic tab to copy your app’s Test Secret Key and paste it as the value for MAGIC_TEST_SECRET_KEY in .env:

    
    MAGIC_TEST_SECRET_KEY=sk_test_XXXXXXXXXX
    
    
  5. Run all .go files with go run .

Client 🖥

  1. git clone https://github.com/magiclabs/scrappy-twitter-api-client
  2. cd scrappy-twitter-api-client
  3. mv .env.local.example .env.local
  4. Populate .env.local with the correct Test keys from your Magic app:

    NEXT_PUBLIC_MAGIC_TEST_PUBLISHABLE_KEY=pk_test_XXXXX
    NEXT_PUBLIC_MAGIC_TEST_SECRET_KEY=sk_test_XXXXX
    NEXT_PUBLIC_HAPI_IRON_SECRET=this-is-a-secret-value-with-at-least-32-characters
    

    Note: The NEXT_PUBLIC_HAPI_IRON_SECRET is needed by @hapi/iron to encrypt an object. Feel free to leave the default value as is while in DEV.

  5. Install package dependencies: yarn

  6. Start the Next.js production server: yarn dev

Postman 📫

  1. Import the DEV version of the Scrappy Twitter API Postman Collection:

    Run in Postman

  2. Generate a DID token on the Client you just started up.

    Note: When you log in from the Client side, the Magic Client SDK generates a DID token which is then converted to an ID token so that it has a longer lifespan (8 hours).

  3. Pass this DID token as a Bearer token into the collection’s HTTP Authorization request header.

Awesome! Now that you have your own local Next.js client and Go server running, let's dive into the server's code.

The Scrappy Twitter Go Rest API

File Structure

This is a simplified view of the Go server's file structure:

├── README.md
├── .env
├── main.go
├── structs.go
├── handlers.go
Enter fullscreen mode Exit fullscreen mode

A Local Database

To keep things simple, when you create or delete a tweet, instead of updating an external database, the Tweets array that’s globally defined and initialized in structs.go is updated accordingly.

// Tweet is struct or data type with an Id, Copy and Author
type Tweet struct {
   ID     string `json:"ID"`
   Copy   string `json:"Copy"`
   Author string `json:"Author"`
}

// Tweets is an array of Tweet structs
var Tweets []Tweet
Enter fullscreen mode Exit fullscreen mode

And when you get all tweets, or a single tweet, the same Tweets array is sent back to the client.

The Routes and Handlers

In summary, this Scrappy Twitter Go Rest API has 4 key routes that are defined in main.go’s handleRequests function:

  1. GET "/tweets" to get all tweets

    myRouter.HandleFunc("/tweets", returnAllTweets)
    
  2. DELETE "/tweet/{id}" to delete a tweet

    myRouter.HandleFunc("/tweet/{id}", deleteATweet).Methods("DELETE")
    
  3. GET "/tweet/{i}" to get a single tweet

    myRouter.HandleFunc("/tweet/{id}", returnSingleTweet)
    
  4. POST "/tweet" to create a tweet

    myRouter.HandleFunc("/tweet", createATweet).Methods("POST")
    
    

Note: The POST and DELETE routes are currently unprotected. Move to the next section to see how we can use a DID token to protect them.

As you can see, each of these routes have their own handlers to properly respond to requests. These handlers are defined in handlers.go:

  1. GET "/tweets" => returnAllTweets()

    // Returns ALL tweets ✨
    func returnAllTweets(w http.ResponseWriter, r *http.Request) {
       fmt.Println("Endpoint Hit: returnAllTweets")
       json.NewEncoder(w).Encode(Tweets)
    }
    
  2. DELETE "/tweet/{id}" => deleteATweet()

    // Deletes a tweet ✨
    func deleteATweet(w http.ResponseWriter, r *http.Request) {
       fmt.Println("Endpoint Hit: deleteATweet")
    
       // Parse the path parameters
       vars := mux.Vars(r)
    
       // Extract the `id` of the tweet we wish to delete
       id := vars["id"]
    
       // Loop through all our tweets
       for index, tweet := range Tweets {
    
           /*
               Checks whether or not our id path
               parameter matches one of our tweets.
           */
           if tweet.ID == id {
    
               // Updates our Tweets array to remove the tweet
               Tweets = append(Tweets[:index], Tweets[index+1:]...)
           }
       }
    
       w.Write([]byte("Yay! Tweet has been DELETED."))
    }
    
  3. GET "/tweet/{i}" => returnSingleTweet()

    // Returns a SINGLE tweet ✨
    func returnSingleTweet(w http.ResponseWriter, r *http.Request) {
       fmt.Println("Endpoint Hit: returnSingleTweet")
       vars := mux.Vars(r)
       key := vars["id"]
    
       /*
           Loop over all of our Tweets
           If the tweet.Id equals the key we pass in
           Return the tweet encoded as JSON
       */
       for _, tweet := range Tweets {
           if tweet.ID == key {
               json.NewEncoder(w).Encode(tweet)
           }
       }
    }
    
  4. POST "/tweet" => createATweet()

    // Creates a tweet ✨
    func createATweet(w http.ResponseWriter, r *http.Request) {
       fmt.Println("Endpoint Hit: createATweet")
       /*
           Get the body of our POST request
           Unmarshal this into a new Tweet struct
       */
       reqBody, _ := ioutil.ReadAll(r.Body)
       var tweet Tweet
       json.Unmarshal(reqBody, &tweet)
    
       /*
           Update our global Tweets array to include
           Our new Tweet
       */
       Tweets = append(Tweets, tweet)
       json.NewEncoder(w).Encode(tweet)
    
       w.Write([]byte("Yay! Tweet CREATED."))
    }
    

Securing the Go Rest API with Magic Admin SDK

Now it’s time to show you how to protect the DELETE "/tweet/{id}" and POST "/tweet" routes, such that only authenticated users are able to create a tweet and only the author of a specific tweet is allowed to delete it.

Magic Setup

  1. Get the Go Magic Admin SDK package:
    go get github.com/magiclabs/magic-admin-go

  2. Configure the Magic Admin SDK in handlers.go:

    1. Import the following packages:

         "github.com/joho/godotenv"
         "github.com/magiclabs/magic-admin-go"
         "github.com/magiclabs/magic-admin-go/client"
         "github.com/magiclabs/magic-admin-go/token"
      
    2. Load the .env file and get the Test Secret Key:

      // Load .env file from given path
      var err = godotenv.Load(".env")
      
      // Get env variables
      var magicSecretKey = os.Getenv("MAGIC_TEST_SECRET_KEY")
      
    3. Instantiate Magic:

      var magicSDK = client.New(magicSecretKey, magic.NewDefaultClient())
      

Magic Admin SDK for Go

In order to protect the routes to POST or DELETE a tweet, we’ll be creating a Gorilla Mux middleware to check whether or not the user is authorized to make requests to these endpoints.

💡 You can think of a middleware as reusable code for HTTP request handling.

checkBearerToken()

Let’s call this middleware checkBearerToken() and define it in handlers.go.

To implement the middleware behavior, we’ll be using chainable closures. This way, we could wrap each handler with a checkBearerToken middleware.

Here’s the initial look of our function:

func checkBearerToken(next httpHandlerFunc) httpHandlerFunc {
   return func(res http.ResponseWriter, req *http.Request) {
      /* More code is coming! */
      next(res, req)
   }
}
Enter fullscreen mode Exit fullscreen mode

💁🏻‍♀️ Now let’s update checkBearerToken to make sure the DID token exists in the HTTP Header Request. If it does, store the value into a variable called didToken:

func checkBearerToken(next httpHandlerFunc) httpHandlerFunc {
   fmt.Println("Middleware Hit: checkBearerToken")
   return func(res http.ResponseWriter, req *http.Request) {

       // Check whether or not DIDT exists in HTTP Header Request
       if !strings.HasPrefix(req.Header.Get("Authorization"), authBearer) {
           fmt.Fprintf(res, "Bearer token is required")
           return
       }

       // Retrieve DIDT token from HTTP Header Request
       didToken := req.Header.Get("Authorization")[len(authBearer)+1:]

      /* More code is coming! */
      next(res, req)
   }
}
Enter fullscreen mode Exit fullscreen mode

Cool. Now that we’ve got a DID token, we can use it to create an instance of a Token. The Token resource provides methods to interact with the DID Token. We’ll need to interact with the DID Token to get the authenticated user’s information. But first, we’ll need to validate it.

💁🏻‍♀️ Update checkBearerToken to include this code:

func checkBearerToken(next httpHandlerFunc) httpHandlerFunc {
   fmt.Println("Middleware Hit: checkBearerToken")
   return func(res http.ResponseWriter, req *http.Request) {

       // Check whether or not DIDT exists in HTTP Header Request
       if !strings.HasPrefix(req.Header.Get("Authorization"), authBearer) {
           fmt.Fprintf(res, "Bearer token is required")
           return
       }

       // Retrieve DIDT token from HTTP Header Request
       didToken := req.Header.Get("Authorization")[len(authBearer)+1:]

       // Create a Token instance to interact with the DID token
       tk, err := token.NewToken(didToken)
       if err != nil {
           fmt.Fprintf(res, "Malformed DID token error: %s", err.Error())
           res.Write([]byte(err.Error()))
           return
       }

       // Validate the Token instance before using it
       if err := tk.Validate(); err != nil {
           fmt.Fprintf(res, "DID token failed validation: %s", err.Error())
           return
       }

      /* More code is coming! */
      next(res, req)
   }
}
Enter fullscreen mode Exit fullscreen mode

Now that we’ve validated the Token (tk), we can call tk.GetIssuer() to retrieve the iss; a Decentralized ID of the Magic user who generated the DID Token. We’ll be passing iss into magicSDK.User.GetMetadataByIssuer to get the authenticated user’s information.

💁🏻‍♀️ Update checkBearerToken again:

func checkBearerToken(next httpHandlerFunc) httpHandlerFunc {
   fmt.Println("Middleware Hit: checkBearerToken")
   return func(res http.ResponseWriter, req *http.Request) {

       // Check whether or not DIDT exists in HTTP Header Request
       if !strings.HasPrefix(req.Header.Get("Authorization"), authBearer) {
           fmt.Fprintf(res, "Bearer token is required")
           return
       }

       // Retrieve DIDT token from HTTP Header Request
       didToken := req.Header.Get("Authorization")[len(authBearer)+1:]

       // Create a Token instance to interact with the DID token
       tk, err := token.NewToken(didToken)
       if err != nil {
           fmt.Fprintf(res, "Malformed DID token error: %s", err.Error())
           res.Write([]byte(err.Error()))
           return
       }

       // Validate the Token instance before using it
       if err := tk.Validate(); err != nil {
           fmt.Fprintf(res, "DID token failed validation: %s", err.Error())
           return
       }

       // Get the user's information
       userInfo, err := magicSDK.User.GetMetadataByIssuer(tk.GetIssuer())
       if err != nil {
           fmt.Fprintf(res, "Error when calling GetMetadataByIssuer: %s", err.Error())
           return
       }

      /* More code is coming! */
      next(res, req)
   }
}
Enter fullscreen mode Exit fullscreen mode

Awesome. If the request was able to make it past this point, then we can be assured that it was an authenticated request. All we need to do now is pass the user’s information to the handler the middleware is chained to. We’ll be using Go's Package context to achieve this.

💡 In short, the Package context will make it easy for us to store objects as context values and pass them to all handlers that are chained to our checkBearerToken middleware.

Make sure to import the "context" in handlers.go.

Then create a userInfoKey at the top of handlers.go (we'll be passing userInfoKey into context.WithValue soon):

type key string
const userInfoKey key = "userInfo"
Enter fullscreen mode Exit fullscreen mode

💁🏻‍♀️ Update checkBearerToken one last time to use context values to store the user’s information:

func checkBearerToken(next httpHandlerFunc) httpHandlerFunc {
   fmt.Println("Middleware Hit: checkBearerToken")
   return func(res http.ResponseWriter, req *http.Request) {

       // Check whether or not DIDT exists in HTTP Header Request
       if !strings.HasPrefix(req.Header.Get("Authorization"), authBearer) {
           fmt.Fprintf(res, "Bearer token is required")
           return
       }

       // Retrieve DIDT token from HTTP Header Request
       didToken := req.Header.Get("Authorization")[len(authBearer)+1:]

       // Create a Token instance to interact with the DID token
       tk, err := token.NewToken(didToken)
       if err != nil {
           fmt.Fprintf(res, "Malformed DID token error: %s", err.Error())
           res.Write([]byte(err.Error()))
           return
       }

       // Validate the Token instance before using it
       if err := tk.Validate(); err != nil {
           fmt.Fprintf(res, "DID token failed validation: %s", err.Error())
           return
       }

       // Get the the user's information
       userInfo, err := magicSDK.User.GetMetadataByIssuer(tk.GetIssuer())
       if err != nil {
           fmt.Fprintf(res, "Error when calling GetMetadataByIssuer: %s", err.Error())
           return
       }

       // Use context values to store user's info
       ctx := context.WithValue(req.Context(), userInfoKey, userInfo)
       req = req.WithContext(ctx)
       next(res, req)
   }
}
Enter fullscreen mode Exit fullscreen mode

Looks good! Writing checkBearerToken() is the bulk of the work needed to Magic-ally protect the routes for posting or deleting a tweet.

All that’s left to do now is:

  1. Wrap createATweet and deleteATweet handlers in main.go’s handleRequests function with this checkBearerToken middleware.
  2. Update these handlers to get the user’s information from the context values, and then tag each tweet with the user’s email so that authors are able to delete their own tweet.

handleRequests()

All we did in main.go’s handleRequest() is wrap the deleteATweet and createATweet handlers with the checkBearerToken middleware function.

func handleRequests() {

   /* REST OF THE CODE IS OMITTED */

   // Delete a tweet ✨
   myRouter.HandleFunc("/tweet/{id}", checkBearerToken(deleteATweet)).Methods("DELETE")

   // Create a tweet ✨
   myRouter.HandleFunc("/tweet", checkBearerToken(createATweet)).Methods("POST")


   /* REST OF THE CODE IS OMITTED */
}
Enter fullscreen mode Exit fullscreen mode

createATweet()

To access the key-value pairs in userInfo object, we first needed to get the object by calling r.Context().Value(userInfoKey) with the userInfoKey we defined earlier, and then we needed to assert two things:

  1. userInfo is not nil
  2. the value stored in userInfo is of type *magic.UserInfo

Both of these assertions are done with userInfo.(*magic.UserInfo).

// Creates a tweet ✨
func createATweet(w http.ResponseWriter, r *http.Request) {

   fmt.Println("Endpoint Hit: createATweet")

   // Get the authenticated author's info from context values
   userInfo := r.Context().Value(userInfoKey)
   userInfoMap := userInfo.(*magic.UserInfo)

   /*
       Get the body of our POST request
       Unmarshal this into a new Tweet struct
       Add the authenticated author to the tweet
   */
   reqBody, _ := ioutil.ReadAll(r.Body)
   var tweet Tweet
   json.Unmarshal(reqBody, &tweet)
   tweet.Author = userInfoMap.Email

   /*
       Update our global Tweets array to include
       Our new Tweet
   */
   Tweets = append(Tweets, tweet)
   json.NewEncoder(w).Encode(tweet)

   fmt.Println("Yay! Tweet CREATED.")
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we’ve also added the authenticated author to the tweet.

deleteATweet()

Now that we know which author created the tweet, we’ll be able to only allow that author to delete it.

// Deletes a tweet ✨
func deleteATweet(w http.ResponseWriter, r *http.Request) {
   fmt.Println("Endpoint Hit: deleteATweet")

   // Get the authenticated author's info from context values
   userInfo := r.Context().Value(userInfoKey)
   userInfoMap := userInfo.(*magic.UserInfo)

   // Parse the path parameters
   vars := mux.Vars(r)
   // Extract the `id` of the tweet we wish to delete
   id := vars["id"]

   // Loop through all our tweets
   for index, tweet := range Tweets {

       /*
           Checks whether or not our id and author path
           parameter matches one of our tweets.
       */
       if (tweet.ID == id) && (tweet.Author == userInfoMap.Email) {

           // Updates our Tweets array to remove the tweet
           Tweets = append(Tweets[:index], Tweets[index+1:]...)
           w.Write([]byte("Yay! Tweet has been DELETED."))
           return
       }
   }

   w.Write([]byte("Ooh. You can't delete someone else's tweet."))
}
Enter fullscreen mode Exit fullscreen mode

Yay! Now you know how to protect Go RESTful API routes 🎉. Feel free to create and delete your own tweet, and also try to delete our default tweet in the Postman Collection to test our protected endpoints.

I hope you enjoyed how quick and easy it was to secure the Go-backed Scrappy Twitter API with the Magic Admin SDK for Go. Next time you want to build a Go REST API for authenticated users, this guide will always have your back.

Btw, if you run into any issues, feel free to reach out @seemcat.

Till next time 🙋🏻‍♀️.

Top comments (0)