Spring Security + Telegram Authentication

в 9:16, , рубрики: authentication, java, spring boot, Spring Security, telegram, Аутетнтификация

Начал писать свое приложение, и решил использовать авторизацию через Telegram, но не нашел ни одной нормальной статьи кроме Аутентификация через телеграм в Spring Boot приложении (спасибо автору, он сделал половину работы). Вторую половину пришлось писать самому. По этому покопавшись пару дней хочу представить вам "простенькое" базовое решение, от которого вы сможете оттолкнуться

Чтобы протестить авторизацию, вам придется задеплоить ваше приложение по определенному адресу в интернете (но мы сможем потестить и локально)

Начало

Вам нужно:

  1. Spring Boot приложение

  2. Зависимости Spring Security

  3. База данных (в моем случае PostgreSQL)

  4. Изучить документацию https://core.telegram.org/widgets/login

  5. Изучить статью https://habr.com/ru/articles/848502/ и создать бота

Telegram Auth

Создаем html форму из основной документации и помещаем в ресурсы по пути /resources/static/telegramAuth.html

Сама по себе форма сможет работать, только если у вашего приложения будет адрес в интернете, но, мы можем чуть изменить ее, чтобы появилась возможность тестировать локально:

telegramAuth.html

<!--<script async src="https://telegram.org/js/telegram-widget.js?22" data-telegram-login="DynamicQrBot" data-size="large"-->
<!--        data-onauth="onTelegramAuth(user)" data-request-access="write"></script>-->
<script type="text/javascript">

    //localhost
    onTelegramAuth(null)
    function onTelegramAuth(user) {
        fetch(
            `http://localhost:8080/login`,
            {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({
                    "id": "1",
                    "first_name": "Vasya",
                    "last_name": "Pupkin",
                    "photo_url": "https://image",
                    "auth_date": null,
                    "hash": "some-hash",
                    "username": "alekseiiagn",
                })
            }
        )
    }

    // prod
    // function onTelegramAuth(user) {
    //     fetch(
    //         `https://${your - domain}/login`,
    //         {
    //             method: 'POST',
    //             headers: {
    //                 'Content-Type': 'application/json'
    //             },
    //             body: JSON.stringify(user)
    //         }
    //     )
    // }
</script>

Для продакшена заменяем код test на prod и раскомментируем 1-2 строчку

Теперь нам нужен контроллер, который будет переопределять базовый Spring Security GET /login и отдавать нашу форму:

TmpAuthController.java (в будущем форму лучше перенести на фронт)

@RestController
@RequestMapping("/login")
@RequiredArgsConstructor
public class TmpAuthController {

    @GetMapping
    public ResponseEntity<Resource> getAuthScript() {
        var resource = new ClassPathResource("/static/telegramAuth.html");
        var headers = new HttpHeaders();
        headers.add(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=telegramAuth.html");
        return ResponseEntity.ok()
                .headers(headers)
                .body(resource);
    }
}

Так же в документации описано, как нужно проверять данные, которыe мы отправим в POST /login , по этому создаем класс для проверки:

TelegramAuthService.java

@Slf4j
@Service
public class TelegramAuthService {

    @Value("${TG_BOT_TOKEN}")
    private String tgBotToken;

    public boolean isDataValid(Map<String, Object> telegramData) {
        var hash = getHash(telegramData);
        var dataCheckString = createDataCheckString(telegramData);
        try {
            var digest = MessageDigest.getInstance("SHA-256");
            var key = digest.digest(tgBotToken.getBytes(StandardCharsets.UTF_8));

            var hmac = Mac.getInstance("HmacSHA256");
            var secretKeySpec = new SecretKeySpec(key, "HmacSHA256");
            hmac.init(secretKeySpec);

            var hmacBytes = hmac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
            var validateHash = new StringBuilder();
            for (byte b : hmacBytes) {
                validateHash.append(String.format("%02x", b));
            }

            return hash.contentEquals(validateHash);
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            log.error("Error while authenticate: {}", e.getMessage());
            return false;
        }
    }

    private String getHash(Map<String, Object> telegramData) {
        var hash = (String) telegramData.get("hash");
        telegramData.remove("hash");
        return hash;
    }

    /**
     * Create a verification line - sort all the parameters and combine them into a line like:
     * auth_date=<auth_date>nfirst_name=<first_name>nid=<id>nusername=<username>
     */
    private String createDataCheckString(Map<String, Object> telegramData) {
        var sb = new StringBuilder();
        telegramData.entrySet().stream()
                .sorted(Map.Entry.comparingByKey())
                .forEach(entry -> sb.append(entry.getKey()).append("=").append(entry.getValue()).append("n"));
        sb.deleteCharAt(sb.length() - 1);
        return sb.toString();
    }
}

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

Spring Security + База данных

У Spring Security есть интерфейс для всех кастомных реализаций пользователей - UserDetails.java. Определим собственный:

TelegramUser.java

@Getter
@Setter
@Entity
@Table(name = "users")
public class TelegramUser implements UserDetails {

    public static final List<SimpleGrantedAuthority> DEFAULT_AUTHORITIES =
            List.of(new SimpleGrantedAuthority("USER"));
    public static final String DEFAULT_PASSWORD = "No password";

    @Id
    private String username;
    private String telegramId;
    private String firstName;
    private String lastName;
    private String photoUrl;

    public TelegramUser(
            String telegramId,
            String username,
            String firstName,
            String lastName,
            String photoUrl
    ) {
        this.telegramId = telegramId;
        this.username = username;
        this.firstName = firstName;
        this.lastName = lastName;
        this.photoUrl = photoUrl;
    }

    public TelegramUser() {
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return DEFAULT_AUTHORITIES;
    }

    @Override
    public String getPassword() {
        return DEFAULT_PASSWORD;
    }
}

username может меняться, по этому лучше в будущем определить свой id, мы же для простоты оставим username

Так же нам нужно создать Repository для того, чтобы была возможность взаимодействовать с базой:

TelegramUserRepository.java

package ru.alekseiiagn.telegramauth.auth.dao;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface TelegramUserRepository extends JpaRepository<TelegramUser, String> { }

У Spring Security есть интерфейс UserDetailsManager.java для работы с UserDetails, но так как у нас своя реализация пользователя, то придется написать и свой Manager:

TelegramUserDetailsManager.java

@RequiredArgsConstructor
public class TelegramUserDetailsManager implements UserDetailsManager {

    private final TelegramUserRepository telegramUserRepository;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        return telegramUserRepository.findById(id)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }

    /**
     * On a repeat call, the user's data will be updated
     */
    @Override
    public void createUser(UserDetails user) {
        telegramUserRepository.save((TelegramUser) user);
    }

    @Override
    public void deleteUser(String id) {
        telegramUserRepository.deleteById(id);
    }

    @Override
    public boolean userExists(String id) {
        return telegramUserRepository.findById(id).isPresent();
    }

    @Override
    public void updateUser(UserDetails user) {
        /* Not implemented */
    }

    @Override
    public void changePassword(String oldPassword, String newPassword) {
        /* Not implemented */
    }
}

Переопределение Spring Security

Рассмотрим коротко, как работает Spring Security:

  1. Запрос отправляется в POST /login (который определен самим Spring Security)

  2. Внутри него вызывается фильтр AbstractAuthenticationProcessingFilter.java, который создает Authentication.java

  3. Он отправляется в AuthenticationManager.java, который вызывает ProviderManager.java

  4. В ProviderManager.java есть свои AuthenticationProvider.java, которые и проверяют все, что нам нужно

  5. После чего по цепочке поднимаемся вверх и AbstractAuthenticationProcessingFilter.java помещает в Spring Context успешную аутентификацию и выдается соответствующая Cookie

Простая схема работы Spring Security

Простая схема работы Spring Security

К сожалению, нам придется затронуть почти все вышеописанное:

TelegramAuthToken.java:

// AbstractAuthenticationToken implements Authentication
@Getter
public class TelegramAuthToken extends AbstractAuthenticationToken {

    private final Object principal;
    private final Object credentials;

    public static TelegramAuthToken unauthenticated(Map<String, Object> data) {
        return new TelegramAuthToken(
                data.get("id"),
                data,
                false
        );
    }

    public static TelegramAuthToken authenticated(UserDetails userDetails) {
        return new TelegramAuthToken(
                userDetails,
                userDetails,
                true
        );
    }

    private TelegramAuthToken(
            Object principal,
            Object credentials,
            boolean authenticated
    ) {
        super(
                TelegramUser.DEFAULT_AUTHORITIES
        );
        this.principal = principal;
        this.credentials = credentials;
        setAuthenticated(authenticated);
    }

    @Override
    public Object getCredentials() {
        return credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

TelegramAuthFilter.java

@Slf4j
@RequiredArgsConstructor
public class TelegramUserDetailsAuthProvider implements AuthenticationProvider {

    private final TelegramAuthService telegramAuthService;
    private final UserDetailsManager userDetailsManager;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        var data = (Map<String, Object>) authentication.getCredentials();
        try {
            if (true) { //for localhost usage
//            if (telegramAuthService.isDataValid(data)) { //for prod
                var telegramUser = new TelegramUser(
                        (String) authentication.getPrincipal(),
                        getStringValue(data, "username"),
                        getStringValue(data, "first_name"),
                        getStringValue(data, "last_name"),
                        getStringValue(data, "photo_url")
                );
                log.info("Successfully checked user {} data", telegramUser.getTelegramId());
                upsertUser(telegramUser);
                var userDetails = userDetailsManager.loadUserByUsername(telegramUser.getUsername());
                return TelegramAuthToken.authenticated(userDetails);
            } else {
                throw new AuthenticationServiceException("Data is not valid");
            }
        } catch (UsernameNotFoundException notFound) {
            throw notFound;
        } catch (Exception repositoryProblem) {
            throw new InternalAuthenticationServiceException(
                    repositoryProblem.getMessage(),
                    repositoryProblem
            );
        }
    }

    private void upsertUser(UserDetails user) {
        if (userDetailsManager.userExists(user.getUsername())) {
            userDetailsManager.updateUser(user);
        } else {
            userDetailsManager.createUser(user);
        }
    }

    private static String getStringValue(Map<String, Object> requestBody, String key) {
        var value = requestBody.get(key);
        return (value != null)
                ? value.toString().trim()
                : "";
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return true;
    }
}

TelegramUserDetailsManager.java

@Slf4j
@RequiredArgsConstructor
public class TelegramUserDetailsAuthProvider implements AuthenticationProvider {

    private final TelegramAuthService telegramAuthService;
    private final UserDetailsManager userDetailsManager;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        var data = (Map<String, Object>) authentication.getCredentials();
        try {
            if (true) { //for localhost usage
//            if (telegramAuthService.isDataValid(data)) { //for prod
                var telegramUser = new TelegramUser(
                        (String) authentication.getPrincipal(),
                        getStringValue(data, "username"),
                        getStringValue(data, "first_name"),
                        getStringValue(data, "last_name"),
                        getStringValue(data, "photo_url")
                );
                log.info("Successfully checked user {} data", telegramUser.getTelegramId());
                upsertUser(telegramUser);
                var userDetails = userDetailsManager.loadUserByUsername(telegramUser.getUsername());
                return TelegramAuthToken.authenticated(userDetails);
            } else {
                throw new AuthenticationServiceException("Data is not valid");
            }
        } catch (UsernameNotFoundException notFound) {
            throw notFound;
        } catch (Exception repositoryProblem) {
            throw new InternalAuthenticationServiceException(
                    repositoryProblem.getMessage(),
                    repositoryProblem
            );
        }
    }

    private void upsertUser(UserDetails user) {
        if (userDetailsManager.userExists(user.getUsername())) {
            userDetailsManager.updateUser(user);
        } else {
            userDetailsManager.createUser(user);
        }
    }

    private static String getStringValue(Map<String, Object> requestBody, String key) {
        var value = requestBody.get(key);
        return (value != null)
                ? value.toString().trim()
                : "";
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return true;
    }
}

TelegramUserDetailsAuthProvider.java

@Slf4j
@RequiredArgsConstructor
public class TelegramUserDetailsAuthProvider implements AuthenticationProvider {

    private final TelegramAuthService telegramAuthService;
    private final UserDetailsManager userDetailsManager;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        var data = (Map<String, Object>) authentication.getCredentials();
        try {
            if (true) { //for localhost usage
//            if (telegramAuthService.isDataValid(data)) { //for prod
                var telegramUser = new TelegramUser(
                        (String) authentication.getPrincipal(),
                        getStringValue(data, "username"),
                        getStringValue(data, "first_name"),
                        getStringValue(data, "last_name"),
                        getStringValue(data, "photo_url")
                );
                log.info("Successfully checked user {} data", telegramUser.getTelegramId());
                upsertUser(telegramUser);
                var userDetails = userDetailsManager.loadUserByUsername(telegramUser.getUsername());
                return TelegramAuthToken.authenticated(userDetails);
            } else {
                throw new AuthenticationServiceException("Data is not valid");
            }
        } catch (UsernameNotFoundException notFound) {
            throw notFound;
        } catch (Exception repositoryProblem) {
            throw new InternalAuthenticationServiceException(
                    repositoryProblem.getMessage(),
                    repositoryProblem
            );
        }
    }

    private void upsertUser(UserDetails user) {
        if (userDetailsManager.userExists(user.getUsername())) {
            userDetailsManager.updateUser(user);
        } else {
            userDetailsManager.createUser(user);
        }
    }

    private static String getStringValue(Map<String, Object> requestBody, String key) {
        var value = requestBody.get(key);
        return (value != null)
                ? value.toString().trim()
                : "";
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return true;
    }
}

Подсвечу, что при локальном запуске мы не сможем получить нормальные данные из telegram, по этому нам придется закомментировать пока что проверку telegramAuthService.isDataValid(data) в TelegramUserDetailsAuthProvider.java

Конфигурация Spring Security

Все, что нам остается - это написать конфигурацию, которая соберет воедино все, что мы написали до этого:

SecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private static final String[] NO_AUTH_URLS = {
            "/hello-world/public",
            "/login",
    };
    private static final String[] AUTH_URLS = {
            "/hello-world/private",
    };


    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            SecurityContextRepository contextRepository,
            AuthenticationManager authenticationManager
    ) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable) // Disable CSRF for simplicity
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(NO_AUTH_URLS).permitAll()
                        .requestMatchers(AUTH_URLS).authenticated()
                        .anyRequest().authenticated()
                )
                .formLogin(formLogin -> formLogin
                        .loginPage("/login")
                        .loginProcessingUrl("/login")
                        .permitAll()
                )
                .addFilterAt(
                        new TelegramAuthFilter(contextRepository, authenticationManager),
                        UsernamePasswordAuthenticationFilter.class
                )
                .build();
    }

    @Bean
    public UserDetailsManager userDetailsManager(
            TelegramUserRepository telegramUserRepository
    ) {
        return new TelegramUserDetailsManager(telegramUserRepository);
    }

    @Bean
    public SecurityContextRepository securityContextRepository() {
        return new HttpSessionSecurityContextRepository();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationProvider telegramAuthProvider,
            UserDetailsManager userDetailsManager
    ) {
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsManager);
        ProviderManager providerManager = new ProviderManager(telegramAuthProvider);
        providerManager.setEraseCredentialsAfterAuthentication(false);
        return providerManager;
    }

    @Bean
    public AuthenticationProvider telegramAuthProvider(
            TelegramAuthService telegramAuthService,
            UserDetailsManager userDetailsManager
    ) {
        return new TelegramUserDetailsAuthProvider(
                telegramAuthService,
                userDetailsManager
        );
    }
}

В данной конфигурации мы определили доступное всем (/hello-world/public) и защищенное (/hello-world/private) API, которое создадим чуть позже. Так же определили базовый путь для аутентификации /login, и использовали написанные выше переопределения классов Spring Security

Тестирование

Для проверки я создал простенький контроллер:

@RestController
@RequestMapping("/hello-world")
@RequiredArgsConstructor
public class HelloWorldController {

    @GetMapping("/public")
    public String helloWorld() {
        return "Hello World";
    }

    @GetMapping("/private")
    public String helloWorldPerson(
            @AuthenticationPrincipal TelegramUser user
    ) {
        return "Hello World, " + user.getUsername();
    }
}

Шаги тестирования:

  1. Вызываем незащищенный метод, получаем ответ

    Ответ из незащищенного метода

    Ответ из незащищенного метода
  2. Вызываем защищенный метод, нас перекидывает /login

    Страница /login

    Страница /login
  3. Проходим аутентификацию если продакшен (если localhost, то она пройдет автоматически)

    Страница /login после log in

    Страница /login после log in
  4. Снова вызываем защищенный метод, получаем ответ:

    Ответ из защищенного метода после log in

    Ответ из защищенного метода после log in

Надеюсь я хоть немного помог вам, спасибо, что прочитали, увидимся в новых статях :-)

Автор: alekseiiagnenkov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js