Не так давно я начинал свой первый проект с микросервисами и не знал как реализовать security. Сейчас информации по этому вопросу уже больше однако она не всегда актуальна и, как правило, не раскрывает проблему security межсервисного взаимодействия. Поэтому я решил написать как бы я решал эту задачу на сегодняшний день.

Задача

Есть два микросервиса: Account и Notification. Account хранит информацию о пользователях, Notification рассылает уведомления. Пользователю необходимо подтвердить ранее сохранённый email, вызвав endpoint в Notification. В теле письма подтверждения нужно показать детали пользователя, которые хранятся в Account. Для этого будем использовать межсервисный http endpoint, а межсервисные запросы должны быть доступны только authenticated users.

Сборка проекта

Для сборки проекта я использую gradle и последние версии spring boot и прочих библиотек на момент написания статьи. Чтоб текст не получился слишком большой gradle код доступен в github(ссылка в конце).

Получение JWT токена

Чтоб не отходить от стандартов микросервисов будем использовать JWT токен. Добавим endpoint в Account для получения токена:

@RestController
class AuthController(
    private val jwtHelper: JwtHelper,
    private val userDetailsService: UserDetailsService,
    private val passwordEncoder: PasswordEncoder
) {
    @PostMapping(path = ["login"], consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE])
    fun login(
        @RequestParam username: String,
        @RequestParam password: String
    ): LoginResult {
        val userDetails = try {
            userDetailsService.loadUserByUsername(username)
        } catch (e: UsernameNotFoundException) {
            throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found")
        }
        if (passwordEncoder.matches(password, userDetails.password)) {
            val claims: MutableMap<String, String> = HashMap()
            claims["username"] = username
            val authorities = userDetails.authorities.joinToString { it.authority.toString() }
            claims["authorities"] = authorities
            claims["userId"] = 1.toString()
            val jwt = jwtHelper.createJwtForClaims(username, claims)
            return LoginResult(jwt)
        }
        throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated")
    }
}

Jwt токен создаётся следующим образом:

@Component
class JwtHelper(
    @Value("\${app.security.jwt.secret}")
    private val jwtSecret: String
) {
    fun createJwtForClaims(subject: String, claims: Map<String, String>): String {
        val jwtBuilder = JWT.create().withSubject(subject)
        claims.forEach { (name: String, value: String) -> jwtBuilder.withClaim(name, value) }
        return jwtBuilder
            .withNotBefore(Date())
            .withExpiresAt(DateUtils.addDays(Date(), 1))
            .sign(Algorithm.HMAC256(jwtSecret))
    }
}
Почему не реализовать свой Authorization server

Resource Owner Password Credentials Grant был исключен из спецификации OAuth 2.1. Остальные grant types подходят для third party authorization servers. Если мы хотим имет возможность логина с помощью пароля - endpoint может послужить хорошим стартом. Позже можно настроить third party authentication, например с помощью firebase.

Почему в данном примере не используется Keycloak

Keycloak это отдельное приложение со своей БД. Для опитмизации ресурсов и простоты эксплуатации легче поддерживать модель пользователей своего бизнеса а не универсальную модель от Red Hat.

Аутентификация

Для аутентификации будем использовать OAuth2 Resource Server. Для этого сконфигурируем JwtDecoder:

@Configuration
class JwtConfiguration(
    @Value("\${app.security.jwt.secret}")
    private val jwtSecret: String
) {
    @Bean
    fun jwtDecoder(): JwtDecoder {
        val key = jwtSecret.toByteArray()
        val originalKey: SecretKey = SecretKeySpec(key, 0, key.size, "AES")
        return NimbusJwtDecoder.withSecretKey(originalKey).build()
    }
}

Так же необходимо сконфигурировать WebSecurityConfig:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class WebSecurityConfig : WebSecurityConfigurerAdapter() {
    override fun configure(http: HttpSecurity) {
        http
            .cors()
            .and()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests{ configurer ->
                configurer
                    .anyRequest()
                    .authenticated()
            }
            .oauth2ResourceServer { obj: OAuth2ResourceServerConfigurer<HttpSecurity?> -> obj.jwt() }
    }
}

Проброс JWT токена для межсервисных запросов

Ниже представлен endpoint для посылки письма подтверждения email. Обратите внимание что Authorization header пробрасывается в межсервисный запрос.

@RestController
class NotificationController() {

    private val logger = KotlinLogging.logger {  }

    @PostMapping("/verifyEmail")
    fun verifyEmail(@RequestHeader("Authorization") authHeader: String) {
        val headers = HttpHeaders()
        headers.set("Authorization", authHeader)

        val restTemplate = RestTemplate()
        val response = restTemplate.exchange(
            "http://localhost:8087/internal/userDetails",
            HttpMethod.GET,
            HttpEntity<Any>(headers),
            object : ParameterizedTypeReference<String>() {})
        logger.info { "TODO: sent verify email to ${response.body}" }
    }
}

Межсервисный контроллер для получения userDetails:

@RestController
@RequestMapping("/internal")
class InternalController() {

    private val logger = KotlinLogging.logger {  }

    @GetMapping("/userDetails")
    fun getUser(authentication: Authentication): String {
        logger.info { "TODO: obtain user name for user ${authentication.name}" }
        return "John Doe"
    }
}

Использование service account для scheduled job

Допустим в Notification есть ежедневная задача по рассылке уведомлений пользователям. Для этого нужно получить список пользователей из Account. Добавим в InternalController endpoint:

@GetMapping("/users")
fun getUsers(): List<String> {
    return listOf("user@mail.com")
}

Однако переодическая задача не иницированна пользователем. Поэтому для безопасного доступа к endpoint можно использовать service account(по примеру google cloud или kubernetes). Объявим ServiceAuthenticationToken для нового типа Authentication:

class ServiceAuthenticationToken(
    val token: String
): AbstractAuthenticationToken(emptyList()) {
    override fun getCredentials(): Any {
        return token
    }
    override fun getPrincipal(): Any {
        return token
    }
}

Далее необходимо определить ServiceAuthenticationProvider:

@Component
class ServiceAuthenticationProvider(
    @Value("\${app.security.service.token}")
    private val serviceToken: String,
) : AuthenticationProvider {

    override fun authenticate(authentication: Authentication): Authentication {
        val name = authentication.name
        val password = authentication.credentials.toString()
        return if (isServiceTokenValid(authentication as ServiceAuthenticationToken)) {
            UsernamePasswordAuthenticationToken(name, password, emptyList())
        } else {
            throw AuthenticationServiceException("Unknown service ${authentication.name}")
        }
    }

    private fun isServiceTokenValid(authentication: ServiceAuthenticationToken) = authentication.token == serviceToken

    override fun supports(authentication: Class<*>): Boolean {
        return authentication == ServiceAuthenticationToken::class.java
    }
}

Также надо определить ServiceTokenAuthenticationFilter:

class ServiceTokenAuthenticationFilter(
    private val authenticationManager: ServiceAuthenticationProvider,
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION)
        if (!StringUtils.startsWithIgnoreCase(authorizationHeader, "service")) {
            filterChain.doFilter(request, response)
            return
        }
        val matcher = authorizationPattern.matcher(authorizationHeader)
        if (!matcher.matches()) {
            throw AuthenticationServiceException("Service token is malformed")
        }
        val token = matcher.group("token")

        try {
            val authenticationResult = authenticationManager.authenticate(ServiceAuthenticationToken(token))
            val context = SecurityContextHolder.createEmptyContext()
            context.authentication = authenticationResult
            SecurityContextHolder.setContext(context)
            if (logger.isDebugEnabled) {
                this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authenticationResult))
            }
            filterChain.doFilter(request, response)
        } catch (failed: AuthenticationException) {
            SecurityContextHolder.clearContext()
            logger.trace("Failed to process authentication request", failed)
            authenticationEntryPoint.commence(request, response, failed)
        }
    }

    companion object {
        val authorizationPattern = Pattern.compile(
            "^Service (?<token>[a-zA-Z0-9-._~+/]+=*)$",
            Pattern.CASE_INSENSITIVE
        )
        val authenticationEntryPoint = AuthenticationEntryPoint {
                request, response, authException -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
        }
    }
}

Ещё надо добавить дополнительный фильтр в WebSecurityConfig:

@Component
class WebSecurityConfig(
    private val passwordEncoder: PasswordEncoder,
    private val serviceAuthenticationProvider: ServiceAuthenticationProvider,
) : WebSecurityConfigurerAdapter() {

    override fun configure(builder: AuthenticationManagerBuilder) {
        builder.authenticationProvider(serviceAuthenticationProvider)
    }

    override fun configure(http: HttpSecurity) {
        http
            .addFilterAfter(
                ServiceTokenAuthenticationFilter(serviceAuthenticationProvider),
                BasicAuthenticationFilter::class.java)
            .cors()
            .and()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests{ configurer ->
                    configurer
                        .antMatchers(
                            "/error",
                            "/login"
                        )
                        .permitAll()
                        .anyRequest()
                        .authenticated()
                }
            .oauth2ResourceServer { obj: OAuth2ResourceServerConfigurer<HttpSecurity?> -> obj.jwt() }
    }

    @Bean
    override fun userDetailsService(): UserDetailsService {
        val user1 = User
            .withUsername("user@mail.com")
            .authorities("USER")
            .passwordEncoder { rawPassword: String? -> passwordEncoder.encode(rawPassword) }
            .password("1234")
            .build()
        val userDetailsManager = InMemoryUserDetailsManager()
        userDetailsManager.createUser(user1)
        return userDetailsManager
    }
}

Теперь осталось создать Daily Job в Notification:

@Component
class DailyNotificationJob(
    @Value("\${app.security.service.token}")
    private val serviceToken: String,
) {

    private val logger = KotlinLogging.logger {  }

    @Scheduled(fixedDelay = DateUtils.MILLIS_PER_DAY)
    fun process() {
        val headers = HttpHeaders()
        headers.set("Authorization", "Service $serviceToken")

        val restTemplate = RestTemplate()
        val response = restTemplate.exchange(
            "http://localhost:8087/internal/users",
            HttpMethod.GET,
            HttpEntity<Any>(headers),
            object : ParameterizedTypeReference<List<String>>() {})
        logger.info { "TODO: notify user: ${response.body}" }
    }
}

DailyNotificationJob запустится сразу после запуска Notification и будет повторяться каждый день. Подергать запросы можно с помощью Postman collection. Все исходники можно посмотреть в github.

Комментарии (4)


  1. muzuro Автор
    04.04.2022 21:14
    +3

    Хм, 1000 просмотров - 0 коментариев. Надо было про эмиграцию писать...


    1. XaBoK
      05.04.2022 01:48
      +4

      Вы же видите +2 и 26 закладок - добро пожаловать на Хабр! Коменты будут, только если нарушена какая-то "великая справедливость". Тут просто "эмиграция" не поможет. Надо "10 советов для удачного релокейшна" и в статье сделать только 9 пунктов. Тогда сразу набегут обсуждать, как правильно писать англитив :)


    1. hamMElion
      06.04.2022 11:31
      +1

      Да с удовольствием. Хорошая статья, но раз уж разговор о безопасности, то есть пара моментов. У вас пароль передается как параметр входной. Это небезопасно, т.к. http запрос может быть перехвачен (да, даже если https, при скомпрометированном SSL сертификате). Лучше всегда с клиента передавать хеш пароля, а не сам пароль, в идеале как base64(username:passwordHash) в заголовке Authorization. Так же вижу, что пароль сравнивается с паролем из БД. Получается вы храните исходные пароли в БД. Это небезопасно, т.к. БД могут украсть, лучше уменьшить ущерб и хранить в БД поперченные хеши паролей, а при сравнении перчить хеш пароля от клиента. Ещё больше повысить безопасность поможет соль (например время, округленное до минуты) с одноразовым перцем, полученного с сервера и созданного с учётом User Agent, добавленные в хеш на стороне клиента. Таким образом у злоумышленника будет всего минута, если он перехватит одноразовый перец. Надеюсь, было интересно.


      1. muzuro Автор
        06.04.2022 12:03

        Спасибо за коммент, действительно интересно
        1. По поводу хеширования пароля base64 - буду применять.
        2. В данном примере используется InMemoryUserDetailsManager(Нет БД). Для сравнения ипсользуется закодированный пароль(BCrypt strong hashing function) из userDetails. Поэтому для сравнения применяется passwordEncoder:

        if (passwordEncoder.matches(password, userDetails.password))

        3.По поводу использования соли - интересно, надо поизучать.