feat(auth): вход через VK ID и Яндекс ID
Владельцу нужны VK и Яндекс (Google/Discord/GitHub остаются в каталоге и включаются своими ключами). VK ID (id.vk.ru, OAuth 2.1): - обязательный PKCE: code_challenge в запросе авторизации, code_verifier при обмене; верификатор выводится из подписанного state (HMAC), хранить нечего; - device_id из callback уходит в обмен кода; - для конфиденциальных приложений секрет передаётся как service_token, а не client_secret (и обмен идёт параметрами в теле, не Basic); - профиль: POST /oauth2/user_info с client_id и access_token; права email и vkid.personal_info. Яндекс ID: - права login:email и login:info; секрет — в теле запроса обмена; - профиль: GET login.yandex.ru/info?format=json с заголовком (не Bearer). Общее: - в профиль добавлено DisplayName: VK отдаёт имя и фамилию, Яндекс — real_name, раньше они терялись; - почта считается подтверждённой, если провайдер её отдал (отдельного флага нет, адрес приходит только по соответствующему праву) — D-083; - новые переменные OAUTH_VK_* и OAUTH_YANDEX_* в установщике и .env.example; - тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
This commit is contained in:
@@ -94,7 +94,7 @@ func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server {
|
||||
TokenURL: server.URL + "/token",
|
||||
},
|
||||
Scopes: []string{"user:email"},
|
||||
FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token) (oauthProfile, error) {
|
||||
FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
|
||||
// Провайдер получает токен так же, как в бою: через oauth2-клиент.
|
||||
authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil)
|
||||
@@ -147,9 +147,15 @@ func TestOAuthNotConfigured(t *testing.T) {
|
||||
if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
|
||||
t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err)
|
||||
}
|
||||
if _, err := service.OAuthAuthorizeURL("vk", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) {
|
||||
if _, err := service.OAuthAuthorizeURL("ok", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) {
|
||||
t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err)
|
||||
}
|
||||
// VK и Яндекс в каталоге есть, но без ключей отвечают «не настроен».
|
||||
for _, provider := range []string{"vk", "yandex"} {
|
||||
if _, err := service.OAuthAuthorizeURL(provider, "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
|
||||
t.Fatalf("%s without config: err = %v, want ErrOAuthNotConfigured", provider, err)
|
||||
}
|
||||
}
|
||||
if len(service.OAuthProviders()) != 0 {
|
||||
t.Fatalf("providers = %v, want empty", service.OAuthProviders())
|
||||
}
|
||||
@@ -206,7 +212,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sign state: %v", err)
|
||||
}
|
||||
user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent")
|
||||
user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("oauth callback: %v", err)
|
||||
}
|
||||
@@ -234,7 +240,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sign state: %v", err)
|
||||
}
|
||||
again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent")
|
||||
again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("second oauth callback: %v", err)
|
||||
}
|
||||
@@ -250,7 +256,7 @@ func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sign state: %v", err)
|
||||
}
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) {
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) {
|
||||
t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err)
|
||||
}
|
||||
}
|
||||
@@ -276,7 +282,7 @@ func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sign state: %v", err)
|
||||
}
|
||||
user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent")
|
||||
user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent")
|
||||
if err != nil {
|
||||
t.Fatalf("oauth callback: %v", err)
|
||||
}
|
||||
@@ -312,7 +318,7 @@ func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sign state: %v", err)
|
||||
}
|
||||
if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) {
|
||||
if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) {
|
||||
t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err)
|
||||
}
|
||||
}
|
||||
@@ -323,17 +329,17 @@ func TestOAuthCallbackStateAndCodeErrors(t *testing.T) {
|
||||
service, _ := oauthTestService(t, oauthTestConfig())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) {
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) {
|
||||
t.Fatalf("bad state: err = %v, want ErrOAuthState", err)
|
||||
}
|
||||
state, err := service.signOAuthState("github", "/app")
|
||||
if err != nil {
|
||||
t.Fatalf("sign state: %v", err)
|
||||
}
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
||||
t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err)
|
||||
}
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
||||
t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err)
|
||||
}
|
||||
}
|
||||
@@ -351,7 +357,7 @@ func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("sign state: %v", err)
|
||||
}
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) {
|
||||
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) {
|
||||
t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err)
|
||||
}
|
||||
}
|
||||
@@ -450,3 +456,211 @@ func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) {
|
||||
t.Fatal("blind indexes must be independent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOAuthVKIDFlow — вход через VK ID: PKCE (code_challenge в запросе
|
||||
// авторизации и code_verifier при обмене), device_id из callback и сервисный
|
||||
// ключ отдельным параметром service_token (VK ID не ждёт client_secret),
|
||||
// профиль забирается методом /oauth2/user_info с client_id в теле.
|
||||
func TestOAuthVKIDFlow(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
var tokenForm url.Values
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tokenForm = r.PostForm
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "vk-access-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"user_id": 42,
|
||||
})
|
||||
})
|
||||
var infoForm url.Values
|
||||
mux.HandleFunc("/user_info", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
infoForm = r.PostForm
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"user": map[string]any{
|
||||
"user_id": 42,
|
||||
"first_name": "Иван",
|
||||
"last_name": "Петров",
|
||||
"email": "ivan@example.com",
|
||||
},
|
||||
})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
originalProviders := oauthProviderSet
|
||||
originalInfoURL := vkUserInfoURL
|
||||
providers := defaultOAuthProviders()
|
||||
vk := providers["vk"]
|
||||
vk.Endpoint.AuthURL = server.URL + "/authorize"
|
||||
vk.Endpoint.TokenURL = server.URL + "/token"
|
||||
providers["vk"] = vk
|
||||
oauthProviderSet = providers
|
||||
vkUserInfoURL = server.URL + "/user_info"
|
||||
t.Cleanup(func() {
|
||||
oauthProviderSet = originalProviders
|
||||
vkUserInfoURL = originalInfoURL
|
||||
})
|
||||
|
||||
cfg := oauthTestConfig()
|
||||
cfg.OAuthVKClientID = "vk-client"
|
||||
cfg.OAuthVKClientSecret = "vk-service-token"
|
||||
service, _ := oauthTestService(t, cfg)
|
||||
if providers := service.OAuthProviders(); len(providers) != 2 || providers[0].ID != "vk" {
|
||||
t.Fatalf("providers = %v, want vk первым", providers)
|
||||
}
|
||||
|
||||
authorizeURL, err := service.OAuthAuthorizeURL("vk", "/app/friends")
|
||||
if err != nil {
|
||||
t.Fatalf("authorize url: %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(authorizeURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse authorize url: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if query.Get("code_challenge") == "" || query.Get("code_challenge_method") != "S256" {
|
||||
t.Fatalf("в ссылке нет PKCE: %s", authorizeURL)
|
||||
}
|
||||
if query.Get("client_id") != "vk-client" {
|
||||
t.Fatalf("client_id = %q", query.Get("client_id"))
|
||||
}
|
||||
if !strings.Contains(query.Get("scope"), "email") {
|
||||
t.Fatalf("scope = %q, want email", query.Get("scope"))
|
||||
}
|
||||
state := query.Get("state")
|
||||
if state == "" {
|
||||
t.Fatal("в ссылке нет state")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
user, token, session, redirect, err := service.OAuthCallback(ctx, "vk",
|
||||
"vk-code", state, url.Values{"device_id": {"device-123"}}, "127.0.0.1", "agent")
|
||||
if err != nil {
|
||||
t.Fatalf("OAuthCallback: %v", err)
|
||||
}
|
||||
if token == "" || session == nil {
|
||||
t.Fatalf("token=%q session=%v", token, session)
|
||||
}
|
||||
// Email зашифрован: наружу его отдаёт только сервис.
|
||||
email, err := service.Email(ctx, user.ID)
|
||||
if err != nil || email != "ivan@example.com" {
|
||||
t.Fatalf("email = %q, err = %v", email, err)
|
||||
}
|
||||
if redirect != "/app/friends" {
|
||||
t.Fatalf("redirect = %q", redirect)
|
||||
}
|
||||
// Обмен кода: PKCE-верификатор (43 символа), device_id и service_token.
|
||||
if len(tokenForm.Get("code_verifier")) != 43 {
|
||||
t.Fatalf("code_verifier = %q (len %d), want 43", tokenForm.Get("code_verifier"), len(tokenForm.Get("code_verifier")))
|
||||
}
|
||||
if tokenForm.Get("device_id") != "device-123" {
|
||||
t.Fatalf("device_id = %q", tokenForm.Get("device_id"))
|
||||
}
|
||||
if tokenForm.Get("service_token") != "vk-service-token" {
|
||||
t.Fatalf("service_token = %q", tokenForm.Get("service_token"))
|
||||
}
|
||||
if tokenForm.Get("client_secret") != "" {
|
||||
t.Fatalf("client_secret не должен уходить в VK: %q", tokenForm.Get("client_secret"))
|
||||
}
|
||||
if tokenForm.Get("state") != state {
|
||||
t.Fatalf("state в обмене = %q, want %q", tokenForm.Get("state"), state)
|
||||
}
|
||||
// Профиль: POST с client_id и токеном.
|
||||
if infoForm.Get("client_id") != "vk-client" || infoForm.Get("access_token") != "vk-access-token" {
|
||||
t.Fatalf("user_info form = %v", infoForm)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOAuthYandexFlow — вход через Яндекс ID: обмен кода с client_id/secret в
|
||||
// теле запроса и профиль по заголовку `Authorization: OAuth <токен>`.
|
||||
func TestOAuthYandexFlow(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
var infoAuth string
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.PostForm.Get("client_id") != "ya-client" || r.PostForm.Get("client_secret") != "ya-secret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "ya-access-token",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) {
|
||||
infoAuth = r.Header.Get("Authorization")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"id": "1000034426",
|
||||
"login": "ivan",
|
||||
"default_email": "ivan@yandex.ru",
|
||||
"emails": []string{"ivan@yandex.ru"},
|
||||
"real_name": "Иван Петров",
|
||||
})
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
originalProviders := oauthProviderSet
|
||||
originalInfoURL := yandexInfoURL
|
||||
providers := defaultOAuthProviders()
|
||||
yandex := providers["yandex"]
|
||||
yandex.Endpoint.AuthURL = server.URL + "/authorize"
|
||||
yandex.Endpoint.TokenURL = server.URL + "/token"
|
||||
providers["yandex"] = yandex
|
||||
oauthProviderSet = providers
|
||||
yandexInfoURL = server.URL + "/info"
|
||||
t.Cleanup(func() {
|
||||
oauthProviderSet = originalProviders
|
||||
yandexInfoURL = originalInfoURL
|
||||
})
|
||||
|
||||
cfg := oauthTestConfig()
|
||||
cfg.OAuthYandexClientID = "ya-client"
|
||||
cfg.OAuthYandexClientSecret = "ya-secret"
|
||||
service, _ := oauthTestService(t, cfg)
|
||||
|
||||
authorizeURL, err := service.OAuthAuthorizeURL("yandex", "/app")
|
||||
if err != nil {
|
||||
t.Fatalf("authorize url: %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(authorizeURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse authorize url: %v", err)
|
||||
}
|
||||
if parsed.Query().Get("code_challenge") != "" {
|
||||
t.Fatal("Яндекс не требует PKCE: code_challenge не нужен")
|
||||
}
|
||||
state := parsed.Query().Get("state")
|
||||
|
||||
ctx := context.Background()
|
||||
user, _, _, _, err := service.OAuthCallback(ctx, "yandex",
|
||||
"ya-code", state, url.Values{}, "127.0.0.1", "agent")
|
||||
if err != nil {
|
||||
t.Fatalf("OAuthCallback: %v", err)
|
||||
}
|
||||
email, err := service.Email(ctx, user.ID)
|
||||
if err != nil || email != "ivan@yandex.ru" {
|
||||
t.Fatalf("email = %q, err = %v", email, err)
|
||||
}
|
||||
if user.Username != "ivan" {
|
||||
t.Fatalf("username = %q, want логин Яндекса", user.Username)
|
||||
}
|
||||
if infoAuth != "OAuth ya-access-token" {
|
||||
t.Fatalf("Authorization = %q, want `OAuth …`", infoAuth)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user