Skip to content

Commit

Permalink
asit-asso#303 - Géneration du QR code sans passer par l'API Image Charts
Browse files Browse the repository at this point in the history
  • Loading branch information
arxit-ygr committed May 16, 2024
1 parent 5a2e11b commit 04a96d5
Show file tree
Hide file tree
Showing 6 changed files with 429 additions and 6 deletions.
10 changes: 10 additions & 0 deletions extract/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@
<artifactId>spring-ldap-core</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
</dependencies>

<profiles>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,28 @@
package ch.asit_asso.extract.authentication.twofactor;

import java.awt.image.BufferedImage;
import java.util.EnumMap;
import java.util.Map;
import javax.validation.constraints.NotNull;
import ch.asit_asso.extract.domain.User;
import ch.asit_asso.extract.domain.User.TwoFactorStatus;
import ch.asit_asso.extract.utils.ImageUtils;
import ch.asit_asso.extract.utils.Secrets;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.j256.twofactorauth.TimeBasedOneTimePasswordUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class TwoFactorApplication {

private static final String REGISTRATION_URL_FORMAT
= "otpauth://totp/§§ISSUER§§:§§USER§§?secret=§§SECRET§§&issuer=§§ISSUER§§";

private final Logger logger = LoggerFactory.getLogger(TwoFactorApplication.class);

private final Secrets secrets;
Expand All @@ -24,8 +37,10 @@ private enum TokenType {
}



public TwoFactorApplication(@NotNull User user, @NotNull Secrets secrets,
@NotNull TwoFactorService twoFactorService) {

this.secrets = secrets;
this.service = twoFactorService;
this.user = user;
Expand All @@ -41,15 +56,18 @@ public boolean authenticate(@NotNull String code) {


public TwoFactorStatus cancelEnabling() {

this.user.setTwoFactorStandbyToken(null);
TwoFactorStatus newStatus;

if (this.user.getTwoFactorToken() == null) {
this.logger.debug("2FA registration canceled. No active 2FA token for the user so 2FA status returned to INACTIVE.");
this.logger.debug(
"2FA registration canceled. No active 2FA token for the user so 2FA status returned to INACTIVE.");
newStatus = TwoFactorStatus.INACTIVE;

} else {
this.logger.debug("2FA registration canceled. There is an active 2FA token for the user so 2FA status returned to ACTIVE.");
this.logger.debug(
"2FA registration canceled. There is an active 2FA token for the user so 2FA status returned to ACTIVE.");
newStatus = TwoFactorStatus.ACTIVE;
}

Expand All @@ -58,7 +76,9 @@ public TwoFactorStatus cancelEnabling() {
}



public void disable() {

assert this.user.getTwoFactorStatus() != TwoFactorStatus.INACTIVE
: "Two-factor authentication must be enabled before disabling it.";

Expand All @@ -71,6 +91,7 @@ public void disable() {


public void enable() {

assert this.user.getTwoFactorStatus() == TwoFactorStatus.INACTIVE
: "Can only enable two-factor authentication if it isn't already active";

Expand All @@ -82,10 +103,12 @@ public void enable() {



public String getQrCodeUrl() {
public String getQrCodeUrl() throws RuntimeException {

BufferedImage image = this.generateQrCodeImage();
String base64bytes = ImageUtils.encodeToBase64(image);

return TimeBasedOneTimePasswordUtil.qrImageUrl(String.format("Extract:%s", this.user.getLogin()),
this.getToken(TokenType.STANDBY));
return "data:image/png;base64," + base64bytes;
}


Expand All @@ -112,10 +135,39 @@ public boolean validateRegistration(@NotNull String code) {



private @NotNull BufferedImage generateQrCodeImage() {

QRCodeWriter writer = new QRCodeWriter();
Map<EncodeHintType, Object> hintMap = new EnumMap<>(EncodeHintType.class);
hintMap.put(EncodeHintType.CHARACTER_SET, "UTF-8");
BitMatrix matrix;

try {
matrix = writer.encode(this.getRegistrationUrl(), BarcodeFormat.QR_CODE, 300, 300, hintMap);

} catch (WriterException writerException) {
throw new RuntimeException("The 2FA registration QR code generation failed.", writerException);
}

return MatrixToImageWriter.toBufferedImage(matrix);
}



private String getRegistrationUrl() {

return TwoFactorApplication.REGISTRATION_URL_FORMAT.replaceAll("§§ISSUER§§", "Extract")
.replaceAll("§§USER§§", this.user.getLogin())
.replaceAll("§§SECRET§§", this.getToken(TokenType.STANDBY));
}



private String getToken(TokenType secretType) {

this.logger.debug("Getting {} token for user {}", secretType.name(), this.user.getLogin());
String encryptedToken = (secretType == TokenType.STANDBY) ? this.user.getTwoFactorStandbyToken()
: this.user.getTwoFactorToken();
: this.user.getTwoFactorToken();
return this.secrets.decrypt(encryptedToken);
}
}
134 changes: 134 additions & 0 deletions extract/src/main/java/ch/asit_asso/extract/utils/ImageUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package ch.asit_asso.extract.utils;

import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
import javax.validation.constraints.NotNull;
import io.micrometer.core.instrument.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public abstract class ImageUtils {

private static final Logger logger = LoggerFactory.getLogger(ImageUtils.class);

private static final String DATA_URL_PREFIX = "data:";

private static final Pattern DATA_URL_REGEX = Pattern.compile("^data:(?<mimeType>[^;]+);(?<encoding>[^,]+),(?<content>.+)$");



public static boolean checkUrl(@NotNull String url) {
ImageUtils.logger.debug("Checking image URL {}", url);

if (url.startsWith(ImageUtils.DATA_URL_PREFIX)) {
ImageUtils.logger.debug("The URL is a data URL");

return ImageUtils.checkDataUrl(url);
}

try {
BufferedImage image = ImageIO.read(new URL(url));

if (image != null) {
ImageUtils.logger.debug("Image successfully read from the URL.");
return true;
}

ImageUtils.logger.debug("Image read from the URL is null.");
// TODO Check for SVG
return false;

} catch (IOException exception) {
ImageUtils.logger.debug(String.format("Checking URL %s produced an error.", url), exception);
return false;
}
}



public static @NotNull String encodeToBase64(BufferedImage image) {

byte[] bytes;

try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
ImageIO.write(image, "PNG", outputStream);
bytes = outputStream.toByteArray();

} catch (IOException ioException) {
throw new RuntimeException("The 2FA registration QR code generation failed.", ioException);
}

return new String(Base64.getEncoder().encode(bytes), StandardCharsets.UTF_8);
}




private static boolean checkDataUrl(@NotNull String url) {
Matcher dataMatcher = ImageUtils.DATA_URL_REGEX.matcher(url);

if (!dataMatcher.find()) {
ImageUtils.logger.debug("The URL passed is not a valid image data URL.");
return false;
}

String mimeType = dataMatcher.group("mimeType");

if (mimeType == null || !mimeType.startsWith("image/")) {
ImageUtils.logger.debug("The MIME type in the data URL is not an image.");
return false;
}

String encoding = dataMatcher.group("encoding");

if (!"base64".equals(encoding)) {
ImageUtils.logger.debug("The data in the URL is not Base64 encoded.");
return false;
}

String base64Content = dataMatcher.group("content");

if (StringUtils.isEmpty(base64Content)) {
ImageUtils.logger.debug("No content in data URL.");
return false;
}

return ImageUtils.loadImageFromBase64(base64Content);
}



private static boolean loadImageFromBase64(@NotNull String base64String) {

Base64.Decoder decoder = Base64.getDecoder();
byte[] imageByte;

try {
imageByte = decoder.decode(base64String);

} catch (IllegalArgumentException invalidBase64Exception) {
ImageUtils.logger.debug("The content is not a valid Base64-encoded string.", invalidBase64Exception);
return false;
}

try (ByteArrayInputStream inputStream = new ByteArrayInputStream(imageByte)) {
BufferedImage image = ImageIO.read(inputStream);

ImageUtils.logger.debug("Image read from Base64 string is {}null", (image == null) ? "" : "NOT ");

return (image != null);

} catch (IOException exception) {
ImageUtils.logger.debug("Checking data string produced an error.", exception);
return false;
}
}
}
4 changes: 4 additions & 0 deletions extract/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,8 @@
requires aerogear.otp.java;
requires two.factor.auth;
requires org.jetbrains.annotations;
requires java.desktop;
requires micrometer.core;
requires com.google.zxing;
requires com.google.zxing.javase;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package ch.asit_asso.extract.unit.authentication.twofactor;

import java.util.concurrent.atomic.AtomicReference;
import ch.asit_asso.extract.authentication.twofactor.TwoFactorApplication;
import ch.asit_asso.extract.authentication.twofactor.TwoFactorService;
import ch.asit_asso.extract.domain.User;
import ch.asit_asso.extract.unit.MockEnabledTest;
import ch.asit_asso.extract.utils.ImageUtils;
import ch.asit_asso.extract.utils.Secrets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.times;

@Tag("unit")
public class TwoFactorApplicationTest extends MockEnabledTest {

private TwoFactorApplication application;

@Mock
private Secrets secrets;

@Mock
private TwoFactorService service;

private User user;

@BeforeEach
public void setUp() {
this.user = new User(1);
this.user.setLogin("testUser");

Mockito.when(this.secrets.encrypt(anyString())).thenAnswer(
(Answer<String>) invocationOnMock -> invocationOnMock.getArgument(0)
);

Mockito.when(this.secrets.decrypt(anyString())).thenAnswer(
(Answer<String>) invocationOnMock -> invocationOnMock.getArgument(0)
);

this.application = new TwoFactorApplication(this.user, this.secrets, this.service);
}

@Test
@DisplayName("Generate QR code")
public void getQrCodeUrl() {
this.user.setTwoFactorStatus(User.TwoFactorStatus.INACTIVE);
this.application.enable();

AtomicReference<String> url = new AtomicReference<>();

assertDoesNotThrow(() -> url.set(this.application.getQrCodeUrl()));

Mockito.verify(this.secrets, times(1)).decrypt(anyString());
assertNotNull(url);
assertTrue(ImageUtils.checkUrl(url.get()), "The resulting URL could not be loaded as an image.");
}
}
Loading

0 comments on commit 04a96d5

Please sign in to comment.