OAuth2 або RFC6749.
Це популярний фреймворк авторизації де власник ресурсу може надати доступ клієнту від свого імені, або ж завчасно налаштована програма може зробити запит від свого імені. Цей протокол працює лише по HTTP.
Але я хочу розповісти лише про клієнтську частину, тобто що й кому відправляти, щоб отримати токен доступу. На мій подив це дуже просто, хоча й не певен про написання коду пов’язаного з безпекою самотужки.
Специфікація має чотири сутності:
- власник ресурсу
- переважно людина, повинна надати згоду та ввести свої облікові дані.
- сервер ресурсу
- сервер що надає API, але потребує токен доступу.
- клієнт
- програма, що буде відправляти авторизовані запити на сервер ресурсу від імені власника ресурсу.
- сервер авторизації
- потенційно третя сторона, що перевіряє облікові дані та питає дозвіл у власника ресурсу, після чого дозволяє отримати токен доступу.
І декілька типів грантів:
- код авторизації
- найбільш розповсюджений тип гранту, розроблений у першу чергу для WEB застосунків. Спрямовує браузер на сайт авторизації, після чого переспрямовує назад до ресурсу з якого надійшов запит авторизації.
- облікові дані клієнта
- комп’ютер ⟷ комп’ютер.
- код пристрою
- логін через інший пристрій ніж який потребує авторизації, наприклад IoT чи телевізор.
неявнийоблікові дані власника ресурсу
Передусім треба отримати 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"
}