mirror of
https://github.com/HangarMC/Hangar.git
synced 2024-11-27 06:01:08 +08:00
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
This commit is contained in:
parent
807f5e776c
commit
58878d1d4c
@ -16,6 +16,13 @@
|
||||
</inspection_tool>
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="ExtendsUtilityClass" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="IncorrectHttpHeaderInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="customHeaders">
|
||||
<set>
|
||||
<option value="X-Hangar-Verify" />
|
||||
</set>
|
||||
</option>
|
||||
</inspection_tool>
|
||||
<inspection_tool class="JSLastCommaInObjectLiteral" enabled="true" level="TEXT ATTRIBUTES" enabled_by_default="true" editorAttributes="CONSIDERATION_ATTRIBUTES" />
|
||||
<inspection_tool class="JavaLangImport" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="LocalCanBeFinal" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
|
@ -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<SettingsResponse.Authenticator> 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;
|
||||
|
@ -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<BackupCodeCredential.BackupCode> 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<BackupCodeCredential.BackupCode> setupBackupCodes() {
|
||||
final List<BackupCodeCredential.BackupCode> 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<BackupCodeCredential.BackupCode> 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
|
||||
|
@ -58,7 +58,7 @@ public class LoginController extends HangarComponent {
|
||||
final UserTable userTable = this.verifyPassword(form.usernameOrEmail(), form.password());
|
||||
final List<CredentialType> 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);
|
||||
|
@ -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<BackupCode> backupCodes, boolean unconfirmed) implements Credential{
|
||||
public record BackupCodeCredential(@JsonProperty("recovery_codes") List<BackupCode> 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) {}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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<CredentialType> credentialTypes = this.getCredentialTypes(this.getHangarPrincipal().getUserId());
|
||||
if (credentialTypes.size() == 1 && credentialTypes.get(0) == CredentialType.BACKUP_CODES) {
|
||||
this.removeCredential(this.getHangarPrincipal().getUserId(), CredentialType.BACKUP_CODES);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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<HangarResponseException> {
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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<String, Object> error;
|
||||
|
||||
private WebHookException(final Map<String, Object> error) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
public Map<String, Object> 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")
|
||||
)))));
|
||||
}
|
||||
}
|
@ -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<Object> handleException(final HangarResponseException exception) {
|
||||
return ResponseEntity.status(exception.getStatus()).headers(exception.getHeaders()).body(exception);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ResponseEntity<Object> handleExceptionInternal(final Exception ex, final Object body, final HttpHeaders headers, final HttpStatusCode status, final WebRequest request) {
|
||||
if (this.config.isDev()) {
|
||||
|
@ -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<AxiosRequestConfig>();
|
||||
const backupCodeModal = ref();
|
||||
const backupCodeConfirm = ref();
|
||||
const otp = ref<string>();
|
||||
|
||||
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() {
|
||||
<div v-if="auth.user">
|
||||
<PageTitle>{{ t("auth.settings.security.header") }}</PageTitle>
|
||||
<h3 class="text-lg font-bold mb-2">Authenticator App</h3>
|
||||
<Button v-if="hasTotp" :disabled="loading" @click="unlinkTotp">Unlink totp</Button>
|
||||
<Button v-if="settings?.hasTotp" :disabled="loading" @click="unlinkTotp">Unlink totp</Button>
|
||||
<Button v-else-if="!totpData" :disabled="loading" @click="setupTotp">Setup 2FA via authenticator app</Button>
|
||||
<div v-else class="flex lt-sm:flex-col gap-8">
|
||||
<div class="flex flex-col gap-2 basis-1/2">
|
||||
@ -181,18 +212,30 @@ async function generateNewCodes() {
|
||||
</div>
|
||||
<Button :disabled="loading" @click="addAuthenticator">Setup 2FA via security key</Button>
|
||||
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">Backup Codes</h3>
|
||||
<div v-if="(hasCodes && showCodes) || (!hasCodes && codes)" class="flex flex-wrap mt-2 mb-2">
|
||||
<div v-for="code in codes" :key="code.code" class="basis-3/12">
|
||||
<code>{{ code["used_at"] ? "Used" : code.code }}</code>
|
||||
<template v-if="settings?.hasBackupCodes">
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">Backup Codes</h3>
|
||||
<div v-if="showCodes" class="flex flex-wrap mt-2 mb-2">
|
||||
<div v-for="code in codes" :key="code.code" class="basis-3/12">
|
||||
<code>{{ code["used_at"] ? "Used" : code.code }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="hasCodes" class="flex gap-2">
|
||||
<Button v-if="!showCodes" :disabled="loading" @click="revealCodes">Reveal</Button>
|
||||
<Button :disabled="loading" @click="generateNewCodes">Generate new codes</Button>
|
||||
</div>
|
||||
<Button v-else-if="!codes" :disabled="loading" @click="setupCodes">Add</Button>
|
||||
<Button v-else :disabled="loading" @click="confirmCodes">Confirm codes</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button v-if="!showCodes" :disabled="loading" @click="revealCodes">Reveal</Button>
|
||||
<Button :disabled="loading" @click="generateNewCodes">Generate new codes</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Modal ref="backupCodeModal" title="Confirm backup codes" @close="backupCodeModal.isOpen = false">
|
||||
You need to configure backup codes before you can activate 2fa. Please save these codes securely!
|
||||
<div class="flex flex-wrap mt-2 mb-2">
|
||||
<div v-for="code in codes" :key="code.code" class="basis-3/12">
|
||||
<code>{{ code.code }}</code>
|
||||
</div>
|
||||
</div>
|
||||
Confirm that you saved the codes by entering one of them below
|
||||
<InputText v-model="backupCodeConfirm" label="Code" />
|
||||
<Button class="mt-2" @click="confirmAndRepeat">Confirm</Button>
|
||||
</Modal>
|
||||
|
||||
<h3 class="text-lg font-bold mt-4 mb-2">Devices</h3>
|
||||
<ComingSoon>
|
||||
|
Loading…
Reference in New Issue
Block a user