feat: save account settings (user/email/pass change)

This commit is contained in:
MiniDigger | Martin 2023-04-08 10:31:03 +02:00
parent e616de04cf
commit 78ca7a346f
7 changed files with 118 additions and 21 deletions

View File

@ -5,23 +5,27 @@ 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;
import io.papermc.hangar.components.auth.model.db.VerificationCodeTable;
import io.papermc.hangar.components.auth.model.dto.AccountForm;
import io.papermc.hangar.components.auth.model.dto.SettingsResponse;
import io.papermc.hangar.components.auth.model.dto.SignupForm;
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.exceptions.HangarApiException;
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.configs.SecurityConfig;
import io.papermc.hangar.service.internal.users.UserService;
import jakarta.servlet.http.HttpSession;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@ -40,12 +44,14 @@ public class AuthController extends HangarComponent {
private final TokenService tokenService;
private final VerificationService verificationService;
private final CredentialsService credentialsService;
private final UserService userService;
public AuthController(final AuthService authService, final TokenService tokenService, final VerificationService verificationService, final CredentialsService credentialsService) {
public AuthController(final AuthService authService, final TokenService tokenService, final VerificationService verificationService, final CredentialsService credentialsService, final UserService userService) {
this.authService = authService;
this.tokenService = tokenService;
this.verificationService = verificationService;
this.credentialsService = credentialsService;
this.userService = userService;
}
@Anyone
@ -104,7 +110,7 @@ public class AuthController extends HangarComponent {
return new SettingsResponse(authenticators, hasBackupCodes, hasTotp, emailVerified, emailPending);
}
public List<SettingsResponse.Authenticator> getAuthenticators(final long userId) {
private List<SettingsResponse.Authenticator> getAuthenticators(final long userId) {
final UserCredentialTable credential = this.credentialsService.getCredential(userId, CredentialType.WEBAUTHN);
if (credential != null) {
final WebAuthNCredential webAuthNCredential = credential.getCredential().get(WebAuthNCredential.class);
@ -116,4 +122,27 @@ public class AuthController extends HangarComponent {
}
return List.of();
}
@Unlocked
@PostMapping("/account")
public void saveAccount(@RequestBody final AccountForm form) {
this.credentialsService.verifyPassword(this.getHangarPrincipal().getUserId(), form.currentPassword());
final UserTable userTable = this.userService.getUserTable(this.getHangarPrincipal().getUserId());
if (userTable == null) {
throw new HangarApiException("No user?!");
}
if (!this.getHangarPrincipal().getUsername().equals(form.username())) {
this.authService.handleUsernameChange(userTable, form.username());
}
if (!this.getHangarPrincipal().getEmail().equals(form.email())) {
this.authService.handleEmailChange(userTable, form.email());
}
if (StringUtils.hasText(form.newPassword())) {
this.authService.handlePasswordChange(userTable, form.newPassword());
}
}
}

View File

@ -51,6 +51,7 @@ 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.RequestMapping;
@ -194,7 +195,7 @@ public class CredentialController extends HangarComponent {
@Unlocked
@PostMapping("/totp/register")
public ResponseEntity<?> registerTotp(@RequestBody final TotpForm form) {
if (!this.codeVerifier.isValidCode(form.secret(), form.code())) {
if (!StringUtils.hasText(form.code()) || !StringUtils.hasText(form.secret()) || !this.codeVerifier.isValidCode(form.secret(), form.code())) {
return ResponseEntity.badRequest().build();
}

View File

@ -0,0 +1,6 @@
package io.papermc.hangar.components.auth.model.dto;
import jakarta.validation.constraints.NotEmpty;
public record AccountForm(@NotEmpty String username, @NotEmpty String email, @NotEmpty String currentPassword, String newPassword) {
}

View File

@ -15,6 +15,8 @@ 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;
import jakarta.validation.constraints.NotEmpty;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
@ -39,8 +41,9 @@ public class AuthService extends HangarComponent implements UserDetailsService {
private final VerificationService verificationService;
private final CredentialsService credentialsService;
private final HibpService hibpService;
private final MailService mailService;
public AuthService(final UserDAO userDAO, final UserCredentialDAO userCredentialDAO, final PasswordEncoder passwordEncoder, final ValidationService validationService, final VerificationService verificationService, final CredentialsService credentialsService, final HibpService hibpService) {
public AuthService(final UserDAO userDAO, final UserCredentialDAO userCredentialDAO, final PasswordEncoder passwordEncoder, final ValidationService validationService, final VerificationService verificationService, final CredentialsService credentialsService, final HibpService hibpService, final MailService mailService) {
this.userDAO = userDAO;
this.userCredentialDAO = userCredentialDAO;
this.passwordEncoder = passwordEncoder;
@ -48,6 +51,7 @@ public class AuthService extends HangarComponent implements UserDetailsService {
this.verificationService = verificationService;
this.credentialsService = credentialsService;
this.hibpService = hibpService;
this.mailService = mailService;
}
@Transactional
@ -116,8 +120,8 @@ public class AuthService extends HangarComponent implements UserDetailsService {
return new HangarPrincipal(userTable.getUserId(), userTable.getName(), userTable.getEmail(), userTable.isLocked(), Permission.ViewPublicInfo, password, userTable.isEmailVerified());
}
// TODO call this
private void handleUsernameChange(final UserTable user, final String newName) {
@Transactional
public void handleUsernameChange(final UserTable user, final String newName) {
// make sure a user with that name doesn't exist yet
if (this.userDAO.getUserTable(newName) != null) {
throw new HangarApiException("A user with that name already exists!");
@ -131,12 +135,54 @@ public class AuthService extends HangarComponent implements UserDetailsService {
throw WebHookException.of("You can't change your name that soon! You have to wait till " + nextChange.format(DateTimeFormatter.RFC_1123_DATE_TIME));
}
}
// do the change
final String oldName = user.getName();
user.setName(newName);
this.userDAO.update(user);
// record the change into the db
this.userDAO.recordNameChange(user.getUuid(), user.getName(), newName);
this.userDAO.recordNameChange(user.getUuid(), oldName, newName);
// email
this.mailService.queueEmail("Hangar Username Changed", user.getEmail(), """
Hey %s,
your username on hangar was changed to %s.
If this wasn't you, please contact support.
""".formatted(oldName, newName));
}
// TODO call this
private void handleEmailChange() {
// todo set email to unconfirmed and send email
@Transactional
public void handleEmailChange(final UserTable userTable, final @NotEmpty String email) {
// make sure a user with that email doesn't exist yet
if (this.userDAO.getUserTable(email) != null) {
throw new HangarApiException("You can't use this email!");
}
// send info mail
this.mailService.queueEmail("Hangar Email Changed", userTable.getEmail(), """
Hey %s,
your email on hangar was changed to %s.
If this wasn't you, please contact support.
""".formatted(userTable.getName(), email));
// update
userTable.setEmail(email);
userTable.setEmailVerified(false);
this.userDAO.update(userTable);
// send verification mail
this.verificationService.sendVerificationCode(userTable.getUserId(), userTable.getEmail(), userTable.getName());
}
@Transactional
public void handlePasswordChange(final UserTable userTable, final String newPassword) {
if (!this.validPassword(newPassword, userTable.getName())) {
return;
}
// update
this.credentialsService.removeCredential(userTable.getUserId(), CredentialType.PASSWORD);
this.credentialsService.registerCredential(userTable.getUserId(), new PasswordCredential(this.passwordEncoder.encode(newPassword)));
// send info mail
this.mailService.queueEmail("Hangar Password Changed", userTable.getEmail(), """
Hey %s,
your password on hangar was updated.
If this wasn't you, reset your password here: https://hangar.papermc.io/auth/reset
""".formatted(userTable.getName()));
}
}

View File

@ -8,7 +8,7 @@ import { useAuthSettings } from "~/composables/useApiHelper";
import Button from "~/components/design/Button.vue";
import InputPassword from "~/components/ui/InputPassword.vue";
import InputText from "~/components/ui/InputText.vue";
import { definePageMeta } from "#imports";
import { definePageMeta, email, required, useInternalApi } from "#imports";
import PageTitle from "~/components/design/PageTitle.vue";
definePageMeta({
@ -24,6 +24,7 @@ const { t } = useI18n();
const v = useVuelidate();
const loading = ref(false);
const error = ref<string>();
const settings = await useAuthSettings();
const accountForm = reactive({
@ -33,12 +34,22 @@ const accountForm = reactive({
newPassword: "",
});
function saveAccount() {
async function saveAccount() {
if (!(await v.value.$validate())) return;
loading.value = true;
error.value = undefined;
try {
// todo saveAccount
await useInternalApi("auth/account", "POST", accountForm);
notification.success("Saved!");
accountForm.currentPassword = "";
accountForm.newPassword = "";
v.value.$reset();
} catch (e) {
notification.error(e);
if (e?.response?.data?.detail) {
error.value = e.response.data.detail;
} else {
notification.error(e);
}
}
loading.value = false;
}
@ -48,9 +59,9 @@ function saveAccount() {
<div v-if="auth.user">
<PageTitle>{{ t("auth.settings.account.header") }}</PageTitle>
<form class="flex flex-col gap-2">
<InputText v-model="accountForm.username" label="Username" />
<InputText v-model="accountForm.email" label="Email" autofill="username" autocomplete="username" />
<Button v-if="!settings?.emailConfirmed" class="w-max" size="small" :disabled="loading" @click.prevent="emit.openEmailConfirmModal">
<InputText v-model="accountForm.username" label="Username" :rules="[required()]" />
<InputText v-model="accountForm.email" label="Email" autofill="username" autocomplete="username" :rules="[required(), email()]" />
<Button v-if="!settings?.emailConfirmed" class="w-max" size="small" :disabled="loading" @click.prevent="$emit('openEmailConfirmModal')">
Confirm email
</Button>
<InputPassword
@ -59,14 +70,16 @@ function saveAccount() {
name="current-password"
autofill="current-password"
autocomplete="current-password"
:rules="[required()]"
/>
<InputPassword
v-model="accountForm.newPassword"
label="New password (optional)"
name="new-password"
autofill="new-password"
autocomplete="new-passwsord"
autocomplete="new-password"
/>
<div v-if="error" class="text-red">{{ error }}</div>
<Button type="submit" class="w-max" :disabled="loading" @click.prevent="saveAccount">Save</Button>
</form>
</div>

View File

@ -11,6 +11,7 @@ import InputText from "~/components/ui/InputText.vue";
import InputSelect from "~/components/ui/InputSelect.vue";
import Button from "~/components/design/Button.vue";
import { definePageMeta } from "#imports";
import { useBackendData } from "~/store/backendData";
import PageTitle from "~/components/design/PageTitle.vue";
definePageMeta({
@ -76,7 +77,7 @@ async function saveProfile() {
</div>
<h3 class="text-lg font-bold mt-4 mb-2">Tagline</h3>
<InputText v-model="profileForm.tagline" label="Tagline" />
<InputText v-model="profileForm.tagline" label="Tagline" counter :maxlength="useBackendData.validations.userTagline.max" />
<h3 class="text-lg font-bold mt-4">Social</h3>
<div v-for="(link, idx) in profileForm.socials" :key="link[0]" class="flex items-center">

View File

@ -10,7 +10,7 @@ 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 } from "#imports";
import { definePageMeta, required } from "#imports";
import PageTitle from "~/components/design/PageTitle.vue";
definePageMeta({
@ -34,6 +34,7 @@ const loading = ref(false);
const authenticatorName = ref<string>();
async function addAuthenticator() {
if (!(await v.value.$validate())) return;
loading.value = true;
try {
const credentialCreateOptions = await useInternalApi<string>("auth/webauthn/setup", "POST", authenticatorName.value, {
@ -176,7 +177,7 @@ async function generateNewCodes() {
</li>
</ul>
<div class="my-2">
<InputText v-model="authenticatorName" label="Name" />
<InputText v-model="authenticatorName" label="Name" :rules="[required()]" />
</div>
<Button :disabled="loading" @click="addAuthenticator">Setup 2FA via security key</Button>