WebDavApplicationCredentialFilter.java

package com.taxonomy.security.webdav;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Base64;
import java.util.Deque;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.LongSupplier;

/** Authenticates revocable WebDAV app credentials without exposing account passwords. */
public final class WebDavApplicationCredentialFilter extends OncePerRequestFilter {

    static final int MAX_FAILURES = 10;
    static final long FAILURE_WINDOW_NANOS = Duration.ofMinutes(1).toNanos();
    static final int DEFAULT_MAX_TRACKED_FAILURE_KEYS = 10_000;
    static final int MAX_AUTHORIZATION_HEADER_CHARS = 4_096;
    static final int MAX_DECODED_CREDENTIAL_BYTES = 2_048;
    static final int MAX_USERNAME_CODE_POINTS = 255;
    static final int MAX_PASSWORD_CODE_POINTS = 255;

    private static final Logger log =
            LoggerFactory.getLogger(WebDavApplicationCredentialFilter.class);
    private static final String BASIC_PREFIX = "Basic ";
    private static final long CLEANUP_INTERVAL_NANOS =
            Duration.ofSeconds(30).toNanos();
    private static final long NANOS_PER_SECOND = Duration.ofSeconds(1).toNanos();

    private final WebDavApplicationCredentialService credentials;
    private final LongSupplier monotonicNanos;
    private final int maxTrackedFailureKeys;
    private final Object stateMonitor = new Object();
    private final Map<String, FailureTracker> failures = new HashMap<>();
    private final FailureTracker overflowFailures = new FailureTracker();
    private long nextCleanupAt;

    public WebDavApplicationCredentialFilter(
            WebDavApplicationCredentialService credentials) {
        this(credentials, System::nanoTime, DEFAULT_MAX_TRACKED_FAILURE_KEYS);
    }

    WebDavApplicationCredentialFilter(
            WebDavApplicationCredentialService credentials,
            LongSupplier monotonicNanos,
            int maxTrackedFailureKeys) {
        this.credentials = Objects.requireNonNull(credentials, "credentials");
        this.monotonicNanos =
                Objects.requireNonNull(monotonicNanos, "monotonicNanos");
        if (maxTrackedFailureKeys <= 0) {
            throw new IllegalArgumentException(
                    "maxTrackedFailureKeys must be positive");
        }
        this.maxTrackedFailureKeys = maxTrackedFailureKeys;
        long now = monotonicNanos.getAsLong();
        this.nextCleanupAt = now + CLEANUP_INTERVAL_NANOS;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        return !isTemplateWebDavRequest(request);
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
        BasicParseResult parsed = parseBasic(authorization);

        if (!parsed.basicScheme()) {
            if (authorization == null
                    && SecurityContextHolder.getContext().getAuthentication() == null) {
                unauthorized(response);
                return;
            }
            chain.doFilter(request, response);
            return;
        }
        if (parsed.credential() == null) {
            unauthorized(response);
            return;
        }

        BasicCredential supplied = parsed.credential();
        if (!WebDavApplicationCredentialService.isApplicationSecret(
                supplied.password())) {
            chain.doFilter(request, response);
            return;
        }
        if (!WebDavApplicationCredentialService.hasExactApplicationSecretFormat(
                supplied.password())) {
            unauthorized(response);
            return;
        }

        String failureKey = failureKey(request, supplied.username());
        LockState currentLock =
                currentLock(failureKey, monotonicNanos.getAsLong());
        if (currentLock.locked()) {
            rateLimited(response, currentLock.retryAfterSeconds());
            return;
        }

        Optional<WebDavApplicationCredentialService.CredentialPrincipal> authenticated =
                credentials.authenticate(supplied.username(), supplied.password());
        if (authenticated.isEmpty()) {
            int failureCount =
                    recordFailure(failureKey, monotonicNanos.getAsLong());
            if (failureCount >= MAX_FAILURES) {
                log.warn(
                        "WEBDAV_APPLICATION_CREDENTIAL_LOCKED failureKeyDigest={} attempts={}",
                        failureKey,
                        failureCount);
            }
            unauthorized(response);
            return;
        }

        clearFailures(failureKey);
        var principal = authenticated.orElseThrow();
        if (!allowedForMethod(principal, request.getMethod())) {
            response.sendError(
                    HttpServletResponse.SC_FORBIDDEN,
                    "The WebDAV application credential does not permit this operation");
            return;
        }

        SecurityContext previous = SecurityContextHolder.getContext();
        SecurityContext applicationContext =
                SecurityContextHolder.createEmptyContext();
        UsernamePasswordAuthenticationToken authentication =
                UsernamePasswordAuthenticationToken.authenticated(
                        principal.username(),
                        "[PROTECTED]",
                        principal.authorities());
        authentication.setDetails(
                new WebAuthenticationDetailsSource().buildDetails(request));
        applicationContext.setAuthentication(authentication);
        SecurityContextHolder.setContext(applicationContext);
        try {
            log.debug(
                    "WebDAV application credential accepted id={} user={} method={}",
                    principal.credentialId(),
                    principal.username(),
                    request.getMethod());
            chain.doFilter(request, response);
        } finally {
            SecurityContextHolder.setContext(previous);
        }
    }

    private static boolean allowedForMethod(
            WebDavApplicationCredentialService.CredentialPrincipal principal,
            String method) {
        String normalized =
                method == null ? "" : method.toUpperCase(Locale.ROOT);
        return switch (normalized) {
            case "GET", "HEAD", "OPTIONS", "PROPFIND" ->
                    principal.readAllowed();
            case "PUT", "LOCK", "UNLOCK", "DELETE", "MKCOL", "MOVE", "COPY" ->
                    principal.writeAllowed();
            default -> false;
        };
    }

    static boolean isTemplateWebDavRequest(HttpServletRequest request) {
        String path = request.getRequestURI();
        String context = request.getContextPath();
        if (context != null && !context.isBlank() && path.startsWith(context)) {
            path = path.substring(context.length());
        }
        return path.equals("/dav/templates")
                || path.startsWith("/dav/templates/");
    }

    private static BasicParseResult parseBasic(String header) {
        if (header == null
                || !header.regionMatches(
                        true, 0, BASIC_PREFIX, 0, BASIC_PREFIX.length())) {
            return BasicParseResult.notBasic();
        }
        if (header.length() > MAX_AUTHORIZATION_HEADER_CHARS) {
            return BasicParseResult.invalid();
        }

        String encoded =
                header.substring(BASIC_PREFIX.length()).strip();
        if (encoded.isEmpty()) {
            return BasicParseResult.invalid();
        }

        try {
            byte[] decoded = Base64.getDecoder().decode(encoded);
            if (decoded.length > MAX_DECODED_CREDENTIAL_BYTES) {
                return BasicParseResult.invalid();
            }
            String value = StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(decoded))
                    .toString();
            int separator = value.indexOf(':');
            if (separator <= 0) {
                return BasicParseResult.invalid();
            }

            String username = value.substring(0, separator);
            String password = value.substring(separator + 1);
            if (username.isBlank()
                    || codePointLength(username) > MAX_USERNAME_CODE_POINTS
                    || codePointLength(password) > MAX_PASSWORD_CODE_POINTS) {
                return BasicParseResult.invalid();
            }
            return BasicParseResult.valid(
                    new BasicCredential(username, password));
        } catch (IllegalArgumentException | CharacterCodingException invalid) {
            return BasicParseResult.invalid();
        }
    }

    private static int codePointLength(String value) {
        return value.codePointCount(0, value.length());
    }

    private LockState currentLock(String key, long now) {
        synchronized (stateMonitor) {
            cleanupExpired(now, false);
            FailureTracker tracker = failures.get(key);
            if (tracker == null && failures.size() >= maxTrackedFailureKeys) {
                cleanupExpired(now, true);
                if (failures.size() >= maxTrackedFailureKeys) {
                    tracker = overflowFailures;
                }
            }
            return tracker == null
                    ? LockState.unlocked()
                    : tracker.lockState(now);
        }
    }

    private int recordFailure(String key, long now) {
        synchronized (stateMonitor) {
            cleanupExpired(now, false);
            FailureTracker tracker = failures.get(key);
            if (tracker == null) {
                if (failures.size() >= maxTrackedFailureKeys) {
                    cleanupExpired(now, true);
                }
                if (failures.size() >= maxTrackedFailureKeys) {
                    tracker = overflowFailures;
                } else {
                    tracker = new FailureTracker();
                    failures.put(key, tracker);
                }
            }
            return tracker.recordFailure(now);
        }
    }

    private void clearFailures(String key) {
        synchronized (stateMonitor) {
            // An overflow identity has no individual entry. Never reset the
            // shared fail-closed bucket on one successful authentication.
            failures.remove(key);
        }
    }

    private void cleanupExpired(long now, boolean force) {
        if (!force && now - nextCleanupAt < 0) {
            return;
        }
        failures.entrySet().removeIf(
                entry -> entry.getValue().purgeAndIsEmpty(now));
        overflowFailures.purge(now);
        nextCleanupAt = now + CLEANUP_INTERVAL_NANOS;
    }

    int trackedFailureKeyCount() {
        synchronized (stateMonitor) {
            return failures.size();
        }
    }

    int overflowFailureCount() {
        synchronized (stateMonitor) {
            return overflowFailures.failureCount();
        }
    }

    private static String failureKey(
            HttpServletRequest request,
            String username) {
        String peer = Objects.toString(request.getRemoteAddr(), "");
        String normalizedUsername = Normalizer.normalize(
                        username.strip(), Normalizer.Form.NFKC)
                .toLowerCase(Locale.ROOT);
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            digest.update(
                    "taxonomy-webdav-credential-lockout"
                            .getBytes(StandardCharsets.UTF_8));
            digest.update((byte) 0);
            digest.update(peer.getBytes(StandardCharsets.UTF_8));
            digest.update((byte) 0);
            digest.update(
                    normalizedUsername.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest.digest());
        } catch (NoSuchAlgorithmException impossible) {
            throw new IllegalStateException(
                    "SHA-256 is unavailable", impossible);
        }
    }

    private static void rateLimited(
            HttpServletResponse response,
            long retryAfterSeconds) throws IOException {
        response.setStatus(429);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType("application/json");
        response.setHeader(
                HttpHeaders.RETRY_AFTER,
                Long.toString(retryAfterSeconds));
        response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store");
        response.getWriter().write(
                "{\"error\":\"TOO_MANY_WEBDAV_AUTHENTICATION_ATTEMPTS\","
                        + "\"status\":429,"
                        + "\"retryAfterSeconds\":"
                        + retryAfterSeconds
                        + "}");
    }

    private static void unauthorized(
            HttpServletResponse response) throws IOException {
        response.setHeader(
                HttpHeaders.WWW_AUTHENTICATE,
                "Basic realm=\"Taxonomy WebDAV\", charset=\"UTF-8\"");
        response.sendError(
                HttpServletResponse.SC_UNAUTHORIZED,
                "A valid WebDAV application credential is required");
    }

    private record BasicCredential(String username, String password) {
    }

    private record BasicParseResult(
            boolean basicScheme,
            BasicCredential credential) {

        private static BasicParseResult notBasic() {
            return new BasicParseResult(false, null);
        }

        private static BasicParseResult invalid() {
            return new BasicParseResult(true, null);
        }

        private static BasicParseResult valid(BasicCredential credential) {
            return new BasicParseResult(true, credential);
        }
    }

    private record LockState(
            boolean locked,
            long retryAfterSeconds,
            int failureCount) {

        private static LockState unlocked() {
            return new LockState(false, 0, 0);
        }
    }

    private static final class FailureTracker {

        private final Deque<Long> attempts = new ArrayDeque<>(MAX_FAILURES);

        private int recordFailure(long now) {
            purge(now);
            if (attempts.size() < MAX_FAILURES) {
                attempts.addLast(now);
            }
            return attempts.size();
        }

        private LockState lockState(long now) {
            purge(now);
            if (attempts.size() < MAX_FAILURES) {
                return LockState.unlocked();
            }
            long elapsed = now - attempts.peekFirst();
            long remaining =
                    Math.max(1L, FAILURE_WINDOW_NANOS - elapsed);
            long retryAfterSeconds =
                    Math.max(
                            1L,
                            (remaining + NANOS_PER_SECOND - 1L)
                                    / NANOS_PER_SECOND);
            return new LockState(
                    true, retryAfterSeconds, attempts.size());
        }

        private boolean purgeAndIsEmpty(long now) {
            purge(now);
            return attempts.isEmpty();
        }

        private void purge(long now) {
            while (!attempts.isEmpty()
                    && now - attempts.peekFirst()
                            >= FAILURE_WINDOW_NANOS) {
                attempts.removeFirst();
            }
        }

        private int failureCount() {
            return attempts.size();
        }
    }
}