WebDavApplicationCredentialService.java

package com.taxonomy.security.webdav;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Creates, verifies and revokes user-bound WebDAV application credentials. */
@Service
public class WebDavApplicationCredentialService {

    public static final String TOKEN_PREFIX = "taxdav_";
    public static final int TOKEN_LENGTH = 71;
    public static final Duration DEFAULT_LIFETIME = Duration.ofDays(30);
    public static final Duration MAX_LIFETIME = Duration.ofDays(90);

    private static final Logger log =
            LoggerFactory.getLogger(WebDavApplicationCredentialService.class);
    private static final Pattern TOKEN = Pattern.compile(
            "^taxdav_([0-9a-f]{24})_([A-Za-z0-9_-]{39})$");
    private static final Duration LAST_USED_WRITE_INTERVAL = Duration.ofMinutes(5);
    private static final Set<String> RETAINED_ROLES =
            Set.of("ROLE_USER", "ROLE_ARCHITECT", "ROLE_ADMIN");

    private final WebDavApplicationCredentialRepository repository;
    private final PasswordEncoder passwordEncoder;
    private final SecureRandom random;
    private final Clock clock;
    private final String dummyHash;

    @org.springframework.beans.factory.annotation.Autowired
    public WebDavApplicationCredentialService(
            WebDavApplicationCredentialRepository repository,
            PasswordEncoder passwordEncoder) {
        this(repository, passwordEncoder, new SecureRandom(), Clock.systemUTC());
    }

    WebDavApplicationCredentialService(
            WebDavApplicationCredentialRepository repository,
            PasswordEncoder passwordEncoder,
            SecureRandom random,
            Clock clock) {
        this.repository = Objects.requireNonNull(repository, "repository");
        this.passwordEncoder = Objects.requireNonNull(passwordEncoder, "passwordEncoder");
        this.random = Objects.requireNonNull(random, "random");
        this.clock = Objects.requireNonNull(clock, "clock");
        this.dummyHash = passwordEncoder.encode("unused-webdav-dummy-secret");
    }

    @Transactional
    public CreatedCredential create(
            Authentication owner,
            String description,
            boolean readAllowed,
            boolean writeAllowed,
            Integer lifetimeDays) {
        requireAuthenticated(owner);
        boolean administrator = hasAuthority(owner, "ROLE_ADMIN");
        if (!readAllowed && !writeAllowed) {
            throw new IllegalArgumentException("Select at least one WebDAV scope");
        }
        if (writeAllowed && !administrator) {
            throw new SecurityException(
                    "Administrator authority is required for a write-capable WebDAV credential");
        }

        Duration lifetime = requestedLifetime(lifetimeDays);
        Instant now = clock.instant();
        String id = randomHex(12);
        // 29 random bytes retain 232 bits of entropy while keeping the complete
        // ASCII token below BCrypt's strict 72-byte input ceiling.
        String rawSecret = randomBase64Url(29);
        String token = TOKEN_PREFIX + id + "_" + rawSecret;

        WebDavApplicationCredential credential = new WebDavApplicationCredential();
        credential.setCredentialId(id);
        credential.setUsername(owner.getName());
        credential.setDescription(normalizeDescription(description));
        credential.setSecretHash(passwordEncoder.encode(token));
        credential.setReadAllowed(readAllowed || writeAllowed);
        credential.setWriteAllowed(writeAllowed);
        credential.setAuthorities(authorityString(owner.getAuthorities()));
        credential.setCreatedAt(now);
        credential.setExpiresAt(now.plus(lifetime));
        repository.save(credential);

        log.info("audit.webdav_credential.created id={} user={} read={} write={} expires={}",
                id, owner.getName(), credential.isReadAllowed(), writeAllowed,
                credential.getExpiresAt());
        return new CreatedCredential(metadata(credential), token);
    }

    @Transactional(readOnly = true)
    public List<CredentialMetadata> list(Authentication owner) {
        requireAuthenticated(owner);
        return repository.findAllByUsernameOrderByCreatedAtDesc(owner.getName()).stream()
                .map(WebDavApplicationCredentialService::metadata)
                .toList();
    }

    @Transactional
    public void revoke(Authentication owner, String credentialId) {
        requireAuthenticated(owner);
        WebDavApplicationCredential credential = repository
                .findByCredentialIdAndUsername(credentialId, owner.getName())
                .orElseThrow(() -> new IllegalArgumentException(
                        "WebDAV application credential was not found"));
        if (credential.getRevokedAt() == null) {
            credential.setRevokedAt(clock.instant());
            repository.save(credential);
            log.info("audit.webdav_credential.revoked id={} user={}",
                    credentialId, owner.getName());
        }
    }

    /**
     * Verify a Basic-auth password that uses the Taxonomy WebDAV token format.
     * Missing IDs still perform a BCrypt comparison to reduce identifier probing.
     */
    @Transactional
    public Optional<CredentialPrincipal> authenticate(String username, String token) {
        if (username == null || username.isBlank() || token == null) {
            passwordEncoder.matches(String.valueOf(token), dummyHash);
            return Optional.empty();
        }
        Matcher matcher = TOKEN.matcher(token);
        if (!matcher.matches()) {
            passwordEncoder.matches(token, dummyHash);
            return Optional.empty();
        }

        String id = matcher.group(1);
        Optional<WebDavApplicationCredential> found =
                repository.findByCredentialIdAndUsername(id, username);
        if (found.isEmpty()) {
            passwordEncoder.matches(token, dummyHash);
            return Optional.empty();
        }
        WebDavApplicationCredential credential = found.orElseThrow();
        Instant now = clock.instant();
        if (credential.getRevokedAt() != null
                || !credential.getExpiresAt().isAfter(now)
                || !passwordEncoder.matches(token, credential.getSecretHash())) {
            return Optional.empty();
        }

        updateLastUsedIfNeeded(credential, now);
        List<GrantedAuthority> authorities = parseAuthorities(credential.getAuthorities());
        authorities.add(new SimpleGrantedAuthority("ROLE_WEBDAV_APPLICATION"));
        if (credential.isReadAllowed()) {
            authorities.add(new SimpleGrantedAuthority("SCOPE_template:read"));
        }
        if (credential.isWriteAllowed()) {
            authorities.add(new SimpleGrantedAuthority("SCOPE_template:write"));
        }
        return Optional.of(new CredentialPrincipal(
                credential.getCredentialId(),
                credential.getUsername(),
                credential.isReadAllowed(),
                credential.isWriteAllowed(),
                List.copyOf(authorities)));
    }

    public static boolean isApplicationSecret(String value) {
        return value != null && value.startsWith(TOKEN_PREFIX);
    }

    public static boolean hasExactApplicationSecretFormat(String value) {
        return value != null
                && value.length() == TOKEN_LENGTH
                && TOKEN.matcher(value).matches();
    }

    private void updateLastUsedIfNeeded(
            WebDavApplicationCredential credential,
            Instant now) {
        Instant previous = credential.getLastUsedAt();
        if (previous != null && previous.plus(LAST_USED_WRITE_INTERVAL).isAfter(now)) {
            return;
        }
        credential.setLastUsedAt(now);
        try {
            repository.save(credential);
        } catch (OptimisticLockingFailureException concurrentUse) {
            log.debug("Concurrent WebDAV credential last-used update for {}",
                    credential.getCredentialId());
        }
    }

    private String randomHex(int bytes) {
        byte[] value = new byte[bytes];
        random.nextBytes(value);
        return HexFormat.of().formatHex(value);
    }

    private String randomBase64Url(int bytes) {
        byte[] value = new byte[bytes];
        random.nextBytes(value);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
    }

    private static Duration requestedLifetime(Integer days) {
        int effective = days == null ? (int) DEFAULT_LIFETIME.toDays() : days;
        if (effective < 1 || effective > MAX_LIFETIME.toDays()) {
            throw new IllegalArgumentException(
                    "WebDAV credential lifetime must be between 1 and "
                            + MAX_LIFETIME.toDays() + " days");
        }
        return Duration.ofDays(effective);
    }

    private static String normalizeDescription(String value) {
        String normalized = value == null || value.isBlank()
                ? "Microsoft Word WebDAV" : value.strip();
        normalized = normalized.replace('\r', ' ').replace('\n', ' ').replace('\t', ' ');
        for (int offset = 0; offset < normalized.length();) {
            int codePoint = normalized.codePointAt(offset);
            if (!(codePoint == 0x9 || codePoint == 0xA || codePoint == 0xD
                    || (codePoint >= 0x20 && codePoint <= 0xD7FF)
                    || (codePoint >= 0xE000 && codePoint <= 0xFFFD)
                    || (codePoint >= 0x10000 && codePoint <= 0x10FFFF))) {
                throw new IllegalArgumentException(
                        "Credential description contains an invalid character");
            }
            offset += Character.charCount(codePoint);
        }
        if (normalized.length() > 160) {
            throw new IllegalArgumentException(
                    "Credential description must not exceed 160 characters");
        }
        return normalized;
    }

    private static String authorityString(
            Collection<? extends GrantedAuthority> authorities) {
        List<String> retained = authorities == null ? new ArrayList<>()
                : authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .filter(RETAINED_ROLES::contains)
                .distinct()
                .sorted()
                .collect(java.util.stream.Collectors.toCollection(ArrayList::new));
        if (retained.isEmpty()) {
            retained.add("ROLE_USER");
        }
        return String.join(",", retained);
    }

    private static List<GrantedAuthority> parseAuthorities(String value) {
        if (value == null || value.isBlank()) {
            return new ArrayList<>();
        }
        return java.util.Arrays.stream(value.split(","))
                .map(String::strip)
                .filter(RETAINED_ROLES::contains)
                .distinct()
                .sorted(Comparator.naturalOrder())
                .map(SimpleGrantedAuthority::new)
                .collect(java.util.stream.Collectors.toCollection(ArrayList::new));
    }

    private static void requireAuthenticated(Authentication authentication) {
        if (authentication == null || !authentication.isAuthenticated()
                || authentication.getName() == null
                || authentication.getName().isBlank()) {
            throw new BadCredentialsException("Authentication is required");
        }
    }

    private static boolean hasAuthority(Authentication authentication, String authority) {
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        return authorities != null && authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .anyMatch(authority::equals);
    }

    private static CredentialMetadata metadata(WebDavApplicationCredential value) {
        List<String> scopes = value.isWriteAllowed()
                ? List.of("template:read", "template:write")
                : value.isReadAllowed() ? List.of("template:read") : List.of();
        return new CredentialMetadata(
                value.getCredentialId(),
                value.getDescription(),
                scopes,
                value.getCreatedAt(),
                value.getExpiresAt(),
                value.getLastUsedAt(),
                value.getRevokedAt());
    }

    public record CreatedCredential(CredentialMetadata credential, String secret) {
        public CreatedCredential {
            Objects.requireNonNull(credential, "credential");
            Objects.requireNonNull(secret, "secret");
        }
    }

    public record CredentialMetadata(
            String id,
            String description,
            List<String> scopes,
            Instant createdAt,
            Instant expiresAt,
            Instant lastUsedAt,
            Instant revokedAt) {
        public boolean active(Instant now) {
            return revokedAt == null && expiresAt.isAfter(now);
        }
    }

    public record CredentialPrincipal(
            String credentialId,
            String username,
            boolean readAllowed,
            boolean writeAllowed,
            List<GrantedAuthority> authorities) {
    }
}