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:
MiniDigger | Martin 2023-04-08 16:22:46 +02:00
parent 807f5e776c
commit 58878d1d4c
12 changed files with 281 additions and 108 deletions

View File

@ -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">

View File

@ -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;

View File

@ -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

View File

@ -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);

View File

@ -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) {}
}

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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();

View File

@ -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();
}
}
}

View File

@ -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")
)))));
}
}

View File

@ -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()) {

View File

@ -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>