Я новичок в экосистеме Spring в целом и в Webflux. Есть две вещи, которые я пытаюсь выяснить и не могу найти никаких подробностей.
Моя установка:
Я пишу Spring Boot 2 REST API, используя WebFlux (не используя контроллеры, а скорее функции обработчика). Сервер аутентификации — это отдельная служба, которая выдает токены JWT, которые прикрепляются к каждому запросу в виде заголовков аутентификации. Вот простой пример метода запроса:
public Mono<ServerResponse> all(ServerRequest serverRequest) {
return principal(serverRequest).flatMap(principal ->
ReactiveResponses.listResponse(this.projectService.all(principal)));
}
Который я использую, чтобы отреагировать на запрос GET для списка всех «Проектов», к которым у пользователя есть доступ.
После этого у меня есть служба, которая извлекает список проектов для этого пользователя, и я отображаю ответ json.
Проблемы:
Теперь, чтобы отфильтровать проекты на основе текущего идентификатора пользователя, мне нужно прочитать его из принципала запроса. Одна из проблем здесь заключается в том, что у меня есть множество сервисных методов, которым нужна текущая информация о пользователе, и передача ее сервису кажется излишним. Одним из решений является чтение принципала внутри службы из:
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Вопрос 1:
Является ли это хорошей практикой в целом при написании функционального кода (если я делаю это вместо распространения принципала)? это хороший подход, несмотря на сложность чтения и отправки принципала из запроса в сервис в каждом методе?
Вопрос 2:
Должен ли я вместо этого использовать локальный поток SecurityContextHolder для получения принципала, и если я это сделаю, как мне написать тесты для моей службы?
Если я использую контекст безопасности, как мне протестировать свои реализации службы, ожидающие принципала типа JWTAuthenticationToken
и я всегда получаю null
, когда пытаюсь сделать что-то вроде описанного здесь: Модульное тестирование с Spring Security
В сервисных тестах В тестах то, что мне удалось сделать до сих пор, - это распространить принципала на методы службы и использовать mockito для имитации принципала. Это довольно просто.
В тестах конечной точки я использую @WithMockUser
для заполнения принципала при выполнении запросов, и я имитирую уровень сервиса. Недостатком этого является то, что основной тип отличается.
Вот как выглядит мой тестовый класс для сервисного слоя:
@DataMongoTest
@Import({ProjectServiceImpl.class})
class ProjectServiceImplTest extends BaseServiceTest {
@Autowired
ProjectServiceImpl projectService;
@Autowired
ProjectRepository projectRepository;
@Mock
Principal principal;
@Mock
Principal principal2;
@BeforeEach
void setUp() {
initMocks(this);
when(principal.getName()).thenReturn("uuid");
when(principal2.getName()).thenReturn("uuid2");
}
// Cleaned for brevity
@Test
public void all_returnsOnlyOwnedProjects() {
Flux<Project> saved = projectRepository.saveAll(
Flux.just(
new Project(null, "First", "uuid"),
new Project(null, "Second", "uuid2"),
new Project(null, "Third", "uuid3")
)
);
Flux<Project> all = projectService.all(principal2);
Flux<Project> composite = saved.thenMany(all);
StepVerifier
.create(composite)
.consumeNextWith(project -> {
assertThat(project.getOwnerUserId()).isEqualTo("uuid2");
})
.verifyComplete();
}
}
Поскольку вы используете Webflux, вы должны использовать ReactiveSecurityContextHolder
для получения принципала следующим образом: Object principal = ReactiveSecurityContextHolder.getContext().getAuthentication().getPrincipal();
Использование нереактивного вернет null, как вы видите.
В этом ответе есть дополнительная информация по теме - https://stackoverflow.com/a/51350355/197342
Основываясь на другом ответе, мне удалось решить эту проблему следующим образом.
Я добавил следующие методы для чтения идентификатора из утверждений, где он обычно находится в токене JWT.
public static Mono<String> currentUserId() {
return jwt().map(jwt -> jwt.getClaimAsString(USER_ID_CLAIM_NAME));
}
public static Mono<Jwt> jwt() {
return ReactiveSecurityContextHolder.getContext()
.map(context -> context.getAuthentication().getPrincipal())
.cast(Jwt.class);
}
Затем я использую это в своих службах везде, где это необходимо, и я не перенаправляю их через обработчик в службу.
Сложной частью всегда было тестирование. Я могу решить эту проблему с помощью пользовательского SecurityContextFactory. Я создал аннотацию, которую я могу прикрепить так же, как @WithMockUser, но вместо этого с некоторыми деталями претензии, которые мне нужны.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockTokenSecurityContextFactory.class)
public @interface WithMockToken {
String sub() default "uuid";
String email() default "[email protected]";
String name() default "Test User";
}
Затем Фабрика:
String token = "....ANY_JWT_TOKEN_GOES_HERE";
@Override
public SecurityContext createSecurityContext(WithMockToken tokenAnnotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
HashMap<String, Object> headers = new HashMap<>();
headers.put("kid", "SOME_ID");
headers.put("typ", "JWT");
headers.put("alg", "RS256");
HashMap<String, Object> claims = new HashMap<>();
claims.put("sub", tokenAnnotation.sub());
claims.put("aud", new ArrayList<>() {{
add("SOME_ID_HERE");
}});
claims.put("updated_at", "2019-06-24T12:16:17.384Z");
claims.put("nickname", tokenAnnotation.email().substring(0, tokenAnnotation.email().indexOf("@")));
claims.put("name", tokenAnnotation.name());
claims.put("exp", new Date());
claims.put("iat", new Date());
claims.put("email", tokenAnnotation.email());
Jwt jwt = new Jwt(token, Instant.now(), Instant.now().plus(1, ChronoUnit.HOURS), headers,
claims);
JwtAuthenticationToken jwtAuthenticationToken = new JwtAuthenticationToken(jwt, AuthorityUtils.NO_AUTHORITIES); // Authorities are needed to pass authentication in the Integration tests
context.setAuthentication(jwtAuthenticationToken);
return context;
}
Тогда простой тест будет выглядеть так:
@Test
@WithMockToken(sub = "uuid2")
public void delete_whenNotOwner() {
Mono<Void> deleted = this.projectService.create(projectDTO)
.flatMap(saved -> this.projectService.delete(saved.getId()));
StepVerifier
.create(deleted)
.verifyError(ProjectDeleteNotAllowedException.class);
}
Большое спасибо. Я не знаю, как я пропустил это. Это привело меня к документации и помогло с пользовательским фиктивным токеном. Я опубликую свое окончательное решение ниже в качестве ссылки.