Новая фича: OpenID Connect — логин на других сайтах через Клуб (+API)

 Публичный пост
8 марта 2023  4645
ОХУЕННО Оффер в FAANG

Всем по два пива, ведь теперь в Клубе есть свой OpenID провайдер!

Он позволит вашим ботам, сайтам и приложениям авторизовать пользователей и проверять, что они пришли из Клуба. Я так делаю у себя в блоге, но создатели сторонних ботов для чатиков и спецпроектов типа Сикрет Сант давно просили тоже дать им такую возможность.

Теперь она есть. Самые прошаренные и нетерпеливые могут уже пойти в настройки своего профиля, зарегать там ключик и получить все необходимые токены и OAuth-эндпоинты: https://vas3k.club/apps/

Для нормальных же людей, которые не фигачат OAuth-авторизацию каждый день, я напишу здесь пошаговый гайд как и что делать, чтобы вам тоже было красиво.

Примеры будут на Python, но если вы красавчик в других языках программирования — продублируйте ваш вариант в комментах, я закреплю. Другим будет полезно.

Шаг 0. Разобраться с OAuth и OpenID Connect

У всего этого великолепия довольно большой порог входа, даже если использовать уже готовые либы (как мы и будем). Если для вас это первый раз в жизни — придётся немного сесть и во всём разобраться.

Зато потом можете сразу добавлять скилл «IAM» в резюме. Рекомендую!

Для погружения с нуля рекомендую вот этот гайд: An Illustrated Guide to OAuth and OpenID Connect (перевод на русский).

Там в картинках рассказано как выглядит весь этот обмен токенами, кодами, и зачем всё это нужно.

TL;DR: OpenID Connect = OAuth 2 + JWT-токены. Раньше это был зоопарк разных протоколов, но сейчас всё более-менее пришло к одному стандарту. Если вы знакомы с OAuth, то OpenID это просто OAuth с унифицированным форматом токенов.

Шаг 1. Зарегистрировать своё «приложение» в Клубе

Чтобы Клуб мог выдавать вам токены, он должен знать что у вас за приложение. Это может быть бот, сайт, скрипт, да вообще что угодно. Создать его можно здесь: https://vas3k.club/apps/

В ответ вам дадут client_id и client_secret. Первый можно рассказывать всем. Второй нужно хранить в секретном месте на вашем бекенде, иначе любой сможет украсть вашу авторизацию и притвориться вами.

Однако, если вашему приложению не нужен доступ к аккаунтам юзеров, то можно вообще забить на OpenID и использовать service_token — это такой универсальный токен, с которым вы сразу можете делать обычные запросы к API, как будто вы залогинены. Этот способ подходит многим простым ботам — через него работает Вастрик.Пей и Вастрик.Контроль, например.

Но в этом гайде мы рассматриваем именно аутентификацию живых юзеров на сторонних ресурсах, так что тут придётся поколдовать.

Шаг 2. Пишем код

Для проектов на Python я рекомендую использовать библиотеку Authlib. Она современная, даёт биндинги под Django и Flask, ну и сам Клуб внутри использует именно её. Вроде как Authlib даже умеет в асинхронность.

Вот их гайд по написанию OAuth-клиентов.

Там много примеров и деталей, так что здесь я покажу лишь самый минимум, а точнее как сделать логин через Клуб за 10 строчек кода.

Для начала создадим саму кнопку «Войти через Вастрик.Клуб».

<a href="/login/club">Войти через Вастрик.Клуб</a>

В это же время на бекенде мы инициализируем наш OAuth-клиент. Вам надо лишь подставить свой client_id и client_secret, всё остальное Authlib возьмет из .well-known конфига (который мы указали в server_metadata_url), включая все эндпоинты и JWT-ключи.

Удобно. Спасибо десятилетиям полировки стандарта OpenID. Раньше было больнее.

from authlib.integrations.django_client import OAuth

oauth = OAuth()
oauth.register({
    name="club",
    client_id="YOUR_CLIENT_ID",
    client_secret=os.getenv("GET_YOUR_CLIENT_SECRET_HERE"),
    server_metadata_url="https://vas3k.club/.well-known/openid-configuration",
    client_kwargs={"scope": "openid"},
})

Затем напишем простейший обработчик для нашей кнопки. Он по сути просто редиректит юзера на сайт Клуба с правильными GET-параметрами.

# GET /login/club

def login_club(request):
    redirect_uri = "https://your.website/login/club/callback"
    return oauth.club.authorize_redirect(request, redirect_uri)

Теперь у нас есть кнопка, которую юзер может кликнуть, и ему откроется классическая форма авторизации.

По нажатию "Разрешить" Клуб редиректнет юзера обратно на ваш сайт, но уже с неким новым волшебным временным кодом (который живет 5 минут). Он будет использовать тот redirect_uri, который вы указали в запросе. В нашем случае это /login/club/callback.

Код — это еще не токен, но уже почти. Если Клуб выдал вам код, значит он признал вас. Теперь вам надо просто обменять ваш временный код на настоящий access_token.

Напишем обработчик для этого.

Мы делаем всё с бекенда, потому что так безопаснее и токен не перехватят злые люди посередине (и так рекомендуется стандартом). Но если у вас чисто клиентское приложение, то на свой страх и риск можете делать это и с фронта.

# GET /login/club/callback

def login_club_callback(request):
    try:
        token = oauth.club.authorize_access_token(request)
    except OAuthError as ex:
        # тут обрабатываем всякие ошибки авторизации
        return render(request, "error.html", {...})

    # парсим базовую инфу о пользователе из id_token
    # в нём есть юзернейм и почта
    user_slug = token["userinfo"]["sub"]
    user_email = token["userinfo"]["email"]

    # этих данных может быть достаточно для создания профиля
    user, _ = User.objects.get_or_create(...)

    # логиним юзера у себя 
    # стандартными средствами джанги
    login(request, user)

    # красава!
    return reverse("profile")

Пример работающего кода на продакшене можно посмотреть в гитхабе моего блога.

Если вы используете другую либу или вообще хотите реализовать весь флоу руками, то гляньте на наш OpenID Configuration — есть вся необходимая инфа о том, какие эндпоинты выдают токены и ключи. Этот формат по идее тоже стандартен и должен автоматически жраться и другими фреймворками.

Шаг 3. Делаем запросы к API

Выданный вам access_token живёт уже 24 часа, но его можно рефрешить через refresh_token, если вам по каким-то причинам надо обращаться к API Клуба позднее. Так что не забудьте сохранить всё в базу, чтобы не просрать.

Хотя для простой аутентификации (как у меня в блоге) можно сразу сделать все нужные запросы и просто выбросить токен. Так даже безопаснее.

Например, вот так я получаю данные профиля пользователя, которого только что аутентифицировал. Всё делается через простой эндпоинт: https://vas3k.club/user/me.json

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" https://vas3k.club/user/me.json

Ну или то же самое на питоне.

import requests

# запрашиваем профиль с аватаркой итд
club_profile = requests.get(
    url="https://vas3k.club/user/me.json",
    headers={
        "Authorization": f"{token['token_type']} {token['access_token']}"
    }
).json()

# например, вы хотите отфильтровать 
# только членов Клуба с активной подпиской
if club_profile["user"]["payment_status"] != "active":
    return render(
    	request, "error.html", 
    	{"message": "Ваша подписка истекла"}
    )

# заводим юзера у себя
user, _ = User.objects.update_or_create(
    club_slug=user_slug,
    defaults=dict(
        full_name=club_profile["user"]["full_name"],
        avatar=club_profile["user"]["avatar"],
        email=user_email,
    )
)

👍 Вы великолепны

Остальные API-эндпоинты можно подсмотреть на странице https://vas3k.club/apps/

Кроме старых добрых JSON-версий постов и профилей через наше маленькое API теперь можно запрашивать фид в формате JSONFeed и комментарии к постам.

Вот так можно получить фид: https://vas3k.club/feed.json

Вот так отфильтровать посты по рейтингу за год: https://vas3k.club/all/top_year/feed.json

Здесь получить данные конкретного поста: https://vas3k.club/post/2584.json

И вот так его комментарии: https://vas3k.club/post/2584/comments.json

Наш API всё еще маленький, но его идея в том, что он практически повторяет URL самого сайта, с добавлением расширения .json или .md для получения данных в нужном машиночитаемом формате.

Если вы будете пытаться делать это на других языках или фреймворках — не стесняйтесь приложить в комменты код или ссылку на гитхаб.

Во славу опенсорса

Кому интересно посмотреть как всё это дело работает на живом проекте на 10к человек изнутри, весь код своих пет-проджектов я пишу в опенсорс, здесь не исключение. Конечно, я херовая ролевая модель, но работает жи!

Провайдер:

Клиент:

Связанные посты
30 комментариев 👇
Programistich Mobile Developer Команда Клуба 9 марта 2023

Туториал для Flutter

  1. Нам понадобится библиотечка oauth2, которую ставим как
flutter pub add oauth2
  1. . Заполняем константы
static const _authorizationEndpoint = 'https://vas3k.club/auth/openid/authorize';
static const _tokenEndpoint = 'https://vas3k.club/auth/openid/token';
static const _identifier = SECRET_ID;
static const _secret = SECRET_SECRET;
static const redirectUrl = CALLBACK;
  1. Создаем инстанс авторизации
  final _grant = oauth2.AuthorizationCodeGrant(
    _identifier,
    Uri.parse(_authorizationEndpoint),
    Uri.parse(_tokenEndpoint),
    secret: _secret,
  );
  1. Теперь создаем ссылку авторизации
final authUrl = _grant.getAuthorizationUrl(Uri.parse(redirectUrl));
  1. По этой ссылке совершаем переход

это может быть внутренний WebVIew или редирект в браузер, не важно - авторизуемся в клуб и разрешаем доступ к нашему приложению

  1. Хендлим callback
  • Если это внутренний WebVIew то просто смотрим за ссылкой, как только она начинается на наш CALLBACK значит авторизация закончена
  • Если вы открывали во внешнем браузере, то нужно будет системно обработать редирект (https://docs.flutter.dev/development/ui/navigation/deep-linking)

Мы получили ссылку вида CALLBACK?code=КОД

  1. Теперь нам нужно получить наши данные
final callBackUri = Uri.parse(CALLBACK?code=КОД);
final client = await _grant.handleAuthorizationResponse(callBackUri.queryParameters);
  1. Вы великолепны, в нашем client можем получить токен вот так:
final accessToken = client.credentials.accessToken;
Dmitriy Rozhkov Distinguished Notion API Consultant 8 марта 2023

Кто такой этот Вастрик и почему он так много делает для клуба? 🤔

  Развернуть 1 комментарий

Занял кошерный id, за покупкой пишите в директ!

  Развернуть 1 комментарий

@sobolev, не думаю, что это приветствуется в клубе.

  Развернуть 1 комментарий

@f0x1sland, это шутка )

  Развернуть 1 комментарий

@sobolev, ну вот :( Похоже я разучался в чувство юмора, не понмаю ее, мне нужна бригада. (Возможно уже скорой помощи)

  Развернуть 1 комментарий
Dmitrij Marchenko сисадмин, питонист 9 марта 2023

Я так понимаю абонент плату скоро повысят.

  Развернуть 1 комментарий

@Xaba-Xaba, так уже ведь повышали, меру знать надо

  Развернуть 1 комментарий

@vas3k, Ну всё, теперь точно повысят :⁠)

  Развернуть 1 комментарий

Офигенно!

  Развернуть 1 комментарий

Там при у картинки при пересылке на превью зерги буквы поели немного, вдруг важно

  Развернуть 1 комментарий

@zdravstvui_dorogoi, QA видно сразу, уважаю

  Развернуть 1 комментарий

@urtow, Контрибьютим по мере сил, хаха

  Развернуть 1 комментарий

@zdravstvui_dorogoi, старый баг, ага. Никто так и не разобрался почему он происходит. Я там лично часов 6 дебага похоронил

  Развернуть 1 комментарий

@vas3k, ну и пофиг, не такой страшный жук, пускай ползает. «Переводим в вонт фикс, закрываем задачу»)

  Развернуть 1 комментарий

@vas3k, блин, я думал это фича, а не баг. Чтобы не парсили картинки на текст. Не знаю правда зачем

  Развернуть 1 комментарий

Привет всем,
Всё тоже самое, но для связки .net 6 + Vue 3:

Код аутентификации для бэкенда:

Код для бэкенда

private static void Main(string[] args)
{
    ...
    builder.Services
            .AddAuthentication(defaultScheme: "cookie")
            .AddCookie("cookie", options =>
            {
                options.Cookie.Name = "cookie_name";
                options.ExpireTimeSpan = TimeSpan.FromDays(1.0);

                options.Cookie.HttpOnly = false;

                options.LoginPath = new PathString("/login");
            })
            .AddCookie("temp")
    		.AddOpenIdConnect("vas3k", options =>
            {
                options.Authority = "https://vas3k.club;

                options.ClientId = "clientId";
                options.ClientSecret = "my_client_secret";

                // Set the callback path, so it will call back to.
                options.CallbackPath = new PathString("/auth/signincallback");

                // Set response type to code
                options.ResponseType = OpenIdConnectResponseType.Code;
                // options.ResponseMode = OpenIdConnectResponseMode.Query;

                // Configure the scope
                options.Scope.Clear();
                options.Scope.Add("openid");

                options.SaveTokens = true;

                options.Events.OnAuthorizationCodeReceived = async (context) =>
                {
                    var request = context.HttpContext.Request;
                    var redirectUri = context.Properties?.Items[OpenIdConnectDefaults.RedirectUriForCodePropertiesKey] ?? "/";
                    var code = context.ProtocolMessage.Code;

                    using var client = new HttpClient();
                    var discoResponsee = await client.GetDiscoveryDocumentAsync(options.Authority);

                    var tokenResponse = await client.RequestAuthorizationCodeTokenAsync(new AuthorizationCodeTokenRequest
                    {
                        Address = discoResponsee.TokenEndpoint,
                        ClientId = options.ClientId!,
                        ClientSecret = options.ClientSecret,
                        Code = code,
                        RedirectUri = redirectUri,
                    });

                    if (tokenResponse.IsError)
                    {
                        // Error handler
                        throw new Exception("Bad auth. Can't exchange code for access token and id token");
                    }

                    var accessToken = tokenResponse.AccessToken ?? string.Empty;
                    var idToken = tokenResponse.IdentityToken ?? string.Empty;

                    context.HandleCodeRedemption(accessToken, idToken);
                };

                //options.GetClaimsFromUserInfoEndpoint = true;
                options.MapInboundClaims = false;

                options.SignInScheme = "temp";
            });
    ...
}

Обрабатываем коллбэк

[Route("[controller]/[action]")]
    [ApiController]
    public class AuthController : ControllerBase
    {
    ...
        [HttpGet]
        public IActionResult SignInOidc()
        {
            var schemeName = "vas3k";
            var props = new AuthenticationProperties
            {
                RedirectUri = new PathString("/auth/SinginCallback"),
                Items =
                {
                    { "scheme", schemeName }
                }
            };

            return Challenge(props, schemeName);
        }
    	
    	[AllowAnonymous]
        public async Task<IActionResult> SinginCallback()
        {
            // Read the outcome of external auth
            var authResult = await HttpContext.AuthenticateAsync("temp");

            if (!authResult.Succeeded)
            {
                return LocalRedirect(new PathString("/"));
            }

            // Read metadata with scheme
            var metadata = authResult.Properties.Items;
            if ((metadata == null) 
                || (!metadata.ContainsKey("scheme"))
                || (string.IsNullOrEmpty(metadata["scheme"])))
            {
                return LocalRedirect(new PathString("/"));
            }

            var schemeName = metadata["scheme"]!;
            try
            {
    		    // Авторизуем юзера по схеме vas3k
                var user = await _userService.AuthenticateAsync(
                    schemeName: schemeName,
                    claims: authResult.Principal.Claims,
                    metadata: metadata!
                );

                var claims = new List<Claim>
                {
                    new("sub", user.Email),
                };

                //var ci = new ClaimsIdentity(claims, "pwd", "name", "role");
                var ci = new ClaimsIdentity(claims, schemeName);
                var cp = new ClaimsPrincipal(ci);

    			// Откладываем куку
                await HttpContext.SignInAsync(cp);
                await HttpContext.SignOutAsync("temp");

                return LocalRedirect(new PathString("/profile"));
            }
            catch (Exception ex)
            {
                return LocalRedirect(new PathString("/"));
            }
        }
    ...
    }

Что в claims?

var sub = claims
    .FirstOrDefault(c => c.Type == "sub")?
    .Value;
var name = claims
    .FirstOrDefault(c => c.Type == "name")?
    .Value;
var email = claims
    .FirstOrDefault(c => c.Type == "email")?
    .Value;

Доработки SPA

  • authService.js
import Cookies from 'js-cookie';

function useAuthService() {
    const appCookieName = "cookie_name";

    function isAuthenticated() {
        const cookie = Cookies.get(appCookieName);

        return Boolean(cookie);
    }

    return {
        isAuthenticated,
    };
}

export default useAuthService;

Вы прекрасны!

  Развернуть 1 комментарий
Nikita Galaiko founding software engineer 8 марта 2023

так вот почему рсс весь заново прилетел одновременно и в вастрик блоге и в клубе!

  Развернуть 1 комментарий
Evgeny Melnikov Senior Software Engineer 9 марта 2023

Тот момент, когда понимаешь, что @vas3k потихоньку переплевывает Цукерберга, и клуб уже потихоньку приближается к своей собственной метавселенной :)

  Развернуть 1 комментарий

Это здорово! Я пока не придумал, зачем мне это нужно, но сама возможность греет душу. )

  Развернуть 1 комментарий

@vas3k получается, сейчас нет возможности, через апишку забрать всех пользователей списком?

  Развернуть 1 комментарий

@sCF0GBekXOrNaaia, Нет пока. Звучит как дырка для спамеров вообще :)

  Развернуть 1 комментарий

@vas3k, можно не отдавать поля, связанные с контактными данными.

Либо апрувить этот метод для каждого App Id в частном порядке, после модерации автора и приложения.

  Развернуть 1 комментарий

@sCF0GBekXOrNaaia, так можно, да. Через /search эндпоинт. Подумаю на досуге

  Развернуть 1 комментарий

@vas3k, Жду с нетерпением :) есть идея для приложения, но нужен список клубчан, хотябы минимальные данные:

  1. имя
  2. фотка
  3. теги
  Развернуть 1 комментарий

@sCF0GBekXOrNaaia, а что за идея?

  Развернуть 1 комментарий

@vas3k, ну в двух словах - "Тиндер" для клубчан, но не про романтику (хотя не без этого), а про "Networking: Профессиональные знакомства".

Клубчан становится все больше, а удобного каталога для поиска людей по интересам/хобби/работе - нет. Да и чем больше сообщество, тем выше психологический порог для "написать в личку". А в случае обоюдного "мэтча" этот порог упрощается в разы - то есть оба участника декларируют свое намерение "завести знакомство".

Механика всем знакомая - стек карточек, карточки свайпаются, а в вершину стека кладутся карточки с профилями, которые наиболее совпадают по тегам (процент совпадения уже реализован).

Дальше дело за малым - у вас совпадают теги интересов или намерений, остается свайпнуть друг друга :)

Тут ближайший аналог - формат Random Coffee (его кстати уже реализовывал один известный клубчанин), но его проблема в слове "random". Я как-то пытался им пользоваться, но когда тебе подкидывают случайного человека - часто оказывается, что нет точки входа для диалога. А совпадающие теги - отличная точка входа.

  Развернуть 1 комментарий

@vas3k, либо я что-то не так делаю, либо вход с нуля через openid не работает у тебя в блоге. Если разлогиниться в клубе и попробовать войти в блоге через клуб – будет вот такая вот ошибка.
(Я просто пытался на iOS завести, но тоже упирался в такую же ошибку)

  Развернуть 1 комментарий
Аватар Programistich Programistich 13 марта 2023 Команда Клуба

@Tregum, заходи в чат разработки в телеге, там уже обсуждали это

  Развернуть 1 комментарий

@Programistich, ага, спасибо, загляну

  Развернуть 1 комментарий

😎

Автор поста открыл его для большого интернета, но комментирование и движухи доступны только участникам Клуба

Что вообще здесь происходит?


Войти  или  Вступить в Клуб