Skip to content
This repository has been archived by the owner on Jan 31, 2023. It is now read-only.

Commit

Permalink
Implement captcha
Browse files Browse the repository at this point in the history
  • Loading branch information
adiantek committed Dec 13, 2022
1 parent f307bfa commit f3fc7f5
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 240 deletions.
208 changes: 142 additions & 66 deletions src/main/java/dev/vernite/vernite/user/auth/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,49 @@

package dev.vernite.vernite.user.auth;

import static java.nio.charset.StandardCharsets.UTF_8;

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.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.Random;
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 jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotNull;
import lombok.Setter;
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.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.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;
Expand All @@ -64,38 +89,33 @@
import dev.vernite.vernite.utils.ErrorType;
import dev.vernite.vernite.utils.ObjectNotFoundException;
import dev.vernite.vernite.utils.SecureStringUtils;

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.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.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 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.constraints.NotNull;
import lombok.Setter;

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

public static final String COOKIE_NAME = "session";
private static final Random RANDOM = new Random();
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;
Expand Down Expand Up @@ -125,6 +145,9 @@ public class AuthController {
@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())
Expand Down Expand Up @@ -235,7 +258,7 @@ public User recoverDeleted(@Parameter(hidden = true) User loggedUser) {
@Content(mediaType = "application/json", schema = @Schema(implementation = User.class))
})
@ApiResponse(responseCode = "403", description = "User is already logged.", content = @Content())
@ApiResponse(responseCode = "404", description = "Username or password is incorrect.", content = @Content())
@ApiResponse(responseCode = "404", description = "Username or password is incorrect or invalid captcha.", content = @Content())
@PostMapping("/login")
public Future<User> login(@Parameter(hidden = true) User loggedUser, @RequestBody LoginRequest req,
HttpServletRequest request, HttpServletResponse response) {
Expand All @@ -248,19 +271,27 @@ public Future<User> login(@Parameter(hidden = true) User loggedUser, @RequestBod
if (req.getEmail() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing username");
}
User u = req.getEmail().indexOf('@') != -1 ? userRepository.findByEmail(req.getEmail())
: userRepository.findByUsername(req.getEmail());
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);
return verifyCaptcha(req.getCaptcha(), request).thenApply(action -> {
if (!"login".equals(action)) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "invalid captcha");
}
}, 500 + RANDOM.nextInt(500), TimeUnit.MILLISECONDS);
return f;
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.")
Expand Down Expand Up @@ -289,9 +320,9 @@ public User edit(@NotNull @Parameter(hidden = true) User loggedUser, @RequestBod
@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.", content = @Content())
@ApiResponse(responseCode = "422", description = "Username or email is already taken.", content = @Content())
@ApiResponse(responseCode = "422", description = "Username or email is already taken or invalid captcha.", content = @Content())
@PostMapping("/register")
public User register(@Parameter(hidden = true) User loggedUser, @RequestBody RegisterRequest req,
public Future<User> register(@Parameter(hidden = true) User loggedUser, @RequestBody RegisterRequest req,
HttpServletRequest request, HttpServletResponse response) {
if (loggedUser != null) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "already logged");
Expand All @@ -317,31 +348,36 @@ public User register(@Parameter(hidden = true) User loggedUser, @RequestBody Reg
if (req.getEmail().indexOf('@') == -1) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing at sign in email");
}
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());
u = userRepository.save(u);
createSession(request, response, u, false);
SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo(req.getEmail());
msg.setFrom("[email protected]");
// TODO activation link
msg.setSubject("Dziękujemy za rejestrację");
msg.setText("Cześć, " + req.getName() + "!\nDziękujemy za zarejestrowanie się w naszym serwisie");
javaMailSender.send(msg);
return u;
return verifyCaptcha(req.getCaptcha(), request).thenApply(action -> {
if (!"register".equals(action)) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "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());
u = userRepository.save(u);
createSession(request, response, u, false);
SimpleMailMessage msg = new SimpleMailMessage();
msg.setTo(req.getEmail());
msg.setFrom("[email protected]");
// TODO activation link
msg.setSubject("Dziękujemy za rejestrację");
msg.setText("Cześć, " + req.getName() + "!\nDziękujemy za zarejestrowanie się w naszym serwisie");
javaMailSender.send(msg);
return u;
});
}

@Operation(summary = "Log out", description = "This method log outs the user.")
Expand Down Expand Up @@ -476,6 +512,46 @@ private void createSession(HttpServletRequest req, HttpServletResponse resp, Use
resp.addCookie(c);
}

/**
* Verify captcha response
*
* @param response response from recaptcha
* @param request request
* @return action if success or null if failed
*/
private CompletableFuture<String> verifyCaptcha(String response, HttpServletRequest request) {
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, UTF_8),
URLEncoder.encode(response, UTF_8),
URLEncoder.encode(remoteip, 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.INTERNAL_SERVER_ERROR, "Captcha verification failed");
}
try {
JsonNode node = MAPPER.readTree(n.body());
if (node.get("success").asBoolean()) {
return node.get("action").asText();
}
} catch (JsonProcessingException e) {
e.printStackTrace();
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Captcha verification failed");
}
return null;
});
}

@Scheduled(cron = "0 * * * * *")
public void deleteOldAccount() {
Date d = Date.from(Instant.now().minus(7, ChronoUnit.DAYS));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,10 @@

package dev.vernite.vernite.user.auth;

public class ChangePasswordRequest {
import lombok.Data;

@Data
public class ChangePasswordRequest {
private String oldPassword;

private String newPassword;

public String getOldPassword() {
return oldPassword;
}

public void setOldPassword(String oldPassword) {
this.oldPassword = oldPassword;
}

public String getNewPassword() {
return newPassword;
}

public void setNewPassword(String newPassword) {
this.newPassword = newPassword;
}

}
11 changes: 3 additions & 8 deletions src/main/java/dev/vernite/vernite/user/auth/DeleteRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,9 @@

package dev.vernite.vernite.user.auth;

import lombok.Data;

@Data
public class DeleteRequest {
private String token;

public String getToken() {
return token;
}

public void setToken(String token) {
this.token = token;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,51 +27,13 @@

package dev.vernite.vernite.user.auth;

import lombok.Data;

@Data
public class EditAccountRequest {
private String avatar;
private String name;
private String surname;
private String language;
private String dateFormat;

public String getAvatar() {
return avatar;
}

public void setAvatar(String avatar) {
this.avatar = avatar;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getSurname() {
return surname;
}

public void setSurname(String surname) {
this.surname = surname;
}

public String getLanguage() {
return language;
}

public void setLanguage(String language) {
this.language = language;
}

public String getDateFormat() {
return dateFormat;
}

public void setDateFormat(String dateFormat) {
this.dateFormat = dateFormat;
}

}
Loading

0 comments on commit f3fc7f5

Please sign in to comment.