Cara membuat teman bot Telegram dengan OpenId Connect

Bayangkan sebuah situasi: analis di Foobar Inc. melakukan studi menyeluruh tentang situasi pasar dan proses bisnis perusahaan dan sampai pada kesimpulan bahwa untuk mengoptimalkan biaya dan secara signifikan meningkatkan laba Foobar, mimisan memerlukan pendamping bot Telegram yang dapat menghibur karyawan di masa-masa sulit.


Tentu saja, Foobar tidak dapat membiarkan pesaing berbahaya mengambil keuntungan dari pengetahuan mereka hanya dengan menambahkan bot mereka ke kontak mereka. Oleh karena itu, bot diharuskan untuk berbicara hanya dengan karyawan Foobar yang mengautentikasi dengan corporate single sign-on (SSO) berdasarkan OpenId Connect.



Dalam teori


OpenId Connect (OIDC) adalah protokol otentikasi berdasarkan keluarga spesifikasi OAuth 2.0. Di dalamnya, proses otentikasi dapat berlangsung sesuai dengan berbagai skenario yang disebut aliran, dan mencakup tiga pihak:


  • pemilik sumber daya - pengguna;
  • client - aplikasi yang meminta otentikasi;
  • server otorisasi (server otorisasi) - aplikasi yang menyimpan informasi tentang pengguna dan dapat mengautentikasi dia.

JWT- (JSON Web Token). OIDC, , , OIDC.


, , : Telegram- SSO. .


, . OIDC :


  • (authorization code flow),
  • (implicit flow),
  • (hybrid flow),
  • , OAuth 2.0.

— HTTP, .


:


Diagram alir dengan kode otorisasi

  1. .
  2. .
  3. .
  4. (, ).
  5. callback URL .
  6. ID .
  7. .

, , , ID , , Telegram- .


state , (XSRF). state 2 URL , 5 callback URL . , 2 state id Telegram-, 7 state id Telegram- . !



, , :


  • Keycloak — open source (Identity and Access Management), OAuth 2.0, Open ID Connect SAML.
  • Spring Boot .
  • TelegramBots — Java Telegram-. — Spring Boot.
  • ScribeJava — OAuth Java. , OAuth , Keycloak. , Keycloak - , Spring Boot — . Keycloak , — Telegram-, state id Telegram-, .
  • Java JWT Auth0 — JWT Java, ID .

:


@Component
public class Bot extends TelegramLongPollingBot {
    private final OidcService oidcService;
    // ...

    // ,       ,
    // inline-   .
    @Override
    public void onUpdateReceived(Update update) {
        if (!update.hasMessage()) {
            log.debug("Update has no message. Skip processing.");
            return;
        }

        // Id Telegram-.
        var userId = update.getMessage().getFrom().getId();
        var chatId = update.getMessage().getChatId();
        //  UserInfo (    ,
        //    )  id Telegram-.
        oidcService.findUserInfo(userId).ifPresentOrElse(
                userInfo -> greet(userInfo, chatId),
                () -> askForLogin(userId, chatId));
    }

    private void greet(UserInfo userInfo, Long chatId) {
        //         
        //  .        
        //     ,   .
        var username = userInfo.getPreferredUsername();
        var message = String.format(
                "Hello, <b>%s</b>!\nYou are the best! Have a nice day!",
                username);
        sendHtmlMessage(message, chatId);
    }

    private void askForLogin(Integer userId, Long chatId) {
        //  URL   
        // (.  2   ).
        var url = oidcService.getAuthUrl(userId);
        var message = String.format("Please, <a href=\"%s\">log in</a>.", url);
        sendHtmlMessage(message, chatId);
    }
    // ...
}

— UserInfo id Telegram- URL :


@Service
public class OidcService {
    // OAuth20Service —   ScribeJava.
    //  , ,       .
    private final OAuth20Service oAuthService;
    // UserTrackerStorage —   
    // ( state -> id Telegram-).
    private final UserTrackerStorage userTrackers;
    // TokenStorage — ""   ( , ID  
    //   (refresh token))    id Telegram-.
    //       ,  
    //        , 
    //   .
    //   ,       -,
    //     ,   
    //  ,       ,
    //        .
    //   offline_access.   ,   .
    //  ,  TokenStorage      .
    private final TokenStorage accessTokens;

    public Optional<UserInfo> findUserInfo(Integer userId) {
        return accessTokens.find(userId)
                .map(UserInfo::of);
    }

    public String getAuthUrl(Integer userId) {
        var state = UUID.randomUUID().toString();
        userTrackers.put(state, userId);
        return oAuthService.getAuthorizationUrl(state);
    }
    // ...
}

, , :


public class UserInfo {
    private final String subject;
    private final String preferredUsername;

    static UserInfo of(OpenIdOAuth2AccessToken token) {
        //  ID       UserInfo.
        var jwt = JWT.decode(token.getOpenIdToken());
        var subject = jwt.getSubject();
        var preferredUsername = jwt.getClaim("preferred_username").asString();

        return new UserInfo(subject, preferredUsername);
    }
}

OAuth20Service:


@Configuration
@EnableConfigurationProperties(OidcProperties.class)
class OidcAutoConfiguration {
    @Bean
    OAuth20Service oAuthService(OidcProperties properties) {
        //   OAuth20Service  id ,
        //       , ...
        return new ServiceBuilder(properties.getClientId())
                //  , ...
                .apiSecret(properties.getClientSecret())
                //     (scopes):
                // openid —     OpenId Connect,
                // offline_access —     - 
                //  ,     .
                .defaultScope("openid offline_access")
                //  callback,     
                //     5.
                .callback(properties.getCallback())
                .build(KeycloakApi.instance(properties.getBaseUrl(), properties.getRealm()));
    }
}

Callback endpoint :


@Component
@Path("/auth")
public class AuthEndpoint {
    private final URI botUri;
    private final OidcService oidcService;

    // ...

    @GET
    @Produces("text/plain; charset=UTF-8")
    public Response auth(
            @QueryParam("state") String state,
            @QueryParam("code") String code) {
        //         
        // (.  6   ).
        return oidcService.completeAuth(state, code)
                //   ,       .
                .map(userInfo -> Response.temporaryRedirect(botUri).build())
                //    state   ,  
                //         ,
                //  HTTP- 500.
                .orElseGet(() -> Response.serverError().entity("Cannot complete authentication").build());
    }
}

OidcService, completeAuth:


@Service
public class OidcService {
    private final OAuth20Service oAuthService;
    private final UserTrackerStorage userTrackers;
    private final TokenStorage accessTokens;

    // ...

    public Optional<UserInfo> completeAuth(String state, String code) {
        //  id Telegram-   state.
        return userTrackers.find(state)
                //         id
                // Telegram-.
                .map(userId -> requestAndStoreToken(code, userId))
                .map(UserInfo::of);
    }

    private OpenIdOAuth2AccessToken requestAndStoreToken(
            String code,
            Integer userId) {
        var token = requestToken(code);
        accessTokens.put(userId, token);
        return token;
    }

    private OpenIdOAuth2AccessToken requestToken(String code) {
        try {
            return (OpenIdOAuth2AccessToken) oAuthService.getAccessToken(code);
        } catch (IOException | InterruptedException | ExecutionException e) {
            throw new RuntimeException("Cannot get access token", e);
        }
    }
}

!



, , , , . , - , / .


Masalah-masalah ini telah diatasi untuk kami di klien OAuth yang lebih bercabang seperti Spring Security dan Google OAuth Client . Tetapi untuk tujuan demonstrasi, kami baik-baik saja :)


Semua sumber dapat ditemukan di GitHub .


All Articles