Go, prosty serwer HTTP + REST + JWT

Świat aplikacji internetowych ewoluował w kierunku rozproszonej architektury komunikującej się między modułami za pomocą interfejsów, np REST (Representational State Transfer). Podejście to umożliwia efektywną komunikację między klientem a serwerem.

Ten artykuł zawiera „szczegółowy” przewodnik dotyczący implementacji prostego serwera HTTP w języku Go obsługującego protokół REST i umożliwiającego autoryzację użytkownika za pomocą tokenów JWT.

Bogata biblioteka standardowa Go, oraz liczne biblioteki dostępne w publicznych repozytoriach, sprawiają, że tworzenie wspomnianej implementacji serwera w Go jest niesamowicie łatwe i intuicyjne. Moje dotychczasowe doświadczenia podpowiadają mi, że była to najprzyjemniejsza implementacja tego typu prototypu z jaką miałem do czynienia.

Czego się nauczysz? W trakcie tego poradnika dowiesz się:

  • jak stworzyć podstawowy serwer REST,
  • jak obsługiwać zapytania HTTP,
  • jak zwracać odpowiedzi odpowiedzi na żądanie HTTP w formacie REST
  • jak autoryzować zapytania użytkownika z wykorzystaniem JWT

Całość projektu podzieliłem na kilka plików, by logicznie podzielić implementację. Struktura projektu wygląda następująco:

├── go.mod 
├── go.sum 
├── main.go 
├── README.md 
└── service 
   └── v1 
       └── rest 
           ├── handlers.go 
           ├── server.go 
           ├── tokens.go 
           └── types.go

Jeśli nie wiesz jak zainicjować projekt podpowiadam

go mod init  prototype

Wynikiem powyżej komendy jest inicjalizacja projektu i utworzenie plików go.mod i go.sum.

Zaczynając od rzeczy najbardziej podstawowych, plik types.go zawierający stałe, zmienne, i typy wykorzystywane w projekcie:

# FILE ./service/v1/rest/types.go
package v1rest

const Version string = "v1.0.0"

var SECRET = []byte("--== HideMeFromPryingEyes ==--")

var userMap = map[string]string{
	"admin": "haslo123",
}

type User struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type VersionMsg struct {
	Version string `json:version`
}

Jak widzisz w prototypie znajduje się wiele rzeczy, na widok których krwawią oczy. Z całą pewnością wartość przypisana do zmiennej SECRET nie powinna się tu znaleźć, podobnie zresztą jak userMap zawierająca mapę z użytkownikami i hasłami. Produkcyjna implementacja powinna wykorzystywać do powyższych odpowiednio zmienne środowiskowe i bazę danych. Ponadto nigdy nie przechowuj haseł użytkownika w niezaszyfrowanej formie!

Plik handlers.go zawiera obsługę żądań o jakie użytkownik będzie mógł wysyłać do naszego prototypowego serwera.

# FILE ./service/v1/rest/handlers.go
package v1rest

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
)

func loginHandler(writer http.ResponseWriter, request *http.Request) {
	switch request.Method {
	case "POST":
		var user User
		err := json.NewDecoder(request.Body).Decode(&user)
		if err != nil {
			fmt.Fprintf(writer, "Invalid or corrupted request!")
			return
		}

		if userMap[user.Username] == "" || userMap[user.Username] != user.Password {
			fmt.Fprintf(writer, "Not enough mana!")
			return
		}

		token, err := createJWT(user.Username)
		if err != nil {
			fmt.Fprintf(writer, "%s", err)
		}

		fmt.Fprintf(writer, token)
		return

	default:
		fmt.Fprintf(writer, "%s is not implemented.", request.Method)
		return
	}
}

func versionHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")

	err := validateJWT(w, r)
	if err != nil {
		w.WriteHeader()
		resp := make(map[string]string)
		resp["message"] = fmt.Sprint(err)
		jsonResp, err := json.Marshal(resp)
		if err != nil {
			log.Fatalf("Error happened in JSON marshal. Err: %s", err)
		}
		w.Write(jsonResp)
		return
	}

	w.WriteHeader(http.StatusOK)
	data := VersionMsg{Version: Version}
	jsonData, err := json.Marshal(data)
	if err != nil {
		fmt.Printf("Marshaling data failed: %s", err.Error())
	}
	w.Write(jsonData)
}

Nasz serwer posiada obsługę dwóch żądań: logowania (loginHandler) i sprawdzenia wersji serwera (versionHandler). Przed wykonaniem zapytania o numer wersji serwera będziemy musieli się uwierzytelnić metodąloginHandler. Po sprawdzeniu, czy nasza nazwa użytkownika i hasło znajdują się w mapie userMap zwróci nam token, niezbędny do obsługi innych żądań, oraz kod odpowiedzi http.StatusOK. W przypadku niezgodności nazwy pary nazwy użytkownika i hasła zwrócony zostanie błąd http.StatusUnauthorized, a zamiast token otrzymamy opis błędu.

Implementacja odpowiedzialna za tworzenie i weryfikację tokenów znajduje się w pliku tokens.go

# FILE ./service/v1/rest/tokens.go
package v1rest

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

	"github.com/golang-jwt/jwt/v5"
)

func createJWT(username string) (string, error) {
	token := jwt.New(jwt.SigningMethodHS512)
	claims := token.Claims.(jwt.MapClaims)

	claims["exp"] = time.Now().Add(1 * time.Minute).Unix()
	claims["authorized"] = true
	claims["user"] = username

	tokenStr, err := token.SignedString(SECRET)
	if err != nil {
		fmt.Errorf("Something went wrong during token creation process: %s", err.Error())
		return "", err
	}

	return tokenStr, nil
}

func validateJWT(w http.ResponseWriter, r *http.Request) (err error) {

	if r.Header["Token"] == nil {
		log.Println("Token is not present in HEADER.")
		return errors.New("Token is not present in HEADER.")
	}

	keyFunc := func(token *jwt.Token) (interface{}, error) {
		/* Receive the parsed token.
		 * Return the cryptographic key for verifying the signature.
		 */
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			log.Println("There was an error in parsing")
			return nil, fmt.Errorf("There was an error in parsing")
		}
		return SECRET, nil
	}

	token, err := jwt.Parse(r.Header["Token"][0], keyFunc)
	if token == nil || err != nil {
		log.Println("There was an error during token parsing.")
		return errors.New("There was an error during token parsing.")
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		fmt.Fprintf(w, "There was an error during claims parsing.")
		return errors.New("There was an error during claims parsing.")
	}

	return nil
}

Metoda createJWT tworzy nam token zgodny ze standardem RFC7519, którego ważność upływa po 1 minucie. Krótki czas ważności tokena może prowadzić do częstego generowania nowych tokenów wprowadzając dodatkowe obciążenie systemu. W praktyce, czas ważności jest zazwyczaj dłuższy, ale zależy to od konkretnych wymagań projektu. Dodatkowo pamiętaj, że klucz używany do podpisywania JWT, w naszym przypadku SECRET, powinien być przechowywany w sposób bezpieczny. Metoda validateJWT sprawdza, czy nagłówek żądanie zawiera w sobie token, a następnie weryfikuje poprawność tokena, oraz czy nie upłynęła jego ważność.

Przedostatnim krokiem jest połączenie wszystkich elementów w jedną całość w pliku server.go:

# FILE ./service/v1/rest/server.go
package v1rest

import (
	"errors"
	"fmt"
	"net/http"
)

type HTTPRestServer struct {
	server *http.Server
}

func (srv *HTTPRestServer) Configure(host, port string) {
	/* Zasadniczo serwer HTTP `mux` zawiera mapę uchwytów przypisaną do ścieżek.
	 */
	mux := http.NewServeMux()
	mux.HandleFunc("/api/v1/version", versionHandler)
	mux.HandleFunc("/api/v1/login", loginHandler)

	srv.server = &http.Server{
		Addr:    host + ":" + port,
		Handler: mux,
	}
}

func (srv *HTTPRestServer) Start() {
	/* Starts HTTPRestServer as a goroutine. */
	go func() {
		err := srv.server.ListenAndServe()
		if errors.Is(err, http.ErrServerClosed) {
			fmt.Printf("HTTP REST Server is closed.\n")
		} else if err != nil {
			fmt.Printf("HTTP REST Server error while listening, %s\n", err)
		}
	}()
}

func (srv *HTTPRestServer) Stop() error {
	return srv.server.Shutdown(nil)
}

Tworzymy w nim nowy typ HTTPRestServer reprezentującą nasz serwer. Posiada on 3 metody - Start() służącą do uruchomienia serwera jako osobna goroutyna - Stop() do ‘eleganckiego’ zatrzymania działającego serwera - Configure(host, port string) która tworzy nowego obsługującego mux, dodaje mu uchwyty przypisane do ścieżek wywołań, oraz tworzy instancję http.Server z zadanym HOST i PORT i spina z nią naszego obsługującego mux. Ilekroć nasz serwer otrzyma żądanie opisane ścieżką /api/v1/version lub /api/v1/login odpowiedni uchwyt zostanie uruchomiony do obsługi żądania.

Ostatnim krokiem jest stworzenie pliku main.go, by użyć nasz prototyp w praktyce:

FILE ./main.go
package main

import (
	v1rest "prototype/service/v1/rest"
	"log"
	"os"
	"os/signal"
	"sync"
	"syscall"
)

const (
	HOST string = "127.0.0.1"
	PORT string = "3500"
)

func main() {
	var wg sync.WaitGroup

	restServer := v1rest.HTTPRestServer{}
	restServer.Configure(HOST, PORT)
	restServer.Start()

	// We want a server to gracefully shutdown after receiving
	// a SIGTERM, or a SIGINT (Ctrl+C) signal.
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
	wg.Add(1)
	go func() {
		defer wg.Done()

		sig := <-sigs
		log.Printf("Received %s signal, terminating.\n", sig)

		err := restServer.Stop()
		if err != nil {
			log.Printf("Error while stopping HTTP Rest Server: %s", err)
		}

	}()
	wg.Wait() // The program will wait here until Ctrl+C is pressed.
}

W pliku main.go tworzymy instancję HTTPRestServer i przypisujemy ją do zmiennej restServer, konfigurujemy ją i uruchamiamy. Reszta pliku to obsługa sygnałów przerwania, byśmy mieli możliwość zatrzymania serwera po naciśnięciu kombinacji klawiszy Ctrl+C.

Czas uruchomić nasz program. Wszystkie komendy poniżej wykonuję w systemie Linux. Jeśli używasz innego systemu operacyjnego musisz sobie jakoś poradzić. W terminalu wydajemy polecenie

go run .

po czym w osobnym terminalu możemy spróbować komunikacji z naszym serwerem:

TOKEN=$(curl --header "Content-Type: application/json" --request POST --data '{"username":"admin","password":"haslo123"}' http://localhost:3500/api/v1/login)

W wyniku żądania powinniśmy otrzymać token, który zostanie zapisany do zmiennej środowiskowej TOKEN. Możemy spróbować otrzymać wersję serwera

curl -v -H 'Accept: application/json' -H "Token: ${TOKEN}" http://localhost:3500/api/v1/version ```

W wyniku wydania powyższej komendy powinniśmy otrzymać coś w podobnego do:

*   Trying 127.0.0.1:3500... 
* Connected to localhost (127.0.0.1) port 3500 (#0) 
> GET /api/v1/version HTTP/1.1 
> Host: localhost:3500 
> User-Agent: curl/7.88.1 
> Accept: application/json 
> Token: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjp0cnVlLCJleHAiOjE2OTk2Mjc5MTgsInVzZXIiOiJzZWJhc3RpYW4ifQ.Hqnd82-Gb52CRTN2UQJBD4Qb3DY2L18kjIpSPADu3nnY4HV6h6UkXB2exhU85YH5IiVJcvoMb7MhaXxDo_vWHg 
>  
< HTTP/1.1 200 OK 
< Content-Type: application/json 
< Date: Fri, 10 Nov 2023 14:51:01 GMT 
< Content-Length: 20 
<  
* Connection #0 to host localhost left intact 
{"Version":"v1.0.0"}

Widzimy w odpowiedzi serwera jej status HTTP/1.1 200 OK, oraz wersję serwera {"Version":"v1.0.0"}. Wszystko działa. Po upływie minuty to samo żądanie powinno zamiast wersji serwera zwrócić nam:

*   Trying 127.0.0.1:3500... 
* Connected to localhost (127.0.0.1) port 3500 (#0) 
> GET /api/v1/version HTTP/1.1 
> Host: localhost:3500 
> User-Agent: curl/7.88.1 
> Accept: application/json 
> Token: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdXRob3JpemVkIjp0cnVlLCJleHAiOjE2OTk2MjgxODAsInVzZXIiOiJzZWJhc3RpYW4ifQ.2DKWwAEYlbtOa93exY0B76i9yqY5XQft9ouoylHF2zwiS2wc5IP8ckIvlxBa41_yfqTONo4BUDBIpro-uulTDg 
>  
< HTTP/1.1 401 Unauthorized 
< Content-Type: application/json 
< Date: Fri, 10 Nov 2023 14:56:21 GMT 
< Content-Length: 54 
<  
* Connection #0 to host localhost left intact 
{"message":"There was an error during token parsing."}

Mamy to. Nasz serwer działa i poprawnie obsługuje żądania. Jeśli chodzi o ten poradnik to tu chciałem zakończyć. Wiesz już jak stworzyć prosty serwer HTTP z obsługą JWT i REST w języku Go. Jeśli chcesz bawić się dalej to podsuwam Ci kilka pomysłów na dalszy rozwój projektu. W dalszych krokach rozwoju prototypu warto zająć się:

  • komunikacją TLS,
  • usunięciem z kodu wrażliwych danych,
  • implementacją bardziej dojrzałego logowania zdarzeń
  • dopracowaniem obsługi błędów
  • pokryciem kodu testami jednostkowymi,
  • separacją konfiguracji od kodu, czyli np. przeniesieniem zmiennych HOST i PORT poza kod,
  • wprowadzić testy integracyjne, aby pokryć nie tylko poszczególne funkcje, ale także całość serwera.
Jeżeli zamierzasz do tego, żeby żyło Ci się wygodnie, prawdopodobnie nigdy nie będziesz bogaty. Lecz jeśli zmierzasz do tego, by być bogatym, prawdopodobnie będzie Ci w końcu niesamowicie wygodnie. 'Bogaty albo biedny' T. Harv Eker