feat: sudo for accounts without passwords

This commit is contained in:
MiniDigger | Martin 2023-12-16 16:01:30 +01:00
parent bf1f824e39
commit 89386deeac
5 changed files with 81 additions and 22 deletions

View File

@ -20,9 +20,13 @@ import io.papermc.hangar.components.auth.service.WebAuthNService;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.security.annotations.Anyone;
import io.papermc.hangar.security.annotations.ratelimit.RateLimit;
import io.papermc.hangar.security.annotations.unlocked.Unlocked;
import io.papermc.hangar.security.authentication.HangarPrincipal;
import io.papermc.hangar.service.internal.users.UserService;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
@ -52,6 +56,13 @@ public class LoginController extends HangarComponent {
this.authService = authService;
}
@Unlocked
@PostMapping("login/sudo")
public LoginResponse loginSudo() {
final List<CredentialType> types = this.credentialsService.getCredentialTypes(this.getHangarPrincipal().getUserId());
return new LoginResponse(this.getHangarPrincipal().getAal(), types, null);
}
@Anyone
@PostMapping("login/password")
public LoginResponse loginPassword(@RequestBody final LoginPasswordForm form) {
@ -68,7 +79,14 @@ public class LoginController extends HangarComponent {
@Anyone
@PostMapping("login/webauthn")
public LoginResponse loginWebAuthN(@RequestBody final LoginWebAuthNForm form) throws IOException {
final UserTable userTable = this.verifyPassword(form.usernameOrEmail(), form.password());
final Optional<HangarPrincipal> principal = this.getOptionalHangarPrincipal();
final UserTable userTable;
//noinspection OptionalIsPresent
if (principal.isPresent()) {
userTable = Objects.requireNonNull(this.userService.getUserTable(principal.get().getUserId()));
} else {
userTable = this.verifyPassword(form.usernameOrEmail(), form.password());
}
final var pkc = PublicKeyCredential.parseAssertionResponseJson(form.publicKeyCredentialJson());
@ -93,6 +111,12 @@ public class LoginController extends HangarComponent {
@Anyone
@PostMapping("login/totp")
public LoginResponse loginTotp(@RequestBody final LoginTotpForm form) {
final Optional<HangarPrincipal> principal = this.getOptionalHangarPrincipal();
if (principal.isPresent()) {
this.credentialsService.verifyTotp(principal.get().getUserId(), form.totpCode());
return this.authService.setAalAndLogin(Objects.requireNonNull(this.userService.getUserTable(principal.get().getUserId())), 2);
}
final UserTable userTable = this.verifyPassword(form.usernameOrEmail(), form.password());
this.credentialsService.verifyTotp(userTable.getUserId(), form.totpCode());
return this.authService.setAalAndLogin(userTable, 2);
@ -101,6 +125,12 @@ public class LoginController extends HangarComponent {
@Anyone
@PostMapping("login/backup")
public LoginResponse loginBackup(@RequestBody final LoginBackupForm form) {
final Optional<HangarPrincipal> principal = this.getOptionalHangarPrincipal();
if (principal.isPresent()) {
this.credentialsService.verifyBackupCode(principal.get().getUserId(), form.backupCode());
return this.authService.setAalAndLogin(Objects.requireNonNull(this.userService.getUserTable(principal.get().getUserId())), 2);
}
final UserTable userTable = this.verifyPassword(form.usernameOrEmail(), form.password());
this.credentialsService.verifyBackupCode(userTable.getUserId(), form.backupCode());
return this.authService.setAalAndLogin(userTable, 2);

View File

@ -9,6 +9,7 @@ import io.papermc.hangar.components.auth.model.credential.Credential;
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.credential.TotpCredential;
import io.papermc.hangar.components.auth.model.credential.WebAuthNCredential;
import io.papermc.hangar.components.auth.model.db.UserCredentialTable;
import io.papermc.hangar.db.customtypes.JSONB;
import io.papermc.hangar.model.db.UserTable;
@ -142,4 +143,20 @@ public class CredentialsService extends HangarComponent {
return 2;
}
}
public boolean isOAuthOnly(final long userId) {
// no pw, no totp
if (this.getCredential(userId, CredentialType.PASSWORD) == null &&
this.getCredential(userId, CredentialType.TOTP) == null) {
final UserCredentialTable credential = this.getCredential(userId, CredentialType.WEBAUTHN);
// either no webauthn
if(credential == null) {
return true;
}
// or not correctly setup webauthn
final WebAuthNCredential webAuthNCredential = credential.getCredential().get(WebAuthNCredential.class);
return webAuthNCredential == null || webAuthNCredential.credentials().isEmpty();
}
return false;
}
}

View File

@ -6,6 +6,7 @@ import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.components.auth.dao.UserRefreshTokenDAO;
import io.papermc.hangar.components.auth.model.credential.CredentialType;
import io.papermc.hangar.components.auth.model.db.UserRefreshToken;
import io.papermc.hangar.db.dao.internal.table.auth.ApiKeyDAO;
import io.papermc.hangar.exceptions.HangarApiException;
@ -98,8 +99,12 @@ public class TokenService extends HangarComponent {
}
// in any case, refreshing the cookie is good
this.addCookie(SecurityConfig.REFRESH_COOKIE_NAME, userRefreshToken.getToken().toString(), this.config.security.refreshTokenExpiry().toSeconds(), true, this.response);
// oauth only users are always privileged
final boolean privileged = this.credentialsService.isOAuthOnly(userTable.getUserId());
// then issue a new access token
return new RefreshResponse(this.newToken0(userTable, false), userTable);
return new RefreshResponse(this.newToken0(userTable, privileged), userTable);
}
public void invalidateToken(final String refreshToken) {

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useHead } from "@unhead/vue";
import { useRoute, useRouter } from "vue-router";
import { ref } from "vue";
import { ref, computed } from "vue";
import * as webauthnJson from "@github/webauthn-json";
import { useVuelidate } from "@vuelidate/core";
import { useI18n } from "vue-i18n";
@ -28,7 +28,9 @@ const i18n = useI18n();
const backendData = useBackendData;
const loading = ref(false);
const supportedMethods = ref([]);
const supportedMethods = ref<string[]>([]);
const returnUrl = computed(() => (route.query.returnUrl as string) || "/auth/settings/profile");
// aal1
const username = ref("");
@ -37,6 +39,12 @@ const password = ref("");
const privileged = (route.query.privileged as boolean) || false;
if (privileged) {
username.value = useAuthStore().user?.name || "";
loading.value = true;
const response = await useInternalApi<LoginResponse>("auth/login/sudo", "POST");
if (response.types?.length) {
supportedMethods.value.push(...response.types);
}
loading.value = false;
}
async function loginPassword() {
@ -47,7 +55,7 @@ async function loginPassword() {
usernameOrEmail: username.value,
password: password.value,
});
if (response.types?.length > 0) {
if (response.types?.length) {
supportedMethods.value.push(...response.types);
} else {
await finish(response);
@ -123,9 +131,7 @@ async function finish(response: LoginResponse) {
authStore.authenticated = true;
authStore.invalidated = false;
authStore.token = response.user.accessToken;
authStore.privileged = true;
const returnUrl = (route.query.returnUrl as string) || "/auth/settings/profile";
await router.push(returnUrl);
await router.push(returnUrl.value);
} else {
notification.error("Did not receive user?");
}
@ -153,18 +159,20 @@ useHead(useSeo("Login", null, route, null));
<InputPassword v-model="password" label="Password" name="password" autocomplete="current-password" :rules="[required()]" />
<div class="flex gap-2">
<Button :disabled="loading" @click.prevent="loginPassword">Login</Button>
<Button
v-for="provider in backendData.security.oauthProviders"
:key="provider"
:disabled="loading"
:href="'/api/internal/oauth/' + provider + '/login?mode=login&returnUrl=/'"
>
<template v-if="provider === 'github'">
<IconMdiGitHub class="mr-1" />
Login with GitHub
</template>
<template v-else> Login with {{ provider }} </template>
</Button>
<template v-if="!privileged">
<Button
v-for="provider in backendData.security.oauthProviders"
:key="provider"
:disabled="loading"
:href="'/api/internal/oauth/' + provider + '/login?mode=login&returnUrl=' + returnUrl"
>
<template v-if="provider === 'github'">
<IconMdiGitHub class="mr-1" />
Login with GitHub
</template>
<template v-else> Login with {{ provider }} </template>
</Button>
</template>
</div>
<Link v-if="!privileged" button-type="secondary" to="/auth/signup" class="w-max">Don't have an account yet? Create one!</Link>
<Link v-if="!privileged" to="/auth/reset" class="w-max">Forgot your password?</Link>

View File

@ -12,7 +12,6 @@ export const useAuthStore = defineStore("auth", () => {
const routePermissionsUser = ref<string | null>(null);
const routePermissionsProject = ref<string | null>(null);
const invalidated = ref<boolean>(false);
const privileged = ref<boolean>(false);
authLog("create authStore");
@ -22,5 +21,5 @@ export const useAuthStore = defineStore("auth", () => {
routePermissionsProject.value = routePermsProject;
}
return { token, authenticated, user, routePermissions, invalidated, setRoutePerms, routePermissionsUser, routePermissionsProject, aal, privileged };
return { token, authenticated, user, routePermissions, invalidated, setRoutePerms, routePermissionsUser, routePermissionsProject, aal };
});