Złe nawyki będą zmiały zgubny wpływ na Twoje życie.

Własne przemyślenia

Ś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.
Artykuł dodano 2023-11-10