Блоґ одного кібера

Історія хвороби контуженого інформаційним вибухом

Простеньке Go API з JWT авторизацією

with 5 comments

Щоб щось зрозуміти іноді легше писати ніж читати (В мене була потреба зрозуміти як працює middleware jwt авторизації, і я цей код читати не міг. Довелось для розминки написати аналогічне, трохи помогло).

Якщо вам цікаво що таке JWT і для чого, то в двох словах – це можливість видати комусь право доступу до чогось без бази даних де б писало що ми йому таке право давали. Тобто сервер на якому користувач авторизується, і сервер до якого він отримує доступ – це можуть бути два абсолютно окремі сервери, які не те що не мають спільної бази даних, вони навіть не спілкуються мережею. Головне – правильні ключі.

Напишемо наступне супер просте API:
POST /login {user: “”, password: “”} – віддає нам JWT токен для дозволу запису
GET / – віддає нам список записів
POST / – з заголовком “Authorization: Bearer ” дозволяє додати новий запис до списку, якщо ми авторизовані.

Для початку зробимо все без авторизації:

package main
 
import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
)
 
func main() {
    initDB()
     
    http.HandleFunc("/", handler)  // За головну сторінку відповідатиме функція handler
    fmt.Println("Listening at 8080")
    http.ListenAndServe(":8080", nil) // Запускаємо сервер на якомусь порті
}
 
// "база даних"
var log []string
 
// Заповнюємо "базу" якимись даними
func initDB() {
    log = make([]string, 0)
    log = append(log, "Hello")
    log = append(log, "World")
}
 
func handler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        data, err := json.Marshal(log) // серіалізуємо базу в JSON
        if errorHandler(w, err, http.StatusInternalServerError) { // якщо була помилка
            return
        }
        w.Write(data) 
    } else if r.Method == "POST" {
        bodybytes, err := ioutil.ReadAll(r.Body) // Читаємо все що передано
        if errorHandler(w, err, http.StatusInternalServerError) {
            return
        }
        log = append(log, string(bodybytes)) // і додаємо як текст в базу даних
        w.Write(bodybytes) // і повертаємо що додали
    } else { // невідомий метод
		errorHandler(w, fmt.Errorf("Method not allowed: %s", r.Method), http.StatusMethodNotAllowed)
    }
}
 
// Функція яка якщо отримує помилку пише у відповідь текст помилки з кодом, і повертає true,
// а якщо не було - просто повертає false.
func errorHandler(w http.ResponseWriter, err error, code int) bool {
    if err == nil {
        return false
    }
    fmt.Println(err)
    msg, _ := json.Marshal(map[string]string{
        "error": err.Error(),
    })
    w.Write(msg)
    return true
}

Запишемо це в файл наприклад main.go і запустимо go run main.go. Тепер можна перевірити все командами:

curl http://localhost:8080/ # Дає ["Hello","World"]
curl -X POST http://localhost:8080/ -d "it works!" # Дає it works!
curl http://localhost:8080/ # Дає ["Hello","World","it works!"]

Якщо працює – пора генерувати ключі. Бо ми ж не хочемо щоб будь-хто міг писати в нашу “базу даних”. Ключі в асиметричній криптографії завжди генеруються парами, один публічний, один приватний.

openssl genrsa -out key.rsa 1024
openssl rsa -in key.rsa -pubout > key.rsa.pub

Перевірте щоб файл key.rsa починався з рядка “—–BEGIN RSA PRIVATE KEY—–“, а key.rsa.pub – з “—–BEGIN PUBLIC KEY—–“. І не переплутайте, а то логін не буде безпечним і взагалі довго дебажити доведеться.

Приватний ключ використовується для підписування документа, тобто документ зашифровується цим ключем і додається до відкритого як підпис. Документом буде JSON Web Token, і він буде містити зрозумілі комп’ютеру твердження на зразок “власник цього токена має право записувати дані на сервер до такого-то числа, підписано сервером”. Токен буде видаватись якщо ми передамо правильні логін і пароль на “/login”. Можна вважати приватний ключ печаткою, а публічний – зразком печатки для порівняння.

Щоб підписувати токени ключами нам знадобиться бібліотека “jwt-go”. Встановлюється командою go get github.com/dgrijalva/jwt-go. Тепер нам треба трохи модифікувати функцію main() і додати бібліотек:

import ( // крім вищенаписаного додати ще ці, знадобиться:
	"crypto/rsa"
	"github.com/dgrijalva/jwt-go"
	"time"
)

func main() {
	initDB()
	err := loadKeys() // Завантаження ключів
	if err != nil {
		fmt.Println(err)
		return
	}
	
	http.HandleFunc("/login", loginHandler) // тут будемо видавати токен
	http.HandleFunc("/", handler) // а тут будемо іноді перевіряти

	fmt.Println("Listening at 8080")
	http.ListenAndServe(":8080", nil)
}

// В цих змінних зберігатимемо ключі
var PublicKey *rsa.PublicKey
var PrivateKey *rsa.PrivateKey

// Ця функція просто прочитає і розпарсить ключі у змінні вище, нічого цікавого
func loadKeys() error {
	pk, err := ioutil.ReadFile("./key.rsa")
	if err != nil {
		return err
	}
	PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(pk)
	if err != nil {
		return err
	}

	pk, err = ioutil.ReadFile("./key.rsa.pub")
	if err != nil {
		return err
	}
	PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(pk)
	return err
}

// Структура в яку ми розпакуємо запит до /login
type UserCredentials struct {
	Login    string `json:"login"`
	Password string `json:"password"`
}

// Якщо отримує запит з правильними логіном і паролем повертає нам
// підписаний токен для доступу до запису в "БД"
func loginHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != "POST" {
		errorHandler(w, fmt.Errorf("Method not allowed: %s", r.Method), http.StatusMethodNotAllowed)
		return
	}
	var user UserCredentials
	var err error
	bodybytes, err := ioutil.ReadAll(r.Body)
	err = json.Unmarshal(bodybytes, &user)
	if errorHandler(w, err, http.StatusUnprocessableEntity) {
		return
	}
	if (user.Login != "LOGIN") || (user.Password != "PASSWORD") {
		errorHandler(w, fmt.Errorf("Bad credentials"), http.StatusForbidden)
		return
	}

	// якщо всі перевірки пройдено - згенерувати токен
	tokenString, err := getJWT()
	if errorHandler(w, err, http.StatusInternalServerError) {
		return
	}
	msg, _ := json.Marshal(map[string]string{
		"token": tokenString,
	})
	w.Write(msg)
}

// Функція що повертає нам текст токена, підписаний приватним ключем
func getJWT() (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
		// тут записуємо дозвіл на post (взагалі, можна що завгодно записувати)
		"allow": "post",
		// пишемо що токен дійсний пів години
		"exp": time.Now().Add(time.Minute * 30).Unix(),
	})
	return token.SignedString(PrivateKey)
}

Пробуємо дістати токен:

curl -X POST http://localhost:8080/login -d '{"login": "LOGIN", "password": "PASSWORD"}'
# Дає {"token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbGxvdyI6InBvc3QiLCJleHAiOjE1MTI5NDE0NjF9.nKZOXO-sTEsmzVRFI3dfSl0MbO9v-fYBRZkS-GzpmlGgwho5NgxUASax0VnZX4v0mLKlI0Jt2bncDn4jZA1TZsmMTCkAetPslcrkWhIt5XaLASmnyzm_-VTCSoSSDtFydpr3pAceEfg41tuBeukhokh-focDGDSZLQTA4_MeY00"}
curl -X POST http://localhost:8080/login -d '{"login": "LOGIN", "password": "WRONG"}' 
# Дає {"error":"Bad credentials"}

Бачимо що токен – це три base64 стрічки записані через крапку. Є веб-сервіс що дозволяє розкодувати і подивитись що там. Перша – заголовок, описує формат токена:

{
  "alg": "RS256",
  "typ": "JWT"
}

Тіло – описує власне якісь твердження, в нашому випадку те що ми дозволяємо деякий час робити POST запити:

{
  "allow": "post",
  "exp": 1512941461
}

Тепер треба оновити основний handler, аби він перевіряв наявність і правильність токена.

func handler(w http.ResponseWriter, r *http.Request) {
	if r.Method == "GET" {
		data, err := json.Marshal(log)
		if errorHandler(w, err, http.StatusInternalServerError) {
			return
		}
		w.Write(data)
	} else if r.Method == "POST" {
		// Цього разу перед тим як робити POST
		allow, err := isPostAllowed(r)  // перевіряємо чи можна
		if errorHandler(w, err, http.StatusInternalServerError) {
			return
		}
		if !allow { // І якщо не можна - повертаємо помилку
			errorHandler(w, fmt.Errorf("Access denied"), http.StatusForbidden)
		}
		bodybytes, err := ioutil.ReadAll(r.Body)
		if errorHandler(w, err, http.StatusInternalServerError) {
			return
		}
		log = append(log, string(bodybytes))
		w.Write(bodybytes)
	} else {
		errorHandler(w, fmt.Errorf("Method not allowed: %s", r.Method), http.StatusMethodNotAllowed)
	}
}

// Перевірка дозволів
func isPostAllowed(r *http.Request) (bool, error) {
	bearer := r.Header.Get("Authorization") // Отримуємо заголовок Authorization
	prefixLen := len("Bearer ")
	if len(bearer) <= prefixLen {
		return false, fmt.Errorf("Authorization header is too short")
	}
	// Пробуємо парсити токен. Два аргументи - токен і функція що повертає потрібний ключ
	token, err := jwt.Parse(bearer[prefixLen:], func(token *jwt.Token) (interface{}, error) {
		return PublicKey, nil
	})
	if err != nil {
		return false, err
	}
	// Якщо вийшло розпарсити, токен валідний, то дістаємо твердження
	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
		allow, ok := claims["allow"].(string) // дивимось чи написано щось про "allow"
		if ok && strings.Contains(allow, "post") { // і чи є там "post"
			return true, nil
		}
		return false, fmt.Errorf("Token does not have claim to allow this action")
	} else {
		return false, fmt.Errorf("Token is invalid")
	}
	return true, nil
}

Тепер попробуємо щось запостити без токена і з ним:

curl -X POST http://localhost:8080/ -d "it works!"
# Дає {"error":"Authorization header is too short"}
curl -X POST http://localhost:8080/ -d "it works!" -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhbGxvdyI6InBvc3QiLCJleHAiOjE1MTI5NDE0NjF9.nKZOXO-sTEsmzVRFI3dfSl0MbO9v-fYBRZkS-GzpmlGgwho5NgxUASax0VnZX4v0mLKlI0Jt2bncDn4jZA1TZsmMTCkAetPslcrkWhIt5XaLASmnyzm_-VTCSoSSDtFydpr3pAceEfg41tuBeukhokh-focDGDSZLQTA4_MeY00"
# Дає it works

Працює! Отож, бачимо що JWT це дуже навіть просто, якщо не рахувати заморочок з дивними способами виклику функцій бібліотеки – виписуємо довідку що щось можна ставимо на неї печатку, а в іншому місці це перевіряємо.

Advertisements

Written by bunyk

Грудень 10, 2017 at 23:24

Оприлюднено в Кодерство, Павутина

Tagged with

Відповідей: 5

Subscribe to comments with RSS.

  1. Ох і давненько ж не було постів у блозі 🙂

    Nemo

    Грудень 11, 2017 at 14:18

  2. Треба буде мені JWT спробувати. Я недавно грався з OAuth2 + GitHub API v4 via GraphQL (описав тут:
    https://worknme.wordpress.com/2017/09/24/why-graphql-does-win-case-study-with-github-api/
    https://medium.com/@lundiak/why-graphql-does-win-case-study-with-github-api-9810f1994621 (альтернатива)
    Поки то собі мутив, то вже й про JWT прочитав.
    Але тепер ящо і буду робити то з Python. Останнім часом ряд проектів маю, де можу пробувати технології сам вирішуючи.

    От тоді може й скористаюсь твоїми тут описаними кроками 🙂

    Andrii Lundiak

    Грудень 12, 2017 at 20:59

    • Так OAuth2 взагалі нібито містить в собі Bearer token. Я наївно вважав що він JWT, але виявляється може бути будь-який.

      bunyk

      Грудень 14, 2017 at 14:54

    • І так, може й мені варто GraphQL вивчити.

      bunyk

      Грудень 14, 2017 at 18:23


Залишити відповідь

Заповніть поля нижче або авторизуйтесь клікнувши по іконці

Лого WordPress.com

Ви коментуєте, використовуючи свій обліковий запис WordPress.com. Log Out / Змінити )

Twitter picture

Ви коментуєте, використовуючи свій обліковий запис Twitter. Log Out / Змінити )

Facebook photo

Ви коментуєте, використовуючи свій обліковий запис Facebook. Log Out / Змінити )

Google+ photo

Ви коментуєте, використовуючи свій обліковий запис Google+. Log Out / Змінити )

З’єднання з %s

%d блогерам подобається це: