На днях решил перечитать документацию Spring Security и обнаружил, к своему удивлению, что фреймворк предоставляет реализацию токен-аутентификации прямо из коробки. Получилось как всегда: вместо того, чтобы изобретать собственные велосипеды, нужно было заглянуть в документацию. В общем, как обычно, RTFM. Да, мой вариант работает вполне нормально и придерживается того же принципа, но логичнее использовать инструменты, предоставляемые разработчиками Spring.
В первом варианте будет использоваться стандартный UserDetails, следовательно, в передаваемом заголовке будет указывать имя пользователя.
Нам понадобится Spring Boot с Spring WebMVC, Spring Security и Spring Test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>name.alexkosarev</groupId> <artifactId>spring-security-token-authentication</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.3.5.RELEASE</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <java.version>1.8</java.version> </properties> </project> |
Для начала напишем интеграционный тест, который поможет нам в разработке проекта. TDD — очень хорошая практика.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
@RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = Application.class) @WebIntegrationTest public class TestGreetingService { private RestTemplate restTemplate; @Before public void setUp() { restTemplate = new TestRestTemplate(); } @Test public void testGreeting() throws URISyntaxException { HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add("X-AUTH-TOKEN", "user"); ResponseEntity<String> greeting = restTemplate.exchange( new RequestEntity(httpHeaders, HttpMethod.GET, new URI("http://localhost:8080/protected/greeting")), String.class); Assert.assertEquals(HttpStatus.OK, greeting.getStatusCode()); Assert.assertEquals("G'day, user!", greeting.getBody()); } @Test public void testGreetingUnauthorizedWithoutHeader() { ResponseEntity<String> greeting = restTemplate.getForEntity("http://localhost:8080/protected/greeting", String.class); Assert.assertEquals(HttpStatus.FORBIDDEN, greeting.getStatusCode()); } @Test public void testGreetingUnauthorizedWithWrongHeaderValue() throws URISyntaxException { HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add("X-AUTH-TOKEN", "otheruser"); ResponseEntity<String> greeting = restTemplate.exchange( new RequestEntity(httpHeaders, HttpMethod.GET, new URI("http://localhost:8080/protected/greeting")), String.class); Assert.assertEquals(HttpStatus.FORBIDDEN, greeting.getStatusCode()); } } |
Тут всё достаточно просто:
- Метод testGreeting тестирует оптимистичный сценарий работы нашего сервиса, когда пользователь аутентифицирован и получает доступ к сервису
- Метод testGreetingUnauthorizedWithoutHeader тестирует вариант сценария, когда клиент не передаёт токен аутентификации вообще
- Метод testGreetingUnauthorizedWithWrongHeaderValue тестирует вариант сценария, когда клиент передаёт токен, по которому не получится аутентифицировать пользователя
Теперь займёмся конфигурированием Spring Security:
- Сначала нам нужно определить фильтр, который будет пытаться авторизовать пользователя, основываясь на передаваемых заголовках:
123456789@Beanpublic RequestHeaderAuthenticationFilter requestHeaderAuthenticationFilter() throws Exception {RequestHeaderAuthenticationFilter requestHeaderAuthenticationFilter = new RequestHeaderAuthenticationFilter();requestHeaderAuthenticationFilter.setPrincipalRequestHeader("X-AUTH-TOKEN");requestHeaderAuthenticationFilter.setAuthenticationManager(authenticationManager());requestHeaderAuthenticationFilter.setExceptionIfHeaderMissing(false);return requestHeaderAuthenticationFilter;}
В principalRequestHeader мы указываем заголовок, значение которого будет использоваться в качестве токена аутентификации, а так же указываем фильтру, с каким менеджером аутентификации ему нужно работать. Так же я указал requestHeaderAuthenticationFilter.setExceptionIfHeaderMissing(false);, чтобы в случае отсутствия заголовка не было выкинуто исключение. Это может быть полезным, если используется больше одного способа аутентификации. - Теперь нужно описать провайдера аутентификации, который будет пытаться аутентифицировать пользователя по переданному токену:
1234567@Beanpublic PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider() {PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider = new PreAuthenticatedAuthenticationProvider();preAuthenticatedAuthenticationProvider.setPreAuthenticatedUserDetailsService(new UserDetailsByNameServiceWrapper<>(userDetailsService()));return preAuthenticatedAuthenticationProvider;}
Провайдеру нужно указать сервис, который будет предоставлять информацию о пользователе. В данном случае мы просто заворачиваем стандартный UserDetailsService в UserDetailsByNameServiceWrapper. - Ну и наконец конфигурируем HttpSecutity и AuthenticationManager:
12345678910@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {auth.authenticationProvider(preAuthenticatedAuthenticationProvider());}@Overrideprotected void configure(HttpSecurity http) throws Exception {http.addFilter(requestHeaderAuthenticationFilter()).antMatcher("/protected/**").authorizeRequests().anyRequest().authenticated();}
Тут в целом всё понятно — мы добавляем наш фильтр в цепочку аутентификации и предоставляем доступ к /protected/** только пользователям прошедшим аутентификацию.
Теперь создадим простенький сервис, на котором протестируем работу токен-аутентификации:
1 2 3 4 5 6 7 8 9 10 11 |
@RestController @RequestMapping("/protected/greeting") public class GreetingService { @RequestMapping public ResponseEntity get() { User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); return ResponseEntity.ok("G'day, " + user.getUsername() + "!"); } } |
На этом моменте интеграционные тесты должны пройти успешно, что говорит об успешности работы механизма аутентификации по токену.
В следующем посте я опишу более сложный вариант аутентификации пользователя по токену.