blob: 72ee2412223d77159c948ac46dfbcd2b53a8a688 [file] [log] [blame]
/****************************************************************
* Licensed to the Apache Software Foundation (ASF) under one *
* or more contributor license agreements. See the NOTICE file *
* distributed with this work for additional information *
* regarding copyright ownership. The ASF licenses this file *
* to you under the Apache License, Version 2.0 (the *
* "License"); you may not use this file except in compliance *
* with the License. You may obtain a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, *
* software distributed under the License is distributed on an *
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY *
* KIND, either express or implied. See the License for the *
* specific language governing permissions and limitations *
* under the License. *
****************************************************************/
package org.apache.james.jmap.http;
import static io.netty.handler.codec.http.HttpHeaderNames.ACCEPT;
import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
import static io.netty.handler.codec.http.HttpResponseStatus.CREATED;
import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN;
import static io.netty.handler.codec.http.HttpResponseStatus.NO_CONTENT;
import static io.netty.handler.codec.http.HttpResponseStatus.OK;
import static io.netty.handler.codec.http.HttpResponseStatus.UNAUTHORIZED;
import static org.apache.james.jmap.HttpConstants.JSON_CONTENT_TYPE;
import static org.apache.james.jmap.HttpConstants.JSON_CONTENT_TYPE_UTF8;
import static org.apache.james.jmap.JMAPUrls.AUTHENTICATION;
import static org.apache.james.jmap.http.LoggingHelper.jmapAction;
import static org.apache.james.jmap.http.LoggingHelper.jmapAuthContext;
import static org.apache.james.jmap.http.LoggingHelper.jmapContext;
import static org.apache.james.util.ReactorUtils.log;
import static org.apache.james.util.ReactorUtils.logOnError;
import java.io.IOException;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.apache.james.core.Username;
import org.apache.james.jmap.Endpoint;
import org.apache.james.jmap.JMAPRoute;
import org.apache.james.jmap.JMAPRoutes;
import org.apache.james.jmap.JMAPUrls;
import org.apache.james.jmap.api.access.AccessToken;
import org.apache.james.jmap.draft.api.AccessTokenManager;
import org.apache.james.jmap.draft.api.SimpleTokenFactory;
import org.apache.james.jmap.draft.api.SimpleTokenManager;
import org.apache.james.jmap.draft.exceptions.BadRequestException;
import org.apache.james.jmap.draft.exceptions.InternalErrorException;
import org.apache.james.jmap.draft.json.MultipleObjectMapperBuilder;
import org.apache.james.jmap.draft.model.AccessTokenRequest;
import org.apache.james.jmap.draft.model.AccessTokenResponse;
import org.apache.james.jmap.draft.model.ContinuationTokenRequest;
import org.apache.james.jmap.draft.model.ContinuationTokenResponse;
import org.apache.james.jmap.draft.model.EndPointsResponse;
import org.apache.james.jmap.exceptions.UnauthorizedException;
import org.apache.james.metrics.api.MetricFactory;
import org.apache.james.user.api.UsersRepository;
import org.apache.james.user.api.UsersRepositoryException;
import org.apache.james.util.ReactorUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.handler.codec.http.HttpMethod;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.netty.http.server.HttpServerRequest;
import reactor.netty.http.server.HttpServerResponse;
public class AuthenticationRoutes implements JMAPRoutes {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticationRoutes.class);
private final ObjectMapper mapper;
private final UsersRepository usersRepository;
private final SimpleTokenManager simpleTokenManager;
private final AccessTokenManager accessTokenManager;
private final SimpleTokenFactory simpleTokenFactory;
private final MetricFactory metricFactory;
private final Authenticator authenticator;
@Inject
public AuthenticationRoutes(UsersRepository usersRepository, SimpleTokenManager simpleTokenManager, AccessTokenManager accessTokenManager, SimpleTokenFactory simpleTokenFactory, MetricFactory metricFactory, @Named(InjectionKeys.DRAFT) Authenticator authenticator) {
this.mapper = new MultipleObjectMapperBuilder()
.registerClass(ContinuationTokenRequest.UNIQUE_JSON_PATH, ContinuationTokenRequest.class)
.registerClass(AccessTokenRequest.UNIQUE_JSON_PATH, AccessTokenRequest.class)
.build();
this.usersRepository = usersRepository;
this.simpleTokenManager = simpleTokenManager;
this.accessTokenManager = accessTokenManager;
this.simpleTokenFactory = simpleTokenFactory;
this.metricFactory = metricFactory;
this.authenticator = authenticator;
}
@Override
public Stream<JMAPRoute> routes() {
return Stream.of(
JMAPRoute.builder()
.endpoint(Endpoint.ofFixedPath(HttpMethod.POST, AUTHENTICATION))
.action(this::post)
.corsHeaders(),
JMAPRoute.builder()
.endpoint(Endpoint.ofFixedPath(HttpMethod.GET, AUTHENTICATION))
.action(this::returnEndPointsResponse)
.corsHeaders(),
JMAPRoute.builder()
.endpoint(Endpoint.ofFixedPath(HttpMethod.DELETE, AUTHENTICATION))
.action(this::delete)
.corsHeaders(),
JMAPRoute.builder()
.endpoint(Endpoint.ofFixedPath(HttpMethod.OPTIONS, AUTHENTICATION))
.action(CORS_CONTROL)
.noCorsHeaders()
);
}
private Mono<Void> post(HttpServerRequest request, HttpServerResponse response) {
return Mono.from(metricFactory.decoratePublisherWithTimerMetric("JMAP-authentication-post",
Mono.just(request)
.map(this::assertJsonContentType)
.map(this::assertAcceptJsonOnly)
.flatMap(this::deserialize)
.flatMap(objectRequest -> {
if (objectRequest instanceof ContinuationTokenRequest) {
return handleContinuationTokenRequest((ContinuationTokenRequest) objectRequest, response);
} else if (objectRequest instanceof AccessTokenRequest) {
return handleAccessTokenRequest((AccessTokenRequest) objectRequest, response);
} else {
throw new RuntimeException(objectRequest.getClass() + " " + objectRequest);
}
})))
.onErrorResume(BadRequestException.class, e -> handleBadRequest(response, LOGGER, e))
.doOnEach(logOnError(e -> LOGGER.error("Unexpected error", e)))
.onErrorResume(e -> handleInternalError(response, LOGGER, e))
.contextWrite(jmapContext(request))
.contextWrite(jmapAction("auth-post"))
.subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER);
}
private Mono<Void> returnEndPointsResponse(HttpServerRequest req, HttpServerResponse resp) {
return authenticator.authenticate(req)
.flatMap(session -> returnEndPointsResponse(resp)
.contextWrite(jmapAuthContext(session)))
.onErrorResume(IllegalArgumentException.class, e -> handleBadRequest(resp, LOGGER, e))
.onErrorResume(BadRequestException.class, e -> handleBadRequest(resp, LOGGER, e))
.doOnEach(logOnError(e -> LOGGER.error("Unexpected error", e)))
.onErrorResume(InternalErrorException.class, e -> handleInternalError(resp, LOGGER, e))
.onErrorResume(UnauthorizedException.class, e -> handleAuthenticationFailure(resp, LOGGER, e))
.contextWrite(jmapContext(req))
.contextWrite(jmapAction("returnEndPoints"))
.subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER);
}
private Mono<Void> returnEndPointsResponse(HttpServerResponse resp) {
try {
byte[] bytes = mapper.writeValueAsBytes(EndPointsResponse
.builder()
.api(JMAPUrls.JMAP)
.eventSource(JMAPUrls.NOT_IMPLEMENTED)
.upload(JMAPUrls.UPLOAD)
.download(JMAPUrls.DOWNLOAD)
.build());
return resp.status(OK)
.header(CONTENT_TYPE, JSON_CONTENT_TYPE_UTF8)
.header(CONTENT_LENGTH, Integer.toString(bytes.length))
.sendByteArray(Mono.just(bytes))
.then();
} catch (JsonProcessingException e) {
throw new InternalErrorException("Error serializing endpoint response", e);
}
}
private Mono<Void> delete(HttpServerRequest req, HttpServerResponse resp) {
String authorizationHeader = req.requestHeaders().get("Authorization");
return authenticator.authenticate(req)
.flatMap(session -> Mono.from(accessTokenManager.revoke(AccessToken.fromString(authorizationHeader)))
.then(resp.status(NO_CONTENT).send().then())
.contextWrite(jmapAuthContext(session)))
.onErrorResume(UnauthorizedException.class, e -> handleAuthenticationFailure(resp, LOGGER, e))
.contextWrite(jmapContext(req))
.contextWrite(jmapAction("auth-delete"))
.subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER);
}
private HttpServerRequest assertJsonContentType(HttpServerRequest req) {
if (!Objects.equals(req.requestHeaders().get(CONTENT_TYPE), JSON_CONTENT_TYPE_UTF8)) {
throw new BadRequestException("Request ContentType header must be set to: " + JSON_CONTENT_TYPE_UTF8);
}
return req;
}
private HttpServerRequest assertAcceptJsonOnly(HttpServerRequest req) {
String accept = req.requestHeaders().get(ACCEPT);
if (accept == null || !accept.contains(JSON_CONTENT_TYPE)) {
throw new BadRequestException("Request Accept header must be set to JSON content type");
}
return req;
}
private Mono<Object> deserialize(HttpServerRequest req) {
return req.receive().aggregate().asInputStream()
.map(inputStream -> {
try {
return mapper.readValue(inputStream, Object.class);
} catch (IOException e) {
throw new BadRequestException("Request can't be deserialized", e);
}
})
.switchIfEmpty(Mono.error(() -> new BadRequestException("Empty body")));
}
private Mono<Void> handleContinuationTokenRequest(ContinuationTokenRequest request, HttpServerResponse resp) {
try {
Mono<byte[]> tokenResponseMono = Mono.fromCallable(() -> ContinuationTokenResponse
.builder()
.continuationToken(simpleTokenFactory.generateContinuationToken(request.getUsername()))
.methods(ContinuationTokenResponse.AuthenticationMethod.PASSWORD)
.build())
.map(token -> {
try {
return mapper.writeValueAsBytes(token);
} catch (JsonProcessingException e) {
throw new InternalErrorException("error serialising JMAP API response json");
}
})
.subscribeOn(Schedulers.parallel());
return tokenResponseMono
.flatMap(bytes -> resp.header(CONTENT_TYPE, JSON_CONTENT_TYPE_UTF8)
.header(CONTENT_LENGTH, Integer.toString(bytes.length))
.sendByteArray(Mono.just(bytes))
.then());
} catch (Exception e) {
throw new InternalErrorException("Error while responding to continuation token", e);
}
}
private Mono<Void> handleAccessTokenRequest(AccessTokenRequest request, HttpServerResponse resp) {
SimpleTokenManager.TokenStatus validity = simpleTokenManager.getValidity(request.getToken());
switch (validity) {
case EXPIRED:
return returnForbiddenAuthentication(resp);
case INVALID:
return returnUnauthorizedResponse(resp)
.doOnEach(log(() -> LOGGER.warn("Use of an invalid ContinuationToken : {}", request.getToken().serialize())));
case OK:
return manageAuthenticationResponse(request, resp);
default:
throw new InternalErrorException(String.format("Validity %s is not implemented", validity));
}
}
private Mono<Void> manageAuthenticationResponse(AccessTokenRequest request, HttpServerResponse resp) {
Username username = Username.of(request.getToken().getUsername());
return authenticate(request, username)
.flatMap(loggedInUser -> loggedInUser.map(value -> returnAccessTokenResponse(resp, value))
.orElseGet(() -> returnUnauthorizedResponse(resp)
.doOnEach(log(() -> LOGGER.info("Authentication failure for {}", username)))));
}
private Mono<Optional<Username>> authenticate(AccessTokenRequest request, Username username) {
return Mono.fromCallable(() -> {
try {
return usersRepository.test(username, request.getPassword());
} catch (UsersRepositoryException e) {
LOGGER.error("Error while trying to validate authentication for user '{}'", username, e);
return Optional.<Username>empty();
}
}).subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER);
}
private Mono<Void> returnAccessTokenResponse(HttpServerResponse resp, Username username) {
return Mono.from(accessTokenManager.grantAccessToken(username))
.map(accessToken -> AccessTokenResponse.builder()
.accessToken(accessToken)
.api(JMAPUrls.JMAP)
.eventSource(JMAPUrls.NOT_IMPLEMENTED)
.upload(JMAPUrls.UPLOAD)
.download(JMAPUrls.DOWNLOAD)
.build())
.flatMap(accessTokenResponse -> {
try {
byte[] bytes = mapper.writeValueAsBytes(accessTokenResponse);
return resp.status(CREATED)
.header(CONTENT_TYPE, JSON_CONTENT_TYPE_UTF8)
.header(CONTENT_LENGTH, Integer.toString(bytes.length))
.sendByteArray(Mono.just(bytes))
.then();
} catch (JsonProcessingException e) {
throw new InternalErrorException("Could not serialize access token response", e);
}
});
}
private Mono<Void> returnUnauthorizedResponse(HttpServerResponse resp) {
return resp.status(UNAUTHORIZED).send().then();
}
private Mono<Void> returnForbiddenAuthentication(HttpServerResponse resp) {
return resp.status(FORBIDDEN).send().then();
}
}