mirror of
https://github.com/HangarMC/Hangar.git
synced 2024-11-21 01:21:54 +08:00
feat: save account settings (user/email/pass change)
This commit is contained in:
parent
e616de04cf
commit
78ca7a346f
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
}
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user