2026-09-26 15:13:24 +03:00
|
|
|
|
package auth
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"log/slog"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
|
|
|
|
|
|
|
|
"glchat/internal/config"
|
|
|
|
|
|
"glchat/internal/crypto"
|
|
|
|
|
|
"glchat/internal/database"
|
|
|
|
|
|
"glchat/internal/store"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// oauthTestConfig — конфиг тестового сервиса с включённым GitHub-провайдером.
|
|
|
|
|
|
func oauthTestConfig() config.Config {
|
|
|
|
|
|
return config.Config{
|
|
|
|
|
|
Domain: "gl.mhspx.su",
|
|
|
|
|
|
InstanceName: "glchat",
|
|
|
|
|
|
SessionPepper: "unit-test-session-pepper",
|
|
|
|
|
|
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
|
|
|
|
|
|
TOTPEncryptionKey: "unit-test-totp-key",
|
|
|
|
|
|
SessionTTLHours: 24,
|
|
|
|
|
|
// Низкие параметры Argon2id: тесты не должны работать секундами.
|
|
|
|
|
|
Argon2MemoryKiB: 1024,
|
|
|
|
|
|
Argon2Iterations: 1,
|
|
|
|
|
|
Argon2Parallelism: 1,
|
|
|
|
|
|
OAuthGitHubClientID: "client-id",
|
|
|
|
|
|
OAuthGitHubClientSecret: "client-secret",
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthTestBaseConfig — конфиг без OAuth: проверяем выключенные провайдеры.
|
|
|
|
|
|
func oauthTestBaseConfig() config.Config {
|
|
|
|
|
|
cfg := oauthTestConfig()
|
|
|
|
|
|
cfg.OAuthGitHubClientID = ""
|
|
|
|
|
|
cfg.OAuthGitHubClientSecret = ""
|
|
|
|
|
|
return cfg
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// oauthTestServer поднимает фальшивого провайдера: обмен кода на токен и
|
|
|
|
|
|
// профиль. Адреса подставляются в каталог провайдеров на время теста.
|
|
|
|
|
|
func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if err := r.ParseForm(); err != nil {
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.Form.Get("code") != "good-code" {
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// Провайдер отвечает JSON: так же ведёт себя GitHub при
|
|
|
|
|
|
// `Accept: application/json`, который ставит golang.org/x/oauth2.
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
|
|
|
|
"access_token": "test-access-token",
|
|
|
|
|
|
"token_type": "bearer",
|
|
|
|
|
|
"expires_in": 3600,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.Header.Get("Authorization") != "Bearer test-access-token" {
|
|
|
|
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
|
|
|
|
|
})
|
|
|
|
|
|
server := httptest.NewServer(mux)
|
|
|
|
|
|
t.Cleanup(server.Close)
|
|
|
|
|
|
|
|
|
|
|
|
original := oauthProviderSet
|
|
|
|
|
|
oauthProviderSet = map[string]oauthProvider{
|
|
|
|
|
|
"github": {
|
|
|
|
|
|
ID: "github",
|
|
|
|
|
|
Name: "GitHub",
|
|
|
|
|
|
Endpoint: oauth2.Endpoint{
|
|
|
|
|
|
AuthURL: server.URL + "/authorize",
|
|
|
|
|
|
TokenURL: server.URL + "/token",
|
|
|
|
|
|
},
|
|
|
|
|
|
Scopes: []string{"user:email"},
|
2026-09-27 17:57:48 +03:00
|
|
|
|
FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
// Провайдер получает токен так же, как в бою: через oauth2-клиент.
|
|
|
|
|
|
authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
|
|
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return oauthProfile{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
response, err := authorized.Do(request)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return oauthProfile{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
|
|
if response.StatusCode != http.StatusOK {
|
|
|
|
|
|
return oauthProfile{}, errors.New("profile request failed")
|
|
|
|
|
|
}
|
|
|
|
|
|
return profile, nil
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
t.Cleanup(func() { oauthProviderSet = original })
|
|
|
|
|
|
return server
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func oauthTestService(t *testing.T, cfg config.Config) (*Service, *store.Store) {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
db, err := database.Open(ctx, database.Options{
|
|
|
|
|
|
Path: filepath.Join(t.TempDir(), "glchat.db"),
|
|
|
|
|
|
ReadPool: 2,
|
|
|
|
|
|
Migrate: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("open database: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
|
|
st := store.New(db)
|
|
|
|
|
|
service, err := New(ctx, cfg, st, slog.New(slog.DiscardHandler))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("auth.New: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return service, st
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthNotConfigured — без клиентских id ручки отвечают понятной ошибкой,
|
|
|
|
|
|
// а не пытаются увести пользователя к провайдеру (AGENT.md 7.1).
|
|
|
|
|
|
func TestOAuthNotConfigured(t *testing.T) {
|
|
|
|
|
|
service, _ := oauthTestService(t, oauthTestBaseConfig())
|
|
|
|
|
|
if service.OAuthEnabled() {
|
|
|
|
|
|
t.Fatal("oauth must be disabled without client credentials")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
|
|
|
|
|
|
t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
if _, err := service.OAuthAuthorizeURL("ok", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if len(service.OAuthProviders()) != 0 {
|
|
|
|
|
|
t.Fatalf("providers = %v, want empty", service.OAuthProviders())
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthAuthorizeURL — ссылка ведёт к провайдеру и несёт подписанный state.
|
|
|
|
|
|
func TestOAuthAuthorizeURL(t *testing.T) {
|
|
|
|
|
|
oauthTestServer(t, oauthProfile{Subject: "1", Email: "user@example.com", EmailVerified: true})
|
|
|
|
|
|
service, _ := oauthTestService(t, oauthTestConfig())
|
|
|
|
|
|
|
|
|
|
|
|
providers := service.OAuthProviders()
|
|
|
|
|
|
if len(providers) != 1 || providers[0].ID != "github" {
|
|
|
|
|
|
t.Fatalf("providers = %v, want [github]", providers)
|
|
|
|
|
|
}
|
|
|
|
|
|
authorizeURL, err := service.OAuthAuthorizeURL("github", "/app/invite/abc")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("authorize url: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !strings.HasPrefix(authorizeURL, "http") || !strings.Contains(authorizeURL, "client_id=client-id") {
|
|
|
|
|
|
t.Fatalf("unexpected authorize url: %s", authorizeURL)
|
|
|
|
|
|
}
|
|
|
|
|
|
parsed, err := url.Parse(authorizeURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("parse authorize url: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
state := parsed.Query().Get("state")
|
|
|
|
|
|
if state == "" || !strings.Contains(state, ".") {
|
|
|
|
|
|
t.Fatalf("state is not signed: %q", state)
|
|
|
|
|
|
}
|
|
|
|
|
|
redirect, err := service.verifyOAuthState("github", state)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("verify state: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if redirect != "/app/invite/abc" {
|
|
|
|
|
|
t.Fatalf("redirect = %q", redirect)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Подмена state и чужой провайдер отклоняются.
|
|
|
|
|
|
if _, err := service.verifyOAuthState("google", state); !errors.Is(err, ErrOAuthState) {
|
|
|
|
|
|
t.Fatalf("state for another provider must fail, got %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := service.verifyOAuthState("github", state+"x"); !errors.Is(err, ErrOAuthState) {
|
|
|
|
|
|
t.Fatalf("tampered state must fail, got %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthCallbackCreatesAndLinksAccounts — вход новым аккаунтом, привязка к
|
|
|
|
|
|
// существующему по email, повторный вход и запрет для забаненного инстанса.
|
|
|
|
|
|
func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
|
|
|
|
|
|
oauthTestServer(t, oauthProfile{Subject: "42", Email: "OAuth@Example.com", EmailVerified: true, Login: "Octo Cat"})
|
|
|
|
|
|
service, st := oauthTestService(t, oauthTestConfig())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
|
|
state, err := service.signOAuthState("github", "/app")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("sign state: %v", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("oauth callback: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if redirect != "/app" || token == "" || session == nil {
|
|
|
|
|
|
t.Fatalf("unexpected callback result: redirect=%q token=%q", redirect, token)
|
|
|
|
|
|
}
|
|
|
|
|
|
if user.Username != "octo_cat" {
|
|
|
|
|
|
t.Fatalf("username = %q, want octo_cat", user.Username)
|
|
|
|
|
|
}
|
|
|
|
|
|
// Email зашифрован, наружу не отдаётся, но расшифровывается сервисом.
|
|
|
|
|
|
email, err := service.Email(ctx, user.ID)
|
|
|
|
|
|
if err != nil || email != "oauth@example.com" {
|
|
|
|
|
|
t.Fatalf("email = %q, err = %v", email, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
accounts, err := st.ListOAuthAccounts(ctx, user.ID)
|
|
|
|
|
|
if err != nil || len(accounts) != 1 {
|
|
|
|
|
|
t.Fatalf("oauth accounts = %v, err = %v", accounts, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if accounts[0].SubjectIndex == "" || strings.Contains(accounts[0].SubjectIndex, "42") {
|
|
|
|
|
|
t.Fatalf("subject must be stored as blind index: %q", accounts[0].SubjectIndex)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Повторный вход тем же аккаунтом: новой записи не появляется.
|
|
|
|
|
|
state, err = service.signOAuthState("github", "/app")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("sign state: %v", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("second oauth callback: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if again.ID != user.ID {
|
|
|
|
|
|
t.Fatalf("second login created another account: %d != %d", again.ID, user.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Бан инстанса запрещает вход через провайдера.
|
|
|
|
|
|
if err := st.BanInstanceUser(ctx, user.ID, user.ID, "тест"); err != nil {
|
|
|
|
|
|
t.Fatalf("ban user: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
state, err = service.signOAuthState("github", "/app")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("sign state: %v", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthCallbackLinksExistingAccountByEmail — провайдер привязывается к
|
|
|
|
|
|
// уже существующему аккаунту с тем же подтверждённым email (blind index).
|
|
|
|
|
|
func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) {
|
|
|
|
|
|
oauthTestServer(t, oauthProfile{Subject: "777", Email: "existing@example.com", EmailVerified: true, Login: "existing"})
|
|
|
|
|
|
service, st := oauthTestService(t, oauthTestConfig())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
|
|
|
|
if _, _, _, err := service.Register(ctx, RegisterInput{
|
|
|
|
|
|
Username: "existing", Email: "existing@example.com", Password: "correct-horse-battery",
|
|
|
|
|
|
}); err != nil {
|
|
|
|
|
|
t.Fatalf("register: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
existing, err := service.UserByEmail(ctx, "existing@example.com")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("find user: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state, err := service.signOAuthState("github", "/app")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("sign state: %v", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent")
|
2026-09-26 15:13:24 +03:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("oauth callback: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if user.ID != existing.ID {
|
|
|
|
|
|
t.Fatalf("oauth login must reuse the account: %d != %d", user.ID, existing.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
accounts, err := st.ListOAuthAccounts(ctx, user.ID)
|
|
|
|
|
|
if err != nil || len(accounts) != 1 {
|
|
|
|
|
|
t.Fatalf("link was not created: %v, err = %v", accounts, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
events, err := st.ListSecurityEvents(ctx, user.ID, 20)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("list security events: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
found := false
|
|
|
|
|
|
for _, event := range events {
|
|
|
|
|
|
if event.Type == "oauth_linked" {
|
|
|
|
|
|
found = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
t.Fatal("security events have no oauth_linked")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthCallbackRejectsUnverifiedEmail — неподтверждённый email не даёт
|
|
|
|
|
|
// войти: иначе чужой адрес в профиле провайдера уводил бы в чужой аккаунт.
|
|
|
|
|
|
func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) {
|
|
|
|
|
|
oauthTestServer(t, oauthProfile{Subject: "5", Email: "unverified@example.com", EmailVerified: false})
|
|
|
|
|
|
service, _ := oauthTestService(t, oauthTestConfig())
|
|
|
|
|
|
|
|
|
|
|
|
state, err := service.signOAuthState("github", "/app")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("sign state: %v", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthCallbackStateAndCodeErrors — неверный state и код отклоняются.
|
|
|
|
|
|
func TestOAuthCallbackStateAndCodeErrors(t *testing.T) {
|
|
|
|
|
|
oauthTestServer(t, oauthProfile{Subject: "6", Email: "user@example.com", EmailVerified: true})
|
|
|
|
|
|
service, _ := oauthTestService(t, oauthTestConfig())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
2026-09-27 17:57:48 +03:00
|
|
|
|
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
t.Fatalf("bad state: err = %v, want ErrOAuthState", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
state, err := service.signOAuthState("github", "/app")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("sign state: %v", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthRegistrationDisabledBlocksNewAccounts — существующие аккаунты
|
|
|
|
|
|
// входят, новые не создаются при выключенной регистрации (7.1).
|
|
|
|
|
|
func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) {
|
|
|
|
|
|
oauthTestServer(t, oauthProfile{Subject: "8", Email: "new@example.com", EmailVerified: true, Login: "new"})
|
|
|
|
|
|
service, st := oauthTestService(t, oauthTestConfig())
|
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
|
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
|
|
|
|
|
|
t.Fatalf("disable registration: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
state, err := service.signOAuthState("github", "/app")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("sign state: %v", err)
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) {
|
2026-09-26 15:13:24 +03:00
|
|
|
|
t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestOAuthStateExpires — state старше окна не принимается, даже если подпись
|
|
|
|
|
|
// верна: иначе ссылку можно было бы использовать неограниченно долго.
|
|
|
|
|
|
func TestOAuthStateExpires(t *testing.T) {
|
|
|
|
|
|
service, _ := oauthTestService(t, oauthTestConfig())
|
|
|
|
|
|
payload, err := json.Marshal(map[string]string{
|
|
|
|
|
|
"provider": "github",
|
|
|
|
|
|
"redirect": "/app",
|
|
|
|
|
|
"issued": strconv.FormatInt(time.Now().UTC().Add(-time.Hour).Unix(), 10),
|
|
|
|
|
|
"nonce": "deadbeefdeadbeef",
|
|
|
|
|
|
})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("marshal payload: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
|
|
|
|
|
expired := encoded + "." + service.signStatePayload(encoded)
|
|
|
|
|
|
if _, err := service.verifyOAuthState("github", expired); !errors.Is(err, ErrOAuthState) {
|
|
|
|
|
|
t.Fatalf("expired state: err = %v, want ErrOAuthState", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestPasskeyHandleStableAndOpaque — user handle не раскрывает id аккаунта и
|
|
|
|
|
|
// стабилен между вызовами.
|
|
|
|
|
|
func TestPasskeyHandleStableAndOpaque(t *testing.T) {
|
|
|
|
|
|
service, _ := oauthTestService(t, oauthTestBaseConfig())
|
|
|
|
|
|
first := service.passkeyHandle(12345)
|
|
|
|
|
|
second := service.passkeyHandle(12345)
|
|
|
|
|
|
if len(first) != 16 || !bytes.Equal(first, second) {
|
|
|
|
|
|
t.Fatalf("handle must be 16 stable bytes: %v / %v", first, second)
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.Contains(string(first), "12345") {
|
|
|
|
|
|
t.Fatal("handle must not contain the account id")
|
|
|
|
|
|
}
|
|
|
|
|
|
if bytes.Equal(service.passkeyHandle(12346), first) {
|
|
|
|
|
|
t.Fatal("handles of different accounts must differ")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestPasskeyCeremonyStoreIsOneTime — церемония выдаётся один раз и по TTL.
|
|
|
|
|
|
func TestPasskeyCeremonyStoreIsOneTime(t *testing.T) {
|
|
|
|
|
|
store := newCeremonyStore()
|
|
|
|
|
|
now := time.Now().UTC()
|
|
|
|
|
|
store.now = func() time.Time { return now }
|
|
|
|
|
|
store.put("token", ceremony{userID: 1, expiresAt: now.Add(time.Minute)})
|
|
|
|
|
|
if _, ok := store.take("token"); !ok {
|
|
|
|
|
|
t.Fatal("ceremony must be available once")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, ok := store.take("token"); ok {
|
|
|
|
|
|
t.Fatal("ceremony must not be reusable")
|
|
|
|
|
|
}
|
|
|
|
|
|
store.put("expired", ceremony{userID: 1, expiresAt: now.Add(-time.Second)})
|
|
|
|
|
|
if _, ok := store.take("expired"); ok {
|
|
|
|
|
|
t.Fatal("expired ceremony must not be usable")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSanitizeUsername — логин провайдера приводится к правилам glchat.
|
|
|
|
|
|
func TestSanitizeUsername(t *testing.T) {
|
|
|
|
|
|
cases := map[string]string{
|
|
|
|
|
|
"Octo-Cat": "octo_cat",
|
|
|
|
|
|
" John Doe ": "john_doe",
|
|
|
|
|
|
"ю": "",
|
|
|
|
|
|
"a": "",
|
|
|
|
|
|
"user.name": "user.name",
|
|
|
|
|
|
"UPPER": "upper",
|
|
|
|
|
|
}
|
|
|
|
|
|
for input, expected := range cases {
|
|
|
|
|
|
if got := sanitizeUsername(input); got != expected {
|
|
|
|
|
|
t.Errorf("sanitizeUsername(%q) = %q, want %q", input, got, expected)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TestSafeRedirect — открытый редирект невозможен.
|
|
|
|
|
|
func TestSafeRedirect(t *testing.T) {
|
|
|
|
|
|
if safeRedirect("//evil.example") != "/app" || safeRedirect("https://evil.example") != "/app" {
|
|
|
|
|
|
t.Fatal("external redirects must be rejected")
|
|
|
|
|
|
}
|
|
|
|
|
|
if safeRedirect("/app/invite/x") != "/app/invite/x" || safeRedirect("") != "/app" {
|
|
|
|
|
|
t.Fatal("internal redirects must be preserved")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// проверяем, что ключ blind index провайдера не совпадает с email-индексом.
|
|
|
|
|
|
func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) {
|
|
|
|
|
|
key, err := crypto.ParseMasterKey(oauthTestConfig().MasterKey)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("parse master key: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
subject := key.BlindIndex("github:42")
|
|
|
|
|
|
email := key.BlindIndex("user@example.com")
|
|
|
|
|
|
if subject == email || len(subject) != len(email) {
|
|
|
|
|
|
t.Fatal("blind indexes must be independent")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-27 17:57:48 +03:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|