Go dla początkujących - Część 15: Sesje i uwierzytelnianie

2/17/2025 Kurs Go

Mateusz Kędziora

image

Witaj w kolejnym artykule z serii Go dla początkujących! Dzisiaj zajmiemy się kluczowym aspektem tworzenia bezpiecznych aplikacji internetowych: sesjami i autentykacją użytkowników. To zagadnienia fundamentalne, jeśli chcesz, aby twoja aplikacja potrafiła odróżniać poszczególnych użytkowników i chronić ich dane.

Wprowadzenie do Bezpieczeństwa w Aplikacjach Web

Kiedy tworzysz aplikację internetową, ważne jest, aby zrozumieć, że cała komunikacja odbywa się przez protokół HTTP. HTTP jest protokołem bezstanowym, co oznacza, że serwer nie pamięta niczego o poprzednich żądaniach od tego samego użytkownika. To świetne dla skalowalności, ale stwarza problem: jak rozpoznać użytkownika, który już się zalogował? Odpowiedzią są sesje i autentykacja.

  • Autentykacja (uwierzytelnianie) to proces weryfikacji tożsamości użytkownika. Najczęściej robi się to, sprawdzając, czy podane hasło zgadza się z tym, co jest zapisane w bazie danych.
  • Sesja to sposób na “zapamiętanie” zalogowanego użytkownika między kolejnymi żądaniami HTTP. Serwer generuje unikalny identyfikator (ID sesji) i przekazuje go do klienta (przeglądarki). Klient wysyła ten ID sesji z każdym żądaniem, pozwalając serwerowi rozpoznać użytkownika.

Sesje w Go z Użyciem gorilla/sessions

gorilla/sessions to popularna biblioteka w Go, która upraszcza zarządzanie sesjami. Zapewnia wygodny interfejs do tworzenia, odczytywania i usuwania sesji.

Instalacja:

Najpierw zainstaluj bibliotekę:

go get github.com/gorilla/sessions

Przykład Implementacji Sesji:

Poniższy kod demonstruje, jak utworzyć sesję, zapisać w niej dane użytkownika (np. jego ID) i odczytać te dane przy kolejnym żądaniu.

package main

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

	"github.com/gorilla/sessions"
)

var (
	// Klucz używany do szyfrowania ciasteczek sesji. Zmień go na prawdziwy klucz w środowisku produkcyjnym!
	key = []byte("bardzo-tajny-klucz") //Zmień to!
	store = sessions.NewCookieStore(key)
)

func loginHandler(w http.ResponseWriter, r *http.Request) {
	// Załóżmy, że sprawdziliśmy dane logowania i są poprawne
	userID := "user123"

	session, _ := store.Get(r, "session-name") // "session-name" to nazwa ciasteczka

	// Ustaw dane użytkownika w sesji
	session.Values["userID"] = userID
	err := session.Save(r, w)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	fmt.Fprintln(w, "Zalogowano!  ID sesji zapisane w ciasteczku.")
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
	session, _ := store.Get(r, "session-name")

	// Odczytaj dane użytkownika z sesji
	userID, ok := session.Values["userID"].(string) // Type Assertion
	if !ok {
		fmt.Fprintln(w, "Niezalogowany")
		return
	}

	fmt.Fprintf(w, "Witaj, %s!\n", userID)
}

func logoutHandler(w http.ResponseWriter, r *http.Request) {
    session, _ := store.Get(r, "session-name")
    session.Options.MaxAge = -1 // Natychmiastowe usunięcie ciasteczka
    err := session.Save(r, w)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    fmt.Fprintln(w, "Wylogowano!")
}


func main() {
	http.HandleFunc("/login", loginHandler)
	http.HandleFunc("/", homeHandler)
	http.HandleFunc("/logout", logoutHandler)


	fmt.Println("Serwer uruchomiony na porcie 8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Wyjaśnienie Kodu:

  1. Import bibliotek: Importujemy potrzebne pakiety, w tym net/http do obsługi żądań HTTP oraz github.com/gorilla/sessions do obsługi sesji.
  2. Konfiguracja sesji:
    • key: To klucz używany do szyfrowania danych sesji. Bardzo ważne: w środowisku produkcyjnym użyj losowo wygenerowanego, silnego klucza. Nigdy nie używaj takiego prostego klucza jak w tym przykładzie!
    • store: Tworzy instancję CookieStore, która przechowuje dane sesji w ciasteczkach (cookies) po stronie klienta. Alternatywą jest FileSystemStore, który przechowuje sesje na serwerze (ale wymaga dodatkowej konfiguracji i zarządzania).
  3. loginHandler:
    • Pobiera sesję za pomocą store.Get(r, "session-name"). session-name to nazwa ciasteczka, w którym przechowywany jest identyfikator sesji.
    • Ustawia wartość "userID" w sesji na identyfikator zalogowanego użytkownika.
    • Zapisuje sesję za pomocą session.Save(r, w). To powoduje wysłanie ciasteczka z ID sesji do przeglądarki użytkownika.
  4. homeHandler:
    • Pobiera sesję.
    • Odczytuje wartość "userID" z sesji za pomocą session.Values["userID"].(string). Zauważ, że potrzebna jest type assertion .(string) aby przekonwertować interfejs (typ danych w mapie session.Values) na string.
    • Wyświetla powitanie dla zalogowanego użytkownika lub informację o braku logowania.
  5. logoutHandler:
    • Pobiera sesję.
    • Ustawia MaxAge na -1 co powoduje natychmiastowe usunięcie ciasteczka z sesją.
    • Zapisuje sesję.
  6. main:
    • Definiuje routing dla loginHandler i homeHandler.
    • Uruchamia serwer HTTP na porcie 8080.

Uruchomienie przykładu:

  1. Zapisz kod jako main.go.
  2. Uruchom: go run main.go
  3. Otwórz w przeglądarce:
    • http://localhost:8080/login (zalogowanie)
    • http://localhost:8080/ (strona główna, wyświetlenie informacji o zalogowaniu)
    • http://localhost:8080/logout (wylogowanie)

Sprawdź ciasteczka w swojej przeglądarce po zalogowaniu. Powinieneś zobaczyć ciasteczko o nazwie session-name.

Ważne kwestie dotyczące bezpieczeństwa sesji:

  • Szyfrowanie sesji: Upewnij się, że używasz silnego klucza do szyfrowania danych sesji. Klucz powinien być przechowywany w bezpiecznym miejscu (np. w zmiennej środowiskowej).
  • HTTPS: Koniecznie używaj HTTPS (SSL/TLS) dla swojej aplikacji. Bez HTTPS, ciasteczka z ID sesji mogą być przechwycone przez atakującego.
  • HttpOnly flag: Ustaw flagę HttpOnly dla ciasteczek sesji. Zapobiega to dostępowi do ciasteczek przez JavaScript, co utrudnia ataki XSS (Cross-Site Scripting). W gorilla/sessions możesz to zrobić konfigurując session.Options.HttpOnly = true.
  • Secure flag: Ustaw flagę Secure dla ciasteczek sesji. Powoduje to, że ciasteczko jest wysyłane tylko przez połączenie HTTPS. W gorilla/sessions możesz to zrobić konfigurując session.Options.Secure = true.
  • Limit czasu trwania sesji: Ustaw limit czasu trwania sesji (np. 30 minut). Po upływie tego czasu sesja powinna automatycznie wygasnąć. W gorilla/sessions możesz to zrobić konfigurując session.Options.MaxAge = 30 * 60 (czas w sekundach).
  • Regeneracja ID sesji: Po zalogowaniu i wylogowaniu użytkownika, regeneruj ID sesji. Zapobiega to atakom typu session fixation. W gorilla/sessions możesz to zrobić wywołując session.ID = "" i następnie session.Save(r, w).

Autentykacja Użytkowników

Teraz zajmiemy się autentykacją, czyli procesem weryfikacji tożsamości użytkownika.

1. Hasła:

Najpopularniejszą metodą autentykacji jest użycie hasła. Jednak przechowywanie haseł w sposób jawny jest bardzo niebezpieczne. Zawsze należy używać funkcji haszujących (np. bcrypt) do przechowywania haseł.

Przykład użycia bcrypt:

package main

import (
	"fmt"
	"log"

	"golang.org/x/crypto/bcrypt"
)

func hashPassword(password string) (string, error) {
	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
	if err != nil {
		return "", err
	}
	return string(bytes), nil
}

func checkPasswordHash(password, hash string) bool {
	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
	return err == nil
}

func main() {
	password := "MojeBardzoSilneHaslo123!"

	// Haszowanie hasła
	hashedPassword, err := hashPassword(password)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Zahasowane hasło:", hashedPassword)

	// Sprawdzanie hasła
	match := checkPasswordHash(password, hashedPassword)
	fmt.Println("Czy hasła pasują:", match)

	wrongPassword := "ZleHaslo"
	match = checkPasswordHash(wrongPassword, hashedPassword)
	fmt.Println("Czy hasła (złe) pasują:", match)
}

Wyjaśnienie Kodu:

  1. Import: Importujemy pakiet golang.org/x/crypto/bcrypt.
  2. hashPassword:
    • bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) haszuje hasło. bcrypt.DefaultCost określa siłę haszowania. Wyższa wartość oznacza większe bezpieczeństwo, ale też dłuższy czas obliczeń.
    • Funkcja zwraca zahaszowane hasło jako string i ewentualny błąd.
  3. checkPasswordHash:
    • bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) porównuje podane hasło z zahaszowanym hasłem.
    • Funkcja zwraca true jeśli hasła pasują, false w przeciwnym wypadku.

Najlepsze praktyki dotyczące haseł:

  • Używaj silnych haseł: Hasła powinny być długie (przynajmniej 12 znaków), zawierać kombinację liter (dużych i małych), cyfr i znaków specjalnych.
  • Nie przechowuj haseł jawnie: Zawsze haszuj hasła przed zapisaniem ich w bazie danych.
  • Używaj bcrypt lub podobnych algorytmów: bcrypt jest odporny na ataki typu brute-force i rainbow table.
  • Salt: bcrypt automatycznie dodaje losowy salt (sól) do hasła przed haszowaniem. Salt zapobiega atakom typu rainbow table.
  • Ogranicz liczbę nieudanych prób logowania: Po kilku nieudanych próbach logowania, zablokuj konto na pewien czas. Zapobiega to atakom brute-force.
  • Wymuszaj regularne zmiany haseł: Zalecaj użytkownikom regularną zmianę haseł.
  • Pamiętaj o resetowaniu haseł: Zaimplementuj mechanizm resetowania haseł, który pozwala użytkownikom na odzyskanie dostępu do konta, jeśli zapomną hasła.

2. Tokeny JWT (JSON Web Tokens):

JWT to standard branżowy służący do bezpiecznego przesyłania informacji między stronami jako obiekt JSON. JWT są często używane do autoryzacji, szczególnie w systemach API.

Zalety JWT:

  • Bezstanowość: Serwer nie musi przechowywać informacji o sesji. Informacje są zawarte w samym tokenie.
  • Skalowalność: Łatwiej skalować aplikacje, które używają JWT, ponieważ serwer nie musi utrzymywać sesji.
  • Mobilność: JWT mogą być używane do autentykacji na różnych platformach (web, mobile, desktop).

Przykład implementacji JWT:

package main

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

	"github.com/dgrijalva/jwt-go"
)

var jwtKey = []byte("BardzoTajnyKluczJWT") // Zmień to!

type Claims struct {
	UserID string `json:"userID"`
	jwt.StandardClaims
}

func generateJWT(userID string) (string, error) {
	expirationTime := time.Now().Add(5 * time.Minute) // Token ważny 5 minut
	claims := &Claims{
		UserID: userID,
		StandardClaims: jwt.StandardClaims{
			ExpiresAt: expirationTime.Unix(),
			Issuer:    "moja-aplikacja",
		},
	}
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString(jwtKey)
	if err != nil {
		return "", err
	}
	return tokenString, nil
}

func authenticateJWT(tokenString string) (*Claims, error) {
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
		return jwtKey, nil
	})
	if err != nil {
		return nil, err
	}
	if !token.Valid {
		return nil, fmt.Errorf("Nieprawidłowy token")
	}

	return claims, nil
}

func loginHandlerJWT(w http.ResponseWriter, r *http.Request) {
	userID := "user123" // Załóżmy, że użytkownik został zweryfikowany

	token, err := generateJWT(userID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	fmt.Fprintf(w, "JWT token: %s\n", token)
}

func homeHandlerJWT(w http.ResponseWriter, r *http.Request) {
	tokenString := r.Header.Get("Authorization") // Pobieranie tokenu z nagłówka Authorization

	if tokenString == "" {
		http.Error(w, "Brak tokenu Authorization", http.StatusUnauthorized)
		return
	}

	claims, err := authenticateJWT(tokenString)
	if err != nil {
		http.Error(w, err.Error(), http.StatusUnauthorized)
		return
	}

	fmt.Fprintf(w, "Witaj, %s! (JWT)\n", claims.UserID)
}


func main() {
	http.HandleFunc("/login-jwt", loginHandlerJWT)
	http.HandleFunc("/home-jwt", homeHandlerJWT)

	fmt.Println("Serwer uruchomiony na porcie 8080 (JWT)")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Wyjaśnienie Kodu:

  1. Import: Importujemy pakiety, w tym github.com/dgrijalva/jwt-go.
  2. jwtKey: Klucz używany do podpisywania tokenów JWT. Bardzo ważne: w środowisku produkcyjnym użyj losowo wygenerowanego, silnego klucza. Nigdy nie używaj takiego prostego klucza jak w tym przykładzie! Klucz powinien być przechowywany bezpiecznie.
  3. Claims: Struktura reprezentująca dane (claims) zawarte w tokenie JWT. Zawiera UserID oraz standardowe pola JWT (np. ExpiresAt, Issuer).
  4. generateJWT:
    • Tworzy obiekt Claims z danymi użytkownika i czasem wygaśnięcia tokenu.
    • Tworzy token JWT za pomocą jwt.NewWithClaims.
    • Podpisuje token za pomocą token.SignedString(jwtKey).
    • Zwraca token JWT jako string.
  5. authenticateJWT:
    • Parsuje token JWT za pomocą jwt.ParseWithClaims.
    • Sprawdza, czy token jest ważny za pomocą token.Valid.
    • Zwraca obiekt Claims z danymi zawartymi w tokenie.
  6. loginHandlerJWT:
    • Generuje token JWT dla użytkownika.
    • Wysyła token do klienta.
  7. homeHandlerJWT:
    • Pobiera token JWT z nagłówka Authorization.
    • Uwierzytelnia token za pomocą authenticateJWT.
    • Wyświetla powitanie dla użytkownika.
  8. main:
    • Definiuje routing.

Uruchomienie przykładu:

  1. Zapisz kod jako main.go.
  2. Zainstaluj bibliotekę JWT: go get github.com/dgrijalva/jwt-go
  3. Uruchom: go run main.go
  4. Otwórz w przeglądarce lub użyj narzędzia takiego jak Postman:
    • http://localhost:8080/login-jwt (otrzymanie tokenu JWT)
    • http://localhost:8080/home-jwt (żądanie do strony chronionej, wymaga nagłówka Authorization z tokenem JWT) Ustaw nagłówek Authorization na wartość token: <otrzymany token JWT>.

Ważne kwestie dotyczące bezpieczeństwa JWT:

  • Silny klucz: Używaj silnego i losowo wygenerowanego klucza do podpisywania tokenów.
  • Przechowywanie klucza: Przechowuj klucz w bezpiecznym miejscu.
  • HTTPS: Używaj HTTPS do przesyłania tokenów.
  • Krótki czas życia tokenu: Ustaw krótki czas życia tokenu (np. 5-15 minut).
  • Odświeżanie tokenów: Zaimplementuj mechanizm odświeżania tokenów, aby użytkownik nie musiał logować się ponownie po wygaśnięciu tokenu.
  • Walidacja tokenów: Zawsze waliduj tokeny po stronie serwera.

Praca Domowa

  1. Rozwiń przykład z sesjami, dodając formularz logowania i rejestracji użytkownika. Hasła przechowuj z użyciem bcrypt.
  2. Zaimplementuj mechanizm resetowania hasła w przykładzie z hasłami.
  3. Spróbuj użyć FileSystemStore zamiast CookieStore w przykładzie z sesjami. Jakie są zalety i wady takiego rozwiązania?
  4. Dodaj middleware, który będzie sprawdzał ważność tokenu JWT dla wszystkich endpointów wymagających autoryzacji.
  5. Poczytaj o metodach autentykacji 2FA (Two-Factor Authentication). Czy mógłbyś zaimplementować prosty 2FA w swojej aplikacji?

Podsumowanie

W tym artykule omówiliśmy podstawy bezpieczeństwa aplikacji Go, skupiając się na sesjach i autentykacji użytkowników. Pokazaliśmy, jak używać biblioteki gorilla/sessions do zarządzania sesjami oraz jak implementować autentykację za pomocą haseł (z użyciem bcrypt) i tokenów JWT. Pamiętaj, że bezpieczeństwo to proces ciągły. Zawsze bądź na bieżąco z najnowszymi zagrożeniami i najlepszymi praktykami.

Zachęcam do dalszego eksperymentowania z kodem, modyfikowania go i dopasowywania do własnych potrzeb. To najlepszy sposób na naukę. Nie zapomnij również przeczytać pozostałych artykułów z serii Go dla początkujących!

Przydatne Źródła

Życzę powodzenia w dalszej nauce Go i tworzeniu bezpiecznych aplikacji!

Polecane artykuły