Сайт Данила

OAuth2 вступ

Кількість слів: ~2440

OAuth2 або RFC6749.

Це популярний фреймворк авторизації де власник ресурсу може надати доступ клієнту від свого імені, або ж завчасно налаштована програма може зробити запит від свого імені. Цей протокол працює лише по HTTP.

Але я хочу розповісти лише про клієнтську частину, тобто що й кому відправляти, щоб отримати токен доступу. На мій подив це дуже просто, хоча й не певен про написання коду пов’язаного з безпекою самотужки.

Специфікація має чотири сутності:

І декілька типів грантів:

Передусім треба отримати client_id і можливо client_secret, а специфікація не вказує як це робити. Наприклад, Codeberg може виступити в ролі провайдера OAuth.

Код авторизації

Я маю HTTP бекенд на Go. Щоб захиститися від ботів хоч трошки, треба додати авторизацію. OAuth надає токен доступу, але сам факт видачі цього токена може слугувати підтвердженням існування облікового запису. Як бонус, додам OpenID Connect для отримання псевдоніма користувача.

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /v1/oauth/{provider}/login", login)
}

func (o *OAuth) Login(w http.ResponseWriter, r *http.Request) {
	provider := r.PathValue("provider")

    var config Provider
    var ok bool

	config, ok = o.providers[provider]
	if !ok {
		RespondError(w, r, http.StatusBadRequest, "oauth provider not found")
		return
	}

	verifier := base64Encode(cryptoRngBytes(32))
	id := cryptoRngBytes(32)
	o.verifiers[string(id)] = verifier

	m := url.Values{
		"response_type":         []string{"code"},
		"redirect_uri":          []string{config.RedirectURL},
		"code_challenge_method": []string{"S256"}, // SHA256
		"code_challenge":        []string{base64Encode(sha256([]byte(verifier)))},
		"client_id":             []string{config.ClientID},
		"scope":                 []string{strings.Join(config.Scopes, " ")},
		"state":                 []string{id},
	}

	var buf strings.Builder
	_, _ = buf.WriteString(config.Endpoint.AuthURL)
	_ = buf.WriteByte('?')
	_, _ = buf.WriteString(m.Encode())

	http.Redirect(w, r, buf.String(), http.StatusFound)
}

func cryptoRngBytes(size uint) []byte
func sha256(data []byte) []byte
func base64Encode(data []byte) string {
	return base64.RawURLEncoding.EncodeToString(data[:])
}

type Endpoint struct {
	AuthURL  string
	TokenURL string
	InHeader bool
}

type Provider struct {
	ClientID     string
	ClientSecret string
	Endpoint     Endpoint
	RedirectURL  string
	Scopes       []string
}

Виглядає не так уже й погано! За цим стандартом, лише два параметри запиту є обов’язковими, але для захисту від атак, суворо рекомендується відправляти PKCE.

// обов'язкові
"response_type":         []string{"code"},
"client_id":             []string{config.ClientID},
// необов'язкові
"redirect_uri":          []string{config.RedirectURL},
"code_challenge_method": []string{"S256"}, // PKCE
"code_challenge":        []string{base64Encode(sha256([]byte(verifier)))}, // PKCE
"scope":                 []string{strings.Join(config.Scopes, " ")},
"state":                 []string{id},

Навіть якщо підтримки PKCE нема, незрозумілі параметри ігноруються, що значить відправляти PKCE завжди безпечно. Однак, тоді треба використати старіший спосіб захисту, додаючи в state параметр унікальну стрічку, яку обов’язково треба перевірити на пізнішому етапі.

Після переспрямування користувача на сервер авторизації, сервер очікує поки користувача не переспрямує назад, передаючи токен авторизації в параметрах(?) запиту.

Обмін коду авторизації

Час парсити параметри переспрямування й обмінювати токен авторизації на токен доступу!

func main() {
    // ...
	mux.HandleFunc("GET /v1/oauth/{provider}/callback", callback)
}

func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
	provider := r.PathValue("provider")
	cfg, ok := o.providers[provider]
	if !ok {
		RespondError(w, r, http.StatusBadRequest, "oauth provider not found")
		return
	}

	ctx := r.Context()
	query := r.URL.Query()

	code := query.Get("code")
	if code == "" {
		RespondError(w, r, http.StatusBadRequest, "empty code")
		return
	}

	state := query.Get("state")

	verifier, ok := o.verifiers[state]
	if !ok {
		RespondError(w, r, http.StatusBadRequest, "bad state")
		return
	}

	delete(o.verifiers, state)

	_, err := cfg.Exchange(ctx, code, verifier)
	if err != nil {
		RespondError(w, r, http.StatusInternalServerError, "could not exchange auth token")
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:     "auth-token",
		Value:    "some crypto random string",
		Path:     "/",
		Domain:   "",
		Expires:  time.Now().Add(7 * 24 * time.Hour),
		Secure:   true,
		HttpOnly: true,
		SameSite: http.SameSiteStrictMode,
	})

	http.Redirect(w, r, "...", http.StatusFound)
}

func (p Provider) Exchange(ctx context.Context, code string, verifier Verifier) (Token, error) {
	m := make(url.Values, 6)
	m.Set("grant_type", "authorization_code")
	m.Set("code", code)
	m.Set("redirect_uri", p.RedirectURL)
	m.Set("code_verifier", string(verifier))
	if !p.Endpoint.InHeader {
		m.Set("client_id", p.ClientID)
		m.Set("client_secret", p.ClientSecret)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Endpoint.TokenURL, strings.NewReader(m.Encode()))
	if err != nil {
		return Token{}, fmt.Errorf("new token url request: %w", err)
	}

	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	if p.Endpoint.InHeader {
		req.SetBasicAuth(p.ClientID, p.ClientSecret)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return Token{}, fmt.Errorf("do token url request: %w", err)
	}

	defer func() {
		_ = resp.Body.Close()
	}()

	bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
	if err != nil {
		return Token{}, fmt.Errorf("read token url response: %w", err)
	}

	if resp.StatusCode != 200 {
		return Token{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
	switch mt {
	case "application/x-www-form-urlencoded":
		return NewTokenFromURLEncoded(string(bodyBytes))
	case "application/json":
		var token Token
		err = json.Unmarshal(bodyBytes, &token)
		if err != nil {
			return Token{}, fmt.Errorf("unmarshal body into token: %w", err)
		}

		return token, nil
	default:
		return Token{}, fmt.Errorf("unexpected response content-type: %s", mt)
	}
}

type Token struct {
	AccessToken      string `json:"access_token"`
	TokenType        string `json:"token_type"`
	RefreshToken     string `json:"refresh_token"`
	ExpiresIn        uint   `json:"expires_in"`
	ErrorCode        string `json:"error"`
	ErrorDescription string `json:"error_description"`
	ErrorURI         string `json:"error_uri"`

	IDToken string `json:"id_token"`
}

У параметрах може надійти: code, state, error, error_*. Під словом code тут мається на увазі код авторизації, що не є кодом доступу. Код авторизації має дуже короткий термін життя, приблизно хвилину. Його треба якомога швидше відправити на сервер авторизації для обміну на токен доступу. Хтось може вже помітив, та до цього моменту токени та важлива інформація передавалися через стрічку параметрів URL, тільки це не дуже безпечний спосіб передачі секретних даних. А втім, сервер авторизації зберігає токен авторизації після першого використання, бо якщо він помітить ще одну спробу отримати токен доступу, можливо стався витік – зрештою безпека передусім, сервер авторизації відкличе цей токен разом з його токеном доступу.

Отримавши code й state, треба перевірити чи існує запис про такий state, якщо ця стрічка – автентична формуємо POST запит для обміну токенів.

Якщо перед цим, будь-хто міг відправити запит на сервер імітуючи переспрямування з сервера авторизації, то зараз бекенд робить прямий HTTP запит на довірену URL адресу, передаючи й отримуючи приватну інформацію. У термінах OAuth це back channel.

m := make(url.Values, 6)
m.Set("grant_type", "authorization_code")
m.Set("code", code)
m.Set("redirect_uri", p.RedirectURL)
m.Set("code_verifier", string(verifier))
if !p.Endpoint.InHeader {
	m.Set("client_id", p.ClientID)
	m.Set("client_secret", p.ClientSecret)
}

Робиться POST запит з тілом application/x-www-form-urlencoded. Із цікавого тут лише PKCE. Коли відбувається перенаправлення браузера на сайт авторизації, відправляють code_challenge, тобто SHA256 випадкових байтів, а при обміні відправляють саме значення, бо це довірений канал зв’язку.

У повному коді вище, є обробка надсилання client_id, client_secret як заголовку Authorization: Basic .... Деякі сервіси можуть очікувати ці параметри лише так! Те саме стосується формату відповіді. По RFC це повинен бути application/json, утім деякі сервіси повертають application/x-www-form-urlencoded.

При успішній відповіді, клієнт отримує токени.

type Token struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	IDToken      string `json:"id_token"`

	// TokenType string `json:"token_type"`
	// ExpiresIn uint   `json:"expires_in"`
	// ...
}

Що ж, місія вдало завершена. Ця відповідь є підтвердженням реального облікового запису користувача. Однак ще є id_token зі специфікації OpenID. І щоб його отримати, не треба нічого переписувати, id_token наявний у відповіді коли вказано openid у полі scope з іншими опціональними значеннями: profile, email, address, phone.

Розкодувавши id_token JWT, можна побачити звичайний JSON з необхідною інформацією про користувача.

{
    "sub": "248289761001",
    "name": "Jane Doe",
    "given_name": "Jane",
    "family_name": "Doe",
    "preferred_username": "j.doe",
    "email": "janedoe@example.com",
    "picture": "http://example.com/janedoe/me.jpg"
}
Парсер з рекурсивним спуском у Go