AuthController.java

/*
 * BSD 2-Clause License
 * 
 * Copyright (c) 2022, [Aleksandra Serba, Marcin Czerniak, Bartosz Wawrzyniak, Adrian Antkowiak]
 * 
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 * 
 * 1. Redistributions of source code must retain the above copyright notice, this
 * list of conditions and the following disclaimer.
 * 
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 * this list of conditions and the following disclaimer in the documentation
 * and/or other materials provided with the distribution.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package dev.vernite.vernite.user.auth;

import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

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

import dev.vernite.vernite.common.utils.counter.CounterSequence;
import dev.vernite.vernite.event.Event;
import dev.vernite.vernite.event.EventFilter;
import dev.vernite.vernite.event.EventService;
import dev.vernite.vernite.integration.calendar.CalendarIntegration;
import dev.vernite.vernite.integration.calendar.CalendarIntegrationRepository;
import dev.vernite.vernite.task.time.TimeTrack;
import dev.vernite.vernite.task.time.TimeTrackRepository;
import dev.vernite.vernite.user.DeleteAccountRequest;
import dev.vernite.vernite.user.DeleteAccountRequestRepository;
import dev.vernite.vernite.user.PasswordRecovery;
import dev.vernite.vernite.user.PasswordRecoveryRepository;
import dev.vernite.vernite.user.User;
import dev.vernite.vernite.user.UserRepository;
import dev.vernite.vernite.user.UserSession;
import dev.vernite.vernite.user.UserSessionRepository;
import dev.vernite.vernite.utils.ErrorType;
import dev.vernite.vernite.utils.ObjectNotFoundException;
import dev.vernite.vernite.utils.SecureStringUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Setter;

@RestController
@RequestMapping("/auth")
public class AuthController {

    public static final String COOKIE_NAME = "session";
    private static final URI RECAPTCHA_URI;
    static {
        try {
            RECAPTCHA_URI = new URI("https://www.google.com/recaptcha/api/siteverify");
        } catch (URISyntaxException e) {
            throw new RuntimeException(e);
        }
    }
    private static final SecureRandom RANDOM = new SecureRandom();
    private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newSingleThreadScheduledExecutor();
    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserSessionRepository userSessionRepository;

    @Autowired
    private JavaMailSender javaMailSender;

    @Autowired
    private DeleteAccountRequestRepository deleteAccountRepository;

    @Autowired
    private PasswordRecoveryRepository passwordRecoveryRepository;

    @Autowired
    private TimeTrackRepository timeTrackRepository;

    @Autowired
    private EventService eventService;

    @Autowired
    private CalendarIntegrationRepository calendarRepository;

    @Setter
    @Value("${server.servlet.context-path}")
    private String cookiePath;

    @Value("${recaptcha.secret}")
    private String recaptchaSecret;

    @Operation(summary = "Logged user", description = "This method returns currently logged user.")
    @ApiResponse(responseCode = "200", description = "Logged user.")
    @ApiResponse(responseCode = "401", description = "User is not logged.", content = @Content())
    @GetMapping("/me")
    public User me(@NotNull @Parameter(hidden = true) User loggedUser) {
        return loggedUser;
    }

    @Operation(summary = "Get time tracks", description = "This method gets time tracks for logged in user.")
    @ApiResponse(responseCode = "200", description = "Time tracks.")
    @ApiResponse(responseCode = "401", description = "User is not logged.", content = @Content())
    @GetMapping("/me/track")
    public List<TimeTrack> getTimeTracks(@NotNull @Parameter(hidden = true) User loggedUser) {
        return timeTrackRepository.findByUser(loggedUser);
    }

    @Operation(summary = "Get user events", description = "This method gets events for logged in user. `From` and `to` are required timestamps.")
    @ApiResponse(responseCode = "200", description = "List with events for current user. Empty list if no events. Tasks are only displayed if they are not finished and assigned to user.")
    @ApiResponse(responseCode = "401", description = "User is not logged.", content = @Content())
    @GetMapping("/me/events")
    public Set<Event> getEvents(@NotNull @Parameter(hidden = true) User loggedUser, long from, long to,
            @ModelAttribute EventFilter filter) {
        return eventService.getUserEvents(loggedUser, new Date(from), new Date(to), filter);
    }

    @Operation(summary = "Create synchronization link", description = "Creates synchronization link for user events calendar")
    @ApiResponse(description = "Link.", responseCode = "200")
    @ApiResponse(description = "No user logged in.", responseCode = "401", content = @Content(schema = @Schema(implementation = ErrorType.class)))
    @PostMapping("/me/events/sync")
    public String createCalendarSync(@NotNull @Parameter(hidden = true) User loggedUser) {
        String key = SecureStringUtils.generateRandomSecureString();
        while (calendarRepository.findByKey(key).isPresent()) {
            key = SecureStringUtils.generateRandomSecureString();
        }
        Optional<CalendarIntegration> integration = calendarRepository.findByUserAndProjectNull(loggedUser);
        if (integration.isPresent()) {
            key = integration.get().getKey();
        } else {
            calendarRepository.save(new CalendarIntegration(loggedUser, key));
        }
        return "https://vernite.dev/api/webhook/calendar?key=" + key;
    }

    @Operation(summary = "Delete account", description = "This method deletes currently logged user by sending an e-mail with a confirmation link.")
    @ApiResponse(responseCode = "200")
    @DeleteMapping("/delete")
    public void delete(@NotNull @Parameter(hidden = true) User loggedUser) {
        DeleteAccountRequest d = new DeleteAccountRequest();
        d.setUser(loggedUser);
        d.setActive(Date.from(Instant.now().plus(30, ChronoUnit.MINUTES)));
        d.setToken(SecureStringUtils.generateRandomSecureString());
        while (true) {
            try {
                d = deleteAccountRepository.save(d);
                break;
            } catch (DataIntegrityViolationException ex) {
                d.setToken(SecureStringUtils.generateRandomSecureString());
            }
        }

        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(loggedUser.getEmail());
        message.setSubject("Potwierdzenie usunięcia Twojego konta");
        message.setText("Aby potwierdzić usuwanie Twojego konta, kliknij w poniższy link:\n" +
                "https://vernite.dev/pl-PL/auth/delete-account?token=" + d.getToken() + "\n" +
                "Link wygaśnie po 30 minutach");
        javaMailSender.send(message);
    }

    @Operation(summary = "Delete account", description = "This method deletes currently logged user after clicking on the confirmation link.")
    @ApiResponse(responseCode = "200", description = "Account deleted.")
    @ApiResponse(responseCode = "403", description = "Token is not compatible with the currently logged in user.", content = @Content())
    @ApiResponse(responseCode = "404", description = "Token is invalid.", content = @Content())
    @DeleteMapping("/delete/confirm")
    public void deleteConfirm(@NotNull @Parameter(hidden = true) User loggedUser, @RequestBody DeleteRequest request) {
        DeleteAccountRequest d = deleteAccountRepository.findByToken(request.getToken());
        if (d == null) {
            throw new ObjectNotFoundException();
        }
        if (d.getUser().getId() != loggedUser.getId()) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "invalid user");
        }
        deleteAccountRepository.delete(d);
        loggedUser.setDeleted(new Date());
        userRepository.save(loggedUser);
    }

    @Operation(summary = "Recover deleted account", description = "This method recovers a deleted account if it was deleted in less than 1 week.")
    @ApiResponse(responseCode = "200", description = "Recovered user.", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = User.class))
    })
    @ApiResponse(responseCode = "401", description = "User is not logged.", content = @Content())
    @PostMapping("/delete/recover")
    public User recoverDeleted(@Parameter(hidden = true) User loggedUser) {
        if (loggedUser == null) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "user not logged");
        }
        if (loggedUser.getDeleted() == null) {
            return loggedUser;
        }
        loggedUser.setDeleted(null);
        this.userRepository.save(loggedUser);
        return loggedUser;
    }

    @Operation(summary = "Logging in", description = "This method logs the user in.")
    @ApiResponse(responseCode = "200", description = "Logged user.", content = {
            @Content(mediaType = "application/json", schema = @Schema(implementation = User.class))
    })
    @ApiResponse(responseCode = "403", description = "User is already logged or invalid captcha.", content = @Content())
    @ApiResponse(responseCode = "404", description = "Username or password is incorrect.", content = @Content())
    @PostMapping("/login")
    public Future<User> login(@Parameter(hidden = true) User loggedUser, @RequestBody LoginRequest req,
            HttpServletRequest request, HttpServletResponse response) {
        if (loggedUser != null) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "already logged");
        }
        if (req.getPassword() == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing password");
        }
        if (req.getEmail() == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing username");
        }
        if (req.getCaptcha() == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing captcha");
        }
        return verifyCaptcha(req.getCaptcha(), request, "login").thenApply(success -> {
            if (!success) {
                throw new ResponseStatusException(HttpStatus.FORBIDDEN, "invalid captcha");
            }
            if (req.getEmail().indexOf('@') != -1) {
                return userRepository.findByEmail(req.getEmail());
            }
            return userRepository.findByUsername(req.getEmail());
        }).thenCompose(u -> {
            CompletableFuture<User> f = new CompletableFuture<>();
            EXECUTOR_SERVICE.schedule(() -> {
                if (u == null || !u.checkPassword(req.getPassword())) {
                    f.completeExceptionally(
                            new ResponseStatusException(HttpStatus.NOT_FOUND, "username or password incorrect"));
                } else {
                    createSession(request, response, u, req.isRemember());
                    f.complete(u);
                }
            }, 500 + RANDOM.nextInt(500), TimeUnit.MILLISECONDS);
            return f;
        });
    }

    @Operation(summary = "Modify user account", description = "This method edits the account.")
    @ApiResponse(responseCode = "200", description = "User after changes.")
    @PutMapping("/edit")
    public User edit(@NotNull @Parameter(hidden = true) User loggedUser, @RequestBody @Valid EditAccountRequest req) {
        if (req.getAvatar() != null) {
            loggedUser.setAvatar(req.getAvatar());
        }
        if (req.getName() != null) {
            loggedUser.setName(req.getName());
        }
        if (req.getSurname() != null) {
            loggedUser.setSurname(req.getSurname());
        }
        if (req.getLanguage() != null) {
            loggedUser.setLanguage(req.getLanguage());
        }
        if (req.getDateFormat() != null) {
            loggedUser.setDateFormat(req.getDateFormat());
        }
        if (req.getTimeFormat() != null) {
            loggedUser.setTimeFormat(req.getTimeFormat());
        }
        if (req.getFirstDayOfWeek() != null) {
            loggedUser.setFirstDayOfWeek(req.getFirstDayOfWeek());
        }
        userRepository.save(loggedUser);
        return loggedUser;
    }

    @GetMapping("/verify/{code}")
    public ResponseEntity<Void> verify(@Parameter(hidden = true) User loggedUser, @PathVariable String code) {
        if (loggedUser != null) {
            return ResponseEntity.status(HttpStatus.FOUND)
                    .location(URI.create("https://vernite.dev/?path=/dashboard"))
                    .build();
        }
        User u = VerificationEmails.pollUser(code);
        if (u != null) {
            userRepository.save(u);
            return ResponseEntity.status(HttpStatus.FOUND)
                    .location(URI.create("https://vernite.dev/?path=/auth/register/token-success"))
                    .build();
        }
        return ResponseEntity.status(HttpStatus.FOUND)
                .location(URI.create("https://vernite.dev/?path=/auth/register/token-expired"))
                .build();
    }

    @Operation(summary = "Register account", description = "This method registers a new account. On success returns newly created user.")
    @ApiResponse(responseCode = "200", description = "Newly created user.")
    @ApiResponse(responseCode = "403", description = "User is already logged or invalid captcha.", content = @Content())
    @ApiResponse(responseCode = "422", description = "Username or email is already taken.", content = @Content())
    @PostMapping("/register")
    public Future<User> register(@Parameter(hidden = true) User loggedUser, @RequestBody @Valid RegisterRequest req,
            HttpServletRequest request, HttpServletResponse response) {
        if (loggedUser != null) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "already logged");
        }
        if (req.getUsername().indexOf('@') != -1) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "invalid character in username");
        }
        if (req.getEmail().indexOf('@') == -1) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing at sign in email");
        }

        // workaround for Not provider of jakarta.mail.util.StreamProvider was found
        // spring uses different classloader than http
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return verifyCaptcha(req.getCaptcha(), request, "register").thenApply(success -> {
            if (!success) {
                throw new ResponseStatusException(HttpStatus.FORBIDDEN, "invalid captcha");
            }
            if (userRepository.findByUsername(req.getUsername()) != null) {
                throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "this username is already taken");
            }
            if (userRepository.findByEmail(req.getEmail()) != null) {
                throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "this email is already taken");
            }
            User u = new User();
            u.setEmail(req.getEmail());
            u.setName(req.getName());
            u.setPassword(req.getPassword());
            u.setSurname(req.getSurname());
            u.setUsername(req.getUsername());
            u.setLanguage(req.getLanguage());
            u.setDateFormat(req.getDateFormat());
            u.setCounterSequence(new CounterSequence());

            String code = VerificationEmails.prepareUser(u);

            ClassLoader old = Thread.currentThread().getContextClassLoader();
            Thread.currentThread().setContextClassLoader(cl);
            SimpleMailMessage msg = new SimpleMailMessage();
            msg.setTo(req.getEmail());
            msg.setFrom("contact@vernite.dev");
            // TODO activation link
            msg.setSubject("Dziękujemy za rejestrację");
            msg.setText("Cześć, " + req.getName() + "!\n"
                    + "Dziękujemy za zarejestrowanie się w naszym serwisie. Aby dokończyć rejestrację, potwierdź swój adres e-mail:\n"
                    + "https://vernite.dev/api/auth/verify/" + code + "\n"
                    + "Link ten wygaśnie za 30 minut.");
            javaMailSender.send(msg);
            Thread.currentThread().setContextClassLoader(old);
            return u;
        });
    }

    @Operation(summary = "Log out", description = "This method log outs the user.")
    @ApiResponse(responseCode = "200", description = "User logged out")
    @PostMapping("/logout")
    public void destroySession(HttpServletRequest req, HttpServletResponse resp,
            @Parameter(hidden = true) @CookieValue(value = AuthController.COOKIE_NAME, required = false) String session) {
        if (session != null) {
            this.userSessionRepository.deleteBySession(session);
            Cookie cookie = new Cookie(COOKIE_NAME, null);
            cookie.setPath("/api");
            cookie.setMaxAge(0);
            resp.addCookie(cookie);
        }
    }

    @Operation(summary = "Change a password", description = "This method is used to change a password.")
    @ApiResponse(responseCode = "200", description = "Password changed")
    @ApiResponse(responseCode = "404", description = "Old password is incorrect.", content = @Content())
    @PostMapping("/password/change")
    public void changePassword(@NotNull @Parameter(hidden = true) User loggedUser,
            @RequestBody ChangePasswordRequest req) {
        if (req.getOldPassword() == null || req.getOldPassword().isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing old password");
        }
        if (req.getNewPassword() == null || req.getNewPassword().isEmpty()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing new password");
        }
        if (req.getNewPassword().length() < 8) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "password too short");
        }
        if (!loggedUser.checkPassword(req.getOldPassword())) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "old password is incorrect");
        }
        loggedUser.setPassword(req.getNewPassword());
        userRepository.save(loggedUser);
    }

    @Operation(summary = "Send email with link to reset password", description = "This method sends an e-mail to the user with a link that allows the user to reset the password.")
    @ApiResponse(responseCode = "200", description = "E-mail address has been sent")
    @ApiResponse(responseCode = "403", description = "User already logged")
    @ApiResponse(responseCode = "404", description = "E-mail not found")
    @PostMapping("/password/recover")
    public void recoverPassword(@Parameter(hidden = true) User loggedUser, @RequestBody PasswordRecoveryRequest req) {
        if (loggedUser != null) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "already logged");
        }
        if (req.getEmail() == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing email");
        }
        User u = userRepository.findByEmail(req.getEmail());
        if (u == null) {
            throw new ObjectNotFoundException();
        }
        PasswordRecovery p = new PasswordRecovery();
        p.setUser(u);
        p.setActive(Date.from(Instant.now().plus(30, ChronoUnit.MINUTES)));
        p.setToken(SecureStringUtils.generateRandomSecureString());
        while (true) {
            try {
                p = passwordRecoveryRepository.save(p);
                break;
            } catch (DataIntegrityViolationException ex) {
                p.setToken(SecureStringUtils.generateRandomSecureString());
            }
        }
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(req.getEmail());
        msg.setFrom("contact@vernite.dev");
        msg.setSubject("Zapomniałeś hasła?");
        msg.setText("Cześć, " + u.getName()
                + "!\nJeśli zapomniałeś hasła to wejdź w link: https://vernite.dev/pl-PL/auth/set-new-password?token="
                + p.getToken() + "\nLink wygaśnie po 30 minutach");
        javaMailSender.send(msg);
    }

    @Operation(summary = "Check token and reset password", description = "This method allows to check if the token is valid and reset the password.")
    @ApiResponse(responseCode = "200", description = "The token is valid and the password (if provided) has been changed.")
    @ApiResponse(responseCode = "403", description = "User is already logged.")
    @ApiResponse(responseCode = "404", description = "The token is not valid or has expired.")
    @PostMapping("/password/reset")
    public void resetPassword(@Parameter(hidden = true) User loggedUser, @RequestBody ResetPasswordRequest req) {
        if (loggedUser != null) {
            throw new ResponseStatusException(HttpStatus.FORBIDDEN, "already logged");
        }
        if (req.getToken() == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing token");
        }
        PasswordRecovery p = passwordRecoveryRepository.findByToken(req.getToken());
        if (p == null) {
            throw new ObjectNotFoundException();
        }
        if (p.getActive().before(new Date())) {
            passwordRecoveryRepository.delete(p);
            throw new ObjectNotFoundException();
        }
        if (req.getPassword() == null) {
            return;
        }
        User u = p.getUser();
        passwordRecoveryRepository.delete(p);
        u.setPassword(req.getPassword());
        userRepository.save(u);
    }

    private void createSession(HttpServletRequest req, HttpServletResponse resp, User user, boolean remembered) {
        UserSession us = new UserSession();
        us.setSession(SecureStringUtils.generateRandomSecureString());
        us.setIp(req.getHeader("X-Forwarded-For"));
        if (us.getIp() == null) {
            us.setIp(req.getRemoteAddr());
        }
        us.setLastUsed(new Date());
        us.setRemembered(remembered);
        us.setUserAgent(req.getHeader("User-Agent"));
        us.setUser(user);
        while (true) {
            try {
                us = userSessionRepository.save(us);
                break;
            } catch (DataIntegrityViolationException ex) {
                us.setSession(SecureStringUtils.generateRandomSecureString());
            }
        }
        Cookie c = new Cookie(COOKIE_NAME, us.getSession());
        c.setPath(cookiePath);
        if (req.getHeader("X-Forwarded-For") != null) {
            c.setSecure(true);
        } else {
            c.setHttpOnly(true);
        }
        resp.addCookie(c);
    }

    /**
     * Verify captcha response
     * 
     * @param response       response from recaptcha
     * @param request        request
     * @param expectedAction expected action
     * @return action if success or null if failed
     */
    private CompletableFuture<Boolean> verifyCaptcha(String response, HttpServletRequest request,
            String expectedAction) {
        String remoteip = request.getHeader("X-Forwarded-For");
        if (remoteip == null) {
            remoteip = request.getRemoteAddr();
        }

        HttpClient client = HttpClient.newHttpClient();
        String data = String.format("secret=%s&response=%s&remoteip=%s",
                URLEncoder.encode(recaptchaSecret, StandardCharsets.UTF_8),
                URLEncoder.encode(response, StandardCharsets.UTF_8),
                URLEncoder.encode(remoteip, StandardCharsets.UTF_8));

        HttpRequest req = HttpRequest.newBuilder(RECAPTCHA_URI)
                .header("Content-Type", "application/x-www-form-urlencoded")
                .POST(HttpRequest.BodyPublishers.ofString(data))
                .build();
        return client.sendAsync(req, BodyHandlers.ofString()).thenApply(n -> {
            if (n.statusCode() != 200) {
                throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Captcha verification failed");
            }
            try {
                JsonNode node = MAPPER.readTree(n.body());
                if (node.get("success").asBoolean()) {
                    if (node.has("action") && expectedAction.equals(node.get("action").asText())) {
                        return true;
                    }
                    if (node.has("hostname") && "testkey.google.com".equals(node.get("hostname").asText())) {
                        return true;
                    }
                    return false;
                }
            } catch (JsonProcessingException e) {
                e.printStackTrace();
                throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Captcha verification failed");
            }
            return false;
        });
    }

    @Scheduled(cron = "0 * * * * *")
    public void deleteOldAccount() {
        Date d = Date.from(Instant.now().minus(7, ChronoUnit.DAYS));
        List<User> users = this.userRepository.findByDeletedPermanentlyFalseAndDeletedLessThan(d);
        for (User u : users) {
            u.setDeletedPermanently(true);
            u.setUsername("(deleted) " + SecureStringUtils.generateRandomSecureString());
            u.setEmail("(deleted) " + SecureStringUtils.generateRandomSecureString());
        }
        this.userRepository.saveAll(users);
    }
}