cebce0ae3b
Фаза 7, AGENT.md 7.1: провайдеры включаются переменными окружения OAUTH_<PROVIDER>_CLIENT_ID/SECRET, привязка внешнего аккаунта идёт по подтверждённому провайдером email через blind index, вход забаненному на инстансе запрещён, все входы и привязки попадают в события безопасности, привязка — ещё и в аудит инстанса. Внешний идентификатор (subject) хранится только индексом HMAC-SHA-256, токены провайдеров не сохраняются вовсе; state подписывается ключом сессий и ограничен по времени, редирект после входа — только внутренний путь. Если провайдер не настроен, ручка отвечает oauth.provider_not_configured, а клиент показывает человеческий текст вместо кнопки. Клиент: кнопки входа по списку включённых провайдеров на экране входа и раздел «Вход через внешние сервисы» в настройках безопасности; адрес возврата для настроек приложения виден в GET /auth/oauth/providers. Миграция 00019 добавляет таблицу oauth_accounts. Установщик и .env.example знают про OAUTH_* и сохраняют значения при --reconfigure. Тесты: Go — выключенный провайдер, подписанный state (подмена и чужой провайдер), создание и повторный вход, привязка к существующему аккаунту, неподтверждённый email, выключенная регистрация, бан инстанса, mocked провайдер (httptest) для обмена кода; web — кнопки провайдеров и ошибка возврата; серверная ручка отдаёт понятный отказ без настроек.
96 lines
3.7 KiB
Go
96 lines
3.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"time"
|
|
)
|
|
|
|
// OAuthAccount — связь внешнего провайдера с пользователем инстанса.
|
|
// Сырого идентификатора и email здесь нет: только blind index (AGENT.md 9.2).
|
|
type OAuthAccount struct {
|
|
ID uint64
|
|
UserID uint64
|
|
Provider string
|
|
SubjectIndex string
|
|
EmailIndex string
|
|
CreatedAt time.Time
|
|
LastLoginAt *time.Time
|
|
}
|
|
|
|
// LinkOAuthAccount привязывает внешний аккаунт к пользователю. Повторная
|
|
// привязка того же провайдера к другому пользователю отклоняется уникальным
|
|
// индексом (provider, subject_index) — это защита от подмены владельца.
|
|
func (s *Store) LinkOAuthAccount(ctx context.Context, userID uint64, provider, subjectIndex, emailIndex string) (*OAuthAccount, error) {
|
|
id := s.NextID()
|
|
_, err := s.writer.ExecContext(ctx, `
|
|
INSERT INTO oauth_accounts (id, user_id, provider, subject_index, email_index, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
int64(id), int64(userID), provider, subjectIndex, emailIndex, s.Now())
|
|
if err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
return s.GetOAuthAccount(ctx, id)
|
|
}
|
|
|
|
func (s *Store) GetOAuthAccount(ctx context.Context, id uint64) (*OAuthAccount, error) {
|
|
row := s.reader.QueryRowContext(ctx, `
|
|
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
|
|
FROM oauth_accounts WHERE id = ?`, int64(id))
|
|
return scanOAuthAccount(row)
|
|
}
|
|
|
|
// OAuthAccountBySubject ищет привязку по blind index внешнего идентификатора.
|
|
func (s *Store) OAuthAccountBySubject(ctx context.Context, provider, subjectIndex string) (*OAuthAccount, error) {
|
|
row := s.reader.QueryRowContext(ctx, `
|
|
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
|
|
FROM oauth_accounts WHERE provider = ? AND subject_index = ?`, provider, subjectIndex)
|
|
return scanOAuthAccount(row)
|
|
}
|
|
|
|
// ListOAuthAccounts возвращает привязки пользователя: показываются в настройках
|
|
// безопасности, чтобы входы через провайдеров не были невидимыми.
|
|
func (s *Store) ListOAuthAccounts(ctx context.Context, userID uint64) ([]OAuthAccount, error) {
|
|
rows, err := s.reader.QueryContext(ctx, `
|
|
SELECT id, user_id, provider, subject_index, email_index, created_at, last_login_at
|
|
FROM oauth_accounts WHERE user_id = ? ORDER BY created_at`, int64(userID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
accounts := make([]OAuthAccount, 0, 2)
|
|
for rows.Next() {
|
|
account, err := scanOAuthAccount(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accounts = append(accounts, *account)
|
|
}
|
|
return accounts, rows.Err()
|
|
}
|
|
|
|
// TouchOAuthAccount отмечает вход через провайдера.
|
|
func (s *Store) TouchOAuthAccount(ctx context.Context, id uint64) error {
|
|
_, err := s.writer.ExecContext(ctx, `UPDATE oauth_accounts SET last_login_at = ? WHERE id = ?`, s.Now(), int64(id))
|
|
return err
|
|
}
|
|
|
|
func scanOAuthAccount(scanner interface{ Scan(...any) error }) (*OAuthAccount, error) {
|
|
var (
|
|
account OAuthAccount
|
|
createdAt string
|
|
lastLoginAt sql.NullString
|
|
)
|
|
if err := scanner.Scan(&account.ID, &account.UserID, &account.Provider, &account.SubjectIndex,
|
|
&account.EmailIndex, &createdAt, &lastLoginAt); err != nil {
|
|
return nil, mapError(err)
|
|
}
|
|
account.CreatedAt = parseTimestamp(createdAt)
|
|
if lastLoginAt.Valid {
|
|
value := parseTimestamp(lastLoginAt.String)
|
|
account.LastLoginAt = &value
|
|
}
|
|
return &account, nil
|
|
}
|