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ę:
REST
,HTTP
,HTTP
w formacie REST
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ę:
TLS
,HOST
i PORT
poza kod,