All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.james.jmap.http.AuthenticationRoutes Maven / Gradle / Ivy

There is a newer version: 3.8.1
Show newest version
/****************************************************************
 * 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_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.http.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 javax.inject.Inject;

import org.apache.james.core.Username;
import org.apache.james.jmap.JMAPRoutes;
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.exceptions.UnauthorizedException;
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.metrics.api.MetricFactory;
import org.apache.james.user.api.UsersRepository;
import org.apache.james.user.api.UsersRepositoryException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.netty.http.server.HttpServerRequest;
import reactor.netty.http.server.HttpServerResponse;
import reactor.netty.http.server.HttpServerRoutes;

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, 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 Logger logger() {
        return LOGGER;
    }

    @Override
    public HttpServerRoutes define(HttpServerRoutes builder) {
        return builder
            .post(AUTHENTICATION, JMAPRoutes.corsHeaders(this::post))
            .get(AUTHENTICATION, JMAPRoutes.corsHeaders(this::returnEndPointsResponse))
            .delete(AUTHENTICATION, JMAPRoutes.corsHeaders(this::delete))
            .options(AUTHENTICATION, CORS_CONTROL);
    }

    private Mono post(HttpServerRequest request, HttpServerResponse response) {
        return Mono.from(metricFactory.runPublishingTimerMetricLogP99("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, e))
            .doOnEach(logOnError(e -> LOGGER.error("Unexpected error", e)))
            .onErrorResume(e -> handleInternalError(response, e))
            .subscriberContext(jmapContext(request))
            .subscriberContext(jmapAction("auth-post"))
            .subscribeOn(Schedulers.elastic());
    }

    private Mono returnEndPointsResponse(HttpServerRequest req, HttpServerResponse resp) {
            return authenticator.authenticate(req)
                .flatMap(session -> returnEndPointsResponse(resp)
                    .subscriberContext(jmapAuthContext(session)))
                .onErrorResume(BadRequestException.class, e -> handleBadRequest(resp, e))
                .doOnEach(logOnError(e -> LOGGER.error("Unexpected error", e)))
                .onErrorResume(InternalErrorException.class, e -> handleInternalError(resp, e))
                .onErrorResume(UnauthorizedException.class, e -> handleAuthenticationFailure(resp, e))
                .subscriberContext(jmapContext(req))
                .subscriberContext(jmapAction("returnEndPoints"))
                .subscribeOn(Schedulers.elastic());
    }

    private Mono returnEndPointsResponse(HttpServerResponse resp) {
        try {
            return resp.status(OK)
                .header(CONTENT_TYPE, JSON_CONTENT_TYPE_UTF8)
                .sendString(Mono.just(mapper.writeValueAsString(EndPointsResponse
                    .builder()
                    .api(JMAPUrls.JMAP)
                    .eventSource(JMAPUrls.NOT_IMPLEMENTED)
                    .upload(JMAPUrls.UPLOAD)
                    .download(JMAPUrls.DOWNLOAD)
                    .build())))
                .then();
        } catch (JsonProcessingException e) {
            throw new InternalErrorException("Error serializing endpoint response", e);
        }
    }

    private Mono 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())
                .subscriberContext(jmapAuthContext(session)))
            .onErrorResume(UnauthorizedException.class, e -> handleAuthenticationFailure(resp, e))
            .subscriberContext(jmapContext(req))
            .subscriberContext(jmapAction("auth-delete"))
            .subscribeOn(Schedulers.elastic());
    }

    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 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 handleContinuationTokenRequest(ContinuationTokenRequest request, HttpServerResponse resp) {
        try {
            ContinuationTokenResponse continuationTokenResponse = ContinuationTokenResponse
                .builder()
                .continuationToken(simpleTokenFactory.generateContinuationToken(request.getUsername()))
                .methods(ContinuationTokenResponse.AuthenticationMethod.PASSWORD)
                .build();
            return resp.header(CONTENT_TYPE, JSON_CONTENT_TYPE_UTF8)
                .sendString(Mono.just(mapper.writeValueAsString(continuationTokenResponse)))
                .then();
        } catch (Exception e) {
            throw new InternalErrorException("Error while responding to continuation token", e);
        }
    }

    private Mono 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 manageAuthenticationResponse(AccessTokenRequest request, HttpServerResponse resp) {
        Username username = Username.of(request.getToken().getUsername());

        return authenticate(request, username)
            .flatMap(success -> {
                if (success) {
                    return returnAccessTokenResponse(resp, username);
                } else {
                    return returnUnauthorizedResponse(resp)
                        .doOnEach(log(() -> LOGGER.info("Authentication failure for {}", username)));
                }
            });
    }

    private Mono 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 false;
            }
        }).subscribeOn(Schedulers.elastic());
    }

    private Mono 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 {
                    return resp.status(CREATED)
                        .header(CONTENT_TYPE, JSON_CONTENT_TYPE_UTF8)
                        .sendString(Mono.just(mapper.writeValueAsString(accessTokenResponse)))
                        .then();
                } catch (JsonProcessingException e) {
                    throw new InternalErrorException("Could not serialize access token response", e);
                }
            });
    }

    private Mono returnUnauthorizedResponse(HttpServerResponse resp) {
        return resp.status(UNAUTHORIZED).send().then();
    }

    private Mono returnForbiddenAuthentication(HttpServerResponse resp) {
        return resp.status(FORBIDDEN).send().then();
    }
}