Не так давно я начинал свой первый проект с микросервисами и не знал как реализовать 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.