From 58878d1d4ca335cf8f352fbb2c96e85fe9d91d0d Mon Sep 17 00:00:00 2001 From: MiniDigger | Martin Date: Sat, 8 Apr 2023 16:22:46 +0200 Subject: [PATCH] feat: request backup codes for MFA, remove when MFA is removed this is cursed. short explanation of how this works: on the final request for totp/webauthn I check if backup codes are there, if not I throw an error 499 with the backup codes in the body and an X-Hangar-Verify header for a OTP (for totp only) UI detects the 499 and opens the modal, saves the request, sends the backup code (plus the otp for totp) in the X-Hangar-Verify header and backend checks that to confirm the backup codes and let the request thru (to finish the mfa registration) otp here means jwt --- .idea/inspectionProfiles/Project_Default.xml | 7 + .../auth/controller/AuthController.java | 10 +- .../auth/controller/CredentialController.java | 105 ++++++++++----- .../auth/controller/LoginController.java | 2 +- .../credential/BackupCodeCredential.java | 8 +- .../components/auth/service/AuthService.java | 5 +- .../auth/service/CredentialsService.java | 9 +- .../components/auth/service/TokenService.java | 18 +++ .../exceptions/HangarResponseException.java | 62 +++++++++ .../hangar/exceptions/WebHookException.java | 27 ---- .../HangarEntityExceptionHandler.java | 9 +- frontend/src/pages/auth/settings/security.vue | 127 ++++++++++++------ 12 files changed, 281 insertions(+), 108 deletions(-) create mode 100644 backend/src/main/java/io/papermc/hangar/exceptions/HangarResponseException.java delete mode 100644 backend/src/main/java/io/papermc/hangar/exceptions/WebHookException.java diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index d4da1258..6bac875f 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -16,6 +16,13 @@ + + + diff --git a/backend/src/main/java/io/papermc/hangar/components/auth/controller/AuthController.java b/backend/src/main/java/io/papermc/hangar/components/auth/controller/AuthController.java index 38231fda..fbee4258 100644 --- a/backend/src/main/java/io/papermc/hangar/components/auth/controller/AuthController.java +++ b/backend/src/main/java/io/papermc/hangar/components/auth/controller/AuthController.java @@ -1,6 +1,7 @@ package io.papermc.hangar.components.auth.controller; import io.papermc.hangar.HangarComponent; +import io.papermc.hangar.components.auth.model.credential.BackupCodeCredential; import io.papermc.hangar.components.auth.model.credential.CredentialType; import io.papermc.hangar.components.auth.model.credential.WebAuthNCredential; import io.papermc.hangar.components.auth.model.db.UserCredentialTable; @@ -103,7 +104,14 @@ public class AuthController extends HangarComponent { public SettingsResponse settings() { final long userId = this.getHangarPrincipal().getUserId(); final List authenticators = this.getAuthenticators(userId); - final boolean hasBackupCodes = this.credentialsService.getCredential(userId, CredentialType.BACKUP_CODES) != null; + final UserCredentialTable backupCodeTable = this.credentialsService.getCredential(userId, CredentialType.BACKUP_CODES); + boolean hasBackupCodes = false; + if (backupCodeTable != null) { + final BackupCodeCredential backupCodeCredential = backupCodeTable.getCredential().get(BackupCodeCredential.class); + if (backupCodeCredential != null && !backupCodeCredential.unconfirmed()) { + hasBackupCodes = true; + } + } final boolean hasTotp = this.credentialsService.getCredential(userId, CredentialType.TOTP) != null; final boolean emailVerified = this.getHangarPrincipal().isEmailVerified(); // TODO email verified should be part of aal final boolean emailPending = this.verificationService.getVerificationCode(userId, VerificationCodeTable.VerificationCodeType.EMAIL_VERIFICATION) != null; diff --git a/backend/src/main/java/io/papermc/hangar/components/auth/controller/CredentialController.java b/backend/src/main/java/io/papermc/hangar/components/auth/controller/CredentialController.java index 7abe1036..882aabc7 100644 --- a/backend/src/main/java/io/papermc/hangar/components/auth/controller/CredentialController.java +++ b/backend/src/main/java/io/papermc/hangar/components/auth/controller/CredentialController.java @@ -33,8 +33,11 @@ import io.papermc.hangar.components.auth.model.dto.TotpSetupResponse; import io.papermc.hangar.components.auth.model.dto.WebAuthNSetupResponse; import io.papermc.hangar.components.auth.service.AuthService; import io.papermc.hangar.components.auth.service.CredentialsService; +import io.papermc.hangar.components.auth.service.TokenService; import io.papermc.hangar.components.auth.service.VerificationService; import io.papermc.hangar.components.auth.service.WebAuthNService; +import io.papermc.hangar.exceptions.HangarApiException; +import io.papermc.hangar.exceptions.HangarResponseException; import io.papermc.hangar.model.db.UserTable; import io.papermc.hangar.security.annotations.Anyone; import io.papermc.hangar.security.annotations.ratelimit.RateLimit; @@ -45,17 +48,18 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.List; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.parameters.P; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Controller; import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.server.ResponseStatusException; @@ -80,8 +84,9 @@ public class CredentialController extends HangarComponent { private final CredentialsService credentialsService; private final RelyingParty relyingParty; private final WebAuthNService webAuthNService; + private final TokenService tokenService; - public CredentialController(final SecretGenerator secretGenerator, final QrDataFactory qrDataFactory, final QrGenerator qrGenerator, final RecoveryCodeGenerator recoveryCodeGenerator, final CodeVerifier codeVerifier, final AuthService authService, final UserService userService, final PasswordEncoder passwordEncoder, final VerificationService verificationService, final CredentialsService credentialsService, final RelyingParty relyingParty, final WebAuthNService webAuthNService) { + public CredentialController(final SecretGenerator secretGenerator, final QrDataFactory qrDataFactory, final QrGenerator qrGenerator, final RecoveryCodeGenerator recoveryCodeGenerator, final CodeVerifier codeVerifier, final AuthService authService, final UserService userService, final PasswordEncoder passwordEncoder, final VerificationService verificationService, final CredentialsService credentialsService, final RelyingParty relyingParty, final WebAuthNService webAuthNService, final TokenService tokenService) { this.secretGenerator = secretGenerator; this.qrDataFactory = qrDataFactory; this.qrGenerator = qrGenerator; @@ -94,6 +99,7 @@ public class CredentialController extends HangarComponent { this.credentialsService = credentialsService; this.relyingParty = relyingParty; this.webAuthNService = webAuthNService; + this.tokenService = tokenService; } /* @@ -103,6 +109,7 @@ public class CredentialController extends HangarComponent { @Unlocked @PostMapping(value = "/webauthn/setup", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.TEXT_PLAIN_VALUE) public String setupWebauthn(@RequestBody final String authenticatorName) throws JsonProcessingException { + // TODO verify that backup codes exist final UserIdentity userIdentity = this.webAuthNService.getExistingUserOrCreate(this.getHangarPrincipal().getUserId(), this.getHangarPrincipal().getName()); final WebAuthNSetupResponse response = new WebAuthNSetupResponse( @@ -126,7 +133,9 @@ public class CredentialController extends HangarComponent { @Unlocked @PostMapping(value = "/webauthn/register", consumes = MediaType.TEXT_PLAIN_VALUE) @ResponseStatus(HttpStatus.OK) - public void registerWebauthn(@RequestBody final String publicKeyCredentialJson) throws IOException { + public void registerWebauthn(@RequestBody final String publicKeyCredentialJson, @RequestHeader(value = "X-Hangar-Verify", required = false) final String header) throws IOException { + final boolean confirmCodes = this.verifyBackupCodes(header); + final var pkc = PublicKeyCredential.parseRegistrationResponseJson(publicKeyCredentialJson); final WebAuthNCredential.PendingSetup pending = this.webAuthNService.retrieveSetupRequest(this.getHangarPrincipal().getUserId()); @@ -149,6 +158,10 @@ public class CredentialController extends HangarComponent { final WebAuthNCredential.Authenticator authenticator = new WebAuthNCredential.Authenticator(registrationResult.getAaguid().getBase64(), registrationResult.getSignatureCount(), false); final WebAuthNCredential.WebAuthNDevice device = new WebAuthNCredential.WebAuthNDevice(id, addedAt, publicKey, displayName, authenticator, false, "none"); this.webAuthNService.addDevice(this.getHangarPrincipal().getUserId(), device); + + if (confirmCodes) { + this.confirmBackupCredential(); + } } @Anyone @@ -167,6 +180,7 @@ public class CredentialController extends HangarComponent { @PostMapping(value = "/webauthn/unregister", consumes = MediaType.TEXT_PLAIN_VALUE) public void unregisterWebauthnDevice(@RequestBody final String id) { this.webAuthNService.removeDevice(this.getHangarPrincipal().getUserId(), id); + this.credentialsService.checkRemoveBackupCodes(); } /* @@ -194,8 +208,10 @@ public class CredentialController extends HangarComponent { @Unlocked @PostMapping("/totp/register") - public ResponseEntity registerTotp(@RequestBody final TotpForm form) { - if (!StringUtils.hasText(form.code()) || !StringUtils.hasText(form.secret()) || !this.codeVerifier.isValidCode(form.secret(), form.code())) { + public ResponseEntity registerTotp(@RequestBody final TotpForm form, @RequestHeader(value = "X-Hangar-Verify", required = false) final String header) { + final boolean confirmCodes = this.verifyBackupCodes(header); + + if (!StringUtils.hasText(form.code()) || !StringUtils.hasText(form.secret()) || (!this.codeVerifier.isValidCode(form.secret(), form.code()) && !this.tokenService.verifyOtp(this.getHangarPrincipal().getUserId(), header))) { return ResponseEntity.badRequest().build(); } @@ -207,6 +223,10 @@ public class CredentialController extends HangarComponent { this.credentialsService.registerCredential(this.getHangarPrincipal().getUserId(), new TotpCredential(totpUrl)); + if (confirmCodes) { + this.confirmBackupCredential(); + } + return ResponseEntity.ok().build(); } @@ -216,6 +236,7 @@ public class CredentialController extends HangarComponent { public void removeTotp() { // TODO security protection this.credentialsService.removeCredential(this.getHangarPrincipal().getId(), CredentialType.TOTP); + this.credentialsService.checkRemoveBackupCodes(); } @Unlocked @@ -229,10 +250,49 @@ public class CredentialController extends HangarComponent { * BACKUP CODES */ - @Unlocked - @PostMapping("/codes/setup") - public List setupBackupCodes() { - // TODO check if there are unconfirmed one in db first + private BackupCodeCredential getBackupCredential() { + final UserCredentialTable table = this.credentialsService.getCredential(this.getHangarPrincipal().getId(), CredentialType.BACKUP_CODES); + if (table == null) { + return null; + } + return table.getCredential().get(BackupCodeCredential.class); + } + + private void confirmBackupCredential() { + BackupCodeCredential cred = this.getBackupCredential(); + if (cred == null) { + throw new HangarApiException("No pending codes"); + } + cred = new BackupCodeCredential(cred.backupCodes(), false); + this.credentialsService.updateCredential(this.getHangarPrincipal().getId(), cred); + } + + private boolean verifyBackupCodes(final String header) { + final BackupCodeCredential backupCredential = this.getBackupCredential(); + if (backupCredential == null) { + // no codes yet? we generate some + final HttpHeaders headers = new HttpHeaders(); + headers.set("X-Hangar-Verify", this.tokenService.otp(this.getHangarPrincipal().getUserId())); + throw new HangarResponseException(HttpStatusCode.valueOf(499), "Setup backup codes first", this.setupBackupCodes(), headers); + } else if (backupCredential.unconfirmed()) { + if (StringUtils.hasText(header)) { + if (!backupCredential.matches(header.split(":")[0])) { + // wrong code? -> proper error + throw new HangarApiException("Backup code doesn't match"); + } + // only if unconfirmed + code is right we mark for confirm + return true; + } else { + // unconfirmed codes? better enter the code! + final HttpHeaders headers = new HttpHeaders(); + headers.set("X-Hangar-Verify", this.tokenService.otp(this.getHangarPrincipal().getUserId())); + throw new HangarResponseException(HttpStatusCode.valueOf(499), "Confirm backup codes first", backupCredential.backupCodes(), headers); + } + } + return false; + } + + private List setupBackupCodes() { final List codes = Arrays.stream(this.recoveryCodeGenerator.generateCodes(12)).map(s -> new BackupCodeCredential.BackupCode(s, null)).toList(); this.credentialsService.registerCredential(this.getHangarPrincipal().getUserId(), new BackupCodeCredential(codes, true)); return codes; @@ -240,30 +300,13 @@ public class CredentialController extends HangarComponent { @Unlocked @PostMapping("/codes/show") - public ResponseEntity showBackupCodes() { + public List showBackupCodes() { // TODO security protection - final UserCredentialTable table = this.credentialsService.getCredential(this.getHangarPrincipal().getId(), CredentialType.BACKUP_CODES); - if (table == null) { - return ResponseEntity.notFound().build(); - } - final BackupCodeCredential cred = table.getCredential().get(BackupCodeCredential.class); + final BackupCodeCredential cred = this.getBackupCredential(); if (cred == null || cred.unconfirmed()) { - return ResponseEntity.notFound().build(); + throw new HangarApiException("You haven't setup backup codes"); } - return ResponseEntity.ok(cred.backupCodes()); - } - - @Unlocked - @PostMapping("/codes/register") - public ResponseEntity registerBackupCodes() { - final UserCredentialTable table = this.credentialsService.getCredential(this.getHangarPrincipal().getId(), CredentialType.BACKUP_CODES); - if (table == null) { - return ResponseEntity.notFound().build(); - } - BackupCodeCredential cred = table.getCredential().get(BackupCodeCredential.class); - cred = new BackupCodeCredential(cred.backupCodes(), false); - this.credentialsService.updateCredential(this.getHangarPrincipal().getId(), cred); - return ResponseEntity.ok().build(); + return cred.backupCodes(); } @Unlocked diff --git a/backend/src/main/java/io/papermc/hangar/components/auth/controller/LoginController.java b/backend/src/main/java/io/papermc/hangar/components/auth/controller/LoginController.java index 6d3de2c2..3ad01809 100644 --- a/backend/src/main/java/io/papermc/hangar/components/auth/controller/LoginController.java +++ b/backend/src/main/java/io/papermc/hangar/components/auth/controller/LoginController.java @@ -58,7 +58,7 @@ public class LoginController extends HangarComponent { final UserTable userTable = this.verifyPassword(form.usernameOrEmail(), form.password()); final List types = this.credentialsService.getCredentialTypes(userTable.getUserId()); final int aal = userTable.isEmailVerified() ? 0 : 1; - if (types.isEmpty()) { + if (types.isEmpty() || (types.size() == 1 && types.get(0) == CredentialType.BACKUP_CODES)) { return this.setAalAndLogin(userTable, aal); } else { return new LoginResponse(aal, types); diff --git a/backend/src/main/java/io/papermc/hangar/components/auth/model/credential/BackupCodeCredential.java b/backend/src/main/java/io/papermc/hangar/components/auth/model/credential/BackupCodeCredential.java index 245d067b..9b153b55 100644 --- a/backend/src/main/java/io/papermc/hangar/components/auth/model/credential/BackupCodeCredential.java +++ b/backend/src/main/java/io/papermc/hangar/components/auth/model/credential/BackupCodeCredential.java @@ -4,12 +4,18 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.time.OffsetDateTime; import java.util.List; import org.jetbrains.annotations.Nullable; +import org.springframework.util.StringUtils; -public record BackupCodeCredential(@JsonProperty("recovery_codes") List backupCodes, boolean unconfirmed) implements Credential{ +public record BackupCodeCredential(@JsonProperty("recovery_codes") List backupCodes, boolean unconfirmed) implements Credential { @Override public CredentialType type() { return CredentialType.BACKUP_CODES; } + public boolean matches(final String code) { + if (!StringUtils.hasText(code)) return false; + return this.backupCodes.stream().anyMatch(b -> b.code().equals(code)); + } + public record BackupCode(String code, @Nullable @JsonProperty("used_at") OffsetDateTime usedAt) {} } diff --git a/backend/src/main/java/io/papermc/hangar/components/auth/service/AuthService.java b/backend/src/main/java/io/papermc/hangar/components/auth/service/AuthService.java index dec8d232..ab5214f8 100644 --- a/backend/src/main/java/io/papermc/hangar/components/auth/service/AuthService.java +++ b/backend/src/main/java/io/papermc/hangar/components/auth/service/AuthService.java @@ -5,14 +5,13 @@ import io.papermc.hangar.components.auth.dao.UserCredentialDAO; import io.papermc.hangar.components.auth.model.credential.CredentialType; import io.papermc.hangar.components.auth.model.credential.PasswordCredential; import io.papermc.hangar.components.auth.model.db.UserCredentialTable; +import io.papermc.hangar.components.auth.model.dto.SignupForm; import io.papermc.hangar.db.customtypes.JSONB; import io.papermc.hangar.db.dao.internal.table.UserDAO; import io.papermc.hangar.exceptions.HangarApiException; -import io.papermc.hangar.exceptions.WebHookException; import io.papermc.hangar.model.api.UserNameChange; import io.papermc.hangar.model.common.Permission; import io.papermc.hangar.model.db.UserTable; -import io.papermc.hangar.components.auth.model.dto.SignupForm; import io.papermc.hangar.security.authentication.HangarPrincipal; import io.papermc.hangar.service.ValidationService; import io.papermc.hangar.service.internal.MailService; @@ -132,7 +131,7 @@ public class AuthService extends HangarComponent implements UserDetailsService { userNameHistory.sort(Comparator.comparing(UserNameChange::date).reversed()); final OffsetDateTime nextChange = userNameHistory.get(0).date().plus(this.config.user.nameChangeInterval(), ChronoUnit.DAYS); if (nextChange.isAfter(OffsetDateTime.now())) { - throw WebHookException.of("You can't change your name that soon! You have to wait till " + nextChange.format(DateTimeFormatter.RFC_1123_DATE_TIME)); + throw new HangarApiException("You can't change your name that soon! You have to wait till " + nextChange.format(DateTimeFormatter.RFC_1123_DATE_TIME)); } } // do the change diff --git a/backend/src/main/java/io/papermc/hangar/components/auth/service/CredentialsService.java b/backend/src/main/java/io/papermc/hangar/components/auth/service/CredentialsService.java index 55b4fa3e..e9b6afe4 100644 --- a/backend/src/main/java/io/papermc/hangar/components/auth/service/CredentialsService.java +++ b/backend/src/main/java/io/papermc/hangar/components/auth/service/CredentialsService.java @@ -115,10 +115,17 @@ public class CredentialsService extends HangarComponent { throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Malformed credentials"); } - if (backupCodeCredential.backupCodes().stream().noneMatch(b -> b.code().equals(code))) { + if (!backupCodeCredential.matches(code)) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Bad credentials"); } // TODO mark as used? } + + public void checkRemoveBackupCodes() { + final List credentialTypes = this.getCredentialTypes(this.getHangarPrincipal().getUserId()); + if (credentialTypes.size() == 1 && credentialTypes.get(0) == CredentialType.BACKUP_CODES) { + this.removeCredential(this.getHangarPrincipal().getUserId(), CredentialType.BACKUP_CODES); + } + } } diff --git a/backend/src/main/java/io/papermc/hangar/components/auth/service/TokenService.java b/backend/src/main/java/io/papermc/hangar/components/auth/service/TokenService.java index 97718099..fdb250f4 100644 --- a/backend/src/main/java/io/papermc/hangar/components/auth/service/TokenService.java +++ b/backend/src/main/java/io/papermc/hangar/components/auth/service/TokenService.java @@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.time.Duration; import java.time.Instant; import java.time.OffsetDateTime; +import java.time.temporal.ChronoUnit; import java.util.Date; import java.util.UUID; import org.jetbrains.annotations.Nullable; @@ -127,6 +128,23 @@ public class TokenService extends HangarComponent { .sign(this.algo); } + public String otp(final long user) { + return JWT.create() + .withIssuer(this.config.security.tokenIssuer()) + .withExpiresAt(Instant.now().plus(10, ChronoUnit.MINUTES)) + .withSubject(String.valueOf(user)) + .sign(this.algo); + } + + public boolean verifyOtp(final long user, final String header) { + try { + final DecodedJWT decoded = this.verify(header.split(":")[1]); + return decoded.getSubject().equals(String.valueOf(user)); + } catch (final Exception ex) { + return false; + } + } + public HangarPrincipal parseHangarPrincipal(final DecodedJWT decodedJWT) { final String subject = decodedJWT.getSubject(); final Long userId = decodedJWT.getClaim("id").asLong(); diff --git a/backend/src/main/java/io/papermc/hangar/exceptions/HangarResponseException.java b/backend/src/main/java/io/papermc/hangar/exceptions/HangarResponseException.java new file mode 100644 index 00000000..6a0d321d --- /dev/null +++ b/backend/src/main/java/io/papermc/hangar/exceptions/HangarResponseException.java @@ -0,0 +1,62 @@ +package io.papermc.hangar.exceptions; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import java.io.IOException; +import org.jetbrains.annotations.Nullable; +import org.springframework.boot.jackson.JsonComponent; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; + +public class HangarResponseException extends RuntimeException { + + private final HttpStatusCode status; + private final String message; + private final Object body; + private final @Nullable HttpHeaders headers; + + public HangarResponseException(final HttpStatusCode status, final String message, final Object body) { + this(status, message, body, null); + } + + public HangarResponseException(final HttpStatusCode status, final String message, final Object body, final @Nullable HttpHeaders headers) { + this.status = status; + this.message = message; + this.body = body; + this.headers = headers; + } + + public Object getBody() { + return this.body; + } + + public HttpStatusCode getStatus() { + return this.status; + } + + public HttpHeaders getHeaders() { + return this.headers; + } + + @Override + public String getMessage() { + return this.message; + } + + @JsonComponent + public static class HangarResponseExceptionSerializer extends JsonSerializer { + + @Override + public void serialize(final HangarResponseException exception, final JsonGenerator gen, final SerializerProvider provider) throws IOException { + final String message = exception.getMessage(); + gen.writeStartObject(); + gen.writeStringField("message", message); + gen.writeBooleanField("isHangarApiException", true); + gen.writeObjectField("body", exception.getBody()); + gen.writeObjectFieldStart("httpError"); + gen.writeNumberField("statusCode", exception.getStatus().value()); + gen.writeEndObject(); + } + } +} diff --git a/backend/src/main/java/io/papermc/hangar/exceptions/WebHookException.java b/backend/src/main/java/io/papermc/hangar/exceptions/WebHookException.java deleted file mode 100644 index dbfd5ec4..00000000 --- a/backend/src/main/java/io/papermc/hangar/exceptions/WebHookException.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.papermc.hangar.exceptions; - -import java.util.List; -import java.util.Map; - -public final class WebHookException extends RuntimeException { - - private final Map error; - - private WebHookException(final Map error) { - this.error = error; - } - - public Map getError() { - return this.error; - } - - public static WebHookException of(final String message) { - return new WebHookException(Map.of("messages", List.of(Map.of( - "instance_ptr", "#/method", - "messages", List.of( - Map.of("id", 123, - "text", message, - "type", "error") - ))))); - } -} diff --git a/backend/src/main/java/io/papermc/hangar/exceptions/handlers/HangarEntityExceptionHandler.java b/backend/src/main/java/io/papermc/hangar/exceptions/handlers/HangarEntityExceptionHandler.java index a9ae0040..056bfbf0 100644 --- a/backend/src/main/java/io/papermc/hangar/exceptions/handlers/HangarEntityExceptionHandler.java +++ b/backend/src/main/java/io/papermc/hangar/exceptions/handlers/HangarEntityExceptionHandler.java @@ -2,7 +2,9 @@ package io.papermc.hangar.exceptions.handlers; import io.papermc.hangar.config.hangar.HangarConfig; import io.papermc.hangar.exceptions.HangarApiException; +import io.papermc.hangar.exceptions.HangarResponseException; import io.papermc.hangar.exceptions.MultiHangarApiException; +import java.util.Map; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; @@ -14,7 +16,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; -@ControllerAdvice(basePackages = "io.papermc.hangar.controller") +@ControllerAdvice(basePackages = "io.papermc.hangar") public class HangarEntityExceptionHandler extends ResponseEntityExceptionHandler { private final HangarConfig config; @@ -34,6 +36,11 @@ public class HangarEntityExceptionHandler extends ResponseEntityExceptionHandler return new ResponseEntity<>(exception, exception.getHeaders(), exception.getStatusCode().value()); } + @ExceptionHandler(HangarResponseException.class) + public ResponseEntity handleException(final HangarResponseException exception) { + return ResponseEntity.status(exception.getStatus()).headers(exception.getHeaders()).body(exception); + } + @Override protected ResponseEntity handleExceptionInternal(final Exception ex, final Object body, final HttpHeaders headers, final HttpStatusCode status, final WebRequest request) { if (this.config.isDev()) { diff --git a/frontend/src/pages/auth/settings/security.vue b/frontend/src/pages/auth/settings/security.vue index fa6396ef..aca020b0 100644 --- a/frontend/src/pages/auth/settings/security.vue +++ b/frontend/src/pages/auth/settings/security.vue @@ -4,14 +4,16 @@ import * as webauthnJson from "@github/webauthn-json"; import { AuthSettings } from "hangar-internal"; import { useI18n } from "vue-i18n"; import { useVuelidate } from "@vuelidate/core"; +import { AxiosRequestConfig } from "axios"; import { useAuthStore } from "~/store/auth"; import { useNotificationStore } from "~/store/notification"; import { useInternalApi } from "~/composables/useApi"; import ComingSoon from "~/components/design/ComingSoon.vue"; import Button from "~/components/design/Button.vue"; import InputText from "~/components/ui/InputText.vue"; -import { definePageMeta, required } from "#imports"; +import { definePageMeta, required, useAxios } from "#imports"; import PageTitle from "~/components/design/PageTitle.vue"; +import Modal from "~/components/modals/Modal.vue"; definePageMeta({ globalPermsRequired: ["EDIT_OWN_USER_SETTINGS"], @@ -26,7 +28,8 @@ const emit = defineEmits<{ const auth = useAuthStore(); const notification = useNotificationStore(); -const { t } = useI18n(); +const i18n = useI18n(); +const { t } = i18n; const v = useVuelidate(); const loading = ref(false); @@ -44,9 +47,16 @@ async function addAuthenticator() { const publicKeyCredential = await webauthnJson.create(parsed); await useInternalApi("auth/webauthn/register", "POST", JSON.stringify(publicKeyCredential), { headers: { "content-type": "text/plain" } }); authenticatorName.value = ""; - emit.refreshSettings(); + emit("refreshSettings"); + v.value.$reset(); } catch (e) { - notification.error(e); + if (e.response.status === 499) { + codes.value = e.response.data.body; + backupCodeModal.value.isOpen = true; + savedRequest.value = e.config; + } else { + notification.fromError(i18n, e); + } } loading.value = false; } @@ -55,14 +65,13 @@ async function unregisterAuthenticator(authenticator: AuthSettings["authenticato loading.value = true; try { await useInternalApi("auth/webauthn/unregister", "POST", authenticator.id, { headers: { "content-type": "text/plain" } }); - emit.refreshSettings(); + emit("refreshSettings"); } catch (e) { - notification.error(e); + notification.fromError(i18n, e); } loading.value = false; } -const hasTotp = ref(props.settings?.hasTotp); const totpData = ref<{ secret: string; qrCode: string } | undefined>(); async function setupTotp() { @@ -70,7 +79,7 @@ async function setupTotp() { try { totpData.value = await useInternalApi<{ secret: string; qrCode: string }>("auth/totp/setup", "POST"); } catch (e) { - notification.error(e); + notification.fromError(i18n, e); } loading.value = false; } @@ -81,9 +90,17 @@ async function addTotp() { loading.value = true; try { await useInternalApi("auth/totp/register", "POST", { secret: totpData.value?.secret, code: totpCode.value }); - hasTotp.value = true; + totpCode.value = undefined; + emit("refreshSettings"); } catch (e) { - notification.error(e); + if (e.response.status === 499) { + codes.value = e.response.data.body; + backupCodeModal.value.isOpen = true; + savedRequest.value = e.config; + otp.value = e.response.headers["x-hangar-verify"]; + } else { + notification.fromError(i18n, e); + } } loading.value = false; } @@ -92,35 +109,49 @@ async function unlinkTotp() { loading.value = true; try { await useInternalApi("auth/totp/remove", "POST"); - hasTotp.value = false; + emit("refreshSettings"); } catch (e) { - notification.error(e); + notification.fromError(i18n, e); } loading.value = false; } -const hasCodes = ref(props.settings?.hasBackupCodes); const showCodes = ref(false); const codes = ref(); -async function setupCodes() { - loading.value = true; - try { - codes.value = await useInternalApi("auth/codes/setup", "POST"); - } catch (e) { - notification.error(e); - } - loading.value = false; -} +const savedRequest = ref(); +const backupCodeModal = ref(); +const backupCodeConfirm = ref(); +const otp = ref(); -async function confirmCodes() { +async function confirmAndRepeat() { loading.value = true; try { - await useInternalApi("auth/codes/register", "POST"); - hasCodes.value = true; - showCodes.value = false; + const req = savedRequest.value; + if (req) { + // set header + let headers = req.headers; + if (!headers) { + headers = {}; + req.headers = headers; + } + headers["X-Hangar-Verify"] = backupCodeConfirm.value + (otp.value ? ":" + otp.value : ""); + // repeat request + await useAxios()(req); + // close modal + backupCodeConfirm.value = undefined; + backupCodeModal.value.isOpen = false; + // reset stuff + emit("refreshSettings"); + totpCode.value = undefined; + totpData.value = undefined; + authenticatorName.value = ""; + v.value.$reset(); + } else { + notification.error("no saved request?"); + } } catch (e) { - notification.error(e); + notification.fromError(i18n, e); } loading.value = false; } @@ -133,7 +164,7 @@ async function revealCodes() { } showCodes.value = true; } catch (e) { - notification.error(e); + notification.fromError(i18n, e); } loading.value = false; } @@ -142,9 +173,9 @@ async function generateNewCodes() { loading.value = true; try { codes.value = await useInternalApi("auth/codes/regenerate", "POST"); - hasCodes.value = false; + emit("refreshSettings"); } catch (e) { - notification.error(e); + notification.fromError(i18n, e); } loading.value = false; } @@ -154,7 +185,7 @@ async function generateNewCodes() {
{{ t("auth.settings.security.header") }}

Authenticator App

- +
@@ -181,18 +212,30 @@ async function generateNewCodes() {
-

Backup Codes

-
-
- {{ code["used_at"] ? "Used" : code.code }} + + + + You need to configure backup codes before you can activate 2fa. Please save these codes securely! +
+
+ {{ code.code }} +
+
+ Confirm that you saved the codes by entering one of them below + + +

Devices