В предыдущем примере для авторизации запросов к серверу использовалась HTTP-сессия (и куки). Однако для реализации REST-сервиса данный способ авторизации не подходит, поскольку одним из требований REST архитектуры является отсутсвие состояния. В данной статье мы реализуем REST-сервис, авторизация запросов к которому будет осуществляться с помощью токена доступа (access token).
Немного теории
Аутентификация это процесс проверки учётных данных пользователя (логин/пароль). Проверка подлинности пользователя осуществляется путём сравнения введённого им логина/пароля с сохраненными данными.
Авторизация это проверка прав пользователя на доступ к определенным ресурсам. Авторизация выполняется непосредственно при обращении пользователя к ресурсу.
Рассмотрим порядок работы двух вышеупомянутых способов авторизации запросов.
Авторизация запросов с помощью HTTP-сессии:
- Пользователь проходит аутентификацию любым из способов.
- На сервере создается HTTP-сессия и куки JSESSIONID, хранящий идентификатор сессии.
- Куки JSESSIONID передается на клиент и сохраняется в браузере.
- С каждым последующим запросом на сервер отправляется куки JSESSIONID.
- Сервер находит соответствующую HTTP-сессию с информацией о текущем пользователе и определяет имеет ли пользователь права на выполнение данного вызова.
- Для выполнения выхода из приложения необходимо удалить с сервера HTTP-сессию.
Авторизация запросов с помощью токена доступа:
- Пользователь проходит аутентификацию любым из способов.
- Сервер создает токен доступа, подписанный секретным ключом, а затем отправляет его клиенту. Токен содержит идентификатор пользователя и его роли.
- Токен сохраняется на клиенте и передается на сервер с каждым последующим запросом. Как правило для передачи токена используетя HTTP заголовок Authorization.
- Сервер сверяет подпись токена, извлекает из него идентификатор пользователя, его роли и определяет имеет ли пользователь права на выполнение данного вызова.
- Для выполнения выхода из приложения достаточно просто удалить токен на клиенте без необходимости взаимодействия с сервером.
Распространенным форматом токена доступа в настоящее время является JSON Web Token (JWT). Токен в формате JWT содержит три блока, разделенных точками: заголовок (header), набор полей (payload) и сигнатуру (подпись). Первые два блока представлены в JSON-формате и закодированы в формат base64. Набор полей может состоять как из зарезервированных имен (iss, iat, exp), так и произвольных пар имя/значение. Подпись может генерироваться как при помощи симметричных, так и асимметричных алгоритмов шифрования.
Реализация
Мы реализуем REST-сервис, предоставляющий следующее API:
- GET /auth/login запустить процесс аутентификации пользователя.
- POST /auth/token запросить новую пару access/refresh токенов.
- GET /api/repositories получить список Bitbucket репозиториев текущего пользователя.
Высокоуровневая архитектура приложения.
Заметим, что поскольку приложение состоит из трех взаимодействующих компонентов, помимо того, что мы выполняем авторизацию запросов клиента к серверу, Bitbucket авторизует запросы сервера к нему. Мы не будем настраивать авторизацию методов по ролям, чтобы не делать пример сложнее. У нас есть только один API метод GET /api/repositories, вызывать который могут только аутентифицированные пользователи. Сервер может выполнять на Bitbucket любые операции, разрешенные при регистрации OAuth клиента.
Процесс регистрации OAuth клиента описан в предыдущей статье.
Для реализации мы будем использовать Spring Boot версии 2.2.2.RELEASE и Spring Security версии 5.2.1.RELEASE.
Переопределим AuthenticationEntryPoint.
В стандартном веб-приложении, когда осуществляется обращение к защищенному ресурсу и в секьюрити контексте отсутствует объект Authentication, Spring Security перенаправляет пользователя на страницу аутентификации. Однако для REST-сервиса более подходящим поведением в этом случае было бы возвращать HTTP статус 401 (UNAUTHORIZED).
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence( HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); }}
Создадим login endpoint.
Для аутентификации пользователя мы по-прежнему используем OAuth2 с типом авторизации Authorization Code. Однако на предыдущем шаге мы заменили стандартный AuthenticationEntryPoint своей реализацией, поэтому нам нужен явный способ запустить процесс аутентификации. При отправке GET запроса по адресу /auth/login мы перенаправим пользователя на страницу аутентификации Bitbucket. Параметром этого метода будет URL обратного вызова, по которому мы возвратим токен доступа после успешной аутентификации.
@Path("/auth")public class AuthEndpoint extends EndpointBase {... @GET @Path("/login") public Response authorize(@QueryParam(REDIRECT_URI) String redirectUri) { String authUri = "/oauth2/authorization/bitbucket"; UriComponentsBuilder builder = fromPath(authUri).queryParam(REDIRECT_URI, redirectUri); return handle(() -> temporaryRedirect(builder.build().toUri()).build()); }}
Переопределим AuthenticationSuccessHandler.
AuthenticationSuccessHandler вызывается после успешной аутентификации. Сгенерируем тут токен доступа, refresh токен и выполним редирект по адресу обратного вызова, который был передан в начале процесса аутентификации. Токен доступа вернем параметром GET запроса, а refresh токен в httpOnly куке. Что такое refresh токен разберем позже.
public class ExampleAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final TokenService tokenService; private final AuthProperties authProperties; private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository; public ExampleAuthenticationSuccessHandler( TokenService tokenService, AuthProperties authProperties, HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) { this.tokenService = requireNonNull(tokenService); this.authProperties = requireNonNull(authProperties); this.authorizationRequestRepository = requireNonNull(authorizationRequestRepository); } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { log.info("Logged in user {}", authentication.getPrincipal()); super.onAuthenticationSuccess(request, response, authentication); } @Override protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { Optional<String> redirectUri = getCookie(request, REDIRECT_URI).map(Cookie::getValue); if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) { throw new BadRequestException("Received unauthorized redirect URI."); } return UriComponentsBuilder.fromUriString(redirectUri.orElse(getDefaultTargetUrl())) .queryParam("token", tokenService.newAccessToken(toUserContext(authentication))) .build().toUriString(); } @Override protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { redirectToTargetUrl(request, response, authentication); } private boolean isAuthorizedRedirectUri(String uri) { URI clientRedirectUri = URI.create(uri); return authProperties.getAuthorizedRedirectUris() .stream() .anyMatch(authorizedRedirectUri -> { // Only validate host and port. Let the clients use different paths if they want to. URI authorizedURI = URI.create(authorizedRedirectUri); return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost()) && authorizedURI.getPort() == clientRedirectUri.getPort(); }); } private TokenService.UserContext toUserContext(Authentication authentication) { ExampleOAuth2User principal = (ExampleOAuth2User) authentication.getPrincipal(); return TokenService.UserContext.builder() .login(principal.getName()) .name(principal.getFullName()) .build(); } private void addRefreshTokenCookie(HttpServletResponse response, Authentication authentication) { RefreshToken token = tokenService.newRefreshToken(toUserContext(authentication)); addCookie(response, REFRESH_TOKEN, token.getId(), (int) token.getValiditySeconds()); } private void redirectToTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { String targetUrl = determineTargetUrl(request, response, authentication); if (response.isCommitted()) { logger.debug("Response has already been committed. Unable to redirect to " + targetUrl); return; } addRefreshTokenCookie(response, authentication); authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); getRedirectStrategy().sendRedirect(request, response, targetUrl); }}
Переопределим AuthenticationFailureHandler.
В случае, если пользователь не прошел аутентификацию, мы перенаправим его по адресу обратного вызова, который был передан в начале процесса аутентификации с параметром error, содержащим текст ошибки.
public class ExampleAuthenticationFailureHandler implements AuthenticationFailureHandler { private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository; public ExampleAuthenticationFailureHandler( HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) { this.authorizationRequestRepository = requireNonNull(authorizationRequestRepository); } @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { String targetUrl = getFailureUrl(request, exception); authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); redirectStrategy.sendRedirect(request, response, targetUrl); } private String getFailureUrl(HttpServletRequest request, AuthenticationException exception) { String targetUrl = getCookie(request, Cookies.REDIRECT_URI) .map(Cookie::getValue) .orElse(("/")); return UriComponentsBuilder.fromUriString(targetUrl) .queryParam("error", exception.getLocalizedMessage()) .build().toUriString(); }}
Создадим TokenAuthenticationFilter.
Задача этого фильтра извлечь токен доступа из заголовка Authorization в случае его наличия, провалидировать его и инициализировать секьюрити контекст.
public class TokenAuthenticationFilter extends OncePerRequestFilter { private final UserService userService; private final TokenService tokenService; public TokenAuthenticationFilter( UserService userService, TokenService tokenService) { this.userService = requireNonNull(userService); this.tokenService = requireNonNull(tokenService); } @Override protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException { try { Optional<String> jwtOpt = getJwtFromRequest(request); if (jwtOpt.isPresent()) { String jwt = jwtOpt.get(); if (isNotEmpty(jwt) && tokenService.isValidAccessToken(jwt)) { String login = tokenService.getUsername(jwt); Optional<User> userOpt = userService.findByLogin(login); if (userOpt.isPresent()) { User user = userOpt.get(); ExampleOAuth2User oAuth2User = new ExampleOAuth2User(user); OAuth2AuthenticationToken authentication = new OAuth2AuthenticationToken(oAuth2User, oAuth2User.getAuthorities(), oAuth2User.getProvider()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } } catch (Exception e) { logger.error("Could not set user authentication in security context", e); } chain.doFilter(request, response); } private Optional<String> getJwtFromRequest(HttpServletRequest request) { String token = request.getHeader(AUTHORIZATION); if (isNotEmpty(token) && token.startsWith("Bearer ")) { token = token.substring(7); } return Optional.ofNullable(token); }}
Создадим refresh token endpoint.
В целях безопасности время жизни токена доступа обычно делают небольшим. Тогда в случае его кражи злоумышленник не сможет пользоваться им бесконечно долго. Чтобы не заставлять пользователя выполнять вход в приложение снова и снова используется refresh токен. Он выдается сервером после успешной аутентификации вместе с токеном доступа и имеет большее время жизни. Используя его можно запросить новую пару токенов. Refresh токен рекомендуют хранить в httpOnly куке.
@Path("/auth")public class AuthEndpoint extends EndpointBase {... @POST @Path("/token") @Produces(APPLICATION_JSON) public Response refreshToken(@CookieParam(REFRESH_TOKEN) String refreshToken) { return handle(() -> { if (refreshToken == null) { throw new InvalidTokenException("Refresh token was not provided."); } RefreshToken oldRefreshToken = tokenService.findRefreshToken(refreshToken); if (oldRefreshToken == null || !tokenService.isValidRefreshToken(oldRefreshToken)) { throw new InvalidTokenException("Refresh token is not valid or expired."); } Map<String, String> result = new HashMap<>(); result.put("token", tokenService.newAccessToken(of(oldRefreshToken.getUser()))); RefreshToken newRefreshToken = newRefreshTokenFor(oldRefreshToken.getUser()); return Response.ok(result).cookie(createRefreshTokenCookie(newRefreshToken)).build(); }); }}
Переопределим AuthorizationRequestRepository.
Spring Security использует объект AuthorizationRequestRepository для хранения объектов OAuth2AuthorizationRequest на время процесса аутентификации. Реализацией по умолчанию является класс HttpSessionOAuth2AuthorizationRequestRepository, который использует HTTP-сессию в качестве хранилища. Т.к. наш сервис не должен хранить состояние, эта реализация нам не подходит. Реализуем свой класс, который будет использовать HTTP cookies.
public class HttpCookieOAuth2AuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> { private static final int COOKIE_EXPIRE_SECONDS = 180; private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "OAUTH2-AUTH-REQUEST"; @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { return getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME) .map(cookie -> deserialize(cookie, OAuth2AuthorizationRequest.class)) .orElse(null); } @Override public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) { if (authorizationRequest == null) { removeAuthorizationRequestCookies(request, response); return; } addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS); String redirectUriAfterLogin = request.getParameter(QueryParams.REDIRECT_URI); if (isNotBlank(redirectUriAfterLogin)) { addCookie(response, REDIRECT_URI, redirectUriAfterLogin, COOKIE_EXPIRE_SECONDS); } } @Override public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) { return loadAuthorizationRequest(request); } public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) { deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); deleteCookie(request, response, REDIRECT_URI); } private static String serialize(Object object) { return Base64.getUrlEncoder().encodeToString(SerializationUtils.serialize(object)); } @SuppressWarnings("SameParameterValue") private static <T> T deserialize(Cookie cookie, Class<T> clazz) { return clazz.cast(SerializationUtils.deserialize(Base64.getUrlDecoder().decode(cookie.getValue()))); }}
Настроим Spring Security.
Соберем все проделанное выше вместе и настроим Spring Security.
@Configuration@EnableWebSecuritypublic static class WebSecurityConfig extends WebSecurityConfigurerAdapter { private final ExampleOAuth2UserService userService; private final TokenAuthenticationFilter tokenAuthenticationFilter; private final AuthenticationFailureHandler authenticationFailureHandler; private final AuthenticationSuccessHandler authenticationSuccessHandler; private final HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository; @Autowired public WebSecurityConfig( ExampleOAuth2UserService userService, TokenAuthenticationFilter tokenAuthenticationFilter, AuthenticationFailureHandler authenticationFailureHandler, AuthenticationSuccessHandler authenticationSuccessHandler, HttpCookieOAuth2AuthorizationRequestRepository authorizationRequestRepository) { this.userService = userService; this.tokenAuthenticationFilter = tokenAuthenticationFilter; this.authenticationFailureHandler = authenticationFailureHandler; this.authenticationSuccessHandler = authenticationSuccessHandler; this.authorizationRequestRepository = authorizationRequestRepository; } @Override protected void configure(HttpSecurity http) throws Exception { http .cors().and() .csrf().disable() .formLogin().disable() .httpBasic().disable() .sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS)) .exceptionHandling(eh -> eh .authenticationEntryPoint(new RestAuthenticationEntryPoint()) ) .authorizeRequests(authorizeRequests -> authorizeRequests .antMatchers("/auth/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2Login -> oauth2Login .failureHandler(authenticationFailureHandler) .successHandler(authenticationSuccessHandler) .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(userService)) .authorizationEndpoint(authEndpoint -> authEndpoint.authorizationRequestRepository(authorizationRequestRepository)) ); http.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }}
Создадим repositories endpoint.
То ради чего и нужна была аутентификация через OAuth2 и Bitbucket возможность использовать Bitbucket API для доступа к своим ресурсам. Используем Bitbucket repositories API для получения списка репозиториев текущего пользователя.
@Path("/api")public class ApiEndpoint extends EndpointBase { @Autowired private BitbucketService bitbucketService; @GET @Path("/repositories") @Produces(APPLICATION_JSON) public List<Repository> getRepositories() { return handle(bitbucketService::getRepositories); }}public class BitbucketServiceImpl implements BitbucketService { private static final String BASE_URL = "https://api.bitbucket.org"; private final Supplier<RestTemplate> restTemplate; public BitbucketServiceImpl(Supplier<RestTemplate> restTemplate) { this.restTemplate = restTemplate; } @Override public List<Repository> getRepositories() { UriComponentsBuilder uriBuilder = fromHttpUrl(format("%s/2.0/repositories", BASE_URL)); uriBuilder.queryParam("role", "member"); ResponseEntity<BitbucketRepositoriesResponse> response = restTemplate.get().exchange( uriBuilder.toUriString(), HttpMethod.GET, new HttpEntity<>(new HttpHeadersBuilder() .acceptJson() .build()), BitbucketRepositoriesResponse.class); BitbucketRepositoriesResponse body = response.getBody(); return body == null ? emptyList() : extractRepositories(body); } private List<Repository> extractRepositories(BitbucketRepositoriesResponse response) { return response.getValues() == null ? emptyList() : response.getValues().stream().map(BitbucketServiceImpl.this::convertRepository).collect(toList()); } private Repository convertRepository(BitbucketRepository bbRepo) { Repository repo = new Repository(); repo.setId(bbRepo.getUuid()); repo.setFullName(bbRepo.getFullName()); return repo; }}
Тестирование
Для тестирования нам понадобится небольшой HTTP-сервер, на который будет отправлен токен доступа. Сначала попробуем вызвать repositories endpoint без токена доступа и убедимся, что в этом случае получим ошибку 401. Затем пройдем аутентификацию. Для этого запустим сервер и перейдем в браузере по адресу http://localhost:8080/auth/login. После того как мы введем логин/пароль, клиент получит токен и вызовет repositories endpoint еще раз. Затем будет запрошен новый токен и снова вызван repositories endpoint с новым токеном.
public class OAuth2JwtExampleClient { /** * Start client, then navigate to http://localhost:8080/auth/login. */ public static void main(String[] args) throws Exception { AuthCallbackHandler authEndpoint = new AuthCallbackHandler(8081); authEndpoint.start(SOCKET_READ_TIMEOUT, true); HttpResponse response = getRepositories(null); assert (response.getStatusLine().getStatusCode() == SC_UNAUTHORIZED); Tokens tokens = authEndpoint.getTokens(); System.out.println("Received tokens: " + tokens); response = getRepositories(tokens.getAccessToken()); assert (response.getStatusLine().getStatusCode() == SC_OK); System.out.println("Repositories: " + IOUtils.toString(response.getEntity().getContent(), UTF_8)); // emulate token usage - wait for some time until iat and exp attributes get updated // otherwise we will receive the same token Thread.sleep(5000); tokens = refreshToken(tokens.getRefreshToken()); System.out.println("Refreshed tokens: " + tokens); // use refreshed token response = getRepositories(tokens.getAccessToken()); assert (response.getStatusLine().getStatusCode() == SC_OK); } private static Tokens refreshToken(String refreshToken) throws IOException { BasicClientCookie cookie = new BasicClientCookie(REFRESH_TOKEN, refreshToken); cookie.setPath("/"); cookie.setDomain("localhost"); BasicCookieStore cookieStore = new BasicCookieStore(); cookieStore.addCookie(cookie); HttpPost request = new HttpPost("http://localhost:8080/auth/token"); request.setHeader(ACCEPT, APPLICATION_JSON.getMimeType()); HttpClient httpClient = HttpClientBuilder.create().setDefaultCookieStore(cookieStore).build(); HttpResponse execute = httpClient.execute(request); Gson gson = new Gson(); Type type = new TypeToken<Map<String, String>>() { }.getType(); Map<String, String> response = gson.fromJson(IOUtils.toString(execute.getEntity().getContent(), UTF_8), type); Cookie refreshTokenCookie = cookieStore.getCookies().stream() .filter(c -> REFRESH_TOKEN.equals(c.getName())) .findAny() .orElseThrow(() -> new IOException("Refresh token cookie not found.")); return Tokens.of(response.get("token"), refreshTokenCookie.getValue()); } private static HttpResponse getRepositories(String accessToken) throws IOException { HttpClient httpClient = HttpClientBuilder.create().build(); HttpGet request = new HttpGet("http://localhost:8080/api/repositories"); request.setHeader(ACCEPT, APPLICATION_JSON.getMimeType()); if (accessToken != null) { request.setHeader(AUTHORIZATION, "Bearer " + accessToken); } return httpClient.execute(request); }}
Консольный вывод клиента.
Received tokens: Tokens(accessToken=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldm9sdmVjaS10ZXN0a2l0IiwidXNlcm5hbWUiOiJFdm9sdmVDSSBUZXN0a2l0IiwiaWF0IjoxNjA1NDY2MDMxLCJleHAiOjE2MDU0NjY2MzF9.UuRYMdIxzc8ZFEI2z8fAgLz-LG_gDxaim25pMh9jNrDFK6YkEaDqDO8Huoav5JUB0bJyf1lTB0nNPaLLpOj4hw, refreshToken=BBF6dboG8tB4XozHqmZE5anXMHeNUncTVD8CLv2hkaU2KsfyqitlJpgkV4HrQqPk)Repositories: [{"id":"{c7bb4165-92f1-4621-9039-bb1b6a74488e}","fullName":"test-namespace/test-repository1"},{"id":"{aa149604-c136-41e1-b7bd-3088fb73f1b2}","fullName":"test-namespace/test-repository2"}]Refreshed tokens: Tokens(accessToken=eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJldm9sdmVjaS10ZXN0a2l0IiwidXNlcm5hbWUiOiJFdm9sdmVDSSBUZXN0a2l0IiwiaWF0IjoxNjA1NDY2MDM2LCJleHAiOjE2MDU0NjY2MzZ9.oR2A_9k4fB7qpzxvV5QKY1eU_8aZMYEom-ngc4Kuc5omeGPWyclfqmiyQTpJW_cHOcXbY9S065AE_GKXFMbh_Q, refreshToken=mdc5sgmtiwLD1uryubd2WZNjNzSmc5UGo6JyyzsiYsBgOpeaY3yw3T3l8IKauKYQ)
Исходный код
Полный исходный код рассмотренного приложения находится на Github.
Ссылки
- OAuth Википедия
- Spring Security Reference
- JSON Web Token (JWT)
- The OAuth 2.0 Authorization Framework
P.S.
Созданный нами REST-сервис работает по протоколу HTTP, чтобы не усложнять пример. Но поскольку токены у нас никак не шифруются, рекомендуется перейти на защищенный канал (HTTPS).