在前面的示例中,使用HTTP会话(和cookie)来授权对服务器的请求。但是,对于REST服务的实现,此授权方法不合适,因为REST体系结构的要求之一是缺少状态。在本文中,我们将实现REST服务,将使用访问令牌对请求进行授权。
一点理论
身份验证是验证用户凭据(登录名/密码)的过程。通过将用户输入的登录名/密码与保存的数据进行比较,可以进行用户认证。
授权是对用户访问某些资源的权限的验证。当用户访问资源时,直接执行授权。
让我们考虑上述两种授权请求方法的工作顺序。
使用HTTP会话授权请求:
- 以任何方式对用户进行身份验证。
- 在服务器上创建一个HTTP会话,并创建一个存储会话标识符的JSESSIONID cookie。
- JSESSIONID cookie被传输到客户端并存储在浏览器中。
- 对于每个后续请求,JSESSIONID cookie被发送到服务器。
- 服务器找到具有有关当前用户信息的相应HTTP会话,并确定该用户是否具有进行此调用的权限。
- 要退出该应用程序,必须从服务器删除HTTP会话。
使用访问令牌授权请求:
- 以任何方式对用户进行身份验证。
- 服务器生成一个用私钥签名的访问令牌,然后将其发送给客户端。令牌包含用户的标识符及其角色。
- 令牌存储在客户端上,并随每个后续请求一起传输到服务器。通常,授权HTTP标头用于传输令牌。
- 服务器验证令牌的签名,从中提取用户ID,他的角色,并确定用户是否有权进行此调用。
- 要退出该应用程序,只需在客户端上删除令牌,而无需与服务器进行交互。
JSON Web令牌(JWT)当前是一种常见的访问令牌格式。JWT令牌包含三个由点分隔的块:标头,有效负载和签名。前两个块为JSON格式,并以base64格式编码。字段集可以由保留名称(iss,iat,exp)或任意名称/值对组成。可以使用对称和非对称加密算法生成签名。
实作
我们将实现提供以下API的REST服务:
- GET / auth / login-启动用户身份验证过程。
- POST / auth /令牌-请求一对新的访问/刷新令牌。
- GET / api / repositories-获取当前用户的Bitbucket存储库列表。
高级应用程序体系结构
请注意,由于应用程序由三个交互组件组成,所以除了授权向服务器的客户端请求外,Bitbucket还授权服务器向服务器的请求。我们不会按角色配置方法授权,以免使示例更加复杂。我们只有一个API方法GET / api /存储库,只能由经过身份验证的用户调用。服务器可以在Bitbucket上执行客户端的OAuth注册所允许的任何操作。上一篇文章中
介绍了注册OAuth客户端的过程,
为实现该功能,我们将使用Spring Boot版本2.2.2.RELEASE和Spring Security版本5.2.1.RELEASE。
覆盖AuthenticationEntryPoint
在标准的Web应用程序中,当访问安全资源并且安全上下文中没有Authentication对象时,Spring Security会将用户重定向到身份验证页面。但是,对于REST服务,在这种情况下,更合适的行为是返回HTTP状态401(未经授权)。
RestAuthenticationEntryPoint
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}
创建一个登录端点
我们仍在使用具有授权码授权类型的OAuth2进行用户身份验证。但是,在上一步中,我们用自己的实现替换了标准的AuthenticationEntryPoint,因此我们需要一种明确的方法来启动身份验证过程。当我们向/ auth / login发送GET请求时,会将用户重定向到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。让我们在此处生成一个访问令牌,一个刷新令牌,并重定向到身份验证过程开始时发送的回调地址。我们使用GET请求参数返回访问令牌,并在httpOnly cookie中返回刷新令牌。稍后我们将分析什么是刷新令牌。
ExampleAuthenticationSuccessHandler
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
如果用户未通过身份验证,我们会将其重定向到身份验证过程开始时传递的回调地址,其中包含包含错误文本的错误参数。
ExampleAuthenticationFailureHandler
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();
}
}
创建令牌认证过滤器
该过滤器的任务是从授权标头(如果存在)中提取访问令牌,以对其进行验证并初始化安全上下文。
令牌认证过滤器
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);
}
}
创建刷新令牌端点
出于安全原因,访问令牌的生存期通常保持较短。然后,如果被盗,攻击者将无法无限期地使用它。为了不强制用户一次又一次地登录应用程序,使用刷新令牌。它由服务器在成功认证后与访问令牌一起发布,并且具有更长的生存期。使用它,您可以请求一对新的令牌。建议将刷新令牌存储在httpOnly cookie中。
刷新令牌端点
@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 cookie的类。
HttpCookieOAuth2AuthorizationRequestRepository
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。
Web安全配置
@Configuration
@EnableWebSecurity
public 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);
}
}
创建存储库端点
因此,需要通过OAuth2和Bitbucket进行身份验证-使用Bitbucket API访问资源的能力。我们使用Bitbucket存储库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服务器来发送访问令牌。首先,让我们尝试在没有访问令牌的情况下调用存储库端点,并确保在这种情况下收到401错误,然后进行身份验证。为此,请启动服务器并转到浏览器,位于http://本地主机:8080 / auth / login。输入用户名/密码后,客户端将收到令牌并再次调用存储库端点。然后将请求一个新令牌,并且将使用新令牌再次调用存储库端点。
OAuth2JwtExampleClient
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上。
链接
PS
我们创建的REST服务通过HTTP协议运行,以免使示例复杂化。但是,由于我们的令牌没有以任何方式进行加密,因此建议切换到安全通道(HTTPS)。